diff --git a/components/files/__tests__/file-browser-shared-folders.test.tsx b/components/files/__tests__/file-browser-shared-folders.test.tsx index 95bec6bc5..432e2b259 100644 --- a/components/files/__tests__/file-browser-shared-folders.test.tsx +++ b/components/files/__tests__/file-browser-shared-folders.test.tsx @@ -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'); + }); }); diff --git a/components/files/file-browser.tsx b/components/files/file-browser.tsx index 16931d3de..489b988c1 100644 --- a/components/files/file-browser.tsx +++ b/components/files/file-browser.tsx @@ -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; onUploadFolder: (files: File[]) => Promise; onCancelUpload: () => void; + /** Delete: into the trash, or for good when in the trash or a shared drive. */ onDelete: (name: string) => Promise; onBatchDelete: (names: string[]) => Promise; + /** In the trash: put items back where they came from. */ + onRestore?: (names: string[]) => Promise; + /** In the trash: delete everything in it for good. */ + onEmptyTrash?: () => void; onRename: (oldName: string, newName: string) => Promise; onDownload: (name: string) => Promise; onBatchDownload: (names: string[]) => Promise; @@ -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 && ( + { closeMenu(); void onRestore(names); }} /> + )} + {one && !one.isDirectory && !one.isSymlink && isPreviewable(one.name) && ( + { closeMenu(); previewResource(one); }} /> + )} + {one && !one.isDirectory && !one.isSymlink && ( + { closeMenu(); void onDownload(one.name); }} /> + )} + + { 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 ; if (showEmpty && searchQuery) return setSearchQuery("")} />; + if (showEmpty && inTrash) { + return ; + } if (showEmpty) { 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 && ( diff --git a/components/files/file-views.tsx b/components/files/file-views.tsx index c5dcd6086..415d69b4a 100644 --- a/components/files/file-views.tsx +++ b/components/files/file-views.tsx @@ -36,6 +36,8 @@ export interface FileViewProps { iconOptions: IconOptions; getImageUrl: (name: string) => Promise; 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 && {t("selected_count", { count: selected.size })}} - +
{t("context_menu")}
@@ -183,13 +187,14 @@ export function FileListView({ iconOptions={iconOptions} getImageUrl={getImageUrl} handlers={handlers} + note={itemNote?.(resource)} /> ))} ); } -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; 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} @@ -260,6 +266,7 @@ function FileListRow({ resource, isSelected, isFocused, isDropTarget, isCut, isS {touch && (
+ {note && <>{note} · } {formatShortDate(resource.lastModified)} {!resource.isDirectory && !resource.isSymlink && <> · {formatFileSize(resource.contentLength)}}
diff --git a/components/files/files-app.tsx b/components/files/files-app.tsx index 3b23e73fe..35c8ba9d3 100644 --- a/components/files/files-app.tsx +++ b/components/files/files-app.tsx @@ -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} diff --git a/components/files/files-header.tsx b/components/files/files-header.tsx index 4070c05b1..9b5d5f730 100644 --- a/components/files/files-header.tsx +++ b/components/files/files-header.tsx @@ -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(); @@ -122,6 +128,15 @@ export function FilesHeader({ {t("selected_count", { count: selection.count })}
+ {selection.trash ? ( + <> + {selection.onRestore && ( + + )} + + + ) : ( + <> {selection.canOpen && ( )} @@ -138,9 +153,11 @@ export function FilesHeader({ {!compact && } + + )}
{/* Outside the scrolling row, so the "more" button never scrolls out of sight. */} - {compact && } + {compact && !selection.trash && }
@@ -281,6 +298,19 @@ export function FilesHeader({ {!toolsHidden && (
+ {emptyTrash && ( + + )} {clipboardCount > 0 && (
)} + {onOpenTrash && ( +
+ +
+ )} + {recent.length === 0 && favorites.length === 0 && (

diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index 223dc4f19..b71ee04e0 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -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: [] }; } diff --git a/lib/files-trash.ts b/lib/files-trash.ts new file mode 100644 index 000000000..8dbc8bd33 --- /dev/null +++ b/lib/files-trash.ts @@ -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~/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 { + const ids = new Set(); + 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; +} diff --git a/locales/ar/common.json b/locales/ar/common.json index b33dfc0ee..54aa8fc5d 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -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": "التفاصيل", diff --git a/locales/ca/common.json b/locales/ca/common.json index aef79a0cc..e42910d17 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -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", diff --git a/locales/cs/common.json b/locales/cs/common.json index 21e183ab4..3a4010b06 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -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", diff --git a/locales/da/common.json b/locales/da/common.json index 84dd6225b..3f9768fc5 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -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", diff --git a/locales/de/common.json b/locales/de/common.json index edef07990..0107d1e42 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -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", diff --git a/locales/en/common.json b/locales/en/common.json index acee4cc0a..fc2aa824f 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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", diff --git a/locales/es/common.json b/locales/es/common.json index 005027896..785014528 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -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", diff --git a/locales/et/common.json b/locales/et/common.json index 2fa7bd8b1..0f30ceda6 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -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", diff --git a/locales/fa/common.json b/locales/fa/common.json index 80b35ba9b..2d1843c70 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -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": "جزئیات", diff --git a/locales/fr/common.json b/locales/fr/common.json index 09f63f3e6..ce30d0ea4 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -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", diff --git a/locales/he/common.json b/locales/he/common.json index 9f22ebceb..59970020b 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -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": "פרטים", diff --git a/locales/hu/common.json b/locales/hu/common.json index d43918a11..7e7ea88e7 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -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", diff --git a/locales/it/common.json b/locales/it/common.json index f234a8dcf..aa925e35e 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -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", diff --git a/locales/ja/common.json b/locales/ja/common.json index 9b9b86897..a4cd9723b 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -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": "詳細", diff --git a/locales/ko/common.json b/locales/ko/common.json index 00875c839..6a9e11466 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -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": "세부 정보", diff --git a/locales/lv/common.json b/locales/lv/common.json index 2b6f6f050..3aeb59fa1 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -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", diff --git a/locales/mn/common.json b/locales/mn/common.json index 87ddb6c2b..f059e218e 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -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": "Дэлгэрэнгүй мэдээлэл", diff --git a/locales/nb/common.json b/locales/nb/common.json index de421bb95..3ac03829f 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -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", diff --git a/locales/nl/common.json b/locales/nl/common.json index 7f157d586..d33e8414b 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -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", diff --git a/locales/pl/common.json b/locales/pl/common.json index 0c7013839..6c527ced8 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -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", diff --git a/locales/pt/common.json b/locales/pt/common.json index 2997c7e34..5d4761fa6 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -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", diff --git a/locales/ro/common.json b/locales/ro/common.json index 10b8e52f4..f2e024816 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -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", diff --git a/locales/ru/common.json b/locales/ru/common.json index 72db907f7..4c95eb9d6 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -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": "Подробности", diff --git a/locales/sk/common.json b/locales/sk/common.json index 84278c76e..949530e69 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -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", diff --git a/locales/th/common.json b/locales/th/common.json index 20ae9ae78..924c88b63 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -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", diff --git a/locales/tr/common.json b/locales/tr/common.json index 75284133c..5bddd9e7f 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -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", diff --git a/locales/uk/common.json b/locales/uk/common.json index 6486a9712..494cc2f63 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -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": "Подробиці", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index e2bf9e422..114ab9d72 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -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": "詳細資料", diff --git a/locales/zh/common.json b/locales/zh/common.json index a2e0fd555..8c6bdd37a 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -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": "详情", diff --git a/stores/__tests__/file-store-trash.test.ts b/stores/__tests__/file-store-trash.test.ts new file mode 100644 index 000000000..d322b1e00 --- /dev/null +++ b/stores/__tests__/file-store-trash.test.ts @@ -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) { + Object.assign(nodes.find(n => n.id === id)!, patch); + }, + async updateFileNodes(updates: Record>) { + 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); + }); +}); diff --git a/stores/file-store.ts b/stores/file-store.ts index bf4b1d832..06e04592f 100644 --- a/stores/file-store.ts +++ b/stores/file-store.ts @@ -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>; to: Partial> }[]; 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; /** Delete any node by id; leaves the folder on screen if it was inside it. */ deleteNode: (id: string) => Promise; + /** + * 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; + /** Move any node by id into the trash, like deleteNode. */ + trashNode: (id: string) => Promise; + /** Put items listed in the trash back where they came from (the root when that is gone). */ + restoreResources: (names: string[]) => Promise; + /** Delete items listed in the trash for good. */ + purgeResources: (names: string[]) => Promise; + /** Delete everything in the trash for good. */ + emptyTrash: () => Promise; /** Download a file by id, wherever it lives. */ downloadNode: (id: string) => Promise; 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 { + 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 { + 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 }): 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((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((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((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((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((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 }; + 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>(); + 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>> = {}; + const targets = new Map(); + 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(); + 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((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: {