feat: keep deleted files in a Files trash with Restore, Delete permanently and Empty trash

This commit is contained in:
Linus Rath
2026-10-28 03:35:46 +01:00
parent 91f9213110
commit 3d72d426d4
39 changed files with 1404 additions and 215 deletions
@@ -376,4 +376,28 @@ describe('FileBrowser', () => {
expect(screen.queryByRole('menu')).toBeNull();
expect(onClearSelection).not.toHaveBeenCalled();
});
it("offers Restore and Delete permanently in the trash, and Empty trash in its toolbar", () => {
const onRestore = vi.fn(async () => {});
const onEmptyTrash = vi.fn();
const trashed = { ...resource(OWN[1]), trash: { deletedAt: '2026-10-20T10:00:00Z', fromParentId: null, batchId: 'b1' } };
act(() => { useFileStore.setState({ trashCount: 1 }); });
renderBrowser({ resources: [trashed], props: { currentPath: '/.Trash', onRestore, onEmptyTrash } });
act(() => { useFileStore.setState({ currentParentId: '*trash', trashCount: 1 }); });
fireEvent.click(screen.getByRole('button', { name: 'empty_trash' }));
expect(onEmptyTrash).toHaveBeenCalled();
fireEvent.contextMenu(within(list()).getByText('notes.txt'));
expect(screen.queryByText('rename')).toBeNull();
fireEvent.click(screen.getByText('restore'));
expect(onRestore).toHaveBeenCalledWith(['notes.txt']);
expect(screen.getByRole('columnheader', { name: 'deleted' })).toBeInTheDocument();
});
it("lists the trash in the sidebar", () => {
const { onNavigate } = renderBrowser();
fireEvent.click(within(tree()).getByRole('button', { name: /trash/ }));
expect(onNavigate).toHaveBeenCalledWith('/.Trash', '*trash');
});
});
+93 -9
View File
@@ -5,7 +5,7 @@ import { useTranslations } from "next-intl";
import {
Upload, FolderPlus, Download, Trash2, Pencil, Copy, Clipboard, Scissors, Info,
FilePlus, CopyPlus, AlertCircle, FolderUp, Share2, SquarePen, Eye, FolderOpen,
RefreshCw, Star, StarOff, Plus, CheckSquare, File,
RefreshCw, Star, StarOff, Plus, CheckSquare, File, RotateCcw,
} from "@/components/icons";
import { useIsDesktop, useIsMobile, useMediaQuery } from "@/hooks/use-media-query";
import { useContextMenu } from "@/hooks/use-context-menu";
@@ -16,6 +16,7 @@ import { cn } from "@/lib/utils";
import { PromptDialog } from "@/components/ui/prompt-dialog";
import { fileNameProblem } from "@/lib/file-name-rules";
import { EmptyFolder, NoSearchResults } from "@/components/files/file-empty-state";
import { EmptyState } from "@/components/ui/empty-state";
import { isAudioFile, isImageFile, isPdfFile, isTextFile, isVideoFile } from "@/components/files/file-icons";
import { loadFilesSettings, saveFilesSettings, type FilesSettings } from "@/components/files/files-settings";
import { FilesSidebar, type CreateKind } from "@/components/files/files-sidebar";
@@ -26,6 +27,7 @@ import { INTERNAL_DRAG_TYPE, anchorEvent, isInternalDrag, readDraggedNames } fro
import { ResizeHandle } from "@/components/layout/resize-handle";
import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils";
import { resourceToView, useFileStore, isSharedFilesPage, sharedFilePages, type FileResource } from "@/stores/file-store";
import { TRASH_LOCATION, TRASH_PATH, isTrashLocation } from "@/lib/files-trash";
import { PluginActions, usePluginActions } from "@/components/plugins/plugin-actions";
import { toast } from "@/stores/toast-store";
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
@@ -60,8 +62,13 @@ interface FileBrowserProps {
onUploadFiles: (files: File[]) => Promise<void>;
onUploadFolder: (files: File[]) => Promise<void>;
onCancelUpload: () => void;
/** Delete: into the trash, or for good when in the trash or a shared drive. */
onDelete: (name: string) => Promise<void>;
onBatchDelete: (names: string[]) => Promise<void>;
/** In the trash: put items back where they came from. */
onRestore?: (names: string[]) => Promise<void>;
/** In the trash: delete everything in it for good. */
onEmptyTrash?: () => void;
onRename: (oldName: string, newName: string) => Promise<void>;
onDownload: (name: string) => Promise<void>;
onBatchDownload: (names: string[]) => Promise<void>;
@@ -118,6 +125,16 @@ type MenuData = { kind: "item"; resource: FileResource } | { kind: "empty" } | {
const SIDEBAR_DEFAULT_WIDTH = 256;
/** A folder's path ("/Documents/Taxes") from the folder list. */
function pathOfFolder(folders: FileResource[], folder: FileResource): string {
const names = [folder.name];
const byId = new Map(folders.map((f) => [f.id, f]));
for (let cur = folder.parentId ? byId.get(folder.parentId) : undefined; cur && names.length < 64; cur = cur.parentId ? byId.get(cur.parentId) : undefined) {
names.unshift(cur.name);
}
return "/" + names.join("/");
}
function isTypingTarget(target: EventTarget | null): boolean {
const el = target as HTMLElement | null;
if (!el) return false;
@@ -139,6 +156,8 @@ export function FileBrowser({
onCancelUpload,
onDelete,
onBatchDelete,
onRestore,
onEmptyTrash,
onRename,
onDownload,
onBatchDownload,
@@ -196,6 +215,10 @@ export function FileBrowser({
const hasListed = useFileStore((s) => s.hasListed);
const currentParentId = useFileStore((s) => s.currentParentId);
const pathStack = useFileStore((s) => s.pathStack);
const trashCount = useFileStore((s) => s.trashCount);
// The trash lists deleted items: they can be restored, previewed or
// deleted for good, nothing else.
const inTrash = isTrashLocation(currentParentId);
useEffect(() => {
if (storeClient) void loadSharedRoots();
}, [storeClient, loadSharedRoots]);
@@ -346,12 +369,15 @@ export function FileBrowser({
// Whether the user may add items to the folder on screen. Nothing can be
// put on a page listing files shared on their own.
const currentFolder = currentParentId ? allFolders.find((f) => f.id === currentParentId) : null;
const canCreate = !accountPickerMode && !isSharedFilesPage(currentParentId)
const canCreate = !accountPickerMode && !isSharedFilesPage(currentParentId) && !inTrash
&& (currentFolder?.myRights?.mayAddChildren ?? true);
// ---- Listing ---------------------------------------------------------------
const displayResources = useMemo(() => {
let list = resources;
// In the trash the date column is when an item was deleted.
let list = inTrash
? resources.map((r) => (r.trash?.deletedAt ? { ...r, lastModified: r.trash.deletedAt } : r))
: resources;
if (!settings.showHiddenFiles) list = list.filter((r) => !r.name.startsWith("."));
if (searchQuery) {
const q = searchQuery.toLowerCase();
@@ -375,7 +401,7 @@ export function FileBrowser({
if (cmp === 0) cmp = a.name.localeCompare(b.name);
return sortDir === "asc" ? cmp : -cmp;
});
}, [resources, settings.showHiddenFiles, searchQuery, sortKey, sortDir]);
}, [resources, inTrash, settings.showHiddenFiles, searchQuery, sortKey, sortDir]);
// Folders other principals shared with the user, listed at the root next to
// the user's own (#1181).
@@ -407,11 +433,12 @@ export function FileBrowser({
// Inside a folder someone shared with the user, the trail starts there.
const insideShared = !!pathStack[1]?.id?.includes(":");
const list: Crumb[] = [{ id: null, name: insideShared ? t("shared_with_me") : t("my_files"), path: "/" }];
if (inTrash) return [...list, { id: TRASH_LOCATION, name: t("trash"), path: TRASH_PATH }];
segments.forEach((name, i) => {
list.push({ id: pathStack[i + 1]?.id ?? null, name, path: "/" + segments.slice(0, i + 1).join("/") });
});
return list;
}, [currentPath, pathStack, t]);
}, [currentPath, pathStack, inTrash, t]);
const folderName = crumbs[crumbs.length - 1].name;
const navigateCrumb = useCallback((crumb: Crumb) => {
@@ -438,6 +465,8 @@ export function FileBrowser({
const openResource = useCallback((resource: FileResource) => {
// A symlink has nothing to open (Stalwart 1.0).
if (resource.isSymlink) return;
// A deleted folder is restored before it is opened; a deleted file can be looked at.
if (resource.trash && (resource.isDirectory || !isPreviewable(resource.name))) return;
if (resource.isDirectory) {
onNavigate(pathOf(resource), resource.id);
} else if (isOfficeEditable?.(resource.name) && onEditFile) {
@@ -456,6 +485,15 @@ export function FileBrowser({
else onPreviewFile(resource.name);
}, [onPreviewImage, onPreviewFile]);
// Where a deleted item came from, as its row's tooltip in the trash.
const trashNote = useCallback((r: FileResource): string | undefined => {
if (!r.trash) return undefined;
const from = r.trash.fromParentId;
const folder = from ? allFolders.find((f) => f.id === from) : null;
const path = from === null ? t("my_files") : folder ? pathOfFolder(allFolders, folder) : null;
return path ? t("deleted_from", { folder: path }) : undefined;
}, [allFolders, t]);
// ---- Selection -------------------------------------------------------------
const selectRange = useCallback((from: string, to: string, additive: boolean) => {
const names = displayResources.map((r) => r.name);
@@ -514,7 +552,7 @@ export function FileBrowser({
}, [onMoveTo]);
const itemDragProps = useCallback((resource: FileResource) => ({
draggable: !touch,
draggable: !touch && !inTrash,
onDragStart: (e: React.DragEvent) => {
const names = selectedResources.has(resource.name) ? [...selectedResources] : [resource.name];
e.dataTransfer.setData(INTERNAL_DRAG_TYPE, JSON.stringify(names));
@@ -536,7 +574,7 @@ export function FileBrowser({
const names = readDraggedNames(e).filter((n) => n !== resource.name);
if (names.length > 0) moveNames(names, resource.id);
},
}), [touch, selectedResources, moveNames]);
}), [touch, inTrash, selectedResources, moveNames]);
const runUpload = useCallback(async (files: File[], asFolder: boolean) => {
if (files.length === 0) return;
@@ -683,6 +721,7 @@ export function FileBrowser({
selectAllVisible();
return;
}
if (inTrash && (mod || e.key === "F2") && ["c", "x", "v", "f2"].includes(e.key.toLowerCase())) return;
if (mod && e.key.toLowerCase() === "c" && selected.length > 0) {
if (window.getSelection()?.toString()) return;
e.preventDefault();
@@ -750,7 +789,7 @@ export function FileBrowser({
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [
showNewFolder, showNewTextFile, renameTarget, renameFolder, shareTargetId, accountPickerMode, selectedList, singleSelected,
showNewFolder, showNewTextFile, renameTarget, renameFolder, shareTargetId, accountPickerMode, selectedList, singleSelected, inTrash,
selectAllVisible, onCopy, onCut, onPaste, clipboard, searchQuery, onClearSelection, deleteNames, openResource,
focusedName, onToggleSelect, currentPath, navigateUp, viewMode, gridColumns, moveFocus,
]);
@@ -779,6 +818,25 @@ export function FileBrowser({
: menuData?.kind === "item" ? [menuData.resource] : [];
const renderItemMenu = (resource: FileResource) => {
if (inTrash) {
const names = menuSelection.map((r) => r.name);
const one = menuSelection.length === 1 ? menuSelection[0] : null;
return (
<>
{onRestore && (
<ContextMenuItem icon={RotateCcw} label={t("restore")} onClick={() => { closeMenu(); void onRestore(names); }} />
)}
{one && !one.isDirectory && !one.isSymlink && isPreviewable(one.name) && (
<ContextMenuItem icon={Eye} label={t("preview")} onClick={() => { closeMenu(); previewResource(one); }} />
)}
{one && !one.isDirectory && !one.isSymlink && (
<ContextMenuItem icon={Download} label={t("download")} onClick={() => { closeMenu(); void onDownload(one.name); }} />
)}
<ContextMenuSeparator />
<ContextMenuItem icon={Trash2} label={t("delete_forever")} destructive onClick={() => { closeMenu(); deleteNames(names); }} />
</>
);
}
if (menuSelection.length > 1) {
const names = menuSelection.map((r) => r.name);
const fileCount = menuSelection.filter((r) => !r.isDirectory && !r.isSymlink).length;
@@ -910,6 +968,9 @@ export function FileBrowser({
onRevealRecent={fromDrawer((id: string) => void onRevealNode(id))}
onDownloadRecent={fromDrawer((id: string) => void onDownloadNode(id))}
onRemoveRecent={onRemoveRecent}
trashCount={trashCount}
onOpenTrash={fromDrawer(() => onNavigate(TRASH_PATH, TRASH_LOCATION))}
onDropToTrash={(names) => deleteNames(names)}
/>
);
@@ -948,6 +1009,9 @@ export function FileBrowser({
// Until the first listing is in, an empty list means "not loaded yet".
if ((isLoading || !hasListed) && resources.length === 0) return <ListSkeleton />;
if (showEmpty && searchQuery) return <NoSearchResults query={searchQuery} onClear={() => setSearchQuery("")} />;
if (showEmpty && inTrash) {
return <EmptyState icon={Trash2} title={t("trash_empty_title")} description={t("trash_empty_hint")} />;
}
if (showEmpty) {
return (
<EmptyFolder
@@ -970,6 +1034,7 @@ export function FileBrowser({
iconOptions,
getImageUrl,
handlers,
itemNote: inTrash ? trashNote : undefined,
};
return (
<>
@@ -984,6 +1049,7 @@ export function FileBrowser({
allSelected={allSelected}
someSelected={someSelected}
onToggleAll={() => (allSelected ? onClearSelection() : selectAllVisible())}
dateLabel={inTrash ? t("deleted") : undefined}
/>
))}
{(sharedAtRoot.length > 0 || sharedPagesAtRoot.length > 0) && (
@@ -1025,7 +1091,24 @@ export function FileBrowser({
/>
);
const selectionActions = selectedList.length > 0 ? {
const selectionActions = selectedList.length > 0 && inTrash ? {
count: selectedList.length,
fileCount: 0,
canOpen: false,
canRename: false,
canShare: false,
trash: true,
onRestore: onRestore ? () => void onRestore(selectedList.map((r) => r.name)) : undefined,
onOpen: () => {},
onDownload: () => {},
onShare: () => {},
onRename: () => {},
onCut: () => {},
onCopy: () => {},
onDelete: () => deleteNames(selectedList.map((r) => r.name)),
onClear: onClearSelection,
pluginFiles: [],
} : selectedList.length > 0 ? {
count: selectedList.length,
fileCount: selectedList.filter((r) => !r.isDirectory && !r.isSymlink).length,
canOpen: !!singleSelected && !singleSelected.isSymlink
@@ -1110,6 +1193,7 @@ export function FileBrowser({
compact={isMobile}
toolsHidden={accountPickerMode}
selection={selectionActions}
emptyTrash={inTrash && onEmptyTrash ? { onEmpty: onEmptyTrash, disabled: trashCount === 0 } : undefined}
/>
{error && (
+11 -4
View File
@@ -36,6 +36,8 @@ export interface FileViewProps {
iconOptions: IconOptions;
getImageUrl: (name: string) => Promise<string>;
handlers: FileItemHandlers;
/** A line about an item, as its tooltip (where a deleted item came from). */
itemNote?: (resource: FileResource) => string | undefined;
}
const LONG_PRESS_MS = 450;
@@ -138,7 +140,7 @@ const LIST_COLUMNS = "grid grid-cols-[auto_minmax(0,1fr)_auto] sm:grid-cols-[aut
export function FileListView({
resources, selected, focusedName, dropTarget, cutNames, starredPaths, pathOf, touch, iconOptions, getImageUrl, handlers,
sortKey, sortDir, onSort, allSelected, someSelected, onToggleAll,
sortKey, sortDir, onSort, allSelected, someSelected, onToggleAll, itemNote, dateLabel,
}: FileViewProps & {
sortKey: SortKey;
sortDir: SortDir;
@@ -146,6 +148,8 @@ export function FileListView({
allSelected: boolean;
someSelected: boolean;
onToggleAll: () => void;
/** The date column's heading, "Modified" unless told otherwise. */
dateLabel?: string;
}) {
const t = useTranslations("files");
const selecting = selected.size > 0;
@@ -165,7 +169,7 @@ export function FileListView({
{someSelected && !allSelected && <span className="sr-only">{t("selected_count", { count: selected.size })}</span>}
</div>
<SortHeader label={t("name")} column="name" sortKey={sortKey} sortDir={sortDir} onSort={onSort} />
<SortHeader label={t("modified")} column="modified" sortKey={sortKey} sortDir={sortDir} onSort={onSort} className="hidden md:block" />
<SortHeader label={dateLabel ?? t("modified")} column="modified" sortKey={sortKey} sortDir={sortDir} onSort={onSort} className="hidden md:block" />
<SortHeader label={t("size")} column="size" sortKey={sortKey} sortDir={sortDir} onSort={onSort} className="hidden sm:block text-end" />
<div role="columnheader" className="w-[4.75rem]"><span className="sr-only">{t("context_menu")}</span></div>
</div>
@@ -183,13 +187,14 @@ export function FileListView({
iconOptions={iconOptions}
getImageUrl={getImageUrl}
handlers={handlers}
note={itemNote?.(resource)}
/>
))}
</div>
);
}
function FileListRow({ resource, isSelected, isFocused, isDropTarget, isCut, isStarred, selecting, touch, iconOptions, getImageUrl, handlers }: {
function FileListRow({ resource, isSelected, isFocused, isDropTarget, isCut, isStarred, selecting, touch, iconOptions, getImageUrl, handlers, note }: {
resource: FileResource;
isSelected: boolean;
isFocused: boolean;
@@ -201,6 +206,7 @@ function FileListRow({ resource, isSelected, isFocused, isDropTarget, isCut, isS
iconOptions: IconOptions;
getImageUrl: (name: string) => Promise<string>;
handlers: FileItemHandlers;
note?: string;
}) {
const t = useTranslations("files");
const touchProps = useTouchPress(touch, selecting, resource, handlers);
@@ -251,7 +257,7 @@ function FileListRow({ resource, isSelected, isFocused, isDropTarget, isCut, isS
}}
onDoubleClick={(e) => e.stopPropagation()}
className={cn("truncate text-start hover:underline underline-offset-2 decoration-muted-foreground/50", resource.isDirectory && "font-medium")}
title={resource.name}
title={note ? `${resource.name}\n${note}` : resource.name}
>
{resource.name}
</button>
@@ -260,6 +266,7 @@ function FileListRow({ resource, isSelected, isFocused, isDropTarget, isCut, isS
</div>
{touch && (
<div className="text-xs text-muted-foreground truncate">
{note && <>{note} · </>}
{formatShortDate(resource.lastModified)}
{!resource.isDirectory && !resource.isSymlink && <> · {formatFileSize(resource.contentLength)}</>}
</div>
+115 -43
View File
@@ -7,7 +7,8 @@ import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from '@/stores/auth-store';
import { useAccountStore } from "@/stores/account-store";
import { useEmailStore } from "@/stores/email-store";
import { useFileStore, isSharedLocation, linkedPreviewResource, resourceServerRef, resourceToView, type UploadReport } from "@/stores/file-store";
import { useFileStore, deletesToTrash, isSharedLocation, linkedPreviewResource, resourceServerRef, resourceToView, type UploadReport } from "@/stores/file-store";
import { isTrashLocation } from "@/lib/files-trash";
import { fileErrorKind, isPartialCopy } from "@/lib/file-errors";
import { toast } from "@/stores/toast-store";
import { cn, formatFileSize } from "@/lib/utils";
@@ -109,6 +110,11 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
lastAction,
shareResource,
openSharedNode,
trashResources,
trashNode,
restoreResources,
purgeResources,
emptyTrash,
} = useFileStore();
const isMobile = useIsMobile();
@@ -397,42 +403,6 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
}
}, [uploadFolder, t, maxSizeUpload, showUploadReport, failureReason]);
const handleDelete = useCallback(async (name: string) => {
const confirmed = await confirmDialog({
title: t("delete_confirm_title"),
message: t("delete_confirm_message", { name }),
confirmText: t("delete"),
variant: "destructive",
});
if (!confirmed) return;
try {
if (!await deleteResource(name)) return;
toast.success(t("delete_success"));
} catch (err) {
console.error("Failed to delete:", err);
toast.error(t("delete_error"), failureReason(err));
}
}, [deleteResource, confirmDialog, t, failureReason]);
const handleBatchDelete = useCallback(async (names: string[]) => {
const confirmed = await confirmDialog({
title: t("delete_confirm_title"),
message: t("batch_delete_confirm_message", { count: names.length }),
confirmText: t("delete"),
variant: "destructive",
});
if (!confirmed) return;
try {
if (!await deleteResources(names)) return;
toast.success(t("batch_delete_success", { count: names.length }));
} catch (err) {
console.error("Failed to batch delete:", err);
toast.error(t("delete_error"), failureReason(err));
}
}, [deleteResources, confirmDialog, t, failureReason]);
const handleUndo = useCallback(async () => {
try {
await undoLastAction();
@@ -443,6 +413,93 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
}
}, [undoLastAction, t, failureReason]);
// Delete (R3): in the own drive items go to the trash, with Undo and no
// question; in the trash and in a drive someone shared they are gone for
// good, so that asks first.
const currentParentIdForDelete = useFileStore((s) => s.currentParentId);
const handleDeleteNames = useCallback(async (names: string[]) => {
if (names.length === 0) return;
if (isTrashLocation(currentParentIdForDelete)) {
const confirmed = await confirmDialog({
title: t("delete_forever_title", { count: names.length }),
message: names.length === 1
? t("delete_forever_message", { name: names[0] })
: t("delete_forever_message_count", { count: names.length }),
confirmText: t("delete_forever"),
variant: "destructive",
});
if (!confirmed) return;
try {
const count = await purgeResources(names);
if (count > 0) toast.success(t("deleted_forever", { count }));
} catch (err) {
console.error("Failed to delete:", err);
toast.error(t("delete_error"), failureReason(err));
}
return;
}
if (deletesToTrash(currentParentIdForDelete)) {
try {
const count = await trashResources(names);
if (count > 0) {
toast.success(t("moved_to_trash", { count }), {
action: { label: t("undo"), onClick: handleUndo },
});
}
} catch (err) {
console.error("Failed to delete:", err);
toast.error(t("delete_error"), failureReason(err));
}
return;
}
const confirmed = await confirmDialog({
title: t("delete_forever_title", { count: names.length }),
message: names.length === 1
? t("delete_forever_message", { name: names[0] })
: t("delete_forever_message_count", { count: names.length }),
confirmText: t("delete_forever"),
variant: "destructive",
});
if (!confirmed) return;
try {
if (names.length === 1 ? !await deleteResource(names[0]) : !await deleteResources(names)) return;
toast.success(t("deleted_forever", { count: names.length }));
} catch (err) {
console.error("Failed to delete:", err);
toast.error(t("delete_error"), failureReason(err));
}
}, [currentParentIdForDelete, confirmDialog, t, purgeResources, trashResources, deleteResource, deleteResources, handleUndo, failureReason]);
const handleDelete = useCallback((name: string) => handleDeleteNames([name]), [handleDeleteNames]);
const handleBatchDelete = handleDeleteNames;
const handleRestore = useCallback(async (names: string[]) => {
try {
const count = await restoreResources(names);
if (count > 0) toast.success(t("restored", { count }));
} catch (err) {
console.error("Failed to restore:", err);
toast.error(t("restore_error"), failureReason(err));
}
}, [restoreResources, t, failureReason]);
const trashCount = useFileStore((s) => s.trashCount);
const handleEmptyTrash = useCallback(async () => {
const confirmed = await confirmDialog({
title: t("empty_trash_title"),
message: t("empty_trash_message", { count: trashCount }),
confirmText: t("empty_trash"),
variant: "destructive",
});
if (!confirmed) return;
try {
await emptyTrash();
toast.success(t("trash_emptied"));
} catch (err) {
console.error("Failed to empty the trash:", err);
toast.error(t("empty_trash_error"), failureReason(err));
}
}, [confirmDialog, t, trashCount, emptyTrash, failureReason]);
const handleRename = useCallback(async (oldName: string, newName: string) => {
try {
if (!await renameResource(oldName, newName)) return;
@@ -546,21 +603,34 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
}, [renameNode, t, handleUndo]);
const handleDeleteNode = useCallback(async (id: string, name: string) => {
// A folder of the own drive goes to the trash; one of a shared drive is gone for good.
if (deletesToTrash(id)) {
try {
if (!await trashNode(id)) return;
toast.success(t("moved_to_trash", { count: 1 }), {
action: { label: t("undo"), onClick: handleUndo },
});
} catch (err) {
console.error("Failed to delete:", err);
toast.error(t("delete_error"), failureReason(err));
}
return;
}
const confirmed = await confirmDialog({
title: t("delete_confirm_title"),
message: t("delete_confirm_message", { name }),
confirmText: t("delete"),
title: t("delete_forever_title", { count: 1 }),
message: t("delete_forever_message", { name }),
confirmText: t("delete_forever"),
variant: "destructive",
});
if (!confirmed) return;
try {
if (!await deleteNode(id)) return;
toast.success(t("delete_success"));
toast.success(t("deleted_forever", { count: 1 }));
} catch (err) {
console.error("Failed to delete:", err);
toast.error(t("delete_error"));
toast.error(t("delete_error"), failureReason(err));
}
}, [deleteNode, confirmDialog, t]);
}, [deleteNode, trashNode, confirmDialog, handleUndo, t, failureReason]);
const handleDownloadNode = useCallback(async (id: string) => {
try {
@@ -716,6 +786,8 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
onCancelUpload={cancelUpload}
onDelete={handleDelete}
onBatchDelete={handleBatchDelete}
onRestore={handleRestore}
onEmptyTrash={() => void handleEmptyTrash()}
onRename={handleRename}
onDownload={handleDownload}
onBatchDownload={handleBatchDownload}
+32 -2
View File
@@ -5,7 +5,7 @@ import { useTranslations } from "next-intl";
import {
Menu, ChevronRight, Search, X, ArrowUpDown, Check, LayoutGrid, LayoutList,
PanelRight, Download, Trash2, Scissors, Copy, Pencil, Share2, Clipboard,
MoreHorizontal, FolderOpen, Folder, ArrowUp, ArrowDown, RefreshCw,
MoreHorizontal, FolderOpen, Folder, ArrowUp, ArrowDown, RefreshCw, RotateCcw,
} from "@/components/icons";
import { Button } from "@/components/ui/button";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
@@ -45,6 +45,9 @@ interface SelectionActions {
pluginFiles: FileResourceView[];
/** The one selected item's type, so plugins can offer type-specific actions; undefined for several. */
pluginMimeType?: string;
/** Items in the trash: Restore and Delete permanently, nothing else. */
trash?: boolean;
onRestore?: () => void;
}
interface FilesHeaderProps {
@@ -74,6 +77,8 @@ interface FilesHeaderProps {
/** Hide the tools that only apply to a folder listing (Pro account picker). */
toolsHidden?: boolean;
selection: SelectionActions | null;
/** In the trash: the Empty trash button. */
emptyTrash?: { onEmpty: () => void; disabled: boolean };
}
const MAX_VISIBLE_CRUMBS = 3;
@@ -103,6 +108,7 @@ export function FilesHeader({
compact,
toolsHidden,
selection,
emptyTrash,
}: FilesHeaderProps) {
const t = useTranslations("files");
const sortMenu = useContextMenu<null>();
@@ -122,6 +128,15 @@ export function FilesHeader({
{t("selected_count", { count: selection.count })}
</span>
<div className="flex items-center gap-0.5 min-w-0 overflow-x-auto">
{selection.trash ? (
<>
{selection.onRestore && (
<ToolButton icon={RotateCcw} label={t("restore")} onClick={selection.onRestore} compact={compact} />
)}
<ToolButton icon={Trash2} label={t("delete_forever")} onClick={selection.onDelete} compact destructive />
</>
) : (
<>
{selection.canOpen && (
<ToolButton icon={FolderOpen} label={t("open")} onClick={selection.onOpen} compact={compact} />
)}
@@ -138,9 +153,11 @@ export function FilesHeader({
<ToolButton icon={Copy} label={t("copy")} onClick={selection.onCopy} compact />
<ToolButton icon={Trash2} label={t("delete")} onClick={selection.onDelete} compact destructive />
{!compact && <SelectionPluginActions files={selection.pluginFiles} mimeType={selection.pluginMimeType} />}
</>
)}
</div>
{/* Outside the scrolling row, so the "more" button never scrolls out of sight. */}
{compact && <SelectionPluginActions files={selection.pluginFiles} mimeType={selection.pluginMimeType} overflow />}
{compact && !selection.trash && <SelectionPluginActions files={selection.pluginFiles} mimeType={selection.pluginMimeType} overflow />}
<div className="flex-1" />
<DetailsToggle showDetails={showDetails} onToggle={onToggleDetails} label={t("details")} />
</div>
@@ -281,6 +298,19 @@ export function FilesHeader({
{!toolsHidden && (
<div className="flex items-center gap-1 shrink-0">
{emptyTrash && (
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5 text-destructive hover:text-destructive"
onClick={emptyTrash.onEmpty}
disabled={emptyTrash.disabled}
title={t("empty_trash")}
>
<Trash2 className="w-4 h-4" />
<span className={cn(compact && "sr-only")}>{t("empty_trash")}</span>
</Button>
)}
{clipboardCount > 0 && (
<Button variant="outline" size="sm" className="h-8 gap-1.5" onClick={onPaste} title={t("paste_here")}>
<Clipboard className="w-4 h-4" />
+40
View File
@@ -6,6 +6,7 @@ import {
Plus, HardDrive, File, Folder, FolderOpen, ChevronRight, ChevronDown, Star, StarOff,
Clock, Share2, Upload, FolderUp, FolderPlus, FilePlus, Pencil, Trash2, Download, Eye, X, FolderSearch,
} from "@/components/icons";
import { isTrashLocation } from "@/lib/files-trash";
import { Button } from "@/components/ui/button";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
import { useContextMenu } from "@/hooks/use-context-menu";
@@ -77,6 +78,11 @@ export interface FilesSidebarProps {
onRevealRecent: (id: string) => void;
onDownloadRecent: (id: string) => void;
onRemoveRecent: (id: string) => void;
/** Items in the trash; the entry shows their number. */
trashCount?: number;
onOpenTrash?: () => void;
/** Delete items dragged onto the trash entry. */
onDropToTrash?: (names: string[]) => void;
}
export function FilesSidebar({
@@ -103,6 +109,9 @@ export function FilesSidebar({
onRevealRecent,
onDownloadRecent,
onRemoveRecent,
trashCount = 0,
onOpenTrash,
onDropToTrash,
}: FilesSidebarProps) {
const t = useTranslations("files");
const newMenu = useContextMenu<null>();
@@ -386,6 +395,37 @@ export function FilesSidebar({
</div>
)}
{onOpenTrash && (
<div className="mt-3">
<button
type="button"
onClick={onOpenTrash}
aria-current={isTrashLocation(currentParentId) ? "page" : undefined}
className={rowClass(isTrashLocation(currentParentId), dropTarget === "__trash__")}
style={{ ...ROW_STYLE, paddingInlineStart: "12px" }}
onDragOver={(e) => {
if (!isInternalDrag(e) || !onDropToTrash || isTrashLocation(currentParentId)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDropTarget("__trash__");
}}
onDragLeave={() => setDropTarget((cur) => (cur === "__trash__" ? null : cur))}
onDrop={(e) => {
if (!isInternalDrag(e) || !onDropToTrash) return;
e.preventDefault();
setDropTarget(null);
const names = readDraggedNames(e);
if (names.length > 0) onDropToTrash(names);
}}
>
<span className="w-3.5 -ms-1 shrink-0" />
<Trash2 className="w-4 h-4 shrink-0" />
<span className="truncate flex-1">{t("trash")}</span>
{trashCount > 0 && <span className="text-xs text-muted-foreground tabular-nums">{trashCount}</span>}
</button>
</div>
)}
{recent.length === 0 && favorites.length === 0 && (
<p className="px-4 pt-4 text-xs text-muted-foreground flex items-start gap-1.5">
<Clock className="w-3.5 h-3.5 shrink-0 mt-px" />
+11
View File
@@ -1408,7 +1408,18 @@ export class DemoJMAPClient implements IJMAPClient {
}
async destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }> {
// Like the server: a folder goes with everything in it (emptying the trash).
const idSet = new Set(ids);
let grew = true;
while (grew) {
grew = false;
for (const n of this.data.fileNodes) {
if (n.parentId && idSet.has(n.parentId) && !idSet.has(n.id)) {
idSet.add(n.id);
grew = true;
}
}
}
this.data.fileNodes = this.data.fileNodes.filter(n => !idSet.has(n.id));
return { destroyed: ids, notDestroyed: [] };
}
+94
View File
@@ -0,0 +1,94 @@
import type { FileNode } from '@/lib/jmap/types';
import { fileNodeKind } from '@/lib/jmap/filenode-kind';
/*
* The Files trash. Neither Stalwart 0.16 nor 1.0 keeps deleted FileNodes:
* FileNode/set destroy removes a node and everything below it for good (1.0
* can label a folder `role: "trash"`, 0.16 ignores the role, and neither moves
* anything there). So the trash is a folder the app manages: `.Trash` at the
* root, hidden from every listing. Each delete puts its items into a folder
* of its own inside it, named after when it happened and where the items came
* from, so Restore can put them back and two deleted files of the same name
* never clash:
*
* /.Trash/1730112000000~<parentId>/report.pdf
*
* The name is the record, so a WebDAV client or another device sees the same
* trash, and nothing else has to be stored.
*/
export const TRASH_FOLDER_NAME = '.Trash';
/** The trash as a place in the Files view. JMAP ids never contain "*". */
export const TRASH_LOCATION = '*trash';
/** The trash's path, for links and the breadcrumb. */
export const TRASH_PATH = `/${TRASH_FOLDER_NAME}`;
export function isTrashLocation(id: string | null | undefined): boolean {
return id === TRASH_LOCATION;
}
/** The trash folder of the account's own drive, if there is one yet. */
export function findTrashFolder(nodes: FileNode[]): FileNode | undefined {
return nodes.find(n =>
(n.parentId ?? null) === null
&& n.name === TRASH_FOLDER_NAME
&& !n.isShared
&& !n.id.includes(':')
&& fileNodeKind(n) === 'directory');
}
/** The name of the folder one delete's items go into. */
export function trashBatchName(parentId: string | null, deletedAt: number = Date.now()): string {
return `${deletedAt}~${parentId ?? ''}`;
}
/** When a batch was deleted and from which folder (null: the root); null for any other name. */
export function parseTrashBatchName(name: string): { deletedAt: number; parentId: string | null } | null {
const match = /^(\d{10,})~([A-Za-z0-9_-]*)$/.exec(name);
if (!match) return null;
return { deletedAt: Number(match[1]), parentId: match[2] || null };
}
/** The trash folder and everything in it, to keep out of normal listings. */
export function trashSubtreeIds(nodes: FileNode[], trashId: string | undefined): Set<string> {
const ids = new Set<string>();
if (!trashId) return ids;
ids.add(trashId);
let grew = true;
while (grew) {
grew = false;
for (const n of nodes) {
if (n.parentId && ids.has(n.parentId) && !ids.has(n.id)) {
ids.add(n.id);
grew = true;
}
}
}
return ids;
}
export interface TrashedItem {
node: FileNode;
/** The delete's folder in the trash; null for something put into the trash directly. */
batchId: string | null;
deletedAt: number | null;
/** Where it came from; null for the root or when unknown. */
fromParentId: string | null;
}
/** What the trash holds, one entry per deleted item. */
export function trashedItems(nodes: FileNode[], trash: FileNode | undefined): TrashedItem[] {
if (!trash) return [];
const items: TrashedItem[] = [];
for (const child of nodes.filter(n => n.parentId === trash.id)) {
const batch = fileNodeKind(child) === 'directory' ? parseTrashBatchName(child.name) : null;
if (!batch) {
items.push({ node: child, batchId: null, deletedAt: null, fromParentId: null });
continue;
}
for (const node of nodes.filter(n => n.parentId === child.id)) {
items.push({ node, batchId: child.id, deletedAt: batch.deletedAt, fromParentId: batch.parentId });
}
}
return items;
}
+19 -5
View File
@@ -3879,8 +3879,6 @@
"name_control_chars": "لا يمكن أن تحتوي الأسماء على أحرف تحكم.",
"name_too_long": "هذا الاسم طويل جدًا بالنسبة للخادم.",
"delete": "حذف",
"delete_confirm_title": "حذف المورد",
"delete_confirm_message": "هل أنت متأكد أنك تريد حذف \"{name}\"؟ لا يمكن التراجع عن هذا.",
"download": "تنزيل",
"name": "الاسم",
"size": "الحجم",
@@ -3906,8 +3904,26 @@
"error_partial_copy": "بقي جزء من النسخة في مكانه.",
"create_folder_success": "تم إنشاء المجلد",
"create_folder_error": "فشل إنشاء المجلد",
"delete_success": "تم الحذف بنجاح",
"delete_error": "فشل الحذف",
"trash": "سلة المهملات",
"restore": "استعادة",
"restored": "{count, plural, one {تمت استعادة العنصر} other {تمت استعادة # عنصر}}",
"restore_error": "تعذّرت الاستعادة",
"moved_to_trash": "{count, plural, one {نُقل إلى سلة المهملات} other {نُقل # عنصر إلى سلة المهملات}}",
"delete_forever": "حذف نهائي",
"delete_forever_title": "{count, plural, one {حذف نهائي؟} other {حذف # عنصر نهائيًا؟}}",
"delete_forever_message": "سيُحذف \"{name}\". لا يمكنك التراجع عن ذلك.",
"delete_forever_message_count": "{count, plural, one {سيُحذف عنصر واحد} other {سيُحذف # عنصر}}. لا يمكنك التراجع عن ذلك.",
"deleted_forever": "{count, plural, one {حُذف نهائيًا} other {حُذف # عنصر نهائيًا}}",
"empty_trash": "إفراغ سلة المهملات",
"empty_trash_title": "إفراغ سلة المهملات؟",
"empty_trash_message": "{count, plural, one {سيُحذف العنصر الموجود في سلة المهملات} other {ستُحذف جميع العناصر (#) في سلة المهملات}} نهائيًا. لا يمكنك التراجع عن ذلك.",
"trash_emptied": "أُفرغت سلة المهملات",
"empty_trash_error": "تعذّر إفراغ سلة المهملات",
"trash_empty_title": "سلة المهملات فارغة",
"trash_empty_hint": "تبقى الملفات المحذوفة هنا حتى تُفرغ سلة المهملات.",
"deleted": "تاريخ الحذف",
"deleted_from": "حُذف من {folder}",
"rename_success": "تمت إعادة التسمية بنجاح",
"rename_error": "فشلت إعادة التسمية",
"download_error": "فشل التنزيل",
@@ -3916,8 +3932,6 @@
"create": "إنشاء",
"save": "حفظ",
"no_results": "لا توجد ملفات مطابقة لبحثك",
"batch_delete_confirm_message": "هل أنت متأكد أنك تريد حذف {count, plural, one {عنصر واحد} other {# عنصر}}؟ لا يمكن التراجع عن هذا.",
"batch_delete_success": "{count, plural, one {تم حذف عنصر واحد} other {تم حذف # عنصر}}",
"grid_view": "عرض شبكي",
"list_view": "عرض قائمة",
"details": "التفاصيل",
+19 -5
View File
@@ -3879,8 +3879,6 @@
"name_control_chars": "Els noms no poden contenir caràcters de control.",
"name_too_long": "Aquest nom és massa llarg per al servidor.",
"delete": "Suprimeix",
"delete_confirm_title": "Suprimeix el recurs",
"delete_confirm_message": "Segur que voleu suprimir «{name}»? Això no es pot desfer.",
"download": "Baixa",
"name": "Nom",
"size": "Mida",
@@ -3906,8 +3904,26 @@
"error_partial_copy": "Part de la còpia s'ha quedat al seu lloc.",
"create_folder_success": "Carpeta creada",
"create_folder_error": "No s'ha pogut crear la carpeta",
"delete_success": "Suprimit correctament",
"delete_error": "No s'ha pogut suprimir",
"trash": "Paperera",
"restore": "Restaura",
"restored": "{count, plural, one {S'ha restaurat l'element} other {S'han restaurat # elements}}",
"restore_error": "No s'ha pogut restaurar",
"moved_to_trash": "{count, plural, one {S'ha mogut a la paperera} other {S'han mogut # elements a la paperera}}",
"delete_forever": "Suprimeix definitivament",
"delete_forever_title": "{count, plural, one {Voleu suprimir-lo definitivament?} other {Voleu suprimir # elements definitivament?}}",
"delete_forever_message": "Se suprimirà «{name}». No es pot desfer.",
"delete_forever_message_count": "{count, plural, one {Se suprimirà 1 element} other {Se suprimiran # elements}}. No es pot desfer.",
"deleted_forever": "{count, plural, one {S'ha suprimit definitivament} other {S'han suprimit # elements definitivament}}",
"empty_trash": "Buida la paperera",
"empty_trash_title": "Voleu buidar la paperera?",
"empty_trash_message": "{count, plural, one {L'element de la paperera se suprimirà} other {Els # elements de la paperera se suprimiran}} definitivament. No es pot desfer.",
"trash_emptied": "S'ha buidat la paperera",
"empty_trash_error": "No s'ha pogut buidar la paperera",
"trash_empty_title": "La paperera és buida",
"trash_empty_hint": "Els fitxers suprimits es queden aquí fins que buideu la paperera.",
"deleted": "Suprimit",
"deleted_from": "Suprimit de {folder}",
"rename_success": "Nom canviat correctament",
"rename_error": "No s'ha pogut canviar el nom",
"download_error": "No s'ha pogut baixar",
@@ -3916,8 +3932,6 @@
"create": "Crea",
"save": "Desa",
"no_results": "Cap fitxer coincideix amb la cerca",
"batch_delete_confirm_message": "Segur que voleu suprimir {count, plural, one {1 element} other {# elements}}? Això no es pot desfer.",
"batch_delete_success": "{count, plural, one {1 element suprimit} other {# elements suprimits}}",
"grid_view": "Vista de quadrícula",
"list_view": "Vista de llista",
"details": "Detalls",
+19 -5
View File
@@ -3833,8 +3833,6 @@
"name_control_chars": "Názvy nesmí obsahovat řídicí znaky.",
"name_too_long": "Tento název je pro server příliš dlouhý.",
"delete": "Odstranit",
"delete_confirm_title": "Odstranit položku",
"delete_confirm_message": "Opravdu chcete odstranit \"{name}\"? Tuto akci nelze vrátit zpět.",
"download": "Stáhnout",
"name": "Název",
"size": "Velikost",
@@ -3859,8 +3857,26 @@
"error_partial_copy": "Část kopie zůstala na místě.",
"create_folder_success": "Složka byla vytvořena",
"create_folder_error": "Vytvoření složky selhalo",
"delete_success": "Úspěšně odstraněno",
"delete_error": "Odstranění selhalo",
"trash": "Koš",
"restore": "Obnovit",
"restored": "{count, plural, one {Položka obnovena} few {Obnoveny # položky} other {Obnoveno # položek}}",
"restore_error": "Obnovení se nezdařilo",
"moved_to_trash": "{count, plural, one {Přesunuto do koše} few {# položky přesunuty do koše} other {# položek přesunuto do koše}}",
"delete_forever": "Trvale smazat",
"delete_forever_title": "{count, plural, one {Trvale smazat?} few {Trvale smazat # položky?} other {Trvale smazat # položek?}}",
"delete_forever_message": "Položka „{name}“ bude smazána. Tuto akci nelze vrátit.",
"delete_forever_message_count": "{count, plural, one {Bude smazána 1 položka} few {Budou smazány # položky} other {Bude smazáno # položek}}. Tuto akci nelze vrátit.",
"deleted_forever": "{count, plural, one {Trvale smazáno} few {Trvale smazány # položky} other {Trvale smazáno # položek}}",
"empty_trash": "Vysypat koš",
"empty_trash_title": "Vysypat koš?",
"empty_trash_message": "{count, plural, one {Položka v koši bude trvale smazána} few {Všechny # položky v koši budou trvale smazány} other {Všech # položek v koši bude trvale smazáno}}. Tuto akci nelze vrátit.",
"trash_emptied": "Koš byl vysypán",
"empty_trash_error": "Koš se nepodařilo vysypat",
"trash_empty_title": "Koš je prázdný",
"trash_empty_hint": "Smazané soubory zůstávají zde, dokud koš nevysypete.",
"deleted": "Smazáno",
"deleted_from": "Smazáno z {folder}",
"rename_success": "Úspěšně přejmenováno",
"rename_error": "Přejmenování selhalo",
"download_error": "Stahování selhalo",
@@ -3869,8 +3885,6 @@
"create": "Vytvořit",
"save": "Uložit",
"no_results": "Hledání neodpovídají žádné soubory",
"batch_delete_confirm_message": "Opravdu chcete odstranit {count, plural, one {1 položku} few {# položky} other {# položek}}? Tuto akci nelze vrátit zpět.",
"batch_delete_success": "{count, plural, one {Odstraněna 1 položka} few {Odstraněny # položky} other {Odstraněno # položek}}",
"grid_view": "Zobrazení mřížky",
"list_view": "Zobrazení seznamu",
"details": "Podrobnosti",
+19 -5
View File
@@ -3879,8 +3879,6 @@
"name_control_chars": "Navne må ikke indeholde kontroltegn.",
"name_too_long": "Dette navn er for langt til serveren.",
"delete": "Slet",
"delete_confirm_title": "Slet ressource",
"delete_confirm_message": "Er du sikker på, at du vil slette \"{name}\"? Dette kan ikke fortrydes.",
"download": "Download",
"name": "Navn",
"size": "Størrelse",
@@ -3905,8 +3903,26 @@
"error_partial_copy": "En del af kopien blev liggende.",
"create_folder_success": "Mappe oprettet",
"create_folder_error": "Kunne ikke oprette mappe",
"delete_success": "Slettet",
"delete_error": "Kunne ikke slette",
"trash": "Papirkurv",
"restore": "Gendan",
"restored": "{count, plural, one {Elementet er gendannet} other {# elementer er gendannet}}",
"restore_error": "Kunne ikke gendanne",
"moved_to_trash": "{count, plural, one {Flyttet til papirkurven} other {# elementer flyttet til papirkurven}}",
"delete_forever": "Slet permanent",
"delete_forever_title": "{count, plural, one {Slet permanent?} other {Slet # elementer permanent?}}",
"delete_forever_message": "\"{name}\" bliver slettet. Det kan ikke fortrydes.",
"delete_forever_message_count": "{count, plural, one {1 element bliver slettet} other {# elementer bliver slettet}}. Det kan ikke fortrydes.",
"deleted_forever": "{count, plural, one {Slettet permanent} other {# elementer slettet permanent}}",
"empty_trash": "Tøm papirkurven",
"empty_trash_title": "Tøm papirkurven?",
"empty_trash_message": "{count, plural, one {Elementet i papirkurven} other {Alle # elementer i papirkurven}} bliver slettet permanent. Det kan ikke fortrydes.",
"trash_emptied": "Papirkurven er tømt",
"empty_trash_error": "Kunne ikke tømme papirkurven",
"trash_empty_title": "Papirkurven er tom",
"trash_empty_hint": "Slettede filer bliver her, indtil du tømmer papirkurven.",
"deleted": "Slettet",
"deleted_from": "Slettet fra {folder}",
"rename_success": "Omdøbt",
"rename_error": "Kunne ikke omdøbe",
"download_error": "Kunne ikke downloade",
@@ -3915,8 +3931,6 @@
"create": "Opret",
"save": "Gem",
"no_results": "Ingen filer matcher din søgning",
"batch_delete_confirm_message": "Er du sikker på, at du vil slette {count, plural, one {1 element} other {# elementer}}? Dette kan ikke fortrydes.",
"batch_delete_success": "{count, plural, one {1 element slettet} other {# elementer slettet}}",
"grid_view": "Gittervisning",
"list_view": "Listevisning",
"details": "Detaljer",
+19 -5
View File
@@ -3833,8 +3833,6 @@
"name_control_chars": "Namen dürfen keine Steuerzeichen enthalten.",
"name_too_long": "Dieser Name ist für den Server zu lang.",
"delete": "Löschen",
"delete_confirm_title": "Ressource löschen",
"delete_confirm_message": "Möchten Sie \"{name}\" wirklich löschen? Dies kann nicht rückgängig gemacht werden.",
"download": "Herunterladen",
"name": "Name",
"size": "Größe",
@@ -3859,8 +3857,26 @@
"error_partial_copy": "Ein Teil der Kopie wurde nicht entfernt.",
"create_folder_success": "Ordner erstellt",
"create_folder_error": "Ordner konnte nicht erstellt werden",
"delete_success": "Erfolgreich gelöscht",
"delete_error": "Löschen fehlgeschlagen",
"trash": "Papierkorb",
"restore": "Wiederherstellen",
"restored": "{count, plural, one {Element wiederhergestellt} other {# Elemente wiederhergestellt}}",
"restore_error": "Wiederherstellen fehlgeschlagen",
"moved_to_trash": "{count, plural, one {In den Papierkorb verschoben} other {# Elemente in den Papierkorb verschoben}}",
"delete_forever": "Endgültig löschen",
"delete_forever_title": "{count, plural, one {Endgültig löschen?} other {# Elemente endgültig löschen?}}",
"delete_forever_message": "„{name}“ wird gelöscht. Das lässt sich nicht rückgängig machen.",
"delete_forever_message_count": "{count, plural, one {1 Element wird gelöscht} other {# Elemente werden gelöscht}}. Das lässt sich nicht rückgängig machen.",
"deleted_forever": "{count, plural, one {Endgültig gelöscht} other {# Elemente endgültig gelöscht}}",
"empty_trash": "Papierkorb leeren",
"empty_trash_title": "Papierkorb leeren?",
"empty_trash_message": "{count, plural, one {Das Element im Papierkorb wird} other {Alle # Elemente im Papierkorb werden}} endgültig gelöscht. Das lässt sich nicht rückgängig machen.",
"trash_emptied": "Papierkorb geleert",
"empty_trash_error": "Papierkorb konnte nicht geleert werden",
"trash_empty_title": "Der Papierkorb ist leer",
"trash_empty_hint": "Gelöschte Dateien bleiben hier, bis Sie den Papierkorb leeren.",
"deleted": "Gelöscht",
"deleted_from": "Gelöscht aus {folder}",
"rename_success": "Erfolgreich umbenannt",
"rename_error": "Umbenennen fehlgeschlagen",
"download_error": "Herunterladen fehlgeschlagen",
@@ -3869,8 +3885,6 @@
"create": "Erstellen",
"save": "Speichern",
"no_results": "Keine Dateien entsprechen Ihrer Suche",
"batch_delete_confirm_message": "Möchten Sie wirklich {count, plural, one {1 Element} other {# Elemente}} löschen? Dies kann nicht rückgängig gemacht werden.",
"batch_delete_success": "{count, plural, one {1 Element gelöscht} other {# Elemente gelöscht}}",
"grid_view": "Rasteransicht",
"list_view": "Listenansicht",
"details": "Details",
+19 -5
View File
@@ -3879,8 +3879,6 @@
"name_control_chars": "Names cannot contain control characters.",
"name_too_long": "This name is too long for the server.",
"delete": "Delete",
"delete_confirm_title": "Delete resource",
"delete_confirm_message": "Are you sure you want to delete \"{name}\"? This cannot be undone.",
"download": "Download",
"name": "Name",
"size": "Size",
@@ -3906,8 +3904,26 @@
"error_partial_copy": "Part of the copy was left in place.",
"create_folder_success": "Folder created",
"create_folder_error": "Failed to create folder",
"delete_success": "Deleted successfully",
"delete_error": "Failed to delete",
"trash": "Trash",
"restore": "Restore",
"restored": "{count, plural, one {Item restored} other {# items restored}}",
"restore_error": "Couldn't restore",
"moved_to_trash": "{count, plural, one {Moved to trash} other {# items moved to trash}}",
"delete_forever": "Delete permanently",
"delete_forever_title": "{count, plural, one {Delete permanently?} other {Delete # items permanently?}}",
"delete_forever_message": "\"{name}\" will be deleted. You can't undo this.",
"delete_forever_message_count": "{count, plural, one {1 item} other {# items}} will be deleted. You can't undo this.",
"deleted_forever": "{count, plural, one {Deleted permanently} other {# items deleted permanently}}",
"empty_trash": "Empty trash",
"empty_trash_title": "Empty trash?",
"empty_trash_message": "{count, plural, one {The item in the trash} other {All # items in the trash}} will be deleted permanently. You can't undo this.",
"trash_emptied": "Trash emptied",
"empty_trash_error": "Couldn't empty the trash",
"trash_empty_title": "Trash is empty",
"trash_empty_hint": "Deleted files stay here until you empty the trash.",
"deleted": "Deleted",
"deleted_from": "Deleted from {folder}",
"rename_success": "Renamed successfully",
"rename_error": "Failed to rename",
"download_error": "Failed to download",
@@ -3916,8 +3932,6 @@
"create": "Create",
"save": "Save",
"no_results": "No files match your search",
"batch_delete_confirm_message": "Are you sure you want to delete {count, plural, one {1 item} other {# items}}? This cannot be undone.",
"batch_delete_success": "{count, plural, one {1 item deleted} other {# items deleted}}",
"grid_view": "Grid view",
"list_view": "List view",
"details": "Details",
+19 -5
View File
@@ -3833,8 +3833,6 @@
"name_control_chars": "Los nombres no pueden contener caracteres de control.",
"name_too_long": "Este nombre es demasiado largo para el servidor.",
"delete": "Eliminar",
"delete_confirm_title": "Eliminar recurso",
"delete_confirm_message": "¿Está seguro de que desea eliminar \"{name}\"? Esta acción no se puede deshacer.",
"download": "Descargar",
"name": "Nombre",
"size": "Tamaño",
@@ -3859,8 +3857,26 @@
"error_partial_copy": "Una parte de la copia se ha quedado en su lugar.",
"create_folder_success": "Carpeta creada",
"create_folder_error": "Error al crear la carpeta",
"delete_success": "Eliminado correctamente",
"delete_error": "Error al eliminar",
"trash": "Papelera",
"restore": "Restaurar",
"restored": "{count, plural, one {Elemento restaurado} other {# elementos restaurados}}",
"restore_error": "No se ha podido restaurar",
"moved_to_trash": "{count, plural, one {Movido a la papelera} other {# elementos movidos a la papelera}}",
"delete_forever": "Eliminar definitivamente",
"delete_forever_title": "{count, plural, one {¿Eliminar definitivamente?} other {¿Eliminar # elementos definitivamente?}}",
"delete_forever_message": "Se eliminará «{name}». No se puede deshacer.",
"delete_forever_message_count": "{count, plural, one {Se eliminará 1 elemento} other {Se eliminarán # elementos}}. No se puede deshacer.",
"deleted_forever": "{count, plural, one {Eliminado definitivamente} other {# elementos eliminados definitivamente}}",
"empty_trash": "Vaciar la papelera",
"empty_trash_title": "¿Vaciar la papelera?",
"empty_trash_message": "{count, plural, one {El elemento de la papelera se eliminará} other {Los # elementos de la papelera se eliminarán}} definitivamente. No se puede deshacer.",
"trash_emptied": "Papelera vaciada",
"empty_trash_error": "No se ha podido vaciar la papelera",
"trash_empty_title": "La papelera está vacía",
"trash_empty_hint": "Los archivos eliminados se quedan aquí hasta que vacíes la papelera.",
"deleted": "Eliminado",
"deleted_from": "Eliminado de {folder}",
"rename_success": "Renombrado correctamente",
"rename_error": "Error al renombrar",
"download_error": "Error al descargar",
@@ -3869,8 +3885,6 @@
"create": "Crear",
"save": "Guardar",
"no_results": "Ningún archivo coincide con su búsqueda",
"batch_delete_confirm_message": "¿Está seguro de que desea eliminar {count, plural, one {1 elemento} other {# elementos}}? Esta acción no se puede deshacer.",
"batch_delete_success": "{count, plural, one {1 elemento eliminado} other {# elementos eliminados}}",
"grid_view": "Vista de cuadrícula",
"list_view": "Vista de lista",
"details": "Detalles",
+19 -5
View File
@@ -3879,8 +3879,6 @@
"name_control_chars": "Nimed ei tohi sisaldada juhtmärke.",
"name_too_long": "See nimi on serveri jaoks liiga pikk.",
"delete": "Kustuta",
"delete_confirm_title": "Kustuta ressurss",
"delete_confirm_message": "Kas oled kindel, et soovid kustutada \"{name}\"? Seda ei saa tagasi võtta.",
"download": "Laadi alla",
"name": "Nimi",
"size": "Suurus",
@@ -3906,8 +3904,26 @@
"error_partial_copy": "Osa koopiast jäi alles.",
"create_folder_success": "Kaust loodud",
"create_folder_error": "Kausta loomine ebaõnnestus",
"delete_success": "Edukalt kustutatud",
"delete_error": "Kustutamine ebaõnnestus",
"trash": "Prügikast",
"restore": "Taasta",
"restored": "{count, plural, one {Üksus taastati} other {# üksust taastati}}",
"restore_error": "Taastamine ebaõnnestus",
"moved_to_trash": "{count, plural, one {Teisaldati prügikasti} other {# üksust teisaldati prügikasti}}",
"delete_forever": "Kustuta jäädavalt",
"delete_forever_title": "{count, plural, one {Kas kustutada jäädavalt?} other {Kas kustutada # üksust jäädavalt?}}",
"delete_forever_message": "„{name}“ kustutatakse. Seda ei saa tagasi võtta.",
"delete_forever_message_count": "{count, plural, one {Kustutatakse 1 üksus} other {Kustutatakse # üksust}}. Seda ei saa tagasi võtta.",
"deleted_forever": "{count, plural, one {Kustutati jäädavalt} other {# üksust kustutati jäädavalt}}",
"empty_trash": "Tühjenda prügikast",
"empty_trash_title": "Kas tühjendada prügikast?",
"empty_trash_message": "{count, plural, one {Prügikastis olev üksus} other {Kõik # prügikastis olevat üksust}} kustutatakse jäädavalt. Seda ei saa tagasi võtta.",
"trash_emptied": "Prügikast tühjendati",
"empty_trash_error": "Prügikasti ei õnnestunud tühjendada",
"trash_empty_title": "Prügikast on tühi",
"trash_empty_hint": "Kustutatud failid jäävad siia, kuni prügikasti tühjendate.",
"deleted": "Kustutatud",
"deleted_from": "Kustutatud kaustast {folder}",
"rename_success": "Edukalt ümber nimetatud",
"rename_error": "Ümbernimetamine ebaõnnestus",
"download_error": "Allalaadimine ebaõnnestus",
@@ -3916,8 +3932,6 @@
"create": "Loo",
"save": "Salvesta",
"no_results": "Otsingule vastavaid faile ei leitud",
"batch_delete_confirm_message": "Kas oled kindel, et soovid kustutada {count, plural, one {1 üksuse} other {# üksust}}? Seda ei saa tagasi võtta.",
"batch_delete_success": "{count, plural, one {1 üksus kustutatud} other {# üksust kustutatud}}",
"grid_view": "Ruudustikvaade",
"list_view": "Loendivaade",
"details": "Üksikasjad",
+19 -5
View File
@@ -3879,8 +3879,6 @@
"name_control_chars": "نام‌ها نمی‌توانند نویسه‌های کنترلی داشته باشند.",
"name_too_long": "این نام برای سرور بیش از حد طولانی است.",
"delete": "حذف",
"delete_confirm_title": "حذف منبع",
"delete_confirm_message": "آیا از حذف \"{name}\" اطمینان دارید؟ این عملیات قابل بازگشت نیست.",
"download": "دانلود",
"name": "نام",
"size": "حجم",
@@ -3906,8 +3904,26 @@
"error_partial_copy": "بخشی از رونوشت در جای خود باقی ماند.",
"create_folder_success": "پوشه ایجاد شد",
"create_folder_error": "ایجاد پوشه ناموفق بود",
"delete_success": "با موفقیت حذف شد",
"delete_error": "حذف ناموفق بود",
"trash": "سطل زباله",
"restore": "بازیابی",
"restored": "{count, plural, one {مورد بازیابی شد} other {# مورد بازیابی شد}}",
"restore_error": "بازیابی انجام نشد",
"moved_to_trash": "{count, plural, one {به سطل زباله منتقل شد} other {# مورد به سطل زباله منتقل شد}}",
"delete_forever": "حذف دائمی",
"delete_forever_title": "{count, plural, one {برای همیشه حذف شود؟} other {# مورد برای همیشه حذف شود؟}}",
"delete_forever_message": "«{name}» حذف می‌شود. این کار قابل بازگشت نیست.",
"delete_forever_message_count": "{count, plural, one {۱ مورد حذف می‌شود} other {# مورد حذف می‌شود}}. این کار قابل بازگشت نیست.",
"deleted_forever": "{count, plural, one {برای همیشه حذف شد} other {# مورد برای همیشه حذف شد}}",
"empty_trash": "خالی کردن سطل زباله",
"empty_trash_title": "سطل زباله خالی شود؟",
"empty_trash_message": "{count, plural, one {مورد داخل سطل زباله} other {همه # مورد داخل سطل زباله}} برای همیشه حذف می‌شود. این کار قابل بازگشت نیست.",
"trash_emptied": "سطل زباله خالی شد",
"empty_trash_error": "سطل زباله خالی نشد",
"trash_empty_title": "سطل زباله خالی است",
"trash_empty_hint": "فایل‌های حذف‌شده تا زمانی که سطل زباله را خالی کنید اینجا می‌مانند.",
"deleted": "حذف‌شده",
"deleted_from": "حذف‌شده از {folder}",
"rename_success": "تغییر نام با موفقیت انجام شد",
"rename_error": "تغییر نام ناموفق بود",
"download_error": "دانلود ناموفق بود",
@@ -3916,8 +3932,6 @@
"create": "ایجاد",
"save": "ذخیره",
"no_results": "فایلی با این جستجو مطابقت ندارد",
"batch_delete_confirm_message": "آیا از حذف {count, plural, one {۱ مورد} other {# مورد}} اطمینان دارید؟",
"batch_delete_success": "{count, plural, one {۱ مورد حذف شد} other {# مورد حذف شد}}",
"grid_view": "نمای شبکه‌ای",
"list_view": "نمای لیست",
"details": "جزئیات",
+19 -5
View File
@@ -3833,8 +3833,6 @@
"name_control_chars": "Les noms ne peuvent pas contenir de caractères de contrôle.",
"name_too_long": "Ce nom est trop long pour le serveur.",
"delete": "Supprimer",
"delete_confirm_title": "Supprimer la ressource",
"delete_confirm_message": "Êtes-vous sûr de vouloir supprimer \"{name}\" ? Cette action est irréversible.",
"download": "Télécharger",
"name": "Nom",
"size": "Taille",
@@ -3859,8 +3857,26 @@
"error_partial_copy": "Une partie de la copie est restée en place.",
"create_folder_success": "Dossier créé",
"create_folder_error": "Échec de la création du dossier",
"delete_success": "Supprimé avec succès",
"delete_error": "Échec de la suppression",
"trash": "Corbeille",
"restore": "Restaurer",
"restored": "{count, plural, one {Élément restauré} other {# éléments restaurés}}",
"restore_error": "Impossible de restaurer",
"moved_to_trash": "{count, plural, one {Placé dans la corbeille} other {# éléments placés dans la corbeille}}",
"delete_forever": "Supprimer définitivement",
"delete_forever_title": "{count, plural, one {Supprimer définitivement ?} other {Supprimer définitivement # éléments ?}}",
"delete_forever_message": "« {name} » sera supprimé. Cette action est irréversible.",
"delete_forever_message_count": "{count, plural, one {1 élément sera supprimé} other {# éléments seront supprimés}}. Cette action est irréversible.",
"deleted_forever": "{count, plural, one {Supprimé définitivement} other {# éléments supprimés définitivement}}",
"empty_trash": "Vider la corbeille",
"empty_trash_title": "Vider la corbeille ?",
"empty_trash_message": "{count, plural, one {L'élément de la corbeille sera supprimé} other {Les # éléments de la corbeille seront supprimés}} définitivement. Cette action est irréversible.",
"trash_emptied": "Corbeille vidée",
"empty_trash_error": "Impossible de vider la corbeille",
"trash_empty_title": "La corbeille est vide",
"trash_empty_hint": "Les fichiers supprimés restent ici jusqu'à ce que vous vidiez la corbeille.",
"deleted": "Supprimé le",
"deleted_from": "Supprimé de {folder}",
"rename_success": "Renommé avec succès",
"rename_error": "Échec du renommage",
"download_error": "Échec du téléchargement",
@@ -3869,8 +3885,6 @@
"create": "Créer",
"save": "Enregistrer",
"no_results": "Aucun fichier ne correspond à votre recherche",
"batch_delete_confirm_message": "Êtes-vous sûr de vouloir supprimer {count, plural, one {1 élément} other {# éléments}} ? Cette action est irréversible.",
"batch_delete_success": "{count, plural, one {1 élément supprimé} other {# éléments supprimés}}",
"grid_view": "Vue en grille",
"list_view": "Vue en liste",
"details": "Détails",
+19 -5
View File
@@ -3750,8 +3750,6 @@
"name_control_chars": "שמות אינם יכולים להכיל תווי בקרה.",
"name_too_long": "השם הזה ארוך מדי עבור השרת.",
"delete": "לִמְחוֹק",
"delete_confirm_title": "מחק משאב",
"delete_confirm_message": "האם אתה בטוח שברצונך למחוק את \"{name}\"? לא ניתן לבטל זאת.",
"download": "הורד",
"name": "שֵׁם",
"size": "גודל",
@@ -3776,8 +3774,26 @@
"error_partial_copy": "חלק מההעתק נשאר במקומו.",
"create_folder_success": "התיקיה נוצרה",
"create_folder_error": "יצירת התיקיה נכשלה",
"delete_success": "נמחק בהצלחה",
"delete_error": "המחיקה נכשלה",
"trash": "אשפה",
"restore": "שחזר",
"restored": "{count, plural, one {הפריט שוחזר} other {# פריטים שוחזרו}}",
"restore_error": "לא ניתן היה לשחזר",
"moved_to_trash": "{count, plural, one {הועבר לאשפה} other {# פריטים הועברו לאשפה}}",
"delete_forever": "מחק לצמיתות",
"delete_forever_title": "{count, plural, one {למחוק לצמיתות?} other {למחוק # פריטים לצמיתות?}}",
"delete_forever_message": "\"{name}\" יימחק. לא ניתן לבטל פעולה זו.",
"delete_forever_message_count": "{count, plural, one {פריט אחד יימחק} other {# פריטים יימחקו}}. לא ניתן לבטל פעולה זו.",
"deleted_forever": "{count, plural, one {נמחק לצמיתות} other {# פריטים נמחקו לצמיתות}}",
"empty_trash": "רוקן את האשפה",
"empty_trash_title": "לרוקן את האשפה?",
"empty_trash_message": "{count, plural, one {הפריט באשפה יימחק} other {כל # הפריטים באשפה יימחקו}} לצמיתות. לא ניתן לבטל פעולה זו.",
"trash_emptied": "האשפה רוקנה",
"empty_trash_error": "לא ניתן היה לרוקן את האשפה",
"trash_empty_title": "האשפה ריקה",
"trash_empty_hint": "קבצים שנמחקו נשארים כאן עד שתרוקן את האשפה.",
"deleted": "נמחק",
"deleted_from": "נמחק מ-{folder}",
"rename_success": "השם שונה בהצלחה",
"rename_error": "שינוי השם נכשל",
"download_error": "ההורדה נכשלה",
@@ -3786,8 +3802,6 @@
"create": "צור",
"save": "שמור",
"no_results": "אין קבצים שתואמים לחיפוש שלך",
"batch_delete_confirm_message": "האם למחוק {count, plural, one {פריט אחד} other {# פריטים}}? לא ניתן לבטל זאת.",
"batch_delete_success": "{count, plural, one {פריט אחד נמחק} other {# פריטים נמחקו}}",
"grid_view": "תצוגת רשת",
"list_view": "תצוגת רשימה",
"details": "פרטים",
+19 -5
View File
@@ -3879,8 +3879,6 @@
"name_control_chars": "A nevek nem tartalmazhatnak vezérlőkaraktereket.",
"name_too_long": "Ez a név túl hosszú a kiszolgáló számára.",
"delete": "Törlés",
"delete_confirm_title": "Erőforrás törlése",
"delete_confirm_message": "Biztosan törölni szeretnéd a \"{name}\" elemet? Ez nem vonható vissza.",
"download": "Letöltés",
"name": "Név",
"size": "Méret",
@@ -3906,8 +3904,26 @@
"error_partial_copy": "A másolat egy része a helyén maradt.",
"create_folder_success": "Mappa létrehozva",
"create_folder_error": "Nem sikerült létrehozni a mappát",
"delete_success": "Sikeresen törölve",
"delete_error": "Nem sikerült törölni",
"trash": "Kuka",
"restore": "Visszaállítás",
"restored": "{count, plural, one {Elem visszaállítva} other {# elem visszaállítva}}",
"restore_error": "Nem sikerült visszaállítani",
"moved_to_trash": "{count, plural, one {Áthelyezve a kukába} other {# elem áthelyezve a kukába}}",
"delete_forever": "Végleges törlés",
"delete_forever_title": "{count, plural, one {Véglegesen törli?} other {Véglegesen törli a(z) # elemet?}}",
"delete_forever_message": "A(z) „{name}” törlődik. Ez nem vonható vissza.",
"delete_forever_message_count": "{count, plural, one {1 elem törlődik} other {# elem törlődik}}. Ez nem vonható vissza.",
"deleted_forever": "{count, plural, one {Véglegesen törölve} other {# elem véglegesen törölve}}",
"empty_trash": "Kuka ürítése",
"empty_trash_title": "Kiüríti a kukát?",
"empty_trash_message": "{count, plural, one {A kukában lévő elem} other {A kukában lévő mind a(z) # elem}} véglegesen törlődik. Ez nem vonható vissza.",
"trash_emptied": "Kuka kiürítve",
"empty_trash_error": "Nem sikerült kiüríteni a kukát",
"trash_empty_title": "A kuka üres",
"trash_empty_hint": "A törölt fájlok itt maradnak, amíg ki nem üríti a kukát.",
"deleted": "Törölve",
"deleted_from": "Törölve innen: {folder}",
"rename_success": "Sikeresen átnevezve",
"rename_error": "Nem sikerült átnevezni",
"download_error": "Nem sikerült letölteni",
@@ -3916,8 +3932,6 @@
"create": "Létrehozás",
"save": "Mentés",
"no_results": "Nincs a keresésnek megfelelő fájl",
"batch_delete_confirm_message": "Biztosan törölni szeretnéd {count, plural, one {az 1 elemet} other {a # elemet}}? Ez nem vonható vissza.",
"batch_delete_success": "{count, plural, one {1 elem törölve} other {# elem törölve}}",
"grid_view": "Rács nézet",
"list_view": "Lista nézet",
"details": "Részletek",
+19 -5
View File
@@ -3833,8 +3833,6 @@
"name_control_chars": "I nomi non possono contenere caratteri di controllo.",
"name_too_long": "Questo nome è troppo lungo per il server.",
"delete": "Elimina",
"delete_confirm_title": "Elimina risorsa",
"delete_confirm_message": "Sei sicuro di voler eliminare \"{name}\"? Questa azione non può essere annullata.",
"download": "Scarica",
"name": "Nome",
"size": "Dimensione",
@@ -3859,8 +3857,26 @@
"error_partial_copy": "Una parte della copia è rimasta al suo posto.",
"create_folder_success": "Cartella creata",
"create_folder_error": "Creazione della cartella non riuscita",
"delete_success": "Eliminato con successo",
"delete_error": "Eliminazione non riuscita",
"trash": "Cestino",
"restore": "Ripristina",
"restored": "{count, plural, one {Elemento ripristinato} other {# elementi ripristinati}}",
"restore_error": "Impossibile ripristinare",
"moved_to_trash": "{count, plural, one {Spostato nel cestino} other {# elementi spostati nel cestino}}",
"delete_forever": "Elimina definitivamente",
"delete_forever_title": "{count, plural, one {Eliminare definitivamente?} other {Eliminare definitivamente # elementi?}}",
"delete_forever_message": "«{name}» verrà eliminato. L'operazione non può essere annullata.",
"delete_forever_message_count": "{count, plural, one {Verrà eliminato 1 elemento} other {Verranno eliminati # elementi}}. L'operazione non può essere annullata.",
"deleted_forever": "{count, plural, one {Eliminato definitivamente} other {# elementi eliminati definitivamente}}",
"empty_trash": "Svuota il cestino",
"empty_trash_title": "Svuotare il cestino?",
"empty_trash_message": "{count, plural, one {L'elemento nel cestino verrà eliminato} other {Tutti i # elementi nel cestino verranno eliminati}} definitivamente. L'operazione non può essere annullata.",
"trash_emptied": "Cestino svuotato",
"empty_trash_error": "Impossibile svuotare il cestino",
"trash_empty_title": "Il cestino è vuoto",
"trash_empty_hint": "I file eliminati restano qui finché non svuoti il cestino.",
"deleted": "Eliminato",
"deleted_from": "Eliminato da {folder}",
"rename_success": "Rinominato con successo",
"rename_error": "Rinominazione non riuscita",
"download_error": "Download non riuscito",
@@ -3869,8 +3885,6 @@
"create": "Crea",
"save": "Salva",
"no_results": "Nessun file corrisponde alla ricerca",
"batch_delete_confirm_message": "Sei sicuro di voler eliminare {count, plural, one {1 elemento} other {# elementi}}? Questa azione non può essere annullata.",
"batch_delete_success": "{count, plural, one {1 elemento eliminato} other {# elementi eliminati}}",
"grid_view": "Vista a griglia",
"list_view": "Vista a elenco",
"details": "Dettagli",
+19 -5
View File
@@ -3833,8 +3833,6 @@
"name_control_chars": "名前に制御文字は使用できません。",
"name_too_long": "この名前はサーバーには長すぎます。",
"delete": "削除",
"delete_confirm_title": "リソースを削除",
"delete_confirm_message": "\"{name}\"を削除してもよろしいですか?この操作は元に戻せません。",
"download": "ダウンロード",
"name": "名前",
"size": "サイズ",
@@ -3859,8 +3857,26 @@
"error_partial_copy": "コピーの一部は削除されずに残っています。",
"create_folder_success": "フォルダーを作成しました",
"create_folder_error": "フォルダーの作成に失敗しました",
"delete_success": "正常に削除しました",
"delete_error": "削除に失敗しました",
"trash": "ゴミ箱",
"restore": "復元",
"restored": "{count, plural, other {#件のアイテムを復元しました}}",
"restore_error": "復元できませんでした",
"moved_to_trash": "{count, plural, other {#件のアイテムをゴミ箱に移動しました}}",
"delete_forever": "完全に削除",
"delete_forever_title": "{count, plural, other {#件のアイテムを完全に削除しますか?}}",
"delete_forever_message": "「{name}」は削除されます。この操作は元に戻せません。",
"delete_forever_message_count": "{count, plural, other {#件のアイテムが削除されます}}。この操作は元に戻せません。",
"deleted_forever": "{count, plural, other {#件のアイテムを完全に削除しました}}",
"empty_trash": "ゴミ箱を空にする",
"empty_trash_title": "ゴミ箱を空にしますか?",
"empty_trash_message": "{count, plural, other {ゴミ箱内の#件のアイテムがすべて完全に削除されます}}。この操作は元に戻せません。",
"trash_emptied": "ゴミ箱を空にしました",
"empty_trash_error": "ゴミ箱を空にできませんでした",
"trash_empty_title": "ゴミ箱は空です",
"trash_empty_hint": "削除したファイルはゴミ箱を空にするまでここに残ります。",
"deleted": "削除日",
"deleted_from": "{folder} から削除",
"rename_success": "正常に名前を変更しました",
"rename_error": "名前の変更に失敗しました",
"download_error": "ダウンロードに失敗しました",
@@ -3869,8 +3885,6 @@
"create": "作成",
"save": "保存",
"no_results": "検索に一致するファイルがありません",
"batch_delete_confirm_message": "{count, plural, other {#件のアイテム}}を削除してもよろしいですか?この操作は元に戻せません。",
"batch_delete_success": "{count, plural, other {#件のアイテムを削除しました}}",
"grid_view": "グリッド表示",
"list_view": "リスト表示",
"details": "詳細",
+19 -5
View File
@@ -3833,8 +3833,6 @@
"name_control_chars": "이름에 제어 문자를 사용할 수 없습니다.",
"name_too_long": "이 이름은 서버에서 사용하기에 너무 깁니다.",
"delete": "삭제",
"delete_confirm_title": "항목 삭제",
"delete_confirm_message": "정말 \"{name}\"을(를) 삭제할까요? 이 작업은 되돌릴 수 없어요.",
"download": "다운로드",
"name": "이름",
"size": "크기",
@@ -3859,8 +3857,26 @@
"error_partial_copy": "복사본의 일부가 그대로 남아 있어요.",
"create_folder_success": "폴더가 만들어졌어요",
"create_folder_error": "폴더를 만들지 못했어요",
"delete_success": "성공적으로 삭제되었어요",
"delete_error": "삭제에 실패했어요",
"trash": "휴지통",
"restore": "복원",
"restored": "{count, plural, other {항목 #개를 복원했습니다}}",
"restore_error": "복원하지 못했습니다",
"moved_to_trash": "{count, plural, other {항목 #개를 휴지통으로 이동했습니다}}",
"delete_forever": "영구 삭제",
"delete_forever_title": "{count, plural, other {항목 #개를 영구 삭제할까요?}}",
"delete_forever_message": "\"{name}\" 항목이 삭제됩니다. 되돌릴 수 없습니다.",
"delete_forever_message_count": "{count, plural, other {항목 #개가 삭제됩니다}}. 되돌릴 수 없습니다.",
"deleted_forever": "{count, plural, other {항목 #개를 영구 삭제했습니다}}",
"empty_trash": "휴지통 비우기",
"empty_trash_title": "휴지통을 비울까요?",
"empty_trash_message": "{count, plural, other {휴지통의 항목 #개가 모두 영구 삭제됩니다}}. 되돌릴 수 없습니다.",
"trash_emptied": "휴지통을 비웠습니다",
"empty_trash_error": "휴지통을 비우지 못했습니다",
"trash_empty_title": "휴지통이 비어 있습니다",
"trash_empty_hint": "삭제한 파일은 휴지통을 비울 때까지 여기에 남아 있습니다.",
"deleted": "삭제한 날짜",
"deleted_from": "{folder}에서 삭제됨",
"rename_success": "이름이 변경되었어요",
"rename_error": "이름을 바꾸지 못했어요",
"download_error": "다운로드에 실패했어요",
@@ -3869,8 +3885,6 @@
"create": "만들기",
"save": "저장",
"no_results": "검색된 파일이 없어요",
"batch_delete_confirm_message": "선택한 {count}개의 항목을 정말 삭제할까요? 이 작업은 되돌릴 수 없어요.",
"batch_delete_success": "{count}개의 항목이 삭제되었어요",
"grid_view": "그리드 뷰",
"list_view": "리스트 뷰",
"details": "세부 정보",
+19 -5
View File
@@ -3833,8 +3833,6 @@
"name_control_chars": "Nosaukumos nedrīkst būt vadības rakstzīmes.",
"name_too_long": "Šis nosaukums serverim ir pārāk garš.",
"delete": "Dzēst",
"delete_confirm_title": "Dzēst resursu",
"delete_confirm_message": "Vai tiešām vēlaties dzēst «{name}»? Šo darbību nevar atcelt.",
"download": "Lejupielādēt",
"name": "Nosaukums",
"size": "Izmērs",
@@ -3859,8 +3857,26 @@
"error_partial_copy": "Daļa kopijas palika savā vietā.",
"create_folder_success": "Mape izveidota",
"create_folder_error": "Neizdevās izveidot mapi",
"delete_success": "Sekmīgi izdzēsts",
"delete_error": "Neizdevās izdzēst",
"trash": "Miskaste",
"restore": "Atjaunot",
"restored": "{count, plural, one {Vienums atjaunots} other {Atjaunoti # vienumi}}",
"restore_error": "Neizdevās atjaunot",
"moved_to_trash": "{count, plural, one {Pārvietots uz miskasti} other {# vienumi pārvietoti uz miskasti}}",
"delete_forever": "Dzēst neatgriezeniski",
"delete_forever_title": "{count, plural, one {Dzēst neatgriezeniski?} other {Dzēst # vienumus neatgriezeniski?}}",
"delete_forever_message": "“{name}” tiks dzēsts. To nevar atsaukt.",
"delete_forever_message_count": "{count, plural, one {Tiks dzēsts 1 vienums} other {Tiks dzēsti # vienumi}}. To nevar atsaukt.",
"deleted_forever": "{count, plural, one {Dzēsts neatgriezeniski} other {# vienumi dzēsti neatgriezeniski}}",
"empty_trash": "Iztukšot miskasti",
"empty_trash_title": "Iztukšot miskasti?",
"empty_trash_message": "{count, plural, one {Miskastē esošais vienums tiks dzēsts} other {Visi # miskastē esošie vienumi tiks dzēsti}} neatgriezeniski. To nevar atsaukt.",
"trash_emptied": "Miskaste iztukšota",
"empty_trash_error": "Neizdevās iztukšot miskasti",
"trash_empty_title": "Miskaste ir tukša",
"trash_empty_hint": "Dzēstie faili paliek šeit, līdz iztukšojat miskasti.",
"deleted": "Dzēsts",
"deleted_from": "Dzēsts no {folder}",
"rename_success": "Sekmīgi pārdēvēts",
"rename_error": "Neizdevās pārdēvēt",
"download_error": "Neizdevās lejupielādēt",
@@ -3869,8 +3885,6 @@
"create": "Izveidot",
"save": "Saglabāt",
"no_results": "Nav failu, kas atbilstu vaicājumam",
"batch_delete_confirm_message": "Vai tiešām vēlaties dzēst {count, plural, one {1 elementu} other {# elementus}}?",
"batch_delete_success": "Izdzēsts {count, plural, one {1 elements} other {# elementi}}",
"grid_view": "Režģis",
"list_view": "Saraksts",
"details": "Sīkāka informācija",
+19 -5
View File
@@ -3879,8 +3879,6 @@
"name_control_chars": "Нэрэнд удирдах тэмдэгт агуулж болохгүй.",
"name_too_long": "Энэ нэр серверийн хувьд хэт урт байна.",
"delete": "Устгах",
"delete_confirm_title": "Нөөцийг устгах",
"delete_confirm_message": "Та \"{name}\"-г устгахдаа итгэлтэй байна уу? Үүнийг буцаах боломжгүй.",
"download": "Татаж авах",
"name": "Нэр",
"size": "Хэмжээ",
@@ -3906,8 +3904,26 @@
"error_partial_copy": "Хуулбарын нэг хэсэг хэвээр үлдлээ.",
"create_folder_success": "Хавтас үүсгэсэн",
"create_folder_error": "Хавтас үүсгэж чадсангүй",
"delete_success": "Амжилттай устгасан",
"delete_error": "Устгаж чадсангүй",
"trash": "Хогийн сав",
"restore": "Сэргээх",
"restored": "{count, plural, one {Зүйлийг сэргээлээ} other {# зүйлийг сэргээлээ}}",
"restore_error": "Сэргээж чадсангүй",
"moved_to_trash": "{count, plural, one {Хогийн сав руу зөөлөө} other {# зүйлийг хогийн сав руу зөөлөө}}",
"delete_forever": "Бүрмөсөн устгах",
"delete_forever_title": "{count, plural, one {Бүрмөсөн устгах уу?} other {# зүйлийг бүрмөсөн устгах уу?}}",
"delete_forever_message": "\"{name}\" устгагдана. Үүнийг буцаах боломжгүй.",
"delete_forever_message_count": "{count, plural, one {1 зүйл устгагдана} other {# зүйл устгагдана}}. Үүнийг буцаах боломжгүй.",
"deleted_forever": "{count, plural, one {Бүрмөсөн устгалаа} other {# зүйлийг бүрмөсөн устгалаа}}",
"empty_trash": "Хогийн савыг хоослох",
"empty_trash_title": "Хогийн савыг хоослох уу?",
"empty_trash_message": "{count, plural, one {Хогийн саван дахь зүйл} other {Хогийн саван дахь бүх # зүйл}} бүрмөсөн устгагдана. Үүнийг буцаах боломжгүй.",
"trash_emptied": "Хогийн савыг хоосоллоо",
"empty_trash_error": "Хогийн савыг хоосолж чадсангүй",
"trash_empty_title": "Хогийн сав хоосон байна",
"trash_empty_hint": "Устгасан файлууд хогийн савыг хоослох хүртэл энд хадгалагдана.",
"deleted": "Устгасан",
"deleted_from": "{folder}-с устгасан",
"rename_success": "Нэрээ амжилттай өөрчилсөн",
"rename_error": "Нэрээ өөрчилж чадсангүй",
"download_error": "Татаж авч чадсангүй",
@@ -3916,8 +3932,6 @@
"create": "Үүсгэх",
"save": "Хадгалах",
"no_results": "Таны хайлтад тохирох файл алга",
"batch_delete_confirm_message": "{count, plural, one {1 элемент} other {# элемент}} устгахыг баталгаажуулж байна уу? Энэ үйлдлийг буцаах боломжгүй.",
"batch_delete_success": "{count, plural, one {1 элемент устгагдлаа} other {# элемент устгагдлаа}}",
"grid_view": "Сүлжээ харах",
"list_view": "Жагсаалт харах",
"details": "Дэлгэрэнгүй мэдээлэл",
+19 -5
View File
@@ -3879,8 +3879,6 @@
"name_control_chars": "Navn kan ikke inneholde kontrolltegn.",
"name_too_long": "Dette navnet er for langt for serveren.",
"delete": "Slett",
"delete_confirm_title": "Slett fil eller mappe",
"delete_confirm_message": "Er du sikker på at du vil slette «{name}»? Dette kan ikke angres.",
"download": "Last ned",
"name": "Navn",
"size": "Størrelse",
@@ -3906,8 +3904,26 @@
"error_partial_copy": "En del av kopien ble liggende igjen.",
"create_folder_success": "Mappen er opprettet",
"create_folder_error": "Kunne ikke opprette mappe",
"delete_success": "Elementet er slettet",
"delete_error": "Kunne ikke slette elementet",
"trash": "Papirkurv",
"restore": "Gjenopprett",
"restored": "{count, plural, one {Elementet er gjenopprettet} other {# elementer er gjenopprettet}}",
"restore_error": "Kunne ikke gjenopprette",
"moved_to_trash": "{count, plural, one {Flyttet til papirkurven} other {# elementer flyttet til papirkurven}}",
"delete_forever": "Slett permanent",
"delete_forever_title": "{count, plural, one {Vil du slette permanent?} other {Vil du slette # elementer permanent?}}",
"delete_forever_message": "«{name}» blir slettet. Dette kan ikke angres.",
"delete_forever_message_count": "{count, plural, one {1 element blir slettet} other {# elementer blir slettet}}. Dette kan ikke angres.",
"deleted_forever": "{count, plural, one {Slettet permanent} other {# elementer slettet permanent}}",
"empty_trash": "Tøm papirkurven",
"empty_trash_title": "Vil du tømme papirkurven?",
"empty_trash_message": "{count, plural, one {Elementet i papirkurven} other {Alle # elementer i papirkurven}} blir slettet permanent. Dette kan ikke angres.",
"trash_emptied": "Papirkurven er tømt",
"empty_trash_error": "Kunne ikke tømme papirkurven",
"trash_empty_title": "Papirkurven er tom",
"trash_empty_hint": "Slettede filer blir liggende her til du tømmer papirkurven.",
"deleted": "Slettet",
"deleted_from": "Slettet fra {folder}",
"rename_success": "Navnet er endret",
"rename_error": "Kunne ikke endre navnet",
"download_error": "Kunne ikke laste ned",
@@ -3916,8 +3932,6 @@
"create": "Opprett",
"save": "Lagre",
"no_results": "Ingen filer passer med søket ditt",
"batch_delete_confirm_message": "Er du sikker på at du vil slette {count, plural, one {1 element} other {# elementer}}? Dette kan ikke angres.",
"batch_delete_success": "{count, plural, one {1 element slettet} other {# elementer slettet}}",
"grid_view": "Rutenettvisning",
"list_view": "Listevisning",
"details": "Detaljer",
+19 -5
View File
@@ -3833,8 +3833,6 @@
"name_control_chars": "Namen mogen geen besturingstekens bevatten.",
"name_too_long": "Deze naam is te lang voor de server.",
"delete": "Verwijderen",
"delete_confirm_title": "Bron verwijderen",
"delete_confirm_message": "Weet u zeker dat u \"{name}\" wilt verwijderen? Dit kan niet ongedaan worden gemaakt.",
"download": "Downloaden",
"name": "Naam",
"size": "Grootte",
@@ -3859,8 +3857,26 @@
"error_partial_copy": "Een deel van de kopie is blijven staan.",
"create_folder_success": "Map aangemaakt",
"create_folder_error": "Map aanmaken mislukt",
"delete_success": "Succesvol verwijderd",
"delete_error": "Verwijderen mislukt",
"trash": "Prullenbak",
"restore": "Herstellen",
"restored": "{count, plural, one {Item hersteld} other {# items hersteld}}",
"restore_error": "Kan niet herstellen",
"moved_to_trash": "{count, plural, one {Naar de prullenbak verplaatst} other {# items naar de prullenbak verplaatst}}",
"delete_forever": "Definitief verwijderen",
"delete_forever_title": "{count, plural, one {Definitief verwijderen?} other {# items definitief verwijderen?}}",
"delete_forever_message": "'{name}' wordt verwijderd. Dit kan niet ongedaan worden gemaakt.",
"delete_forever_message_count": "{count, plural, one {1 item wordt verwijderd} other {# items worden verwijderd}}. Dit kan niet ongedaan worden gemaakt.",
"deleted_forever": "{count, plural, one {Definitief verwijderd} other {# items definitief verwijderd}}",
"empty_trash": "Prullenbak legen",
"empty_trash_title": "Prullenbak legen?",
"empty_trash_message": "{count, plural, one {Het item in de prullenbak wordt} other {Alle # items in de prullenbak worden}} definitief verwijderd. Dit kan niet ongedaan worden gemaakt.",
"trash_emptied": "Prullenbak geleegd",
"empty_trash_error": "Kan de prullenbak niet legen",
"trash_empty_title": "De prullenbak is leeg",
"trash_empty_hint": "Verwijderde bestanden blijven hier tot je de prullenbak leegt.",
"deleted": "Verwijderd",
"deleted_from": "Verwijderd uit {folder}",
"rename_success": "Succesvol hernoemd",
"rename_error": "Hernoemen mislukt",
"download_error": "Downloaden mislukt",
@@ -3869,8 +3885,6 @@
"create": "Aanmaken",
"save": "Opslaan",
"no_results": "Geen bestanden komen overeen met uw zoekopdracht",
"batch_delete_confirm_message": "Weet u zeker dat u {count, plural, one {1 item} other {# items}} wilt verwijderen? Dit kan niet ongedaan worden gemaakt.",
"batch_delete_success": "{count, plural, one {1 item verwijderd} other {# items verwijderd}}",
"grid_view": "Rasterweergave",
"list_view": "Lijstweergave",
"details": "Details",
+19 -5
View File
@@ -3833,8 +3833,6 @@
"name_control_chars": "Nazwy nie mogą zawierać znaków sterujących.",
"name_too_long": "Ta nazwa jest zbyt długa dla serwera.",
"delete": "Usuń",
"delete_confirm_title": "Usuń zasób",
"delete_confirm_message": "Czy na pewno chcesz usunąć \"{name}\"? Tej operacji nie można cofnąć.",
"download": "Pobierz",
"name": "Nazwa",
"size": "Rozmiar",
@@ -3859,8 +3857,26 @@
"error_partial_copy": "Część kopii pozostała na miejscu.",
"create_folder_success": "Folder utworzono",
"create_folder_error": "Nie udało się utworzyć folderu",
"delete_success": "Usunięto pomyślnie",
"delete_error": "Nie udało się usunąć",
"trash": "Kosz",
"restore": "Przywróć",
"restored": "{count, plural, one {Przywrócono element} few {Przywrócono # elementy} other {Przywrócono # elementów}}",
"restore_error": "Nie udało się przywrócić",
"moved_to_trash": "{count, plural, one {Przeniesiono do kosza} few {Przeniesiono # elementy do kosza} other {Przeniesiono # elementów do kosza}}",
"delete_forever": "Usuń trwale",
"delete_forever_title": "{count, plural, one {Usunąć trwale?} few {Usunąć trwale # elementy?} other {Usunąć trwale # elementów?}}",
"delete_forever_message": "„{name}” zostanie usunięty. Tej operacji nie można cofnąć.",
"delete_forever_message_count": "{count, plural, one {Zostanie usunięty 1 element} few {Zostaną usunięte # elementy} other {Zostanie usuniętych # elementów}}. Tej operacji nie można cofnąć.",
"deleted_forever": "{count, plural, one {Usunięto trwale} few {Usunięto trwale # elementy} other {Usunięto trwale # elementów}}",
"empty_trash": "Opróżnij kosz",
"empty_trash_title": "Opróżnić kosz?",
"empty_trash_message": "{count, plural, one {Element w koszu zostanie usunięty} few {Wszystkie # elementy w koszu zostaną usunięte} other {Wszystkie # elementów w koszu zostanie usuniętych}} trwale. Tej operacji nie można cofnąć.",
"trash_emptied": "Kosz opróżniony",
"empty_trash_error": "Nie udało się opróżnić kosza",
"trash_empty_title": "Kosz jest pusty",
"trash_empty_hint": "Usunięte pliki pozostają tutaj, dopóki nie opróżnisz kosza.",
"deleted": "Usunięto",
"deleted_from": "Usunięto z {folder}",
"rename_success": "Nazwę zmieniono pomyślnie",
"rename_error": "Nie udało się zmienić nazwy",
"download_error": "Nie udało się pobrać",
@@ -3869,8 +3885,6 @@
"create": "Utwórz",
"save": "Zapisz",
"no_results": "Żadne pliki nie pasują do wyszukiwania",
"batch_delete_confirm_message": "Czy na pewno chcesz usunąć {count, plural, one {1 element} other {# elementów}}? Tej operacji nie można cofnąć.",
"batch_delete_success": "{count, plural, one {Usunięto 1 element} other {Usunięto # elementów}}",
"grid_view": "Widok siatki",
"list_view": "Widok listy",
"details": "Szczegóły",
+19 -5
View File
@@ -3833,8 +3833,6 @@
"name_control_chars": "Os nomes não podem conter caracteres de controle.",
"name_too_long": "Este nome é longo demais para o servidor.",
"delete": "Eliminar",
"delete_confirm_title": "Eliminar recurso",
"delete_confirm_message": "Tem a certeza de que deseja eliminar \"{name}\"? Esta ação não pode ser desfeita.",
"download": "Transferir",
"name": "Nome",
"size": "Tamanho",
@@ -3859,8 +3857,26 @@
"error_partial_copy": "Parte da cópia ficou no lugar.",
"create_folder_success": "Pasta criada",
"create_folder_error": "Falha ao criar a pasta",
"delete_success": "Eliminado com sucesso",
"delete_error": "Falha ao eliminar",
"trash": "Lixeira",
"restore": "Restaurar",
"restored": "{count, plural, one {Item restaurado} other {# itens restaurados}}",
"restore_error": "Não foi possível restaurar",
"moved_to_trash": "{count, plural, one {Movido para a lixeira} other {# itens movidos para a lixeira}}",
"delete_forever": "Excluir permanentemente",
"delete_forever_title": "{count, plural, one {Excluir permanentemente?} other {Excluir # itens permanentemente?}}",
"delete_forever_message": "\"{name}\" será excluído. Não é possível desfazer.",
"delete_forever_message_count": "{count, plural, one {1 item será excluído} other {# itens serão excluídos}}. Não é possível desfazer.",
"deleted_forever": "{count, plural, one {Excluído permanentemente} other {# itens excluídos permanentemente}}",
"empty_trash": "Esvaziar lixeira",
"empty_trash_title": "Esvaziar a lixeira?",
"empty_trash_message": "{count, plural, one {O item da lixeira será excluído} other {Todos os # itens da lixeira serão excluídos}} permanentemente. Não é possível desfazer.",
"trash_emptied": "Lixeira esvaziada",
"empty_trash_error": "Não foi possível esvaziar a lixeira",
"trash_empty_title": "A lixeira está vazia",
"trash_empty_hint": "Os arquivos excluídos ficam aqui até você esvaziar a lixeira.",
"deleted": "Excluído",
"deleted_from": "Excluído de {folder}",
"rename_success": "Renomeado com sucesso",
"rename_error": "Falha ao renomear",
"download_error": "Falha ao transferir",
@@ -3869,8 +3885,6 @@
"create": "Criar",
"save": "Guardar",
"no_results": "Nenhum arquivo corresponde à sua pesquisa",
"batch_delete_confirm_message": "Tem a certeza de que deseja eliminar {count, plural, one {1 item} other {# itens}}? Esta ação não pode ser desfeita.",
"batch_delete_success": "{count, plural, one {1 item eliminado} other {# itens eliminados}}",
"grid_view": "Vista em grelha",
"list_view": "Vista em lista",
"details": "Detalhes",
+19 -5
View File
@@ -3879,8 +3879,6 @@
"name_control_chars": "Numele nu pot conține caractere de control.",
"name_too_long": "Acest nume este prea lung pentru server.",
"delete": "Șterge",
"delete_confirm_title": "Ștergeți resursa",
"delete_confirm_message": "Sunteți sigur că doriți să ștergeți „{name}”? Această acțiune nu poate fi anulată.",
"download": "Descărcați",
"name": "Nume",
"size": "Dimensiune",
@@ -3906,8 +3904,26 @@
"error_partial_copy": "O parte din copie a rămas pe loc.",
"create_folder_success": "Folder creat",
"create_folder_error": "Nu s-a putut crea folderul",
"delete_success": "Șters cu succes",
"delete_error": "Nu s-a putut șterge",
"trash": "Coș de gunoi",
"restore": "Restaurați",
"restored": "{count, plural, one {Element restaurat} other {# elemente restaurate}}",
"restore_error": "Nu s-a putut restaura",
"moved_to_trash": "{count, plural, one {Mutat în coșul de gunoi} other {# elemente mutate în coșul de gunoi}}",
"delete_forever": "Ștergeți definitiv",
"delete_forever_title": "{count, plural, one {Ștergeți definitiv?} other {Ștergeți definitiv # elemente?}}",
"delete_forever_message": "„{name}” va fi șters. Acțiunea nu poate fi anulată.",
"delete_forever_message_count": "{count, plural, one {Va fi șters 1 element} other {Vor fi șterse # elemente}}. Acțiunea nu poate fi anulată.",
"deleted_forever": "{count, plural, one {Șters definitiv} other {# elemente șterse definitiv}}",
"empty_trash": "Goliți coșul de gunoi",
"empty_trash_title": "Goliți coșul de gunoi?",
"empty_trash_message": "{count, plural, one {Elementul din coșul de gunoi va fi șters} other {Toate cele # elemente din coșul de gunoi vor fi șterse}} definitiv. Acțiunea nu poate fi anulată.",
"trash_emptied": "Coșul de gunoi a fost golit",
"empty_trash_error": "Nu s-a putut goli coșul de gunoi",
"trash_empty_title": "Coșul de gunoi este gol",
"trash_empty_hint": "Fișierele șterse rămân aici până când goliți coșul de gunoi.",
"deleted": "Șters",
"deleted_from": "Șters din {folder}",
"rename_success": "Redenumit cu succes",
"rename_error": "Nu s-a putut redenumi",
"download_error": "Descărcarea a eșuat",
@@ -3916,8 +3932,6 @@
"create": "Creați",
"save": "Salvare",
"no_results": "Nu există fișiere care să corespundă căutării dvs.",
"batch_delete_confirm_message": "Sunteți sigur că doriți să ștergeți {count, plural, one {1 element} few {# elemente} other {# de elemente}}? Această acțiune nu poate fi anulată.",
"batch_delete_success": "{count, plural, one {1 element șters} few {# elemente șterse} other {# de elemente șterse}}",
"grid_view": "Vizualizare tabelară",
"list_view": "Vizualizare listă",
"details": "Detalii",
+19 -5
View File
@@ -3833,8 +3833,6 @@
"name_control_chars": "Имена не могут содержать управляющие символы.",
"name_too_long": "Это имя слишком длинное для сервера.",
"delete": "Удалить",
"delete_confirm_title": "Удалить ресурс",
"delete_confirm_message": "Вы уверены, что хотите удалить «{name}»? Это действие нельзя отменить.",
"download": "Скачать",
"name": "Имя",
"size": "Размер",
@@ -3859,8 +3857,26 @@
"error_partial_copy": "Часть копии осталась на месте.",
"create_folder_success": "Папка создана",
"create_folder_error": "Не удалось создать папку",
"delete_success": "Успешно удалено",
"delete_error": "Не удалось удалить",
"trash": "Корзина",
"restore": "Восстановить",
"restored": "{count, plural, one {Элемент восстановлен} few {Восстановлено # элемента} other {Восстановлено # элементов}}",
"restore_error": "Не удалось восстановить",
"moved_to_trash": "{count, plural, one {Перемещено в корзину} few {# элемента перемещено в корзину} other {# элементов перемещено в корзину}}",
"delete_forever": "Удалить навсегда",
"delete_forever_title": "{count, plural, one {Удалить навсегда?} few {Удалить # элемента навсегда?} other {Удалить # элементов навсегда?}}",
"delete_forever_message": "«{name}» будет удалён. Это действие нельзя отменить.",
"delete_forever_message_count": "{count, plural, one {Будет удалён 1 элемент} few {Будут удалены # элемента} other {Будут удалены # элементов}}. Это действие нельзя отменить.",
"deleted_forever": "{count, plural, one {Удалено навсегда} few {# элемента удалено навсегда} other {# элементов удалено навсегда}}",
"empty_trash": "Очистить корзину",
"empty_trash_title": "Очистить корзину?",
"empty_trash_message": "{count, plural, one {Элемент в корзине будет удалён} few {Все # элемента в корзине будут удалены} other {Все # элементов в корзине будут удалены}} навсегда. Это действие нельзя отменить.",
"trash_emptied": "Корзина очищена",
"empty_trash_error": "Не удалось очистить корзину",
"trash_empty_title": "Корзина пуста",
"trash_empty_hint": "Удалённые файлы хранятся здесь, пока вы не очистите корзину.",
"deleted": "Удалено",
"deleted_from": "Удалено из {folder}",
"rename_success": "Успешно переименовано",
"rename_error": "Не удалось переименовать",
"download_error": "Не удалось скачать",
@@ -3869,8 +3885,6 @@
"create": "Создать",
"save": "Сохранить",
"no_results": "Нет файлов, соответствующих вашему запросу",
"batch_delete_confirm_message": "Вы уверены, что хотите удалить {count, plural, one {1 элемент} other {# элементов}}? Это действие нельзя отменить.",
"batch_delete_success": "{count, plural, one {1 элемент удалён} other {# элементов удалено}}",
"grid_view": "Сеткой",
"list_view": "Списком",
"details": "Подробности",
+19 -5
View File
@@ -3879,8 +3879,6 @@
"name_control_chars": "Názvy nesmú obsahovať riadiace znaky.",
"name_too_long": "Tento názov je pre server príliš dlhý.",
"delete": "Odstrániť",
"delete_confirm_title": "Odstrániť položku",
"delete_confirm_message": "Naozaj chcete odstrániť \"{name}\"? Túto akciu nie je možné vrátiť.",
"download": "Stiahnuť",
"name": "Názov",
"size": "Veľkosť",
@@ -3906,8 +3904,26 @@
"error_partial_copy": "Časť kópie zostala na mieste.",
"create_folder_success": "Priečinok bol vytvorený",
"create_folder_error": "Vytvorenie priečinka zlyhalo",
"delete_success": "Úspešne odstránené",
"delete_error": "Odstránenie zlyhalo",
"trash": "Kôš",
"restore": "Obnoviť",
"restored": "{count, plural, one {Položka obnovená} few {Obnovené # položky} other {Obnovených # položiek}}",
"restore_error": "Obnovenie sa nepodarilo",
"moved_to_trash": "{count, plural, one {Presunuté do koša} few {# položky presunuté do koša} other {# položiek presunutých do koša}}",
"delete_forever": "Natrvalo odstrániť",
"delete_forever_title": "{count, plural, one {Natrvalo odstrániť?} few {Natrvalo odstrániť # položky?} other {Natrvalo odstrániť # položiek?}}",
"delete_forever_message": "„{name}“ sa odstráni. Túto akciu nie je možné vrátiť.",
"delete_forever_message_count": "{count, plural, one {Odstráni sa 1 položka} few {Odstránia sa # položky} other {Odstráni sa # položiek}}. Túto akciu nie je možné vrátiť.",
"deleted_forever": "{count, plural, one {Natrvalo odstránené} few {Natrvalo odstránené # položky} other {Natrvalo odstránených # položiek}}",
"empty_trash": "Vyprázdniť kôš",
"empty_trash_title": "Vyprázdniť kôš?",
"empty_trash_message": "{count, plural, one {Položka v koši sa natrvalo odstráni} few {Všetky # položky v koši sa natrvalo odstránia} other {Všetkých # položiek v koši sa natrvalo odstráni}}. Túto akciu nie je možné vrátiť.",
"trash_emptied": "Kôš bol vyprázdnený",
"empty_trash_error": "Kôš sa nepodarilo vyprázdniť",
"trash_empty_title": "Kôš je prázdny",
"trash_empty_hint": "Odstránené súbory tu zostanú, kým kôš nevyprázdnite.",
"deleted": "Odstránené",
"deleted_from": "Odstránené z {folder}",
"rename_success": "Úspešne premenované",
"rename_error": "Premenovanie zlyhalo",
"download_error": "Sťahovanie zlyhalo",
@@ -3916,8 +3932,6 @@
"create": "Vytvoriť",
"save": "Uložiť",
"no_results": "Hľadaniu nezodpovedajú žiadne súbory",
"batch_delete_confirm_message": "Naozaj chcete odstrániť {count, plural, one {1 položku} other {# položiek}}? Túto akciu nie je možné vrátiť.",
"batch_delete_success": "{count, plural, one {Odstránená 1 položka} other {Odstránených # položiek}}",
"grid_view": "Zobrazenie mriežky",
"list_view": "Zobrazenie zoznamu",
"details": "Podrobnosti",
+19 -5
View File
@@ -1471,8 +1471,6 @@
"settings_section": "เริ่มต้นใช้งาน"
},
"files": {
"batch_delete_confirm_message": "คุณแน่ใจหรือไม่ว่าต้องการลบ {count, plural, other {# รายการ}} ที่เลือกไว้? การดำเนินการนี้ไม่สามารถยกเลิกได้",
"batch_delete_success": "{count, plural, other {ลบแล้ว # รายการ}}",
"breadcrumb_root": "หน้าแรก",
"breadcrumbs": "เส้นทางโฟลเดอร์",
"cancel": "ยกเลิก",
@@ -1491,11 +1489,27 @@
"create_folder_success": "สร้างโฟลเดอร์เรียบร้อยแล้ว",
"cut": "ตัด",
"delete": "ลบ",
"delete_confirm_message": "คุณแน่ใจหรือไม่ว่าต้องการลบ “{name}”? การดำเนินการนี้ไม่สามารถยกเลิกได้",
"delete_confirm_title": "ลบรายการ",
"delete_count": "{count, plural, other {ลบ # รายการ}}",
"delete_error": "ลบไม่สำเร็จ",
"delete_success": "ลบเรียบร้อยแล้ว",
"trash": "ถังขยะ",
"restore": "กู้คืน",
"restored": "{count, plural, other {กู้คืน # รายการแล้ว}}",
"restore_error": "กู้คืนไม่ได้",
"moved_to_trash": "{count, plural, other {ย้าย # รายการไปที่ถังขยะแล้ว}}",
"delete_forever": "ลบถาวร",
"delete_forever_title": "{count, plural, other {ลบ # รายการถาวรหรือไม่?}}",
"delete_forever_message": "\"{name}\" จะถูกลบ คุณไม่สามารถเลิกทำได้",
"delete_forever_message_count": "{count, plural, other {จะลบ # รายการ}} คุณไม่สามารถเลิกทำได้",
"deleted_forever": "{count, plural, other {ลบ # รายการถาวรแล้ว}}",
"empty_trash": "ล้างถังขยะ",
"empty_trash_title": "ล้างถังขยะหรือไม่?",
"empty_trash_message": "{count, plural, other {ทั้ง # รายการในถังขยะจะถูกลบถาวร}} คุณไม่สามารถเลิกทำได้",
"trash_emptied": "ล้างถังขยะแล้ว",
"empty_trash_error": "ล้างถังขยะไม่ได้",
"trash_empty_title": "ถังขยะว่างเปล่า",
"trash_empty_hint": "ไฟล์ที่ลบจะอยู่ที่นี่จนกว่าคุณจะล้างถังขยะ",
"deleted": "วันที่ลบ",
"deleted_from": "ลบจาก {folder}",
"details": "รายละเอียด",
"details_hint": "เลือกไฟล์หรือโฟลเดอร์เพื่อดูรายละเอียด",
"disabled_description": "การอัปโหลดไฟล์ขนาดใหญ่ผ่าน WebDAV อาจทำให้ฐานข้อมูล Stalwart/RocksDB ไม่เสถียร รวมถึงการใช้หน่วยความจำเกินและพื้นที่ดิสก์ ฟังก์ชันนี้ไม่แนะนำให้เปิดใช้งานในสภาพแวดล้อม Production",
+19 -5
View File
@@ -3879,8 +3879,6 @@
"name_control_chars": "Adlar denetim karakterleri içeremez.",
"name_too_long": "Bu ad sunucu için çok uzun.",
"delete": "Sil",
"delete_confirm_title": "Kaynağı sil",
"delete_confirm_message": "\"{name}\" silinsin mi? Bu geri alınamaz.",
"download": "İndir",
"name": "Ad",
"size": "Boyut",
@@ -3905,8 +3903,26 @@
"error_partial_copy": "Kopyanın bir kısmı yerinde kaldı.",
"create_folder_success": "Klasör oluşturuldu",
"create_folder_error": "Klasör oluşturulamadı",
"delete_success": "Başarıyla silindi",
"delete_error": "Silinemedi",
"trash": "Çöp kutusu",
"restore": "Geri yükle",
"restored": "{count, plural, one {Öğe geri yüklendi} other {# öğe geri yüklendi}}",
"restore_error": "Geri yüklenemedi",
"moved_to_trash": "{count, plural, one {Çöp kutusuna taşındı} other {# öğe çöp kutusuna taşındı}}",
"delete_forever": "Kalıcı olarak sil",
"delete_forever_title": "{count, plural, one {Kalıcı olarak silinsin mi?} other {# öğe kalıcı olarak silinsin mi?}}",
"delete_forever_message": "\"{name}\" silinecek. Bu işlem geri alınamaz.",
"delete_forever_message_count": "{count, plural, one {1 öğe silinecek} other {# öğe silinecek}}. Bu işlem geri alınamaz.",
"deleted_forever": "{count, plural, one {Kalıcı olarak silindi} other {# öğe kalıcı olarak silindi}}",
"empty_trash": "Çöp kutusunu boşalt",
"empty_trash_title": "Çöp kutusu boşaltılsın mı?",
"empty_trash_message": "{count, plural, one {Çöp kutusundaki öğe} other {Çöp kutusundaki # öğenin tümü}} kalıcı olarak silinecek. Bu işlem geri alınamaz.",
"trash_emptied": "Çöp kutusu boşaltıldı",
"empty_trash_error": "Çöp kutusu boşaltılamadı",
"trash_empty_title": "Çöp kutusu boş",
"trash_empty_hint": "Silinen dosyalar siz çöp kutusunu boşaltana kadar burada kalır.",
"deleted": "Silinme",
"deleted_from": "{folder} konumundan silindi",
"rename_success": "Başarıyla yeniden adlandırıldı",
"rename_error": "Yeniden adlandırılamadı",
"download_error": "İndirilemedi",
@@ -3915,8 +3931,6 @@
"create": "Oluştur",
"save": "Kaydet",
"no_results": "Aramanızla eşleşen dosya yok",
"batch_delete_confirm_message": "{count, plural, one {1 öge} other {# öge}} silinsin mi? Bu geri alınamaz.",
"batch_delete_success": "{count, plural, one {1 öge silindi} other {# öge silindi}}",
"grid_view": "Izgara görünümü",
"list_view": "Liste görünümü",
"details": "Ayrıntılar",
+19 -5
View File
@@ -3833,8 +3833,6 @@
"name_control_chars": "Назви не можуть містити керівні символи.",
"name_too_long": "Ця назва задовга для сервера.",
"delete": "Видалити",
"delete_confirm_title": "Видалити ресурс",
"delete_confirm_message": "Ви впевнені, що хочете видалити \"{name}\"? Це неможливо скасувати.",
"download": "Завантажити",
"name": "Ім'я",
"size": "Розмір",
@@ -3859,8 +3857,26 @@
"error_partial_copy": "Частина копії залишилася на місці.",
"create_folder_success": "Папка створена",
"create_folder_error": "Не вдалося створити папку",
"delete_success": "Успішно видалено",
"delete_error": "Не вдалося видалити",
"trash": "Кошик",
"restore": "Відновити",
"restored": "{count, plural, one {Елемент відновлено} few {Відновлено # елементи} other {Відновлено # елементів}}",
"restore_error": "Не вдалося відновити",
"moved_to_trash": "{count, plural, one {Переміщено в кошик} few {# елементи переміщено в кошик} other {# елементів переміщено в кошик}}",
"delete_forever": "Видалити назавжди",
"delete_forever_title": "{count, plural, one {Видалити назавжди?} few {Видалити # елементи назавжди?} other {Видалити # елементів назавжди?}}",
"delete_forever_message": "«{name}» буде видалено. Цю дію не можна скасувати.",
"delete_forever_message_count": "{count, plural, one {Буде видалено 1 елемент} few {Буде видалено # елементи} other {Буде видалено # елементів}}. Цю дію не можна скасувати.",
"deleted_forever": "{count, plural, one {Видалено назавжди} few {# елементи видалено назавжди} other {# елементів видалено назавжди}}",
"empty_trash": "Очистити кошик",
"empty_trash_title": "Очистити кошик?",
"empty_trash_message": "{count, plural, one {Елемент у кошику буде видалено} few {Усі # елементи в кошику буде видалено} other {Усі # елементів у кошику буде видалено}} назавжди. Цю дію не можна скасувати.",
"trash_emptied": "Кошик очищено",
"empty_trash_error": "Не вдалося очистити кошик",
"trash_empty_title": "Кошик порожній",
"trash_empty_hint": "Видалені файли зберігаються тут, доки ви не очистите кошик.",
"deleted": "Видалено",
"deleted_from": "Видалено з {folder}",
"rename_success": "Успішно перейменовано",
"rename_error": "Не вдалося перейменувати",
"download_error": "Не вдалося завантажити",
@@ -3869,8 +3885,6 @@
"create": "Створити",
"save": "зберегти",
"no_results": "Немає файлів, що відповідають вашому пошуку",
"batch_delete_confirm_message": "Ви впевнені, що хочете видалити {count, plural, one {1 item} other {# items}}? Це неможливо скасувати.",
"batch_delete_success": "{count, plural, one {1 елемент видалено} few {# елементи видалено} many {# елементів видалено} other {# елементів видалено}}",
"grid_view": "Вигляд сітки",
"list_view": "Перегляд списку",
"details": "Подробиці",
+19 -5
View File
@@ -3879,8 +3879,6 @@
"name_control_chars": "名稱不能包含控制字元。",
"name_too_long": "此名稱對伺服器來說過長。",
"delete": "刪除",
"delete_confirm_title": "刪除項目",
"delete_confirm_message": "您確定要刪除「{name}」嗎?此動作無法復原。",
"download": "下載",
"name": "名稱",
"size": "大小",
@@ -3906,8 +3904,26 @@
"error_partial_copy": "部分副本仍保留在原處。",
"create_folder_success": "資料夾已建立",
"create_folder_error": "建立資料夾失敗",
"delete_success": "刪除成功",
"delete_error": "刪除失敗",
"trash": "垃圾桶",
"restore": "還原",
"restored": "{count, plural, other {已還原 # 個項目}}",
"restore_error": "無法還原",
"moved_to_trash": "{count, plural, other {已將 # 個項目移到垃圾桶}}",
"delete_forever": "永久刪除",
"delete_forever_title": "{count, plural, other {要永久刪除 # 個項目嗎?}}",
"delete_forever_message": "「{name}」將被刪除。此動作無法復原。",
"delete_forever_message_count": "{count, plural, other {將刪除 # 個項目}}。此動作無法復原。",
"deleted_forever": "{count, plural, other {已永久刪除 # 個項目}}",
"empty_trash": "清空垃圾桶",
"empty_trash_title": "要清空垃圾桶嗎?",
"empty_trash_message": "{count, plural, other {垃圾桶中的 # 個項目將被永久刪除}}。此動作無法復原。",
"trash_emptied": "已清空垃圾桶",
"empty_trash_error": "無法清空垃圾桶",
"trash_empty_title": "垃圾桶是空的",
"trash_empty_hint": "刪除的檔案會保留在這裡,直到您清空垃圾桶。",
"deleted": "刪除時間",
"deleted_from": "從 {folder} 刪除",
"rename_success": "重新命名成功",
"rename_error": "重新命名失敗",
"download_error": "下載失敗",
@@ -3916,8 +3932,6 @@
"create": "建立",
"save": "儲存",
"no_results": "找不到符合搜尋條件的檔案",
"batch_delete_confirm_message": "您確定要刪除{count, plural, one {1 個項目} other {# 個項目}}嗎?此動作無法復原。",
"batch_delete_success": "{count, plural, one {已刪除 1 個項目} other {已刪除 # 個項目}}",
"grid_view": "網格檢視",
"list_view": "清單檢視",
"details": "詳細資料",
+19 -5
View File
@@ -3833,8 +3833,6 @@
"name_control_chars": "名称不能包含控制字符。",
"name_too_long": "此名称对服务器来说过长。",
"delete": "删除",
"delete_confirm_title": "删除资源",
"delete_confirm_message": "您确定要删除\"{name}\"吗?此操作无法撤消。",
"download": "下载",
"name": "名称",
"size": "大小",
@@ -3859,8 +3857,26 @@
"error_partial_copy": "部分副本被保留在原处。",
"create_folder_success": "文件夹已创建",
"create_folder_error": "创建文件夹失败",
"delete_success": "删除成功",
"delete_error": "删除失败",
"trash": "回收站",
"restore": "恢复",
"restored": "{count, plural, other {已恢复 # 个项目}}",
"restore_error": "无法恢复",
"moved_to_trash": "{count, plural, other {已将 # 个项目移到回收站}}",
"delete_forever": "永久删除",
"delete_forever_title": "{count, plural, other {永久删除 # 个项目?}}",
"delete_forever_message": "“{name}”将被删除。此操作无法撤销。",
"delete_forever_message_count": "{count, plural, other {将删除 # 个项目}}。此操作无法撤销。",
"deleted_forever": "{count, plural, other {已永久删除 # 个项目}}",
"empty_trash": "清空回收站",
"empty_trash_title": "清空回收站?",
"empty_trash_message": "{count, plural, other {回收站中的 # 个项目将被永久删除}}。此操作无法撤销。",
"trash_emptied": "已清空回收站",
"empty_trash_error": "无法清空回收站",
"trash_empty_title": "回收站为空",
"trash_empty_hint": "删除的文件会保留在这里,直到您清空回收站。",
"deleted": "删除时间",
"deleted_from": "从 {folder} 删除",
"rename_success": "重命名成功",
"rename_error": "重命名失败",
"download_error": "下载失败",
@@ -3869,8 +3885,6 @@
"create": "创建",
"save": "保存",
"no_results": "没有与您的搜索匹配的文件",
"batch_delete_confirm_message": "确定要删除{count, plural, one {1 个项目} other {# 个项目}}吗?此操作无法撤销。",
"batch_delete_success": "{count, plural, one {已删除 1 个项目} other {已删除 # 个项目}}",
"grid_view": "网格视图",
"list_view": "列表视图",
"details": "详情",
+167
View File
@@ -0,0 +1,167 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useFileStore, deletesToTrash } from '../file-store';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { FileNode } from '@/lib/jmap/types';
import { TRASH_LOCATION, parseTrashBatchName, trashBatchName } from '@/lib/files-trash';
// An in-memory FileNode server: a folder is a node without a blob, and
// destroying a node destroys what is below it, as on Stalwart.
function makeServer(initial: FileNode[]) {
let nodes = initial.map(n => ({ ...n }));
let seq = 0;
const now = () => new Date().toISOString();
const client = {
getCapabilities: () => ({}),
async listAllFileNodes() { return nodes.map(n => ({ ...n })); },
async listAllFileNodesAcrossAccounts() { return nodes.map(n => ({ ...n })); },
async createFileDirectory(name: string, parentId: string | null) {
const node: FileNode = { id: `n${++seq}`, parentId, name, type: '', blobId: null, size: 0, created: now(), modified: now() };
nodes.push(node);
return { ...node };
},
async updateFileNode(id: string, patch: Partial<FileNode>) {
Object.assign(nodes.find(n => n.id === id)!, patch);
},
async updateFileNodes(updates: Record<string, Partial<FileNode>>) {
const updated: string[] = [];
for (const [id, patch] of Object.entries(updates)) {
const node = nodes.find(n => n.id === id);
if (!node) continue;
const clash = nodes.some(n => n.id !== id && (n.parentId ?? null) === (patch.parentId !== undefined ? patch.parentId : node.parentId) && n.name === (patch.name ?? node.name));
if (clash) continue;
Object.assign(node, patch);
updated.push(id);
}
return { updated, notUpdated: {} };
},
async destroyFileNodes(ids: string[]) {
const gone = new Set(ids);
let grew = true;
while (grew) {
grew = false;
for (const n of nodes) {
if (n.parentId && gone.has(n.parentId) && !gone.has(n.id)) { gone.add(n.id); grew = true; }
}
}
nodes = nodes.filter(n => !gone.has(n.id));
return { destroyed: [...gone], notDestroyed: [] };
},
nodes: () => nodes,
};
return client as unknown as IJMAPClient & { nodes: () => FileNode[] };
}
const dir = (id: string, name: string, parentId: string | null): FileNode => ({
id, parentId, name, type: '', blobId: null, size: 0, created: '', modified: '',
});
const file = (id: string, name: string, parentId: string | null): FileNode => ({
id, parentId, name, type: 'text/plain', blobId: `b-${id}`, size: 3, created: '', modified: '',
});
const names = () => useFileStore.getState().resources.map(r => r.name);
describe('file-store trash', () => {
beforeEach(() => {
localStorage.clear();
useFileStore.setState({
client: null, currentParentId: null, currentPath: '/', pathStack: [{ id: null, name: '' }],
resources: [], selectedResources: new Set(), clipboard: null, lastAction: null,
favorites: [], recentFiles: [], trashCount: 0,
});
});
async function open(nodes: FileNode[], folder: string | null = null) {
const server = makeServer(nodes);
useFileStore.getState().initClient(server);
await useFileStore.getState().navigate(folder, folder ?? undefined);
return server;
}
it('moves deleted items into the trash instead of destroying them, and hides the trash', async () => {
const server = await open([dir('docs', 'Documents', null), file('a', 'a.txt', 'docs'), file('b', 'b.txt', 'docs')], 'docs');
expect(await useFileStore.getState().trashResources(['a.txt'])).toBe(1);
expect(server.nodes().find(n => n.id === 'a')).toBeDefined();
expect(names()).toEqual(['b.txt']);
expect(useFileStore.getState().trashCount).toBe(1);
await useFileStore.getState().navigate(null);
expect(names()).toEqual(['Documents']);
expect(useFileStore.getState().allFolders.map(f => f.name)).toEqual(['Documents']);
});
it('records where an item came from in the name of its delete', () => {
expect(parseTrashBatchName(trashBatchName('docs', 1730112000000))).toEqual({ deletedAt: 1730112000000, parentId: 'docs' });
expect(parseTrashBatchName(trashBatchName(null, 1730112000000))).toEqual({ deletedAt: 1730112000000, parentId: null });
expect(parseTrashBatchName('Holiday photos')).toBeNull();
});
it('puts the items back on undo and leaves no empty folder in the trash', async () => {
const server = await open([dir('docs', 'Documents', null), file('a', 'a.txt', 'docs')], 'docs');
await useFileStore.getState().trashResources(['a.txt']);
await useFileStore.getState().undoLastAction();
expect(names()).toEqual(['a.txt']);
expect(useFileStore.getState().trashCount).toBe(0);
const trash = server.nodes().find(n => n.name === '.Trash')!;
expect(server.nodes().filter(n => n.parentId === trash.id)).toEqual([]);
});
it('lists the trash with where each item came from, and restores it there', async () => {
const server = await open([dir('docs', 'Documents', null), file('a', 'a.txt', 'docs')], 'docs');
await useFileStore.getState().trashResources(['a.txt']);
await useFileStore.getState().navigate(TRASH_LOCATION);
const [item] = useFileStore.getState().resources;
expect(item.name).toBe('a.txt');
expect(item.trash?.fromParentId).toBe('docs');
expect(useFileStore.getState().currentPath).toBe('/.Trash');
expect(await useFileStore.getState().restoreResources(['a.txt'])).toBe(1);
expect(server.nodes().find(n => n.id === 'a')?.parentId).toBe('docs');
expect(names()).toEqual([]);
});
it('restores to the root when the folder is gone, under a new name when the old one is taken', async () => {
const server = await open([dir('docs', 'Documents', null), file('a', 'a.txt', 'docs'), file('top', 'a.txt', null)], 'docs');
await useFileStore.getState().trashResources(['a.txt']);
await server.destroyFileNodes(['docs']);
await useFileStore.getState().navigate(TRASH_LOCATION);
await useFileStore.getState().restoreResources(['a.txt']);
const restored = server.nodes().find(n => n.id === 'a')!;
expect(restored.parentId).toBeNull();
expect(restored.name).toBe('a (1).txt');
});
it('deletes items for good from the trash, and empties it', async () => {
const server = await open([file('a', 'a.txt', null), file('b', 'b.txt', null)]);
await useFileStore.getState().trashResources(['a.txt']);
await useFileStore.getState().trashResources(['b.txt']);
await useFileStore.getState().navigate(TRASH_LOCATION);
expect(await useFileStore.getState().purgeResources(['a.txt'])).toBe(1);
expect(server.nodes().find(n => n.id === 'a')).toBeUndefined();
expect(names()).toEqual(['b.txt']);
await useFileStore.getState().emptyTrash();
expect(server.nodes()).toEqual([]);
expect(useFileStore.getState().trashCount).toBe(0);
});
it('trashes a folder from the tree and steps out of it', async () => {
const server = await open([dir('docs', 'Documents', null), dir('sub', 'Sub', 'docs')], 'docs');
await useFileStore.getState().navigate('sub', 'Sub');
expect(await useFileStore.getState().trashNode('docs')).toBe(true);
expect(useFileStore.getState().currentParentId).toBeNull();
expect(server.nodes().find(n => n.id === 'docs')).toBeDefined();
expect(names()).toEqual([]);
});
it('has no trash in a drive someone shared', () => {
expect(deletesToTrash(null)).toBe(true);
expect(deletesToTrash('docs')).toBe(true);
expect(deletesToTrash('e:team')).toBe(false);
expect(deletesToTrash(TRASH_LOCATION)).toBe(false);
});
});
+266 -12
View File
@@ -9,6 +9,10 @@ import { imageBlobUrl } from '@/lib/file-preview';
import { fileHooks } from '@/lib/plugin-hooks';
import { fileToView } from '@/lib/plugin-projection';
import type { FileResourceView } from '@/lib/plugin-types';
import {
TRASH_FOLDER_NAME, TRASH_LOCATION, TRASH_PATH, findTrashFolder, isTrashLocation, trashBatchName,
trashSubtreeIds, trashedItems,
} from '@/lib/files-trash';
export interface FileResource {
id: string;
@@ -31,6 +35,8 @@ export interface FileResource {
isShared?: boolean;
ownerAccountId?: string;
ownerName?: string;
/** Set for an item in the trash: when it was deleted and where from (null: the root). */
trash?: { deletedAt: string | null; fromParentId: string | null; batchId: string | null };
}
interface UploadProgress {
@@ -73,6 +79,8 @@ interface UndoAction {
type: 'rename' | 'move';
entries: { id: string; from: Partial<Pick<FileNode, 'name' | 'parentId'>>; to: Partial<Pick<FileNode, 'name' | 'parentId'>> }[];
sourceParentId: string | null;
/** A delete into the trash: the trash folder its items went into, removed on undo. */
trashBatchId?: string;
}
interface FileState {
@@ -105,6 +113,8 @@ interface FileState {
allFolders: FileResource[];
/** False until the first listing for the attached account has finished. */
hasListed: boolean;
/** How many items the trash holds, from the last listing. */
trashCount: number;
// Actions
initClient: (client: IJMAPClient, accountId?: string | null) => void;
@@ -160,6 +170,19 @@ interface FileState {
renameNode: (id: string, newName: string) => Promise<boolean>;
/** Delete any node by id; leaves the folder on screen if it was inside it. */
deleteNode: (id: string) => Promise<boolean>;
/**
* Move items of the folder on screen into the trash; how many went (0 when
* a plugin stopped it). Undo puts them back.
*/
trashResources: (names: string[]) => Promise<number>;
/** Move any node by id into the trash, like deleteNode. */
trashNode: (id: string) => Promise<boolean>;
/** Put items listed in the trash back where they came from (the root when that is gone). */
restoreResources: (names: string[]) => Promise<number>;
/** Delete items listed in the trash for good. */
purgeResources: (names: string[]) => Promise<number>;
/** Delete everything in the trash for good. */
emptyTrash: () => Promise<void>;
/** Download a file by id, wherever it lives. */
downloadNode: (id: string) => Promise<void>;
removeRecentFile: (id: string) => void;
@@ -284,11 +307,45 @@ export function linkedPreviewResource(
: resources.find(r => r.name === link.name);
}
// A shared-files page lists files; nothing can be put into it.
// A shared-files page lists files; nothing can be put into it, nor into the
// trash other than by deleting.
function assertCanAddTo(parentId: string | null): void {
if (sharedFilesOwner(parentId) !== null) {
throw jmapError('Nothing can be added to a list of shared files', { type: 'forbidden' });
}
if (isTrashLocation(parentId)) {
throw jmapError('Nothing can be added to the trash', { type: 'forbidden' });
}
}
/**
* Whether what is deleted in a folder goes to the trash: in the user's own
* drive. A drive someone shared has no trash of the user's, and what is
* deleted there is gone.
*/
export function deletesToTrash(parentId: string | null): boolean {
return !isCrossAccountId(parentId) && !isTrashLocation(parentId);
}
/** The trash folder, created on the first delete. */
async function ensureTrashFolder(client: IJMAPClient, nodes: FileNode[]): Promise<FileNode> {
return findTrashFolder(nodes) ?? client.createFileDirectory(TRASH_FOLDER_NAME, null);
}
/** Remove the trash folders of deletes whose items have all left the trash. */
async function removeEmptyTrashBatches(client: IJMAPClient, gone: FileResource[]): Promise<void> {
const batchIds = [...new Set(gone.map(r => r.trash?.batchId).filter((id): id is string => !!id))];
if (batchIds.length === 0) return;
const nodes = await client.listAllFileNodes();
const empty = batchIds.filter(id => !nodes.some(n => n.parentId === id));
if (empty.length > 0) await client.destroyFileNodes(empty);
}
/** Throw the first refusal when nothing of an update went through. */
function assertUpdated(result: { updated: string[]; notUpdated: Record<string, string> }): void {
if (result.updated.length > 0) return;
const [reason] = Object.values(result.notUpdated);
throw jmapError(reason || 'The server refused the change', { type: 'forbidden' });
}
// The top of what other accounts share with the user: shared nodes whose
@@ -535,6 +592,7 @@ export const useFileStore = create<FileState>((set, get) => ({
sharedRoots: [],
allFolders: [],
hasListed: false,
trashCount: 0,
favorites: (() => {
try { return JSON.parse(localStorage.getItem('files-favorites') || '[]'); } catch { return []; }
})(),
@@ -790,6 +848,8 @@ export const useFileStore = create<FileState>((set, get) => ({
} else if (sharedFilesOwner(parentId) !== null) {
// A shared-files page sits right below Home, whatever was open before.
newStack = [{ id: null, name: '' }, { id: parentId, name: name || parentId }];
} else if (isTrashLocation(parentId)) {
newStack = [{ id: null, name: '' }, { id: TRASH_LOCATION, name: TRASH_FOLDER_NAME }];
} else {
// Check if navigating to a parent in the stack
const existingIdx = pathStack.findIndex(s => s.id === parentId);
@@ -816,25 +876,39 @@ export const useFileStore = create<FileState>((set, get) => ({
? await client.listAllFileNodesAcrossAccounts()
: await client.listAllFileNodes();
const owner = sharedFilesOwner(parentId);
const resources = owner !== null
? withUniqueNames(sortResources(
sharedRootsOf(allNodes).filter(n => n.accountId === owner && !isFolder(n)).map(nodeToResource),
))
// The owner's folders on the way down to a shared node aren't shown
// (see sharedRootsOf); the user can do nothing in them.
: sortResources(childrenOf(allNodes, parentId).filter(n => !n.discoverableOnly).map(nodeToResource));
// The trash and what is in it stay out of every listing but its own.
const trash = findTrashFolder(allNodes);
const inTrash = trashSubtreeIds(allNodes, trash?.id);
const trashed = trashedItems(allNodes, trash);
const resources = isTrashLocation(parentId)
? withUniqueNames(sortResources(trashed.map(item => ({
...nodeToResource(item.node),
trash: {
deletedAt: item.deletedAt !== null ? new Date(item.deletedAt).toISOString() : null,
fromParentId: item.fromParentId,
batchId: item.batchId,
},
}))))
: owner !== null
? withUniqueNames(sortResources(
sharedRootsOf(allNodes).filter(n => n.accountId === owner && !isFolder(n)).map(nodeToResource),
))
// The owner's folders on the way down to a shared node aren't shown
// (see sharedRootsOf); the user can do nothing in them.
: sortResources(childrenOf(allNodes, parentId).filter(n => !n.discoverableOnly && !inTrash.has(n.id)).map(nodeToResource));
// The owner's folders above a shared one stay out of the tree as well.
const allFolders = allNodes.filter(n => isFolder(n) && !n.discoverableOnly).map(nodeToResource);
const allFolders = allNodes.filter(n => isFolder(n) && !n.discoverableOnly && !inTrash.has(n.id)).map(nodeToResource);
// Prune recent files whose backing node no longer exists on the server
// or is in the trash.
const { recentFiles } = get();
const existingIds = new Set(allNodes.map(n => n.id));
const existingIds = new Set(allNodes.filter(n => !inTrash.has(n.id)).map(n => n.id));
const prunedRecent = recentFiles.filter(r => existingIds.has(r.id));
if (prunedRecent.length !== recentFiles.length) {
try { localStorage.setItem('files-recent-files', JSON.stringify(prunedRecent)); } catch { /* ignore */ }
set({ resources, allFolders, recentFiles: prunedRecent, isLoading: false, hasListed: true });
set({ resources, allFolders, trashCount: trashed.length, recentFiles: prunedRecent, isLoading: false, hasListed: true });
} else {
set({ resources, allFolders, isLoading: false, hasListed: true });
set({ resources, allFolders, trashCount: trashed.length, isLoading: false, hasListed: true });
}
if (opensFolder) void fileHooks.onFileNavigate.emit({ path: newPath });
} catch (error) {
@@ -853,6 +927,10 @@ export const useFileStore = create<FileState>((set, get) => ({
await navigate(null);
return;
}
if (path === TRASH_PATH) {
await navigate(TRASH_LOCATION, TRASH_FOLDER_NAME);
return;
}
// Try to match the path against the current pathStack
const segments = path.split('/').filter(Boolean);
const targetDepth = segments.length;
@@ -1611,6 +1689,180 @@ export const useFileStore = create<FileState>((set, get) => ({
return true;
},
trashResources: async (names: string[]) => {
const { client, resources, currentParentId, currentPath, recentFiles, favorites, refresh } = get();
if (!client) return 0;
const items = names
.map(name => resources.find(r => r.name === name))
.filter((r): r is FileResource => !!r);
if (items.length === 0) return 0;
const files = items.map(r => resourceToView(r, currentPath));
if (!(await fileHooks.onBeforeFileDelete.intercept({ files }))) return 0;
const trash = await ensureTrashFolder(client, await client.listAllFileNodes());
const batch = await client.createFileDirectory(trashBatchName(currentParentId), trash.id);
let result: { updated: string[]; notUpdated: Record<string, string> };
try {
result = await client.updateFileNodes(Object.fromEntries(items.map(r => [r.id, { parentId: batch.id }])));
assertUpdated(result);
} catch (err) {
await client.destroyFileNodes([batch.id]).catch(() => undefined);
throw err;
}
const moved = new Set(result.updated);
const trashed = items.filter(r => moved.has(r.id));
void fileHooks.onAfterFileDelete.emit({ files: files.filter(f => moved.has(f.id)) });
// Like a delete: recent files and stars of what went drop off.
const gonePaths = trashed.filter(r => r.isDirectory).map(r => joinPath(currentPath, r.name));
const nextFavorites = favorites.filter(f => !gonePaths.some(path => isUnder(f, path)));
const nextRecent = recentFiles.filter(r => !moved.has(r.id));
set({
favorites: nextFavorites,
recentFiles: nextRecent,
selectedResources: new Set(),
lastAction: {
type: 'move',
entries: trashed.map(r => ({ id: r.id, from: { parentId: r.parentId ?? null }, to: { parentId: batch.id } })),
sourceParentId: currentParentId,
trashBatchId: batch.id,
},
});
try {
localStorage.setItem('files-favorites', JSON.stringify(nextFavorites));
localStorage.setItem('files-recent-files', JSON.stringify(nextRecent));
} catch { /* ignore */ }
await refresh();
return trashed.length;
},
trashNode: async (id: string) => {
const { client, allFolders, resources, favorites, recentFiles, pathStack, navigate, refresh } = get();
if (!client) return false;
const nodes = await client.listAllFileNodes();
const node = nodes.find(n => n.id === id);
if (!node) return false;
const path = folderPath(allFolders, id);
const known = allFolders.find(f => f.id === id) ?? resources.find(r => r.id === id);
const files = [known ? resourceToView(known, parentPath(allFolders, known.parentId)) : unknownNodeView(id)];
if (!(await fileHooks.onBeforeFileDelete.intercept({ files }))) return false;
const trash = await ensureTrashFolder(client, nodes);
const batch = await client.createFileDirectory(trashBatchName(node.parentId ?? null), trash.id);
try {
assertUpdated(await client.updateFileNodes({ [id]: { parentId: batch.id } }));
} catch (err) {
await client.destroyFileNodes([batch.id]).catch(() => undefined);
throw err;
}
void fileHooks.onAfterFileDelete.emit({ files });
const nextFavorites = path ? favorites.filter(f => !isUnder(f, path)) : favorites;
const nextRecent = recentFiles.filter(r => r.id !== id);
set({
favorites: nextFavorites,
recentFiles: nextRecent,
lastAction: {
type: 'move',
entries: [{ id, from: { parentId: node.parentId ?? null }, to: { parentId: batch.id } }],
sourceParentId: node.parentId ?? null,
trashBatchId: batch.id,
},
});
try {
localStorage.setItem('files-favorites', JSON.stringify(nextFavorites));
localStorage.setItem('files-recent-files', JSON.stringify(nextRecent));
} catch { /* ignore */ }
// Standing in the deleted folder or below it: step out to its parent.
const idx = pathStack.findIndex(s => s.id === id);
if (idx > 0) {
const parent = pathStack[idx - 1];
set({ pathStack: pathStack.slice(0, idx) });
await navigate(parent.id, parent.name);
return true;
}
await refresh();
return true;
},
restoreResources: async (names: string[]) => {
const { client, resources, refresh } = get();
if (!client) return 0;
const items = names
.map(name => resources.find(r => r.name === name && r.trash))
.filter((r): r is FileResource => !!r);
if (items.length === 0) return 0;
const nodes = await client.listAllFileNodes();
const inTrash = trashSubtreeIds(nodes, findTrashFolder(nodes)?.id);
const folderIds = new Set(nodes.filter(n => isFolder(n) && !inTrash.has(n.id)).map(n => n.id));
// Names taken in each folder, so a restored item never clashes.
const taken = new Map<string | null, Set<string>>();
const namesIn = (parentId: string | null) => {
let set = taken.get(parentId);
if (!set) {
set = new Set(childrenOf(nodes, parentId).filter(n => !inTrash.has(n.id)).map(n => n.name));
taken.set(parentId, set);
}
return set;
};
const updates: Record<string, Partial<Pick<FileNode, 'name' | 'parentId'>>> = {};
const targets = new Map<string, string | null>();
for (const item of items) {
const from = item.trash?.fromParentId ?? null;
// A folder that is gone (or itself in the trash) gives the item back to the root.
const target = from !== null && folderIds.has(from) ? from : null;
const used = namesIn(target);
const name = getUniqueName(item.serverName, used);
used.add(name);
updates[item.id] = name === item.serverName ? { parentId: target } : { parentId: target, name };
targets.set(item.id, target);
}
const result = await client.updateFileNodes(updates);
assertUpdated(result);
const restored = new Set(result.updated);
const allFolders = nodes.filter(n => isFolder(n) && !inTrash.has(n.id)).map(nodeToResource);
const byTarget = new Map<string, FileResourceView[]>();
for (const item of items.filter(r => restored.has(r.id))) {
const toPath = parentPath(allFolders, targets.get(item.id) ?? null);
const list = byTarget.get(toPath) ?? [];
list.push(resourceToView(item, toPath));
byTarget.set(toPath, list);
}
for (const [toPath, files] of byTarget) void fileHooks.onFileMove.emit({ files, toPath });
await removeEmptyTrashBatches(client, items.filter(r => restored.has(r.id)));
set({ selectedResources: new Set() });
await refresh();
return restored.size;
},
purgeResources: async (names: string[]) => {
const { client, resources, refresh } = get();
if (!client) return 0;
const items = names
.map(name => resources.find(r => r.name === name && r.trash))
.filter((r): r is FileResource => !!r);
if (items.length === 0) return 0;
// The server removes what is inside a folder with it.
const result = await client.destroyFileNodes(items.map(r => r.id));
const destroyed = new Set(result.destroyed);
await removeEmptyTrashBatches(client, items.filter(r => destroyed.has(r.id)));
set({ selectedResources: new Set() });
await refresh();
return items.filter(r => destroyed.has(r.id)).length;
},
emptyTrash: async () => {
const { client, refresh } = get();
if (!client) return;
const trash = findTrashFolder(await client.listAllFileNodes());
// The folder goes with everything in it; the next delete makes a new one.
if (trash) await client.destroyFileNodes([trash.id]);
set({ selectedResources: new Set() });
await refresh();
},
downloadNode: async (id: string) => {
const { client } = get();
if (!client) return;
@@ -1637,6 +1889,8 @@ export const useFileStore = create<FileState>((set, get) => ({
for (const entry of lastAction.entries) {
await client.updateFileNode(entry.id, entry.from);
}
// A delete's folder in the trash is empty again.
if (lastAction.trashBatchId) await client.destroyFileNodes([lastAction.trashBatchId]);
set({ lastAction: null });
void fileHooks.onFileUndo.emit({
action: {