diff --git a/components/files/__tests__/file-browser-shared-folders.test.tsx b/components/files/__tests__/file-browser-shared-folders.test.tsx index a035a9368..d39f33a08 100644 --- a/components/files/__tests__/file-browser-shared-folders.test.tsx +++ b/components/files/__tests__/file-browser-shared-folders.test.tsx @@ -198,20 +198,23 @@ describe('FileBrowser', () => { expect(within(tree()).getByText('Documents')).toBeInTheDocument(); }); - it('opens a folder from its name and selects it from the row', () => { + // pim-3: the name is plain text, as in a file manager. A click selects, a + // double-click opens. + it('selects a folder from its name and opens it on a double-click', () => { const { onNavigate, onSetSelection } = renderBrowser(); - const row = within(list()).getByText('Documents').closest('[role=row]')!; - fireEvent.click(row); + fireEvent.click(within(list()).getByText('Documents')); expect(onSetSelection).toHaveBeenCalledWith(new Set(['Documents'])); expect(onNavigate).not.toHaveBeenCalled(); - fireEvent.click(within(list()).getByText('Documents')); + fireEvent.doubleClick(within(list()).getByText('Documents')); expect(onNavigate).toHaveBeenCalledWith('/Documents', 'f-docs'); }); - it('previews a text file when its name is clicked', () => { + it('previews a text file when its name is double-clicked', () => { const { onPreviewFile } = renderBrowser(); fireEvent.click(within(list()).getByText('notes.txt')); + expect(onPreviewFile).not.toHaveBeenCalled(); + fireEvent.doubleClick(within(list()).getByText('notes.txt')); expect(onPreviewFile).toHaveBeenCalledWith('notes.txt'); }); diff --git a/components/files/__tests__/file-name-middle-cut.test.tsx b/components/files/__tests__/file-name-middle-cut.test.tsx new file mode 100644 index 000000000..3cfe16210 --- /dev/null +++ b/components/files/__tests__/file-name-middle-cut.test.tsx @@ -0,0 +1,62 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen } from '@testing-library/react'; +import { FileGridView } from '../file-views'; +import { splitNameForMiddleCut } from '../file-item-parts'; +import type { FileResource } from '@/stores/file-store'; + +// pim-21: long names in the grid are cut in the middle, so the extension and +// the part that tells similar names apart stay in view. + +afterEach(cleanup); + +describe('splitNameForMiddleCut', () => { + it('keeps the end of the name and the extension', () => { + expect(splitNameForMiddleCut('IMG_20250611_1003.png')).toEqual({ head: 'IMG_20250611_', tail: '1003.png' }); + expect(splitNameForMiddleCut('meeting-notes-2026-q3.md')).toEqual({ head: 'meeting-notes-202', tail: '6-q3.md' }); + expect(splitNameForMiddleCut('Großes Angebot – final (v3).txt')).toEqual({ head: 'Großes Angebot – final ', tail: '(v3).txt' }); + }); + + it('leaves short names whole', () => { + expect(splitNameForMiddleCut('a.txt')).toEqual({ head: 'a.txt', tail: '' }); + expect(splitNameForMiddleCut('readme.txt')).toEqual({ head: 'readme.txt', tail: '' }); + }); + + it('cuts names without an extension near the end too', () => { + expect(splitNameForMiddleCut('Quarterly reports archive')).toEqual({ head: 'Quarterly reports arc', tail: 'hive' }); + }); +}); + +describe('the grid', () => { + it('cuts the start of a long name, keeps its end, and has the whole name as title', () => { + const resource = { + id: 'i', name: 'IMG_20250611_1003.png', serverName: 'IMG_20250611_1003.png', isDirectory: false, contentType: 'image/png', + contentLength: 10, lastModified: '2026-10-01T00:00:00Z', blobId: 'b', parentId: null, + } as FileResource; + render( + '/'} + touch={false} + iconOptions={{ showIcons: true, coloredIcons: true, showThumbnails: false }} + getImageUrl={async () => ''} + handlers={{ + onSelectClick: vi.fn(), onToggleSelect: vi.fn(), onOpen: vi.fn(), onContextMenu: vi.fn(), onShare: vi.fn(), + canShare: () => false, dragProps: () => ({}), onFocusItem: vi.fn(), + }} + />, + ); + const name = screen.getByTitle('IMG_20250611_1003.png'); + const [head, tail] = Array.from(name.children) as HTMLElement[]; + expect(head.textContent).toBe('IMG_20250611_'); + expect(head.className).toContain('truncate'); + expect(tail.textContent).toBe('1003.png'); + expect(tail.className).toContain('shrink-0'); + expect(name.textContent).toBe('IMG_20250611_1003.png'); + }); +}); diff --git a/components/files/__tests__/image-preview-modal.test.tsx b/components/files/__tests__/image-preview-modal.test.tsx index cba97a9b5..927d6fb4c 100644 --- a/components/files/__tests__/image-preview-modal.test.tsx +++ b/components/files/__tests__/image-preview-modal.test.tsx @@ -24,7 +24,11 @@ function tap(el: Element, x = 10, y = 10) { describe('ImagePreviewModal', () => { it('closes when the area around the image is clicked', async () => { const { onClose, box } = await renderViewer(); + // Not in the first 400 ms: that is still the click that opened it (pim-3). + const later = Date.now() + 1000; + const now = vi.spyOn(Date, 'now').mockReturnValue(later); tap(box); + now.mockRestore(); expect(onClose).toHaveBeenCalledTimes(1); }); diff --git a/components/files/__tests__/ux-pim-files-dialogs.test.tsx b/components/files/__tests__/ux-pim-files-dialogs.test.tsx new file mode 100644 index 000000000..25b9b3d0b --- /dev/null +++ b/components/files/__tests__/ux-pim-files-dialogs.test.tsx @@ -0,0 +1,123 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { UploadConflictDialog } from '../upload-conflict-dialog'; +import { FileNoPreview } from '../file-no-preview'; +import { EmptyFolder } from '../file-empty-state'; +import { FileBrowser } from '../file-browser'; +import { sharedNodeLink } from '../share-message'; +import { useFileStore, type FileResource } from '@/stores/file-store'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } })); +// The share dialog itself isn't ours: a stand-in that adds and changes a share. +vi.mock('@/components/settings/share-collection-dialog', () => ({ + ShareCollectionDialog: ({ onShare }: { onShare: (principalId: string, rights: unknown) => Promise }) => ( +
+ + + +
+ ), +})); + +afterEach(cleanup); + +describe('the upload conflict question (pim-10)', () => { + const conflict = { name: 'report.txt', kind: 'file' as const }; + + it('offers Replace, Keep both, Skip and Cancel, and "for all" only with more conflicts left', () => { + const answer = vi.fn(); + const { rerender } = render(); + expect(screen.getByRole('alertdialog', { name: 'conflict_title' })).toBeInTheDocument(); + expect(screen.queryByRole('checkbox')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'keep_both' })); + expect(answer).toHaveBeenLastCalledWith({ choice: 'keep', applyToAll: false }); + + rerender(); + fireEvent.click(screen.getByRole('checkbox', { name: 'apply_to_all' })); + fireEvent.click(screen.getByRole('button', { name: 'replace' })); + expect(answer).toHaveBeenLastCalledWith({ choice: 'replace', applyToAll: true }); + fireEvent.click(screen.getByRole('button', { name: 'skip' })); + expect(answer).toHaveBeenLastCalledWith({ choice: 'skip', applyToAll: true }); + fireEvent.click(screen.getByRole('button', { name: 'cancel' })); + expect(answer).toHaveBeenLastCalledWith(null); + }); + + it('explains Replace for a folder as a merge', () => { + render(); + expect(screen.getByText('conflict_folder_hint')).toBeInTheDocument(); + }); +}); + +describe('the pane for a file with no preview (pim-16)', () => { + const budget = { + id: 'x', name: 'budget-2026.xlsx', serverName: 'budget-2026.xlsx', isDirectory: false, contentType: 'application/octet-stream', + contentLength: 1600, lastModified: '', blobId: 'b', parentId: null, + } as FileResource; + + it('shows the name, size and Download, and Open in default app only when the desktop app can', () => { + const onDownload = vi.fn(); + const onOpenInApp = vi.fn(); + const { rerender } = render(); + const pane = screen.getByTestId('file-no-preview'); + expect(pane).toHaveTextContent('budget-2026.xlsx'); + expect(pane).toHaveTextContent('1.6 KB'); + expect(screen.queryByRole('button', { name: 'open_in_app' })).toBeNull(); + fireEvent.click(screen.getAllByRole('button', { name: 'download' }).at(-1)!); + expect(onDownload).toHaveBeenCalled(); + + rerender(); + fireEvent.click(screen.getByRole('button', { name: 'open_in_app' })); + expect(onOpenInApp).toHaveBeenCalled(); + }); +}); + +describe('"Upload files…" ends in "…" (pim-26)', () => { + it('in an empty folder too', () => { + render(); + expect(screen.getByRole('button', { name: 'files_upload_files' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'files_new_folder' })).toBeInTheDocument(); + }); +}); + +describe('after sharing with someone new (pim-26)', () => { + it('reports who was added, not a change or a removal', async () => { + const report = { + id: 'r1', name: 'report.pdf', serverName: 'report.pdf', isDirectory: false, contentType: 'application/pdf', contentLength: 5, + lastModified: '', blobId: 'b', parentId: null, shareWith: { 'p-old': { mayRead: true } }, + } as unknown as FileResource; + const client = { listAllFileNodesAcrossAccounts: vi.fn(async () => []) } as unknown as IJMAPClient; + act(() => { + useFileStore.setState({ client, sharedRoots: [], allFolders: [], currentParentId: null, pathStack: [{ id: null, name: '' }], hasListed: true }); + }); + const onShare = vi.fn(async () => {}); + const onShared = vi.fn(); + const noop = vi.fn(async () => {}); + render( + ''} favorites={[]} recentFiles={[]} + onToggleFavorite={vi.fn()} onOpenRecent={vi.fn()} onRenameNode={noop} onDeleteNode={noop} onDownloadNode={noop} onRevealNode={noop} + onRemoveRecent={vi.fn()} client={client} ownAccountId="a" sharingEnabled onShare={onShare} onShared={onShared} + />, + ); + fireEvent.contextMenu(screen.getByTitle('report.pdf')); + fireEvent.click(screen.getByRole('menuitem', { name: 'files_share' })); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'change old' })); }); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'remove' })); }); + expect(onShared).not.toHaveBeenCalled(); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'add new' })); }); + expect(onShare).toHaveBeenLastCalledWith('r1', 'p-new', { mayRead: true }); + expect(onShared).toHaveBeenCalledWith(expect.objectContaining({ id: 'r1' }), 'p-new'); + }); + + it('links the person to the node as it is listed for them, a file with its preview', () => { + expect(sharedNodeLink('https://mail.example', 'acc1', { id: 'n7', name: 'report.pdf', isDirectory: false })) + .toBe('https://mail.example/files?shared=acc1%3An7&preview=report.pdf'); + expect(sharedNodeLink('https://mail.example', 'acc1', { id: 'd2', name: 'Team', isDirectory: true })) + .toBe('https://mail.example/files?shared=acc1%3Ad2'); + }); +}); diff --git a/components/files/__tests__/ux-pim-open-files.test.tsx b/components/files/__tests__/ux-pim-open-files.test.tsx new file mode 100644 index 000000000..5c8181728 --- /dev/null +++ b/components/files/__tests__/ux-pim-open-files.test.tsx @@ -0,0 +1,211 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'; +import { FileGridView, FileListView, type FileItemHandlers } from '../file-views'; +import { FileBrowser } from '../file-browser'; +import { FilePreviewModal } from '../file-preview-modal'; +import { BACKDROP_GRACE_MS } from '../preview-shell'; +import { useFileStore, type FileResource } from '@/stores/file-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { DEFAULT_FILES_SETTINGS } from '@/lib/files-settings'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +// pim-3: a double-click opens a file and the preview stays open; the name is +// plain text in both views (a click selects). pim-16: a file with no preview +// and no editor opens a pane with Download instead of downloading at once. + +vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } })); +vi.mock('@/components/settings/share-collection-dialog', () => ({ ShareCollectionDialog: () => null })); +vi.mock('@/lib/standalone-display', () => ({ canOpenUrlInNewWindow: () => false })); + +const res = (id: string, name: string, extra: Partial = {}): FileResource => ({ + id, name, serverName: name, isDirectory: false, contentType: 'text/plain', contentLength: 10, + lastModified: '2026-10-01T00:00:00Z', blobId: `b-${id}`, parentId: null, ...extra, +} as FileResource); +const notes = res('n', 'notes.txt'); +const budget = res('x', 'budget.xlsx', { contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); +const docs = res('d', 'Documents', { isDirectory: true, contentType: '', blobId: null }); + +type Spy = ReturnType; +function handlers(): FileItemHandlers & { onOpen: Spy; onSelectClick: Spy } { + return { + onSelectClick: vi.fn(), + onToggleSelect: vi.fn(), + onOpen: vi.fn(), + onContextMenu: vi.fn(), + onDownload: vi.fn(), + onShare: vi.fn(), + canShare: () => false, + dragProps: () => ({}), + onFocusItem: vi.fn(), + } as never; +} + +const viewProps = (h: FileItemHandlers) => ({ + resources: [docs, notes], + selected: new Set(), + focusedName: null, + tabStopName: null, + dropTarget: null, + cutNames: new Set(), + starredPaths: new Set(), + pathOf: (r: FileResource) => `/${r.name}`, + touch: false, + iconOptions: { showIcons: true, coloredIcons: true, showThumbnails: false }, + getImageUrl: async () => '', + handlers: h, +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe('a click on a name selects, a double-click opens (pim-3)', () => { + it('in the list', () => { + const h = handlers(); + render(); + for (const name of ['notes.txt', 'Documents']) { + const row = screen.getByTitle(name).closest('[role="row"]')!; + expect(within(row as HTMLElement).queryByRole('button', { name })).toBeNull(); + fireEvent.click(screen.getByTitle(name)); + expect(h.onOpen).not.toHaveBeenCalled(); + expect(h.onSelectClick).toHaveBeenLastCalledWith(expect.objectContaining({ name }), expect.anything()); + fireEvent.doubleClick(screen.getByTitle(name)); + expect(h.onOpen).toHaveBeenLastCalledWith(expect.objectContaining({ name })); + h.onOpen.mockClear(); + } + }); + + it('in the grid', () => { + const h = handlers(); + render(); + for (const name of ['notes.txt', 'Documents']) { + fireEvent.click(screen.getByTitle(name)); + expect(h.onOpen).not.toHaveBeenCalled(); + expect(h.onSelectClick).toHaveBeenLastCalledWith(expect.objectContaining({ name }), expect.anything()); + fireEvent.doubleClick(screen.getByTitle(name)); + expect(h.onOpen).toHaveBeenLastCalledWith(expect.objectContaining({ name })); + h.onOpen.mockClear(); + } + }); + + it('on touch a tap on the name still opens', () => { + const h = handlers(); + render(); + fireEvent.click(screen.getByTitle('notes.txt')); + expect(h.onOpen).toHaveBeenCalledWith(expect.objectContaining({ name: 'notes.txt' })); + }); +}); + +describe('the preview stays open after the click that opened it (pim-3)', () => { + it('ignores a backdrop click in the first 400 ms', () => { + let now = 1_000_000; + vi.spyOn(Date, 'now').mockImplementation(() => now); + const onClose = vi.fn(); + render( new Promise(() => {})} />); + const backdrop = screen.getByRole('dialog'); + now += BACKDROP_GRACE_MS - 50; + fireEvent.click(backdrop); + expect(onClose).not.toHaveBeenCalled(); + now += 100; + fireEvent.click(backdrop); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); + +function renderBrowser(props: Partial> = {}) { + const client = { listAllFileNodesAcrossAccounts: vi.fn(async () => []) } as unknown as IJMAPClient; + act(() => { + useFileStore.setState({ client, sharedRoots: [], allFolders: [docs], currentParentId: null, pathStack: [{ id: null, name: '' }], hasListed: true }); + }); + const h = { + onPreviewFile: vi.fn(), + onPreviewImage: vi.fn(), + onDownload: vi.fn(async () => {}), + onShowFileInfo: vi.fn(), + onSetSelection: vi.fn(), + }; + const noop = vi.fn(async () => {}); + render( + ''} + favorites={[]} + recentFiles={[]} + onToggleFavorite={vi.fn()} + onOpenRecent={vi.fn()} + onRenameNode={noop} + onDeleteNode={noop} + onDownloadNode={noop} + onRevealNode={noop} + onRemoveRecent={vi.fn()} + client={client} + ownAccountId="a" + {...h} + {...props} + />, + ); + return h; +} + +describe('a file with no preview and no editor (pim-16)', () => { + beforeEach(() => { + localStorage.clear(); + act(() => { useSettingsStore.setState({ filesSettings: DEFAULT_FILES_SETTINGS }); }); + }); + + it('opens the pane instead of downloading', () => { + const h = renderBrowser(); + fireEvent.doubleClick(screen.getByTitle('budget.xlsx')); + expect(h.onShowFileInfo).toHaveBeenCalledWith('budget.xlsx'); + expect(h.onDownload).not.toHaveBeenCalled(); + fireEvent.doubleClick(screen.getByTitle('notes.txt')); + expect(h.onPreviewFile).toHaveBeenCalledWith('notes.txt'); + }); + + it('has Open in its menu, and in the desktop app Open in default app', () => { + const onOpenInApp = vi.fn(); + const h = renderBrowser({ canOpenInApp: (r) => r.name === 'budget.xlsx', onOpenInApp }); + fireEvent.contextMenu(screen.getByTitle('budget.xlsx')); + fireEvent.click(screen.getByRole('menuitem', { name: 'open' })); + expect(h.onShowFileInfo).toHaveBeenCalledWith('budget.xlsx'); + fireEvent.contextMenu(screen.getByTitle('budget.xlsx')); + fireEvent.click(screen.getByRole('menuitem', { name: 'open_in_app' })); + expect(onOpenInApp).toHaveBeenCalledWith(expect.objectContaining({ name: 'budget.xlsx' })); + // A text file previews: no extra Open there. + fireEvent.contextMenu(screen.getByTitle('notes.txt')); + expect(screen.queryByRole('menuitem', { name: 'open' })).toBeNull(); + }); + + it('Enter opens the row Tab put the focus on', () => { + const h = renderBrowser(); + const row = document.querySelector('[data-resource="budget.xlsx"]')!; + fireEvent.focus(row); + fireEvent.keyDown(row, { key: 'Enter' }); + expect(h.onShowFileInfo).toHaveBeenCalledWith('budget.xlsx'); + }); +}); diff --git a/components/files/file-browser.tsx b/components/files/file-browser.tsx index 2b514c5ef..b0f00c24d 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, Pin, PinOff, FilePlus, CopyPlus, AlertCircle, FolderUp, Share2, SquarePen, Eye, FolderOpen, - RefreshCw, Star, StarOff, Plus, CheckSquare, File, RotateCcw, FolderInput, + RefreshCw, Star, StarOff, Plus, CheckSquare, File, RotateCcw, FolderInput, ExternalLink, } from "@/components/icons"; import { useIsFullDesktop, useIsMobile, useMediaQuery } from "@/hooks/use-media-query"; import { useContextMenu } from "@/hooks/use-context-menu"; @@ -138,6 +138,13 @@ interface FileBrowserProps { sharingEnabled?: boolean; /** Add/update/remove a principal's share on a node. Set null rights to revoke. */ onShare?: (id: string, principalId: string, rights: FileNodeRights | null) => Promise; + /** A file with no preview and no editor: shows its icon, size and Download (pim-16). Left out: it downloads. */ + onShowFileInfo?: (name: string) => void; + /** The desktop app can open this file in its program. */ + canOpenInApp?: (resource: FileResource) => boolean; + onOpenInApp?: (resource: FileResource) => void; + /** A principal was given access to a node they couldn't see before. */ + onShared?: (resource: FileResource, principalId: string) => void; } function isPreviewable(name: string): boolean { @@ -220,9 +227,14 @@ export function FileBrowser({ ownAccountId, sharingEnabled, onShare, + onShowFileInfo, + canOpenInApp, + onOpenInApp, + onShared, }: FileBrowserProps) { const t = useTranslations("files"); const tMenu = useTranslations("menuAudit"); + const tUx = useTranslations("ux_pim_files"); // Menu entries that open a dialog end in "…". const newFileLabel = newFileFormats.length > 0 ? tMenu("files_new_file") : tMenu("files_new_text_file"); const fileNameRules = useMemo(() => client?.getFileNameRules?.() ?? null, [client]); @@ -517,10 +529,12 @@ export function FileBrowser({ onPreviewImage(resource.name); } else if (isPreviewable(resource.name)) { onPreviewFile(resource.name); + } else if (onShowFileInfo) { + onShowFileInfo(resource.name); } else { void onDownload(resource.name); } - }, [onNavigate, pathOf, isOfficeEditable, onEditFile, onPreviewImage, onPreviewFile, onDownload]); + }, [onNavigate, pathOf, isOfficeEditable, onEditFile, onPreviewImage, onPreviewFile, onShowFileInfo, onDownload]); const previewResource = useCallback((resource: FileResource) => { if (isImageFile(resource.name)) onPreviewImage(resource.name); @@ -810,9 +824,14 @@ export function FileBrowser({ setRenameTarget(singleSelected.name); return; } - if (e.key === "Enter" && singleSelected) { + // Enter opens the selected item, or the one Tab put the focus on. + const enterTarget = singleSelected + ?? (selected.length === 0 && focusedName && (e.target as HTMLElement | null)?.dataset?.resource === focusedName + ? displayResources.find((r) => r.name === focusedName) ?? null + : null); + if (e.key === "Enter" && enterTarget) { e.preventDefault(); - openResource(singleSelected); + openResource(enterTarget); return; } if (e.key === " " && focusedName) { @@ -852,7 +871,7 @@ export function FileBrowser({ showNewFolder, showNewTextFile, renameTarget, renameFolder, shareTargetId, moveTarget, accountPickerMode, selectedList, singleSelected, inTrash, showDetails, isNarrow, toggleDetails, selectAllVisible, onCopy, onCut, onPaste, clipboard, searchQuery, onClearSelection, deleteNames, openResource, - focusedName, onToggleSelect, currentPath, navigateUp, viewMode, gridColumns, moveFocus, + focusedName, onToggleSelect, currentPath, navigateUp, viewMode, gridColumns, moveFocus, displayResources, ]); // ---- Context menu ---------------------------------------------------------- @@ -943,6 +962,13 @@ export function FileBrowser({ {isOfficeEditable?.(resource.name) && onEditFile && ( { closeMenu(); onEditFile(resource.name); }} /> )} + {/* Neither previewed nor edited here: Open shows what there is (pim-16). */} + {!isPreviewable(resource.name) && !(isOfficeEditable?.(resource.name) && onEditFile) && onShowFileInfo && ( + { closeMenu(); onShowFileInfo(resource.name); }} /> + )} + {canOpenInApp?.(resource) && onOpenInApp && ( + { closeMenu(); onOpenInApp(resource); }} /> + )} )} {canKeepOffline(resource) && ( @@ -1501,7 +1527,10 @@ export function FileBrowser({ shareWith={shareTarget.shareWith} ownAccountId={ownAccountId || ""} onShare={async (principalId, rights) => { + const added = !!rights && !shareTarget.shareWith?.[principalId]; await onShare(shareTarget.id, principalId, rights as FileNodeRights | null); + // Someone new can see it now: offer to tell them (pim-26). + if (added) onShared?.(shareTarget, principalId); }} onClose={() => setShareTargetId(null)} /> diff --git a/components/files/file-empty-state.tsx b/components/files/file-empty-state.tsx index 87ca6a714..270155f19 100644 --- a/components/files/file-empty-state.tsx +++ b/components/files/file-empty-state.tsx @@ -15,6 +15,8 @@ interface EmptyFolderProps { /** What an empty folder shows: what to do next, and that dropping files works. */ export function EmptyFolder({ isRoot, canCreate, onUpload, onCreateFolder }: EmptyFolderProps) { const t = useTranslations("files"); + // Both open a picker or a dialog, so they end in "…" as the menus' entries do. + const tMenu = useTranslations("menuAudit"); return ( ) : undefined} diff --git a/components/files/file-item-parts.tsx b/components/files/file-item-parts.tsx index 37a33b43e..a2364ca85 100644 --- a/components/files/file-item-parts.tsx +++ b/components/files/file-item-parts.tsx @@ -203,6 +203,34 @@ export function readDraggedNames(e: React.DragEvent): string[] { } } +/** + * A file name split for cutting in the middle: the start gives way, the end + * stays, so "IMG_20250611_1003.png" reads "IMG_20250…1003.png" and the + * extension and the part that tells similar names apart stay in view. Short + * names and folders' names (no extension) keep a shorter tail. + */ +export function splitNameForMiddleCut(name: string): { head: string; tail: string } { + const dot = name.lastIndexOf("."); + const ext = dot > 0 && name.length - dot <= 10 ? name.slice(dot) : ""; + const tailLength = Math.min(ext.length + 4, Math.floor(name.length / 2)); + if (name.length < 12 || tailLength <= 0) return { head: name, tail: "" }; + return { head: name.slice(0, name.length - tailLength), tail: name.slice(name.length - tailLength) }; +} + +/** + * A name cut in the middle when there is no room (pim-21). The whole name is + * the title; screen readers read it as one text. + */ +export function MiddleCutName({ name, title, className }: { name: string; title?: string; className?: string }) { + const { head, tail } = splitNameForMiddleCut(name); + return ( + + {head} + {tail && {tail}} + + ); +} + /** Open a shared context menu as a dropdown under a button. */ export function anchorEvent(el: HTMLElement, align: "start" | "end" = "start"): React.MouseEvent { const rect = el.getBoundingClientRect(); diff --git a/components/files/file-no-preview.tsx b/components/files/file-no-preview.tsx new file mode 100644 index 000000000..3b8f78b6f --- /dev/null +++ b/components/files/file-no-preview.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { Download, ExternalLink } from "@/components/icons"; +import { Button } from "@/components/ui/button"; +import { PreviewShell, type PreviewNeighbours } from "@/components/files/preview-shell"; +import { FileSize, ResourceIcon } from "@/components/files/file-item-parts"; +import { useCloseOnBack } from "@/hooks/use-close-on-back"; +import type { FileResource } from "@/stores/file-store"; + +const ICON_OPTIONS = { showIcons: true, coloredIcons: true }; + +/** + * A file the app can't show and no office editor opens (a spreadsheet with + * no Collabora or ONLYOFFICE set up, a zip): its icon, name and size, with + * Download and, in the desktop app, Open in the program the computer uses + * for it (pim-16). It used to download at once, with no word. + */ +export function FileNoPreview({ resource, onClose, onDownload, onOpenInApp, neighbours }: { + resource: FileResource; + onClose: () => void; + /** Left out when a device policy turned downloads off. */ + onDownload?: () => void; + /** Only in the desktop app, for a file it may open. */ + onOpenInApp?: () => void; + neighbours?: PreviewNeighbours; +}) { + const t = useTranslations("files"); + const tUx = useTranslations("ux_pim_files"); + useCloseOnBack({ onBack: onClose }); + return ( + +
+
e.stopPropagation()} + > + +

{resource.name}

+

+

{tUx("no_preview")}

+
+ {onOpenInApp && ( + + )} + {onDownload && ( + + )} +
+
+
+
+ ); +} diff --git a/components/files/file-preview-modal.tsx b/components/files/file-preview-modal.tsx index 89d1a114c..59f681f03 100644 --- a/components/files/file-preview-modal.tsx +++ b/components/files/file-preview-modal.tsx @@ -4,7 +4,7 @@ import { useState, useEffect, useMemo } from "react"; import { useTranslations } from "next-intl"; import { ExternalLink } from "@/components/icons"; import { Spinner } from "@/components/ui/spinner"; -import { PreviewShell, type PreviewNeighbours } from "@/components/files/preview-shell"; +import { PreviewShell, useBackdropClose, type PreviewNeighbours } from "@/components/files/preview-shell"; import { IconButton } from "@/components/ui/icon-button"; import { ZoomableImage } from "@/components/files/zoomable-image"; import { getFilePreviewKind, imageBlobUrl, isMimeTypeSafeForInlinePreview, previewBlobType } from "@/lib/file-preview"; @@ -233,6 +233,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi // Back closes the preview instead of leaving Files. useCloseOnBack({ onBack: onClose }); + const closeFromBackdrop = useBackdropClose(onClose); const fileType = resolvedFileType; // The browser's own PDF viewer has Download and Print buttons of its own. @@ -412,7 +413,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi {!loading && !error && fileType === "image" && objectUrl && (
- +
)} diff --git a/components/files/file-views.tsx b/components/files/file-views.tsx index 972e8cfad..7087c3d62 100644 --- a/components/files/file-views.tsx +++ b/components/files/file-views.tsx @@ -7,7 +7,7 @@ import { cn } from "@/lib/utils"; import { IconButton } from "@/components/ui/icon-button"; import type { FileResource } from "@/stores/file-store"; import { - FileSize, ResourceIcon, ShareBadge, Thumbnail, canShowThumbnail, formatFullDate, formatShortDate, + FileSize, MiddleCutName, ResourceIcon, ShareBadge, Thumbnail, canShowThumbnail, formatFullDate, formatShortDate, type IconOptions, } from "@/components/files/file-item-parts"; import type { SortDir, SortKey } from "@/components/files/files-header"; @@ -262,21 +262,16 @@ function FileListRow({ resource, isSelected, isFocused, isTabStop, isDropTarget, ) : icon}
- + {isStarred && }
@@ -427,16 +422,9 @@ function FolderTile({ resource, isSelected, isFocused, isTabStop, isDropTarget, )} - + {isStarred && }
- +
{!resource.isSymlink && <> · }{formatShortDate(resource.lastModified)} diff --git a/components/files/files-app.tsx b/components/files/files-app.tsx index a4bdf9456..8b8786e1a 100644 --- a/components/files/files-app.tsx +++ b/components/files/files-app.tsx @@ -8,7 +8,7 @@ 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, deletesToTrash, isSharedFilesPage, isSharedLocation, linkedPreviewResource, resourceServerRef, resourceToView, type UploadReport } from "@/stores/file-store"; +import { useFileStore, deletesToTrash, isSharedFilesPage, isSharedLocation, linkedPreviewResource, resourceServerRef, resourceToView, type FileResource, type UploadReport } from "@/stores/file-store"; import { hasQueuedFilesUpload, setFilesDropTarget, subscribeQueuedFilesUpload, takeQueuedFilesUpload } from "@/lib/file-drop/files-target"; import { isTrashLocation } from "@/lib/files-trash"; import { fileErrorKind, isPartialCopy } from "@/lib/file-errors"; @@ -29,6 +29,15 @@ import type { FileNodeRights } from "@/lib/jmap/types"; import { ImagePreviewModal } from "@/components/files/image-preview-modal"; import { FilePreviewModal } from "@/components/files/file-preview-modal"; import { WopiEditor } from "@/components/files/wopi-editor"; +import { UploadConflictDialog, type UploadConflictQuestion } from "@/components/files/upload-conflict-dialog"; +import { FileNoPreview } from "@/components/files/file-no-preview"; +import { sharedNodeLink } from "@/components/files/share-message"; +import { loadPrincipalDirectory } from "@/lib/jmap/principal-directory"; +import { savePendingMailto } from "@/lib/protocol-handlers/session"; +import { formatRecipient } from "@/lib/email-composer-utils"; +import { useRouter } from "@/i18n/navigation"; +import { useEditAttachment } from "@/hooks/use-edit-attachment"; +import { resolveConflictsInTurn, type ConflictAnswer, type UploadConflictResolver } from "@/lib/files-upload-conflicts"; import { useWopiStatus, canWopiOpen, fileExtension } from "@/hooks/use-wopi-status"; import { apiFetch } from "@/lib/browser-navigation"; import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot"; @@ -141,6 +150,8 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); const [previewImage, setPreviewImage] = useState(null); const [previewFile, setPreviewFile] = useState(null); + // A file with no preview and no editor: its icon, size and Download (pim-16). + const [infoFile, setInfoFile] = useState(null); // WOPI document editing (#425): name of the file open in the editor overlay. const [editFile, setEditFile] = useState(null); const wopiStatus = useWopiStatus(filesEnabled); @@ -380,6 +391,22 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { const maxSizeUpload = client?.getMaxSizeUpload() || 0; + // A name that is taken in the target folder: asked one at a time (pim-10). + const [conflictQuestion, setConflictQuestion] = useState(null); + const resolveConflicts = useCallback((conflicts) => resolveConflictsInTurn( + conflicts, + (conflict, index, total) => new Promise((resolve) => { + setConflictQuestion({ + conflict, + remaining: total - index, + answer: (answer) => { + setConflictQuestion(null); + resolve(answer); + }, + }); + }), + ), []); + const handleUploadFiles = useCallback(async (files: File[]) => { if (maxSizeUpload > 0) { const oversized = files.filter(f => f.size > maxSizeUpload); @@ -390,12 +417,12 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { } if (files.length === 0) return; try { - showUploadReport(await uploadFiles(files)); + showUploadReport(await uploadFiles(files, { resolveConflicts })); } catch (err) { console.error("Failed to upload files:", err); toast.error(t("upload_error"), failureReason(err)); } - }, [uploadFiles, t, maxSizeUpload, showUploadReport, failureReason]); + }, [uploadFiles, t, maxSizeUpload, showUploadReport, failureReason, resolveConflicts]); const handleUploadFolder = useCallback(async (files: File[]) => { if (maxSizeUpload > 0) { @@ -407,12 +434,12 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { } if (files.length === 0) return; try { - showUploadReport(await uploadFolder(files)); + showUploadReport(await uploadFolder(files, { resolveConflicts })); } catch (err) { console.error("Failed to upload folder:", err); toast.error(t("upload_error"), failureReason(err)); } - }, [uploadFolder, t, maxSizeUpload, showUploadReport, failureReason]); + }, [uploadFolder, t, maxSizeUpload, showUploadReport, failureReason, resolveConflicts]); const handleUndo = useCallback(async () => { try { @@ -648,6 +675,27 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { addRecentFile(name, findResourceId(name)); }, [addRecentFile, findResourceId]); + const handleShowFileInfo = useCallback((name: string) => { + setInfoFile(name); + addRecentFile(name, findResourceId(name)); + }, [addRecentFile, findResourceId]); + + // The desktop app opens a file in the program the computer uses for it, + // the way Edit opens an attachment (pim-16). Nothing in a browser. + const filesLocalAccount = useFileStore((s) => s.currentAccountId) ?? activeAccountId ?? ""; + const { canEdit: canOpenNameInApp, edit: openInDesktopApp } = useEditAttachment(filesLocalAccount); + const canOpenInApp = useCallback((r: FileResource) => !r.isDirectory && !r.isSymlink && canOpenNameInApp(r.name), [canOpenNameInApp]); + const handleOpenInApp = useCallback((r: FileResource) => { + void openInDesktopApp({ + key: `files:${filesLocalAccount}:${r.id}`, + name: r.name, + type: r.contentType || "application/octet-stream", + account: filesLocalAccount, + source: { kind: "file" }, + load: async () => (await getFileContent(r.name)).blob, + }); + }, [openInDesktopApp, filesLocalAccount, getFileContent]); + const handleRenameNode = useCallback(async (id: string, newName: string) => { try { if (!await renameNode(id, newName)) return; @@ -719,6 +767,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { if (isOfficeEditable(name)) setEditFile(name); else if (isImageFile(name)) setPreviewImage(name); else if (isFilePreviewable(name)) setPreviewFile(name); + else setInfoFile(name); }, [revealNode, addRecentFile, isOfficeEditable, tDeepLink]); // The permalink for the folder on screen. In the Pro shell only the focused @@ -776,6 +825,36 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { await shareResource(id, principalId, rights); }, [shareResource]); + // After sharing with someone new: "Send a message" opens the composer to + // them with a link to it (pim-26). The server doesn't tell them. + const tUx = useTranslations("ux_pim_files"); + const router = useRouter(); + const handleShared = useCallback((resource: FileResource, principalId: string) => { + if (!storeClient || !filesAccountId) return; + void loadPrincipalDirectory(storeClient).then(({ principals }) => { + const person = principals.find((p) => p.id === principalId); + const email = person?.email?.trim(); + if (!person || !email) return; + const link = sharedNodeLink(window.location.origin, filesAccountId, resource); + toast.success(tUx("shared_with", { name: resource.name, person: person.name || email }), { + action: { + label: tUx("send_message"), + onClick: () => { + savePendingMailto({ + to: [formatRecipient(person.name && person.name !== email ? person.name : "", email)], + cc: [], + bcc: [], + subject: tUx("share_mail_subject", { name: resource.name }), + body: tUx("share_mail_body", { name: resource.name, link }), + inApp: true, + }); + router.push("/"); + }, + }, + }); + }); + }, [storeClient, filesAccountId, tUx, router]); + // Pro shell only: all connected accounts are equal top-level entries at // the root. The root path "/" itself is a cross-account picker - no // account's files are shown until the user enters one. @@ -949,6 +1028,10 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { ownAccountId={filesAccountId} sharingEnabled={sharingEnabled} onShare={handleShare} + onShowFileInfo={handleShowFileInfo} + canOpenInApp={canOpenInApp} + onOpenInApp={handleOpenInApp} + onShared={handleShared} /> )}
@@ -990,6 +1073,20 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { /> )} + {/* A file with no preview (pim-16) */} + {infoFile && (() => { + const resource = resources.find((r) => r.name === infoFile); + if (!resource) return null; + return ( + setInfoFile(null)} + onDownload={filesDownloadsOff ? undefined : () => void handleDownload(infoFile)} + onOpenInApp={canOpenInApp(resource) ? () => handleOpenInApp(resource) : undefined} + /> + ); + })()} + {/* WOPI document editor overlay (#425) */} {editFile && (() => { const editResource = resources.find(r => r.name === editFile); @@ -1028,6 +1125,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { +
); diff --git a/components/files/image-preview-modal.tsx b/components/files/image-preview-modal.tsx index eb8510498..f5dc709d8 100644 --- a/components/files/image-preview-modal.tsx +++ b/components/files/image-preview-modal.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from "react"; import { Spinner } from "@/components/ui/spinner"; -import { PreviewShell, type PreviewNeighbours } from "@/components/files/preview-shell"; +import { PreviewShell, useBackdropClose, type PreviewNeighbours } from "@/components/files/preview-shell"; import { useTranslations } from "next-intl"; import { ZoomableImage } from "@/components/files/zoomable-image"; import { FilePreviewPluginSection } from "@/components/files/file-preview-plugin-section"; @@ -35,6 +35,7 @@ export function ImagePreviewModal({ name, onClose, onDownload, getImageUrl, file // Back closes the preview instead of leaving Files, as for every other preview. useCloseOnBack({ onBack: onClose }); + const closeFromBackdrop = useBackdropClose(onClose); useEffect(() => { let revoke: string | null = null; @@ -76,7 +77,7 @@ export function ImagePreviewModal({ name, onClose, onDownload, getImageUrl, file )} {imageUrl && !error && ( - + )} {imageUrl && !error && } diff --git a/components/files/preview-shell.tsx b/components/files/preview-shell.tsx index e349033c6..d604d63de 100644 --- a/components/files/preview-shell.tsx +++ b/components/files/preview-shell.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState, type ReactNode } from "react"; +import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { useTranslations } from "next-intl"; import { ChevronLeft, ChevronRight, Download, X } from "@/components/icons"; import { IconButton } from "@/components/ui/icon-button"; @@ -14,6 +14,25 @@ export interface PreviewNeighbours { onGo: (name: string) => void; } +/** A backdrop click this soon after the preview opened is part of the click that opened it. */ +export const BACKDROP_GRACE_MS = 400; + +/** + * `onClose` for a click on the area around the content. The rest of the + * click that opened the preview (a double-click's second click, a + * triple-click) is ignored, so it can't close the preview again (pim-3). + */ +export function useBackdropClose(onClose: () => void): () => void { + const openedAt = useRef(0); + useEffect(() => { + openedAt.current = Date.now(); + }, []); + return useCallback(() => { + if (Date.now() - openedAt.current < BACKDROP_GRACE_MS) return; + onClose(); + }, [onClose]); +} + interface PreviewShellProps { name: string; onClose: () => void; @@ -34,6 +53,7 @@ interface PreviewShellProps { */ export function PreviewShell({ name, onClose, onDownload, actions, neighbours, closeOnBackdrop = true, children }: PreviewShellProps) { const t = useTranslations("files"); + const closeFromBackdrop = useBackdropClose(onClose); // Tab stays in the preview and the focus goes back to the file that was // opened when it closes. Escape is the listener below. const shellRef = useFocusTrap({ isActive: true, restoreFocus: true }); @@ -86,7 +106,7 @@ export function PreviewShell({ name, onClose, onDownload, actions, neighbours, c aria-modal="true" aria-label={name} className="fixed inset-0 z-50 flex flex-col bg-black/80" - onClick={closeOnBackdrop ? onClose : undefined} + onClick={closeOnBackdrop ? closeFromBackdrop : undefined} > {/* Full-screen under `viewport-fit=cover`: the bar pads itself out of the iOS status bar (#936). The insets are physical, so pl/pr. */} diff --git a/components/files/share-message.ts b/components/files/share-message.ts new file mode 100644 index 000000000..40873ddde --- /dev/null +++ b/components/files/share-message.ts @@ -0,0 +1,15 @@ +import { appPath, buildSharedFilesPath } from "@/lib/deep-links"; + +/** + * The link a person a file or folder was shared with opens it by. The node + * is theirs to see under ":" (the id a drive shared + * with them is listed by); a file opens in its preview there. + */ +export function sharedNodeLink( + origin: string, + ownerAccountId: string, + node: { id: string; name: string; isDirectory: boolean }, +): string { + const shared = `${ownerAccountId}:${node.id}`; + return `${origin}${appPath(buildSharedFilesPath(shared, node.isDirectory ? null : node.name))}`; +} diff --git a/components/files/upload-conflict-dialog.tsx b/components/files/upload-conflict-dialog.tsx new file mode 100644 index 000000000..21431d3ac --- /dev/null +++ b/components/files/upload-conflict-dialog.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { useEffect, useId, useRef, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { Dialog } from "@/components/ui/dialog"; +import type { ConflictAnswer, UploadConflict, UploadConflictChoice } from "@/lib/files-upload-conflicts"; + +export interface UploadConflictQuestion { + conflict: UploadConflict; + /** How many conflicts are left, this one included. */ + remaining: number; + answer: (answer: ConflictAnswer) => void; +} + +/** + * " already exists." Replace, Keep both or Skip, and with more than + * one conflict left, "Do this for all n conflicts" (pim-10). Closing it + * (Escape, Cancel) calls off the whole upload. + */ +export function UploadConflictDialog({ question }: { question: UploadConflictQuestion | null }) { + const t = useTranslations("ux_pim_files"); + const tConfirm = useTranslations("confirm_dialog"); + const [applyToAll, setApplyToAll] = useState(false); + const checkboxId = useId(); + const replaceRef = useRef(null); + // Each question starts unticked. + useEffect(() => setApplyToAll(false), [question]); + if (!question) return null; + const { conflict, remaining, answer } = question; + const choose = (choice: UploadConflictChoice) => answer({ choice, applyToAll: applyToAll && remaining > 1 }); + return ( + answer(null)} + role="alertdialog" + title={t("conflict_title", { name: conflict.name })} + description={conflict.kind === "folder" ? t("conflict_folder_hint") : t("conflict_file_hint")} + showClose={false} + initialFocus={replaceRef} + testId="upload-conflict-dialog" + footer={ + <> + + + + + + } + > + {remaining > 1 && ( + + )} + + ); +} diff --git a/integration/tests/25-ux-pim-files.spec.ts b/integration/tests/25-ux-pim-files.spec.ts new file mode 100644 index 000000000..e3d4894bc --- /dev/null +++ b/integration/tests/25-ux-pim-files.spec.ts @@ -0,0 +1,230 @@ +import { test, expect, type Page } from '@playwright/test'; +import path from 'node:path'; +import { contextOptions } from './helpers/ent'; +import { JmapClient } from './helpers/jmap'; +import { accountSwitcher, neutralizeDevOverlay } from './helpers/app'; + +/** + * Usability fixes in Files (pim-3, pim-10, pim-16, pim-26) against a VM 106 + * Stalwart, 1.0 and 0.16. FileNode differs between them (1.0: draft 18 with + * nodeType and `@type`; 0.16: blobId == null marks a folder), so the server + * state is read back over JMAP after every step. + * + * - pim-10: uploading a name that exists asks Replace / Keep both / Skip. + * Replace keeps the node id and gives it a new blob, Keep both makes the + * "(1)" copy, Skip changes nothing; "Do this for all" answers the rest. + * - pim-3: a double-click opens the preview and it stays open. + * - pim-16: a file with no preview opens a pane with Download. + * - pim-26: delete has Undo, and sharing offers "Send a message" with a link. + * + * Account dso-ux-pim (and dso-ux-pim-b to share with), password + * IT_DSO_PASSWORD. Every node made here is named "UXFIX-files …" and removed. + */ + +const PASSWORD = process.env.IT_DSO_PASSWORD ?? ''; +const A = 'dso-ux-pim@example.org'; +const B = 'dso-ux-pim-b@example.org'; +const CORE = 'urn:ietf:params:jmap:core'; +const FILENODE = 'urn:ietf:params:jmap:filenode'; +const RUN = Date.now().toString(36); +const PREFIX = `UXFIX-files ${RUN}`; +const SHOTS = process.env.IT_UXPIM_SHOTS; + +test.skip(!PASSWORD, 'Set IT_DSO_PASSWORD (vault "Stalwart test – dso-* throwaway accounts")'); + +type Node = { id: string; name: string; parentId: string | null; blobId: string | null; shareWith?: Record | null }; + +async function nodes(c: JmapClient): Promise { + const r = await c.request([['FileNode/get', { accountId: c.filesAccountId, ids: null }, '0']], [CORE, FILENODE]); + return r.methodResponses[0][1].list as Node[]; +} + +async function ours(c: JmapClient): Promise { + return (await nodes(c)).filter((n) => n.name.startsWith(PREFIX)); +} + +async function cleanUp(c: JmapClient): Promise { + // Our nodes wherever they are, the trash's batch folders included. + const all = await nodes(c); + const ids = all.filter((n) => n.name.startsWith(PREFIX)).map((n) => n.id); + if (ids.length) await c.request([['FileNode/set', { accountId: c.filesAccountId, destroy: ids }, '0']], [CORE, FILENODE]); + // The draft "Send a message" started. + const q = await c.request([['Email/query', { accountId: c.accountId, filter: { subject: PREFIX } }, '0']]); + const drafts = q.methodResponses[0][1].ids as string[]; + if (drafts?.length) await c.request([['Email/set', { accountId: c.accountId, destroy: drafts }, '0']]); +} + +async function login(page: Page, email: string): Promise { + await neutralizeDevOverlay(page); + await page.goto('/'); + await page.locator('#username').waitFor({ state: 'visible', timeout: 60000 }); + await page.fill('#username', email); + await page.fill('#password', PASSWORD); + await page.click('button[type="submit"]'); + await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60000 }); +} + +async function openFilesRoot(page: Page): Promise { + await page.goto('/files'); + await expect(page.getByRole('grid', { name: 'Files and folders' }).first()).toBeVisible({ timeout: 60000 }); +} + +async function upload(page: Page, files: { name: string; text: string }[]): Promise { + await page.locator('input[type="file"][multiple]:not([webkitdirectory])').first().setInputFiles( + files.map((f) => ({ name: f.name, mimeType: 'text/plain', buffer: Buffer.from(f.text) })), + ); +} + +async function shot(page: Page, name: string): Promise { + if (SHOTS) await page.screenshot({ path: path.join(SHOTS, name) }); +} + +const row = (page: Page, name: string) => page.locator(`[data-resource="${name}"]`).first(); + +test.describe('Files (pim-3, pim-10, pim-16, pim-26)', () => { + test.describe.configure({ mode: 'serial' }); + + test.afterAll(async () => { + const a = await JmapClient.connect(A, PASSWORD); + await cleanUp(a); + }); + + test('an upload over an existing name asks, and each answer shows on the server', async ({ browser }) => { + test.setTimeout(240_000); + const a = await JmapClient.connect(A, PASSWORD); + const name = `${PREFIX}.txt`; + const copy = `${PREFIX} (1).txt`; + const context = await browser.newContext({ ...contextOptions(), viewport: { width: 1366, height: 768 } }); + const page = await context.newPage(); + await login(page, A); + await openFilesRoot(page); + + await upload(page, [{ name, text: 'one' }]); + await expect.poll(async () => (await ours(a)).map((n) => n.name), { timeout: 30000 }).toEqual([name]); + const [first] = await ours(a); + await expect(row(page, name)).toBeVisible({ timeout: 30000 }); + + // Replace: the same node, a new blob, no copy. + await upload(page, [{ name, text: 'two, longer' }]); + const dialog = page.getByRole('alertdialog', { name: `${name} already exists.` }); + await expect(dialog).toBeVisible({ timeout: 30000 }); + await expect(dialog.getByRole('checkbox')).toHaveCount(0); + await shot(page, 'pim-fix-10-after.png'); + await dialog.getByRole('button', { name: 'Replace', exact: true }).click(); + await expect.poll(async () => { + const list = await ours(a); + return list.map((n) => ({ id: n.id, name: n.name, newBlob: n.blobId !== first.blobId })); + }, { timeout: 30000 }).toEqual([{ id: first.id, name, newBlob: true }]); + const replaced = (await ours(a))[0]; + + // Keep both: the "(1)" copy, the original untouched. + await upload(page, [{ name, text: 'three' }]); + await dialog.getByRole('button', { name: 'Keep both' }).click(); + await expect.poll(async () => (await ours(a)).map((n) => n.name).sort(), { timeout: 30000 }).toEqual([copy, name].sort()); + expect((await ours(a)).find((n) => n.name === name)).toMatchObject({ id: first.id, blobId: replaced.blobId }); + const before = JSON.stringify((await ours(a)).map((n) => [n.id, n.name, n.blobId]).sort()); + + // Skip: nothing changes. + await expect(row(page, copy)).toBeVisible({ timeout: 30000 }); + await upload(page, [{ name, text: 'four' }]); + await dialog.getByRole('button', { name: 'Skip' }).click(); + await expect(dialog).toBeHidden(); + // Two conflicts: "Do this for all 2 conflicts", and Skip answers both. + await upload(page, [{ name, text: 'five' }, { name: copy, text: 'six' }]); + await expect(dialog).toBeVisible(); + await dialog.getByRole('checkbox', { name: 'Do this for all 2 conflicts' }).check(); + await dialog.getByRole('button', { name: 'Skip' }).click(); + await expect(page.getByRole('alertdialog')).toHaveCount(0); + await page.waitForTimeout(3000); + expect(JSON.stringify((await ours(a)).map((n) => [n.id, n.name, n.blobId]).sort())).toBe(before); + await context.close(); + }); + + test('double-click opens a preview that stays, a file with no preview gets a pane, delete has Undo', async ({ browser }) => { + test.setTimeout(240_000); + const a = await JmapClient.connect(A, PASSWORD); + const name = `${PREFIX}.txt`; + const zip = `${PREFIX}.bin`; + const context = await browser.newContext({ ...contextOptions(), viewport: { width: 1366, height: 768 } }); + const page = await context.newPage(); + await login(page, A); + await openFilesRoot(page); + await upload(page, [{ name: zip, text: 'binary-ish' }]); + await expect(row(page, zip)).toBeVisible({ timeout: 30000 }); + + // pim-3: one click selects, a double-click opens, and it stays open. + await row(page, name).getByTitle(name).click(); + await expect(row(page, name)).toHaveAttribute('aria-selected', 'true'); + await expect(page.getByRole('dialog', { name })).toHaveCount(0); + await row(page, name).getByTitle(name).dblclick(); + await page.waitForTimeout(1000); + await expect(page.getByRole('dialog', { name })).toBeVisible(); + await shot(page, 'pim-fix-3-after.png'); + await page.keyboard.press('Escape'); + await expect(page.getByRole('dialog', { name })).toHaveCount(0); + + // pim-16: no preview, no editor: the pane, not a download. + let downloaded = false; + page.on('download', () => { downloaded = true; }); + await row(page, zip).getByTitle(zip).dblclick(); + const pane = page.getByTestId('file-no-preview'); + await expect(pane).toBeVisible(); + await expect(pane).toContainText('There is no preview for this type of file.'); + await expect(pane.getByRole('button', { name: 'Download' })).toBeVisible(); + await shot(page, 'pim-fix-16-after.png'); + expect(downloaded).toBe(false); + await page.keyboard.press('Escape'); + + // pim-26: delete goes to the trash with Undo, and Undo puts it back. + await row(page, zip).click(); + await page.keyboard.press('Delete'); + const undo = page.getByRole('button', { name: 'Undo' }).last(); + await expect(undo).toBeVisible({ timeout: 30000 }); + await shot(page, 'pim-fix-26-undo-after.png'); + const zipId = (await ours(a)).find((n) => n.name === zip)!.id; + await expect.poll(async () => (await nodes(a)).find((n) => n.id === zipId)?.parentId ?? null, { timeout: 30000 }).not.toBeNull(); + await undo.click(); + await expect.poll(async () => (await nodes(a)).find((n) => n.id === zipId)?.parentId ?? null, { timeout: 30000 }).toBeNull(); + await expect(row(page, zip)).toBeVisible({ timeout: 30000 }); + await context.close(); + }); + + test('sharing with someone offers a message with a link to the file', async ({ browser }) => { + test.setTimeout(240_000); + const a = await JmapClient.connect(A, PASSWORD); + const b = await JmapClient.connect(B, PASSWORD); + const name = `${PREFIX}.txt`; + const context = await browser.newContext({ ...contextOptions(), viewport: { width: 1366, height: 768 } }); + const page = await context.newPage(); + await login(page, A); + await openFilesRoot(page); + // Run on its own (-g), it makes the file the first test made. + if (!(await ours(a)).some((n) => n.name === name)) await upload(page, [{ name, text: 'one' }]); + await expect.poll(async () => (await ours(a)).some((n) => n.name === name), { timeout: 30000 }).toBe(true); + const node = (await ours(a)).find((n) => n.name === name)!; + await expect(row(page, name)).toBeVisible({ timeout: 30000 }); + + await row(page, name).getByTitle(name).click({ button: 'right' }); + await page.getByRole('menuitem', { name: /^Share/ }).click(); + const shareDialog = page.getByRole('dialog').last(); + await shareDialog.getByRole('button', { name: 'Add person or group' }).click(); + await shareDialog.getByRole('textbox', { name: 'Search by name or email…' }).fill('dso-ux-pim-b'); + await shareDialog.getByRole('button', { name: /dso-ux-pim-b/ }).first().click(); + const send = page.getByRole('button', { name: 'Send a message' }); + await expect(send).toBeVisible({ timeout: 30000 }); + await expect.poll(async () => Object.keys((await nodes(a)).find((n) => n.id === node.id)?.shareWith ?? {}).length, { timeout: 30000 }).toBe(1); + await shot(page, 'pim-fix-26-share-after.png'); + await send.click(); + + // The composer, to b, with a link the person opens it by. + const link = `?shared=${encodeURIComponent(`${a.filesAccountId}:${node.id}`)}`; + await expect(page.getByText(B).first()).toBeVisible({ timeout: 60000 }); + await expect(page.locator('[contenteditable="true"], textarea').filter({ hasText: link }).first()).toBeVisible({ timeout: 30000 }); + await shot(page, 'pim-fix-26-compose-after.png'); + + // b sees the node under that id. + const shared = await b.request([['FileNode/get', { accountId: a.filesAccountId, ids: [node.id], properties: ['id', 'name'] }, '0']], [CORE, FILENODE]); + expect(shared.methodResponses[0][1].list?.[0]?.name).toBe(name); + await context.close(); + }); +}); diff --git a/lib/__tests__/files-upload-conflicts.test.ts b/lib/__tests__/files-upload-conflicts.test.ts new file mode 100644 index 000000000..2aae678de --- /dev/null +++ b/lib/__tests__/files-upload-conflicts.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from 'vitest'; +import { findUploadConflicts, resolveConflictsInTurn, type UploadConflict } from '@/lib/files-upload-conflicts'; + +describe('findUploadConflicts', () => { + it('lists same-kind clashes once, in upload order', () => { + const incoming: UploadConflict[] = [ + { name: 'b.txt', kind: 'file' }, + { name: 'Docs', kind: 'folder' }, + { name: 'Docs', kind: 'folder' }, + { name: 'Photos', kind: 'folder' }, + { name: 'new.txt', kind: 'file' }, + ]; + const existing = [ + { name: 'Docs', isDirectory: true }, + { name: 'b.txt', isDirectory: false }, + { name: 'Photos', isDirectory: false }, + ]; + expect(findUploadConflicts(incoming, existing)).toEqual([ + { name: 'b.txt', kind: 'file' }, + { name: 'Docs', kind: 'folder' }, + ]); + }); +}); + +describe('resolveConflictsInTurn', () => { + const conflicts: UploadConflict[] = [ + { name: 'a.txt', kind: 'file' }, + { name: 'b.txt', kind: 'file' }, + { name: 'c.txt', kind: 'file' }, + ]; + + it('asks one at a time, and "Do this for all" answers the rest', async () => { + const ask = vi.fn() + .mockResolvedValueOnce({ choice: 'skip', applyToAll: false }) + .mockResolvedValueOnce({ choice: 'replace', applyToAll: true }); + const choices = await resolveConflictsInTurn(conflicts, ask); + expect(ask).toHaveBeenCalledTimes(2); + expect(ask.mock.calls.map((c) => [c[0].name, c[1], c[2]])).toEqual([['a.txt', 0, 3], ['b.txt', 1, 3]]); + expect([...choices!]).toEqual([['a.txt', 'skip'], ['b.txt', 'replace'], ['c.txt', 'replace']]); + }); + + it('stops when a question is called off', async () => { + const ask = vi.fn().mockResolvedValueOnce({ choice: 'keep', applyToAll: false }).mockResolvedValueOnce(null); + expect(await resolveConflictsInTurn(conflicts, ask)).toBeNull(); + expect(ask).toHaveBeenCalledTimes(2); + }); +}); diff --git a/lib/files-upload-conflicts.ts b/lib/files-upload-conflicts.ts new file mode 100644 index 000000000..829d135bc --- /dev/null +++ b/lib/files-upload-conflicts.ts @@ -0,0 +1,75 @@ +/* + * Uploading something whose name is already taken in the target folder + * (pim-10). The upload asks first, per name: Replace, Keep both or Skip. + * + * - A file over a file: Replace gives the existing node the new content, so + * it keeps its id, its shares and its links. Keep both adds "name (1).ext". + * - A folder over a folder (folder upload): Replace puts the folder's content + * into the existing folder and replaces files of the same name in it, as a + * file manager's "merge" does. Keep both makes "Folder (1)". + * - A file and a folder of the same name can't replace each other: the new + * one is kept beside it with a number, without asking, as before. + */ + +export type UploadConflictKind = 'file' | 'folder'; + +export interface UploadConflict { + /** The name in the target folder: a file's name, or a folder upload's top folder. */ + name: string; + kind: UploadConflictKind; +} + +export type UploadConflictChoice = 'replace' | 'keep' | 'skip'; + +/** + * Asks about the conflicts, in order. Resolves to a choice per name, or to + * null when the user called the whole upload off. + */ +export type UploadConflictResolver = (conflicts: UploadConflict[]) => Promise | null>; + +export interface ExistingEntry { + name: string; + isDirectory: boolean; +} + +/** + * What of `incoming` clashes with `existing`, in the order of `incoming`. + * Only same-kind clashes are conflicts; each name is listed once. + */ +export function findUploadConflicts(incoming: UploadConflict[], existing: ExistingEntry[]): UploadConflict[] { + const files = new Set(existing.filter((e) => !e.isDirectory).map((e) => e.name)); + const folders = new Set(existing.filter((e) => e.isDirectory).map((e) => e.name)); + const seen = new Set(); + const conflicts: UploadConflict[] = []; + for (const entry of incoming) { + const key = `${entry.kind}:${entry.name}`; + if (seen.has(key)) continue; + seen.add(key); + if ((entry.kind === 'file' ? files : folders).has(entry.name)) conflicts.push(entry); + } + return conflicts; +} + +/** A conflict's answer: the choice, whether it covers the rest, or stop. */ +export type ConflictAnswer = { choice: UploadConflictChoice; applyToAll: boolean } | null; + +/** + * Goes through the conflicts one question at a time; "Do this for all" + * answers the rest with the same choice. Null when a question was called off. + */ +export async function resolveConflictsInTurn( + conflicts: UploadConflict[], + ask: (conflict: UploadConflict, index: number, total: number) => Promise, +): Promise | null> { + const choices = new Map(); + for (let i = 0; i < conflicts.length; i++) { + const answer = await ask(conflicts[i], i, conflicts.length); + if (!answer) return null; + if (answer.applyToAll) { + for (const rest of conflicts.slice(i)) choices.set(rest.name, answer.choice); + break; + } + choices.set(conflicts[i].name, answer.choice); + } + return choices; +} diff --git a/locales/ar/common.json b/locales/ar/common.json index 58baccead..edc0b35dd 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -5677,6 +5677,21 @@ "next_file": "الملف التالي", "loading": "جارٍ التحميل…" }, + "ux_pim_files": { + "conflict_title": "{name} موجود بالفعل.", + "conflict_file_hint": "الاستبدال يضع المحتوى الجديد في الملف الموجود، فتبقى مشاركاته. الاحتفاظ بالاثنين يضيف نسخة برقم.", + "conflict_folder_hint": "الاستبدال يضيف الملفات إلى المجلد الموجود ويستبدل الملفات التي تحمل الاسم نفسه. الاحتفاظ بالاثنين ينشئ مجلدًا ثانيًا برقم.", + "replace": "استبدال", + "keep_both": "الاحتفاظ بالاثنين", + "skip": "تخطي", + "apply_to_all": "{count, plural, other {تطبيق ذلك على كل التعارضات ({count})}}", + "no_preview": "لا توجد معاينة لهذا النوع من الملفات.", + "open_in_app": "فتح في التطبيق الافتراضي", + "shared_with": "أصبح {name} مشتركًا مع {person}.", + "send_message": "إرسال رسالة", + "share_mail_subject": "شاركت {name} معك", + "share_mail_body": "مرحبًا،\n\nشاركت «{name}» معك. يمكنك فتحه من هنا:\n{link}" + }, "smime": { "your_certificates": "شهاداتك", "your_certificates_desc": "استيراد وإدارة شهادات S/MIME الخاصة بك لتوقيع الرسائل وتشفيرها", diff --git a/locales/ca/common.json b/locales/ca/common.json index 881836067..b6d70367d 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -5677,6 +5677,21 @@ "next_file": "Fitxer següent", "loading": "S'està carregant…" }, + "ux_pim_files": { + "conflict_title": "{name} ja existeix.", + "conflict_file_hint": "Substitueix posa el contingut nou al fitxer existent, i se’n mantenen les comparticions. Conserva’ls tots dos afegeix una còpia amb un número.", + "conflict_folder_hint": "Substitueix afegeix els fitxers a la carpeta existent i substitueix els que tenen el mateix nom. Conserva’ls tots dos crea una segona carpeta amb un número.", + "replace": "Substitueix", + "keep_both": "Conserva’ls tots dos", + "skip": "Omet", + "apply_to_all": "{count, plural, one {Fes el mateix amb el conflicte} other {Fes el mateix amb els # conflictes}}", + "no_preview": "No hi ha previsualització per a aquest tipus de fitxer.", + "open_in_app": "Obre amb l’aplicació predeterminada", + "shared_with": "{name} ara està compartit amb {person}.", + "send_message": "Envia un missatge", + "share_mail_subject": "He compartit {name} amb tu", + "share_mail_body": "Hola,\n\nHe compartit «{name}» amb tu. El pots obrir aquí:\n{link}" + }, "smime": { "your_certificates": "Els vostres certificats", "your_certificates_desc": "Importeu i gestioneu els certificats S/MIME per signar i xifrar correus", diff --git a/locales/cs/common.json b/locales/cs/common.json index dc6ea865e..c9cef720e 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -5415,6 +5415,21 @@ "next_file": "Další soubor", "loading": "Načítání…" }, + "ux_pim_files": { + "conflict_title": "{name} už existuje.", + "conflict_file_hint": "Nahradit vloží nový obsah do existujícího souboru, takže jeho sdílení zůstane. Ponechat obojí přidá kopii s číslem.", + "conflict_folder_hint": "Nahradit přidá soubory do existující složky a nahradí soubory se stejným názvem. Ponechat obojí vytvoří druhou složku s číslem.", + "replace": "Nahradit", + "keep_both": "Ponechat obojí", + "skip": "Přeskočit", + "apply_to_all": "{count, plural, other {Udělat totéž u všech konfliktů ({count})}}", + "no_preview": "Pro tento typ souboru není náhled.", + "open_in_app": "Otevřít ve výchozí aplikaci", + "shared_with": "{name} je teď sdílen s {person}.", + "send_message": "Poslat zprávu", + "share_mail_subject": "Sdílel jsem s vámi {name}", + "share_mail_body": "Dobrý den,\n\nsdílel jsem s vámi „{name}“. Otevřete to zde:\n{link}" + }, "smime": { "your_certificates": "Vaše certifikáty", "your_certificates_desc": "Importujte a spravujte své certifikáty S/MIME pro podepisování a šifrování e-mailů", diff --git a/locales/da/common.json b/locales/da/common.json index 2f16c3e51..1dadcb8ce 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -5677,6 +5677,21 @@ "next_file": "Næste fil", "loading": "Indlæser…" }, + "ux_pim_files": { + "conflict_title": "{name} findes allerede.", + "conflict_file_hint": "Erstat lægger det nye indhold i den eksisterende fil, så dens delinger bliver. Behold begge tilføjer en kopi med et nummer.", + "conflict_folder_hint": "Erstat lægger filerne i den eksisterende mappe og erstatter filer med samme navn. Behold begge opretter en ekstra mappe med et nummer.", + "replace": "Erstat", + "keep_both": "Behold begge", + "skip": "Spring over", + "apply_to_all": "{count, plural, one {Gør det samme for konflikten} other {Gør det samme for alle # konflikter}}", + "no_preview": "Der er ingen forhåndsvisning af denne filtype.", + "open_in_app": "Åbn i standardprogram", + "shared_with": "{name} er nu delt med {person}.", + "send_message": "Send en besked", + "share_mail_subject": "Jeg har delt {name} med dig", + "share_mail_body": "Hej\n\nJeg har delt \"{name}\" med dig. Du kan åbne den her:\n{link}" + }, "smime": { "your_certificates": "Dine certifikater", "your_certificates_desc": "Importér og administrér dine S/MIME-certifikater til signering og kryptering af e-mails", diff --git a/locales/de/common.json b/locales/de/common.json index 435212075..040a2303f 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -5415,6 +5415,21 @@ "next_file": "Nächste Datei", "loading": "Wird geladen…" }, + "ux_pim_files": { + "conflict_title": "{name} ist schon vorhanden.", + "conflict_file_hint": "Ersetzen legt den neuen Inhalt in die vorhandene Datei, ihre Freigaben bleiben. Beide behalten legt eine Kopie mit einer Nummer an.", + "conflict_folder_hint": "Ersetzen legt die Dateien in den vorhandenen Ordner und ersetzt Dateien mit gleichem Namen. Beide behalten legt einen zweiten Ordner mit einer Nummer an.", + "replace": "Ersetzen", + "keep_both": "Beide behalten", + "skip": "Überspringen", + "apply_to_all": "{count, plural, other {Für alle # Konflikte so machen}}", + "no_preview": "Für diese Art von Datei gibt es keine Vorschau.", + "open_in_app": "Im Standardprogramm öffnen", + "shared_with": "{name} ist jetzt für {person} freigegeben.", + "send_message": "Nachricht senden", + "share_mail_subject": "Ich habe {name} für Sie freigegeben", + "share_mail_body": "Hallo,\n\nich habe „{name}“ für Sie freigegeben. Sie können es hier öffnen:\n{link}" + }, "smime": { "your_certificates": "Ihre Zertifikate", "your_certificates_desc": "Importieren und verwalten Sie Ihre S/MIME-Zertifikate zum Signieren und Verschlüsseln von E-Mails", diff --git a/locales/en/common.json b/locales/en/common.json index b8a6dd742..cb4bab0f9 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -5677,6 +5677,21 @@ "next_file": "Next file", "loading": "Loading…" }, + "ux_pim_files": { + "conflict_title": "{name} already exists.", + "conflict_file_hint": "Replace puts the new content into the existing file, so its shares stay. Keep both adds a copy with a number.", + "conflict_folder_hint": "Replace adds the files to the existing folder and replaces files with the same name. Keep both makes a second folder with a number.", + "replace": "Replace", + "keep_both": "Keep both", + "skip": "Skip", + "apply_to_all": "{count, plural, other {Do this for all # conflicts}}", + "no_preview": "There is no preview for this type of file.", + "open_in_app": "Open in default app", + "shared_with": "{name} is now shared with {person}.", + "send_message": "Send a message", + "share_mail_subject": "I shared {name} with you", + "share_mail_body": "Hi,\n\nI shared \"{name}\" with you. You can open it here:\n{link}" + }, "smime": { "your_certificates": "Your Certificates", "your_certificates_desc": "Import and manage your S/MIME certificates for signing and encrypting emails", diff --git a/locales/es/common.json b/locales/es/common.json index 6d5cf80cb..b461f0989 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -5415,6 +5415,21 @@ "next_file": "Archivo siguiente", "loading": "Cargando…" }, + "ux_pim_files": { + "conflict_title": "{name} ya existe.", + "conflict_file_hint": "Reemplazar pone el contenido nuevo en el archivo existente, así se mantienen sus permisos. Conservar ambos añade una copia con un número.", + "conflict_folder_hint": "Reemplazar añade los archivos a la carpeta existente y reemplaza los que tienen el mismo nombre. Conservar ambos crea una segunda carpeta con un número.", + "replace": "Reemplazar", + "keep_both": "Conservar ambos", + "skip": "Omitir", + "apply_to_all": "{count, plural, one {Hacer lo mismo con el conflicto} other {Hacer lo mismo con los # conflictos}}", + "no_preview": "No hay vista previa para este tipo de archivo.", + "open_in_app": "Abrir en la aplicación predeterminada", + "shared_with": "{name} ahora está compartido con {person}.", + "send_message": "Enviar un mensaje", + "share_mail_subject": "He compartido {name} contigo", + "share_mail_body": "Hola:\n\nHe compartido «{name}» contigo. Puedes abrirlo aquí:\n{link}" + }, "smime": { "your_certificates": "Tus certificados", "your_certificates_desc": "Importa y administra tus certificados S/MIME para firmar y cifrar correos", diff --git a/locales/et/common.json b/locales/et/common.json index c0c25b494..d0aabf794 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -5677,6 +5677,21 @@ "next_file": "Järgmine fail", "loading": "Laadimine…" }, + "ux_pim_files": { + "conflict_title": "{name} on juba olemas.", + "conflict_file_hint": "Asenda paneb uue sisu olemasolevasse faili, nii jäävad selle jagamised alles. Jäta mõlemad lisab nummerdatud koopia.", + "conflict_folder_hint": "Asenda lisab failid olemasolevasse kausta ja asendab sama nimega failid. Jäta mõlemad loob teise, nummerdatud kausta.", + "replace": "Asenda", + "keep_both": "Jäta mõlemad", + "skip": "Jäta vahele", + "apply_to_all": "{count, plural, one {Tee sama konfliktiga} other {Tee sama kõigi # konfliktiga}}", + "no_preview": "Seda tüüpi failil pole eelvaadet.", + "open_in_app": "Ava vaikerakenduses", + "shared_with": "{name} on nüüd jagatud kasutajaga {person}.", + "send_message": "Saada sõnum", + "share_mail_subject": "Jagasin sinuga faili {name}", + "share_mail_body": "Tere\n\nJagasin sinuga „{name}“. Saad selle avada siin:\n{link}" + }, "smime": { "your_certificates": "Sinu sertifikaadid", "your_certificates_desc": "Impordi ja halda oma S/MIME sertifikaate e-kirjade allkirjastamiseks ja krüpteerimiseks", diff --git a/locales/fa/common.json b/locales/fa/common.json index 4688b2036..9a4a9f7ac 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -5677,6 +5677,21 @@ "next_file": "فایل بعدی", "loading": "در حال بارگیری…" }, + "ux_pim_files": { + "conflict_title": "{name} از قبل وجود دارد.", + "conflict_file_hint": "جایگزینی محتوای تازه را در همان فایل موجود می‌گذارد و اشتراک‌هایش می‌مانند. نگه‌داشتن هر دو یک رونوشت شماره‌دار می‌سازد.", + "conflict_folder_hint": "جایگزینی فایل‌ها را به پوشهٔ موجود اضافه می‌کند و فایل‌های هم‌نام را جایگزین می‌کند. نگه‌داشتن هر دو پوشهٔ دومی با شماره می‌سازد.", + "replace": "جایگزینی", + "keep_both": "نگه‌داشتن هر دو", + "skip": "رد شدن", + "apply_to_all": "{count, plural, other {همین کار برای همهٔ # تداخل}}", + "no_preview": "برای این نوع فایل پیش‌نمایشی وجود ندارد.", + "open_in_app": "باز کردن در برنامهٔ پیش‌فرض", + "shared_with": "{name} اکنون با {person} به اشتراک گذاشته شد.", + "send_message": "ارسال پیام", + "share_mail_subject": "{name} را با شما به اشتراک گذاشتم", + "share_mail_body": "سلام،\n\n«{name}» را با شما به اشتراک گذاشتم. می‌توانید آن را اینجا باز کنید:\n{link}" + }, "smime": { "your_certificates": "گواهی‌های شما", "your_certificates_desc": "مدیریت گواهی‌های S/MIME", diff --git a/locales/fr/common.json b/locales/fr/common.json index 53806d8ee..b55681222 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -5415,6 +5415,21 @@ "next_file": "Fichier suivant", "loading": "Chargement…" }, + "ux_pim_files": { + "conflict_title": "{name} existe déjà.", + "conflict_file_hint": "Remplacer met le nouveau contenu dans le fichier existant, ses partages restent. Conserver les deux ajoute une copie numérotée.", + "conflict_folder_hint": "Remplacer ajoute les fichiers au dossier existant et remplace ceux qui portent le même nom. Conserver les deux crée un second dossier numéroté.", + "replace": "Remplacer", + "keep_both": "Conserver les deux", + "skip": "Ignorer", + "apply_to_all": "{count, plural, one {Faire de même pour le conflit} other {Faire de même pour les # conflits}}", + "no_preview": "Aucun aperçu pour ce type de fichier.", + "open_in_app": "Ouvrir dans l’application par défaut", + "shared_with": "{name} est maintenant partagé avec {person}.", + "send_message": "Envoyer un message", + "share_mail_subject": "J’ai partagé {name} avec vous", + "share_mail_body": "Bonjour,\n\nJ’ai partagé « {name} » avec vous. Vous pouvez l’ouvrir ici :\n{link}" + }, "smime": { "your_certificates": "Vos certificats", "your_certificates_desc": "Importez et gérez vos certificats S/MIME pour signer et chiffrer les e-mails", diff --git a/locales/he/common.json b/locales/he/common.json index 633a96218..3ad638404 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -5308,6 +5308,21 @@ "next_file": "הקובץ הבא", "loading": "טוען…" }, + "ux_pim_files": { + "conflict_title": "{name} כבר קיים.", + "conflict_file_hint": "החלפה שמה את התוכן החדש בקובץ הקיים, כך שהשיתופים שלו נשמרים. שמירת שניהם מוסיפה עותק עם מספר.", + "conflict_folder_hint": "החלפה מוסיפה את הקבצים לתיקייה הקיימת ומחליפה קבצים עם אותו שם. שמירת שניהם יוצרת תיקייה שנייה עם מספר.", + "replace": "החלפה", + "keep_both": "שמירת שניהם", + "skip": "דילוג", + "apply_to_all": "{count, plural, other {לעשות כך לכל # ההתנגשויות}}", + "no_preview": "אין תצוגה מקדימה לסוג קובץ זה.", + "open_in_app": "פתיחה ביישום ברירת המחדל", + "shared_with": "{name} משותף עכשיו עם {person}.", + "send_message": "שליחת הודעה", + "share_mail_subject": "שיתפתי איתך את {name}", + "share_mail_body": "שלום,\n\nשיתפתי איתך את \"{name}\". אפשר לפתוח אותו כאן:\n{link}" + }, "smime": { "your_certificates": "התעודות שלך", "your_certificates_desc": "ייבא ונהל את אישורי ה-S/MIME שלך עבור חתימה והצפנה של הודעות דוא\"ל", diff --git a/locales/hu/common.json b/locales/hu/common.json index 1096602bc..044709be3 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -5677,6 +5677,21 @@ "next_file": "Következő fájl", "loading": "Betöltés…" }, + "ux_pim_files": { + "conflict_title": "{name} már létezik.", + "conflict_file_hint": "A Csere az új tartalmat a meglévő fájlba teszi, így a megosztásai megmaradnak. A Mindkettő megtartása számozott másolatot ad hozzá.", + "conflict_folder_hint": "A Csere a fájlokat a meglévő mappába teszi, és lecseréli az azonos nevű fájlokat. A Mindkettő megtartása egy második, számozott mappát hoz létre.", + "replace": "Csere", + "keep_both": "Mindkettő megtartása", + "skip": "Kihagyás", + "apply_to_all": "{count, plural, other {Ugyanez mind a(z) # ütközésnél}}", + "no_preview": "Ehhez a fájltípushoz nincs előnézet.", + "open_in_app": "Megnyitás az alapértelmezett alkalmazásban", + "shared_with": "{name} most már meg van osztva vele: {person}.", + "send_message": "Üzenet küldése", + "share_mail_subject": "Megosztottam veled: {name}", + "share_mail_body": "Szia!\n\nMegosztottam veled ezt: „{name}”. Itt tudod megnyitni:\n{link}" + }, "smime": { "your_certificates": "Tanúsítványaid", "your_certificates_desc": "S/MIME tanúsítványok importálása és kezelése e-mailek aláírásához és titkosításához", diff --git a/locales/it/common.json b/locales/it/common.json index f1d150516..3d8d9fc69 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -5415,6 +5415,21 @@ "next_file": "File successivo", "loading": "Caricamento…" }, + "ux_pim_files": { + "conflict_title": "{name} esiste già.", + "conflict_file_hint": "Sostituisci mette il nuovo contenuto nel file esistente, così le sue condivisioni restano. Mantieni entrambi aggiunge una copia numerata.", + "conflict_folder_hint": "Sostituisci aggiunge i file alla cartella esistente e sostituisce quelli con lo stesso nome. Mantieni entrambi crea una seconda cartella numerata.", + "replace": "Sostituisci", + "keep_both": "Mantieni entrambi", + "skip": "Salta", + "apply_to_all": "{count, plural, one {Fai lo stesso per il conflitto} other {Fai lo stesso per tutti i # conflitti}}", + "no_preview": "Nessuna anteprima per questo tipo di file.", + "open_in_app": "Apri con l’app predefinita", + "shared_with": "{name} ora è condiviso con {person}.", + "send_message": "Invia un messaggio", + "share_mail_subject": "Ho condiviso {name} con te", + "share_mail_body": "Ciao,\n\nho condiviso «{name}» con te. Puoi aprirlo qui:\n{link}" + }, "smime": { "your_certificates": "I tuoi certificati", "your_certificates_desc": "Importa e gestisci i tuoi certificati S/MIME per firmare e cifrare le email", diff --git a/locales/ja/common.json b/locales/ja/common.json index eeff72436..2fea4191b 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -5415,6 +5415,21 @@ "next_file": "次のファイル", "loading": "読み込み中…" }, + "ux_pim_files": { + "conflict_title": "{name} は既に存在します。", + "conflict_file_hint": "置き換えると、既存のファイルに新しい内容が入り、共有設定はそのまま残ります。両方残すと、番号付きのコピーが追加されます。", + "conflict_folder_hint": "置き換えると、既存のフォルダーにファイルが追加され、同じ名前のファイルは置き換えられます。両方残すと、番号付きの別フォルダーが作られます。", + "replace": "置き換える", + "keep_both": "両方残す", + "skip": "スキップ", + "apply_to_all": "{count, plural, other {# 件すべての競合に適用}}", + "no_preview": "この種類のファイルはプレビューできません。", + "open_in_app": "既定のアプリで開く", + "shared_with": "{name} を {person} と共有しました。", + "send_message": "メッセージを送信", + "share_mail_subject": "{name} を共有しました", + "share_mail_body": "こんにちは。\n\n「{name}」を共有しました。こちらから開けます:\n{link}" + }, "smime": { "your_certificates": "あなたの証明書", "your_certificates_desc": "メールの署名と暗号化のために S/MIME 証明書をインポートして管理します", diff --git a/locales/ko/common.json b/locales/ko/common.json index 2bb52c5dd..630ac90bd 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -5415,6 +5415,21 @@ "next_file": "다음 파일", "loading": "불러오는 중…" }, + "ux_pim_files": { + "conflict_title": "{name}이(가) 이미 있습니다.", + "conflict_file_hint": "바꾸기를 하면 기존 파일에 새 내용이 들어가고 공유 설정은 그대로 유지됩니다. 둘 다 유지하면 번호가 붙은 사본이 추가됩니다.", + "conflict_folder_hint": "바꾸기를 하면 기존 폴더에 파일이 추가되고 이름이 같은 파일은 바뀝니다. 둘 다 유지하면 번호가 붙은 두 번째 폴더가 만들어집니다.", + "replace": "바꾸기", + "keep_both": "둘 다 유지", + "skip": "건너뛰기", + "apply_to_all": "{count, plural, other {충돌 #개 모두에 적용}}", + "no_preview": "이 유형의 파일은 미리 볼 수 없습니다.", + "open_in_app": "기본 앱에서 열기", + "shared_with": "{name}을(를) 이제 {person}님과 공유합니다.", + "send_message": "메시지 보내기", + "share_mail_subject": "{name}을(를) 공유했습니다", + "share_mail_body": "안녕하세요.\n\n\"{name}\"을(를) 공유했습니다. 여기에서 열 수 있습니다:\n{link}" + }, "smime": { "your_certificates": "내 인증서", "your_certificates_desc": "이메일 서명과 암호화를 위한 S/MIME 인증서를 가져오고 관리해요", diff --git a/locales/lv/common.json b/locales/lv/common.json index 62dcafd39..8863e8ff5 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -5415,6 +5415,21 @@ "next_file": "Nākamais fails", "loading": "Ielādē…" }, + "ux_pim_files": { + "conflict_title": "{name} jau pastāv.", + "conflict_file_hint": "Aizstāt ieliek jauno saturu esošajā failā, tāpēc tā koplietošana paliek. Paturēt abus pievieno numurētu kopiju.", + "conflict_folder_hint": "Aizstāt pievieno failus esošajai mapei un aizstāj failus ar tādu pašu nosaukumu. Paturēt abus izveido otru, numurētu mapi.", + "replace": "Aizstāt", + "keep_both": "Paturēt abus", + "skip": "Izlaist", + "apply_to_all": "{count, plural, other {Darīt tāpat visiem # konfliktiem}}", + "no_preview": "Šim faila tipam nav priekšskatījuma.", + "open_in_app": "Atvērt noklusējuma lietotnē", + "shared_with": "{name} tagad ir koplietots ar {person}.", + "send_message": "Sūtīt ziņu", + "share_mail_subject": "Es koplietoju ar jums {name}", + "share_mail_body": "Sveiki!\n\nEs koplietoju ar jums “{name}”. To var atvērt šeit:\n{link}" + }, "smime": { "your_certificates": "Jūsu sertifikāti", "your_certificates_desc": "Importējiet un pārvaldiet savus S/MIME sertifikātus parakstīšanai un šifrēšanai", diff --git a/locales/mn/common.json b/locales/mn/common.json index 6f767a14d..697c22ee5 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -5677,6 +5677,21 @@ "next_file": "Дараах файл", "loading": "Ачаалж байна…" }, + "ux_pim_files": { + "conflict_title": "{name} аль хэдийн байна.", + "conflict_file_hint": "Солих нь шинэ агуулгыг байгаа файлд хийх тул хуваалцсан тохиргоо нь хэвээр үлдэнэ. Хоёуланг нь хадгалах нь дугаартай хуулбар нэмнэ.", + "conflict_folder_hint": "Солих нь файлуудыг байгаа хавтсанд нэмж, ижил нэртэй файлуудыг солино. Хоёуланг нь хадгалах нь дугаартай хоёр дахь хавтас үүсгэнэ.", + "replace": "Солих", + "keep_both": "Хоёуланг нь хадгалах", + "skip": "Алгасах", + "apply_to_all": "{count, plural, other {Бүх # зөрчилд ингэх}}", + "no_preview": "Энэ төрлийн файлыг урьдчилан харах боломжгүй.", + "open_in_app": "Үндсэн програмаар нээх", + "shared_with": "{name}-г одоо {person}-тэй хуваалцаж байна.", + "send_message": "Мессеж илгээх", + "share_mail_subject": "Би {name}-г тантай хуваалцлаа", + "share_mail_body": "Сайн байна уу,\n\nБи \"{name}\"-г тантай хуваалцлаа. Эндээс нээж болно:\n{link}" + }, "smime": { "your_certificates": "Таны сертификатууд", "your_certificates_desc": "Имэйлд гарын үсэг зурах болон шифрлэхэд ашиглах S/MIME сертификатаа импортолж, удирдана", diff --git a/locales/nb/common.json b/locales/nb/common.json index ac7970c7e..4b176047b 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -5677,6 +5677,21 @@ "next_file": "Neste fil", "loading": "Laster inn…" }, + "ux_pim_files": { + "conflict_title": "{name} finnes allerede.", + "conflict_file_hint": "Erstatt legger det nye innholdet i den eksisterende filen, så delingene blir. Behold begge legger til en kopi med et nummer.", + "conflict_folder_hint": "Erstatt legger filene i den eksisterende mappen og erstatter filer med samme navn. Behold begge lager en ny mappe med et nummer.", + "replace": "Erstatt", + "keep_both": "Behold begge", + "skip": "Hopp over", + "apply_to_all": "{count, plural, one {Gjør det samme for konflikten} other {Gjør det samme for alle # konfliktene}}", + "no_preview": "Det finnes ingen forhåndsvisning for denne filtypen.", + "open_in_app": "Åpne i standardprogram", + "shared_with": "{name} er nå delt med {person}.", + "send_message": "Send en melding", + "share_mail_subject": "Jeg har delt {name} med deg", + "share_mail_body": "Hei,\n\nJeg har delt «{name}» med deg. Du kan åpne den her:\n{link}" + }, "smime": { "your_certificates": "Dine sertifikater", "your_certificates_desc": "Importer og administrer S/MIME-sertifikatene dine for signering og kryptering av e-post", diff --git a/locales/nl/common.json b/locales/nl/common.json index e82cf2dfe..b352cd51c 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -5415,6 +5415,21 @@ "next_file": "Volgend bestand", "loading": "Laden…" }, + "ux_pim_files": { + "conflict_title": "{name} bestaat al.", + "conflict_file_hint": "Vervangen zet de nieuwe inhoud in het bestaande bestand, zodat het gedeeld blijft. Beide behouden voegt een kopie met een nummer toe.", + "conflict_folder_hint": "Vervangen voegt de bestanden toe aan de bestaande map en vervangt bestanden met dezelfde naam. Beide behouden maakt een tweede map met een nummer.", + "replace": "Vervangen", + "keep_both": "Beide behouden", + "skip": "Overslaan", + "apply_to_all": "{count, plural, one {Dit doen voor het conflict} other {Dit doen voor alle # conflicten}}", + "no_preview": "Er is geen voorbeeld voor dit type bestand.", + "open_in_app": "Openen in standaard-app", + "shared_with": "{name} is nu gedeeld met {person}.", + "send_message": "Bericht sturen", + "share_mail_subject": "Ik heb {name} met je gedeeld", + "share_mail_body": "Hoi,\n\nIk heb \"{name}\" met je gedeeld. Je kunt het hier openen:\n{link}" + }, "smime": { "your_certificates": "Uw certificaten", "your_certificates_desc": "Importeer en beheer uw S/MIME-certificaten voor het ondertekenen en versleutelen van e-mails", diff --git a/locales/pl/common.json b/locales/pl/common.json index 37aae1a32..6840399f7 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -5415,6 +5415,21 @@ "next_file": "Następny plik", "loading": "Wczytywanie…" }, + "ux_pim_files": { + "conflict_title": "{name} już istnieje.", + "conflict_file_hint": "Zastąp wkłada nową treść do istniejącego pliku, więc jego udostępnienia zostają. Zachowaj oba dodaje kopię z numerem.", + "conflict_folder_hint": "Zastąp dodaje pliki do istniejącego folderu i zastępuje pliki o tej samej nazwie. Zachowaj oba tworzy drugi folder z numerem.", + "replace": "Zastąp", + "keep_both": "Zachowaj oba", + "skip": "Pomiń", + "apply_to_all": "{count, plural, other {Zrób tak dla wszystkich konfliktów ({count})}}", + "no_preview": "Dla tego typu pliku nie ma podglądu.", + "open_in_app": "Otwórz w domyślnej aplikacji", + "shared_with": "{name} jest teraz udostępniony dla {person}.", + "send_message": "Wyślij wiadomość", + "share_mail_subject": "Udostępniłem Ci {name}", + "share_mail_body": "Cześć,\n\nUdostępniłem Ci „{name}”. Możesz to otworzyć tutaj:\n{link}" + }, "smime": { "your_certificates": "Twoje certyfikaty", "your_certificates_desc": "Importuj i zarządzaj swoimi certyfikatami S/MIME do podpisywania i szyfrowania wiadomości e-mail", diff --git a/locales/pt/common.json b/locales/pt/common.json index 9f1a26f38..c642e43d9 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -5415,6 +5415,21 @@ "next_file": "Próximo arquivo", "loading": "Carregando…" }, + "ux_pim_files": { + "conflict_title": "{name} já existe.", + "conflict_file_hint": "Substituir coloca o novo conteúdo no arquivo existente, e os compartilhamentos continuam. Manter ambos adiciona uma cópia com um número.", + "conflict_folder_hint": "Substituir adiciona os arquivos à pasta existente e substitui os que têm o mesmo nome. Manter ambos cria uma segunda pasta com um número.", + "replace": "Substituir", + "keep_both": "Manter ambos", + "skip": "Ignorar", + "apply_to_all": "{count, plural, one {Fazer o mesmo para o conflito} other {Fazer o mesmo para os # conflitos}}", + "no_preview": "Não há pré-visualização para este tipo de arquivo.", + "open_in_app": "Abrir no aplicativo padrão", + "shared_with": "{name} agora está compartilhado com {person}.", + "send_message": "Enviar uma mensagem", + "share_mail_subject": "Compartilhei {name} com você", + "share_mail_body": "Olá,\n\nCompartilhei \"{name}\" com você. Você pode abrir aqui:\n{link}" + }, "smime": { "your_certificates": "Seus certificados", "your_certificates_desc": "Importe e gerencie seus certificados S/MIME para assinar e criptografar e-mails", diff --git a/locales/ro/common.json b/locales/ro/common.json index b6b53057c..fa0a066d3 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -5677,6 +5677,21 @@ "next_file": "Fișierul următor", "loading": "Se încarcă…" }, + "ux_pim_files": { + "conflict_title": "{name} există deja.", + "conflict_file_hint": "Înlocuiește pune conținutul nou în fișierul existent, iar partajările lui rămân. Păstrează-le pe ambele adaugă o copie numerotată.", + "conflict_folder_hint": "Înlocuiește adaugă fișierele în dosarul existent și înlocuiește fișierele cu același nume. Păstrează-le pe ambele creează un al doilea dosar numerotat.", + "replace": "Înlocuiește", + "keep_both": "Păstrează-le pe ambele", + "skip": "Omite", + "apply_to_all": "{count, plural, other {Fă la fel pentru toate cele # conflicte}}", + "no_preview": "Nu există previzualizare pentru acest tip de fișier.", + "open_in_app": "Deschide în aplicația implicită", + "shared_with": "{name} este acum partajat cu {person}.", + "send_message": "Trimite un mesaj", + "share_mail_subject": "Am partajat {name} cu tine", + "share_mail_body": "Salut,\n\nAm partajat „{name}” cu tine. Îl poți deschide aici:\n{link}" + }, "smime": { "your_certificates": "Certificatele dvs.", "your_certificates_desc": "Importați și gestionați certificatele S/MIME pentru semnarea și criptarea e-mailurilor", diff --git a/locales/ru/common.json b/locales/ru/common.json index 08a3ee2f1..16c65fda5 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -5415,6 +5415,21 @@ "next_file": "Следующий файл", "loading": "Загрузка…" }, + "ux_pim_files": { + "conflict_title": "{name} уже существует.", + "conflict_file_hint": "«Заменить» кладёт новое содержимое в существующий файл, поэтому общий доступ к нему сохраняется. «Оставить оба» добавляет копию с номером.", + "conflict_folder_hint": "«Заменить» добавляет файлы в существующую папку и заменяет файлы с теми же именами. «Оставить оба» создаёт вторую папку с номером.", + "replace": "Заменить", + "keep_both": "Оставить оба", + "skip": "Пропустить", + "apply_to_all": "{count, plural, other {Так же для всех конфликтов ({count})}}", + "no_preview": "Для этого типа файлов нет предпросмотра.", + "open_in_app": "Открыть в приложении по умолчанию", + "shared_with": "{name}: теперь есть доступ у {person}.", + "send_message": "Отправить сообщение", + "share_mail_subject": "Я открыл вам доступ к {name}", + "share_mail_body": "Здравствуйте!\n\nЯ открыл вам доступ к «{name}». Открыть можно здесь:\n{link}" + }, "smime": { "your_certificates": "Ваши сертификаты", "your_certificates_desc": "Импортируйте и управляйте своими сертификатами S/MIME для подписи и шифрования писем", diff --git a/locales/sk/common.json b/locales/sk/common.json index d4dcd98c6..8d91cbeb9 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -5677,6 +5677,21 @@ "next_file": "Nasledujúci súbor", "loading": "Načítava sa…" }, + "ux_pim_files": { + "conflict_title": "{name} už existuje.", + "conflict_file_hint": "Nahradiť vloží nový obsah do existujúceho súboru, takže jeho zdieľanie zostane. Ponechať oboje pridá kópiu s číslom.", + "conflict_folder_hint": "Nahradiť pridá súbory do existujúceho priečinka a nahradí súbory s rovnakým názvom. Ponechať oboje vytvorí druhý priečinok s číslom.", + "replace": "Nahradiť", + "keep_both": "Ponechať oboje", + "skip": "Preskočiť", + "apply_to_all": "{count, plural, other {Urobiť to isté pri všetkých konfliktoch ({count})}}", + "no_preview": "Pre tento typ súboru nie je náhľad.", + "open_in_app": "Otvoriť v predvolenej aplikácii", + "shared_with": "{name} je teraz zdieľaný s {person}.", + "send_message": "Poslať správu", + "share_mail_subject": "Zdieľal som s vami {name}", + "share_mail_body": "Dobrý deň,\n\nzdieľal som s vami „{name}“. Otvoríte to tu:\n{link}" + }, "smime": { "your_certificates": "Vaše certifikáty", "your_certificates_desc": "Importujte a spravujte svoje certifikáty S/MIME na podpisovanie a šifrovanie e-mailov", diff --git a/locales/th/common.json b/locales/th/common.json index be2b47dfa..9307b15f2 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -2636,6 +2636,21 @@ "zoom_out": "ซูมออก", "zoom_reset": "พอดีหน้าจอ" }, + "ux_pim_files": { + "conflict_title": "{name} มีอยู่แล้ว", + "conflict_file_hint": "แทนที่จะใส่เนื้อหาใหม่ลงในไฟล์เดิม การแชร์ของไฟล์จึงยังคงอยู่ เก็บทั้งสองจะเพิ่มสำเนาที่มีหมายเลข", + "conflict_folder_hint": "แทนที่จะเพิ่มไฟล์ลงในโฟลเดอร์เดิมและแทนที่ไฟล์ที่ชื่อซ้ำกัน เก็บทั้งสองจะสร้างโฟลเดอร์ที่สองที่มีหมายเลข", + "replace": "แทนที่", + "keep_both": "เก็บทั้งสอง", + "skip": "ข้าม", + "apply_to_all": "{count, plural, other {ทำแบบนี้กับข้อขัดแย้งทั้ง # รายการ}}", + "no_preview": "ไฟล์ประเภทนี้ไม่มีตัวอย่าง", + "open_in_app": "เปิดในแอปเริ่มต้น", + "shared_with": "ตอนนี้แชร์ {name} กับ {person} แล้ว", + "send_message": "ส่งข้อความ", + "share_mail_subject": "ฉันแชร์ {name} กับคุณ", + "share_mail_body": "สวัสดี\n\nฉันแชร์ \"{name}\" กับคุณแล้ว เปิดได้ที่นี่:\n{link}" + }, "protocol_handlers": { "title": "แอปพลิเคชันเริ่มต้น", "description": "เลือกว่าจะเปิดลิงก์อีเมลและปฏิทินใน {appName} หรือไม่", diff --git a/locales/tr/common.json b/locales/tr/common.json index 053fc2917..d2fcaeb62 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -5677,6 +5677,21 @@ "next_file": "Sonraki dosya", "loading": "Yükleniyor…" }, + "ux_pim_files": { + "conflict_title": "{name} zaten var.", + "conflict_file_hint": "Değiştir, yeni içeriği mevcut dosyaya koyar; paylaşımları korunur. İkisini de tut, numaralı bir kopya ekler.", + "conflict_folder_hint": "Değiştir, dosyaları mevcut klasöre ekler ve aynı adlı dosyaları değiştirir. İkisini de tut, numaralı ikinci bir klasör oluşturur.", + "replace": "Değiştir", + "keep_both": "İkisini de tut", + "skip": "Atla", + "apply_to_all": "{count, plural, other {Tüm # çakışma için bunu yap}}", + "no_preview": "Bu dosya türü için önizleme yok.", + "open_in_app": "Varsayılan uygulamada aç", + "shared_with": "{name} artık {person} ile paylaşılıyor.", + "send_message": "Mesaj gönder", + "share_mail_subject": "{name} dosyasını seninle paylaştım", + "share_mail_body": "Merhaba,\n\n\"{name}\" dosyasını seninle paylaştım. Buradan açabilirsin:\n{link}" + }, "smime": { "your_certificates": "Sertifikalarınız", "your_certificates_desc": "E-postaları imzalamak ve şifrelemek için S/MIME sertifikalarınızı içe aktarın ve yönetin", diff --git a/locales/uk/common.json b/locales/uk/common.json index d9da831c0..e3ac71be0 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -5415,6 +5415,21 @@ "next_file": "Наступний файл", "loading": "Завантаження…" }, + "ux_pim_files": { + "conflict_title": "{name} уже існує.", + "conflict_file_hint": "«Замінити» кладе новий вміст в наявний файл, тому спільний доступ до нього лишається. «Залишити обидва» додає копію з номером.", + "conflict_folder_hint": "«Замінити» додає файли в наявну папку й замінює файли з тими самими назвами. «Залишити обидва» створює другу папку з номером.", + "replace": "Замінити", + "keep_both": "Залишити обидва", + "skip": "Пропустити", + "apply_to_all": "{count, plural, other {Так само для всіх конфліктів ({count})}}", + "no_preview": "Для цього типу файлів немає попереднього перегляду.", + "open_in_app": "Відкрити в програмі за замовчуванням", + "shared_with": "{name}: тепер є доступ у {person}.", + "send_message": "Надіслати повідомлення", + "share_mail_subject": "Я надав вам доступ до {name}", + "share_mail_body": "Вітаю!\n\nЯ надав вам доступ до «{name}». Відкрити можна тут:\n{link}" + }, "smime": { "your_certificates": "Ваші сертифікати", "your_certificates_desc": "Імпортуйте та керуйте своїми сертифікатами S/MIME для підпису та шифрування електронних листів", diff --git a/locales/vi/common.json b/locales/vi/common.json index d5555583a..b25ec0393 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -5689,6 +5689,21 @@ "next_file": "Tệp tiếp theo", "loading": "Đang tải…" }, + "ux_pim_files": { + "conflict_title": "{name} đã tồn tại.", + "conflict_file_hint": "Thay thế đưa nội dung mới vào tệp hiện có, nên các chia sẻ của tệp vẫn giữ nguyên. Giữ cả hai thêm một bản sao có đánh số.", + "conflict_folder_hint": "Thay thế thêm các tệp vào thư mục hiện có và thay các tệp trùng tên. Giữ cả hai tạo thêm một thư mục có đánh số.", + "replace": "Thay thế", + "keep_both": "Giữ cả hai", + "skip": "Bỏ qua", + "apply_to_all": "{count, plural, other {Làm vậy cho cả # xung đột}}", + "no_preview": "Không có bản xem trước cho loại tệp này.", + "open_in_app": "Mở bằng ứng dụng mặc định", + "shared_with": "{name} giờ đã được chia sẻ với {person}.", + "send_message": "Gửi tin nhắn", + "share_mail_subject": "Tôi đã chia sẻ {name} với bạn", + "share_mail_body": "Chào bạn,\n\nTôi đã chia sẻ \"{name}\" với bạn. Bạn có thể mở tại đây:\n{link}" + }, "smime": { "your_certificates": "Chứng chỉ của bạn", "your_certificates_desc": "Nhập và quản lý chứng chỉ S/MIME của bạn để ký và mã hóa email", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index e2ed1c967..823bab767 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -5677,6 +5677,21 @@ "next_file": "下一個檔案", "loading": "載入中…" }, + "ux_pim_files": { + "conflict_title": "{name} 已存在。", + "conflict_file_hint": "取代會把新內容放進現有檔案,共用設定維持不變。保留兩者會新增一個帶編號的副本。", + "conflict_folder_hint": "取代會把檔案加入現有資料夾,並取代同名檔案。保留兩者會建立一個帶編號的資料夾。", + "replace": "取代", + "keep_both": "保留兩者", + "skip": "略過", + "apply_to_all": "{count, plural, other {對全部 # 個衝突執行此動作}}", + "no_preview": "此類檔案無法預覽。", + "open_in_app": "用預設應用程式開啟", + "shared_with": "{name} 已與 {person} 共用。", + "send_message": "傳送訊息", + "share_mail_subject": "我與你共用了 {name}", + "share_mail_body": "你好,\n\n我與你共用了「{name}」。可以在這裡開啟:\n{link}" + }, "smime": { "your_certificates": "您的憑證", "your_certificates_desc": "匯入與管理用於郵件簽章與加密的 S/MIME 憑證", diff --git a/locales/zh/common.json b/locales/zh/common.json index 1c5ce5603..57a463336 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -5415,6 +5415,21 @@ "next_file": "下一个文件", "loading": "正在加载…" }, + "ux_pim_files": { + "conflict_title": "{name} 已存在。", + "conflict_file_hint": "替换会把新内容放进现有文件,共享设置保持不变。保留两者会添加一个带编号的副本。", + "conflict_folder_hint": "替换会把文件添加到现有文件夹,并替换同名文件。保留两者会新建一个带编号的文件夹。", + "replace": "替换", + "keep_both": "保留两者", + "skip": "跳过", + "apply_to_all": "{count, plural, other {对全部 # 个冲突执行此操作}}", + "no_preview": "此类文件无法预览。", + "open_in_app": "用默认应用打开", + "shared_with": "{name} 已与 {person} 共享。", + "send_message": "发送消息", + "share_mail_subject": "我与你共享了 {name}", + "share_mail_body": "你好,\n\n我与你共享了“{name}”。可以在这里打开:\n{link}" + }, "smime": { "your_certificates": "您的证书", "your_certificates_desc": "导入和管理您的 S/MIME 证书以对邮件进行签名和加密", diff --git a/stores/__tests__/file-store-upload-conflicts.test.ts b/stores/__tests__/file-store-upload-conflicts.test.ts new file mode 100644 index 000000000..30a8cf28d --- /dev/null +++ b/stores/__tests__/file-store-upload-conflicts.test.ts @@ -0,0 +1,159 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useFileStore } from '../file-store'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import type { FileNode } from '@/lib/jmap/types'; +import type { UploadConflict, UploadConflictChoice } from '@/lib/files-upload-conflicts'; + +// pim-10: an upload whose name is taken asks Replace / Keep both / Skip. +// Replace gives the existing node new content (same id), Keep both adds +// "name (1)", Skip leaves it alone. + +function makeClient(initial: FileNode[]) { + let nodes: FileNode[] = initial.map(n => ({ ...n })); + let seq = 0; + const client = { + getCapabilities: () => ({}), + getFilesAccountId: () => 'acct', + async listAllFileNodes() { 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: '', modified: '' }; + nodes.push(node); + return { ...node }; + }, + async createFileNode(name: string, blobId: string, type: string, size: number, parentId: string | null) { + const node: FileNode = { id: `n${++seq}`, parentId, name, type, blobId, size, created: '', modified: '' }; + nodes.push(node); + return { ...node }; + }, + replaceFileNodeBlob: vi.fn(async (id: string, blobId: string) => { + const node = nodes.find(n => n.id === id); + if (!node) throw new Error('not found'); + node.blobId = blobId; + }), + async uploadBlob() { return { blobId: `blob${++seq}`, type: 'text/plain' }; }, + _nodes: () => nodes, + _reset: (next: FileNode[]) => { nodes = next; }, + }; + return client as unknown as IJMAPClient & { _nodes: () => FileNode[]; replaceFileNodeBlob: ReturnType }; +} + +const folder = (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: `old-${id}`, size: 3, created: '', modified: '', +}); +const upload = (name: string, path?: string) => { + const f = new File(['new'], name, { type: 'text/plain' }); + if (path) Object.defineProperty(f, 'webkitRelativePath', { value: path }); + return f; +}; + +/** Answers every conflict with `choice`, and records what was asked. */ +function answering(choice: UploadConflictChoice | null) { + const asked: UploadConflict[][] = []; + const resolveConflicts = vi.fn(async (conflicts: UploadConflict[]) => { + asked.push(conflicts); + return choice ? new Map(conflicts.map(c => [c.name, choice])) : null; + }); + return { asked, resolveConflicts }; +} + +async function start(nodes: FileNode[]) { + const client = makeClient(nodes); + useFileStore.getState().initClient(client); + await useFileStore.getState().navigate(null); + return client; +} + +beforeEach(() => { + useFileStore.setState({ + client: null, currentParentId: null, currentPath: '/', pathStack: [{ id: null, name: '' }], + resources: [], selectedResources: new Set(), clipboard: null, lastAction: null, + }); +}); + +describe('uploading files whose name is taken (pim-10)', () => { + it('asks only about the taken names', async () => { + await start([file('r', 'report.txt', null), folder('d', 'new.txt', null)]); + const { asked, resolveConflicts } = answering('keep'); + await useFileStore.getState().uploadFiles([upload('report.txt'), upload('other.txt'), upload('new.txt')], { resolveConflicts }); + // A folder named like the file is no conflict: the file is numbered beside it. + expect(asked).toEqual([[{ name: 'report.txt', kind: 'file' }]]); + }); + + it('Replace gives the existing node the new content and keeps its id', async () => { + const client = await start([file('r', 'report.txt', null)]); + const { resolveConflicts } = answering('replace'); + const report = await useFileStore.getState().uploadFiles([upload('report.txt')], { resolveConflicts }); + expect(report.uploaded).toBe(1); + expect(client.replaceFileNodeBlob).toHaveBeenCalledWith('r', expect.stringMatching(/^blob/)); + const named = client._nodes().filter(n => n.name.startsWith('report')); + expect(named.map(n => [n.id, n.name])).toEqual([['r', 'report.txt']]); + expect(named[0].blobId).not.toBe('old-r'); + }); + + it('Keep both adds a numbered copy', async () => { + const client = await start([file('r', 'report.txt', null)]); + const { resolveConflicts } = answering('keep'); + await useFileStore.getState().uploadFiles([upload('report.txt')], { resolveConflicts }); + expect(client._nodes().map(n => n.name).sort()).toEqual(['report (1).txt', 'report.txt']); + expect(client.replaceFileNodeBlob).not.toHaveBeenCalled(); + }); + + it('Skip leaves the file alone and uploads the rest', async () => { + const client = await start([file('r', 'report.txt', null)]); + const { resolveConflicts } = answering('skip'); + const report = await useFileStore.getState().uploadFiles([upload('report.txt'), upload('other.txt')], { resolveConflicts }); + expect(report).toMatchObject({ uploaded: 1, skipped: 1 }); + expect(client._nodes().map(n => [n.name, n.blobId]).sort()).toEqual([['other.txt', expect.stringMatching(/^blob/)], ['report.txt', 'old-r']]); + }); + + it('calling it off uploads nothing', async () => { + const client = await start([file('r', 'report.txt', null)]); + const { resolveConflicts } = answering(null); + const report = await useFileStore.getState().uploadFiles([upload('report.txt'), upload('other.txt')], { resolveConflicts }); + expect(report).toMatchObject({ uploaded: 0, canceled: true }); + expect(client._nodes().map(n => n.name)).toEqual(['report.txt']); + }); +}); + +describe('uploading a folder whose name is taken (pim-10)', () => { + const tree = () => [ + folder('docs', 'Docs', null), + file('a', 'a.txt', 'docs'), + folder('sub', 'Sub', 'docs'), + file('b', 'b.txt', 'sub'), + ]; + const files = () => [upload('a.txt', 'Docs/a.txt'), upload('b.txt', 'Docs/Sub/b.txt'), upload('c.txt', 'Docs/Sub/c.txt')]; + + it('asks about the folder, and Replace merges into it', async () => { + const client = await start(tree()); + const { asked, resolveConflicts } = answering('replace'); + const report = await useFileStore.getState().uploadFolder(files(), { resolveConflicts }); + expect(asked).toEqual([[{ name: 'Docs', kind: 'folder' }]]); + expect(report.uploaded).toBe(3); + const nodes = client._nodes(); + // No second Docs or Sub; a.txt and b.txt keep their ids with new content; c.txt is new in Sub. + expect(nodes.filter(n => n.blobId === null).map(n => n.id)).toEqual(['docs', 'sub']); + expect(nodes.find(n => n.id === 'a')!.blobId).toMatch(/^blob/); + expect(nodes.find(n => n.id === 'b')!.blobId).toMatch(/^blob/); + expect(nodes.filter(n => n.parentId === 'sub').map(n => n.name).sort()).toEqual(['b.txt', 'c.txt']); + }); + + it('Keep both makes a second folder', async () => { + const client = await start(tree()); + const { resolveConflicts } = answering('keep'); + await useFileStore.getState().uploadFolder(files(), { resolveConflicts }); + expect(client._nodes().filter(n => n.parentId === null).length).toBe(2); + expect(client.replaceFileNodeBlob).not.toHaveBeenCalled(); + }); + + it('Skip leaves the folder out', async () => { + const client = await start(tree()); + const { resolveConflicts } = answering('skip'); + const report = await useFileStore.getState().uploadFolder(files(), { resolveConflicts }); + expect(report).toMatchObject({ uploaded: 0, skipped: 3 }); + expect(client._nodes()).toHaveLength(4); + }); +}); diff --git a/stores/file-store.ts b/stores/file-store.ts index fcee224eb..349dbe939 100644 --- a/stores/file-store.ts +++ b/stores/file-store.ts @@ -16,6 +16,9 @@ import { } from '@/lib/files-trash'; import { buildZip, planZip, saveBlob, type ZipProgress } from '@/lib/files-zip'; import { downloadAllowed } from '@/lib/download-guard'; +import { + findUploadConflicts, type UploadConflict, type UploadConflictChoice, type UploadConflictResolver, +} from '@/lib/files-upload-conflicts'; export interface FileResource { id: string; @@ -59,6 +62,13 @@ export interface UploadReport { uploaded: number; failed: { name: string; error: unknown }[]; canceled: boolean; + /** Left out because the name was taken and the user chose Skip. */ + skipped?: number; +} + +export interface UploadOptions { + /** Asked when a name is taken in the target folder; without it, new ones are numbered. */ + resolveConflicts?: UploadConflictResolver; } interface ClipboardState { @@ -137,8 +147,8 @@ interface FileState { refresh: () => Promise; createDirectory: (name: string) => Promise; uploadFile: (file: File) => Promise; - uploadFiles: (files: File[]) => Promise; - uploadFolder: (files: File[]) => Promise; + uploadFiles: (files: File[], options?: UploadOptions) => Promise; + uploadFolder: (files: File[], options?: UploadOptions) => Promise; cancelUpload: () => void; // Deletes, renames and moves resolve to false when a plugin stopped them // (onBeforeFileDelete / Rename / Move); nothing changed then. @@ -471,6 +481,32 @@ function resolvePathToId(nodes: FileNode[], path: string): string | null | undef return parentId; } +/** + * The answer to every name of an upload that is taken in the target folder + * (pim-10): an empty map when nothing clashes or nobody can be asked (the + * new ones are then numbered, as before), null when the user called it off. + */ +async function askUploadConflicts( + incoming: UploadConflict[], + existing: Pick[], + resolve: UploadConflictResolver | undefined, +): Promise | null> { + if (!resolve) return new Map(); + // A symlink's name is taken but it can't be replaced: kept beside it. + const entries = existing.filter(r => !r.isSymlink).map(r => ({ name: r.serverName ?? r.name, isDirectory: r.isDirectory })); + const conflicts = findUploadConflicts(incoming, entries); + if (conflicts.length === 0) return new Map(); + return resolve(conflicts); +} + +/** A file whose content an upload replaced, as plugins see it (onAfterFileUpload). */ +function replacedView(id: string, file: File, type: string | undefined, folderPath: string): FileResourceView { + return { + id, name: file.name, type: 'file', size: file.size, mimeType: type || file.type || 'application/octet-stream', + path: folderPath, modified: new Date().toISOString(), + }; +} + function getUniqueName(name: string, existingNames: Set): string { if (!existingNames.has(name)) return name; const dotIndex = name.lastIndexOf('.'); @@ -1083,7 +1119,7 @@ export const useFileStore = create((set, get) => ({ } }, - uploadFiles: async (allFiles: File[]) => { + uploadFiles: async (allFiles: File[], options?: UploadOptions) => { const { client, currentParentId, currentPath, resources } = get(); const report: UploadReport = { uploaded: 0, failed: [], canceled: false }; if (!client) return report; @@ -1098,6 +1134,22 @@ export const useFileStore = create((set, get) => ({ } if (files.length === 0) return report; + // A name that is taken: ask Replace, Keep both or Skip (pim-10). + const choices = await askUploadConflicts( + files.map(f => ({ name: f.name, kind: 'file' as const })), + resources, + options?.resolveConflicts, + ); + if (!choices) { + report.canceled = true; + return report; + } + const replaceIds = new Map(resources.filter(r => !r.isDirectory && !r.isSymlink).map(r => [r.serverName ?? r.name, r.id])); + const kept = files.filter(f => choices.get(f.name) !== 'skip'); + if (kept.length < files.length) report.skipped = files.length - kept.length; + files.splice(0, files.length, ...kept); + if (files.length === 0) return report; + const abortController = new AbortController(); set({ uploadAbortController: abortController }); const totalFiles = files.length; @@ -1106,7 +1158,8 @@ export const useFileStore = create((set, get) => ({ for (let i = 0; i < files.length; i++) { if (abortController.signal.aborted) break; const file = files[i]; - const uniqueName = getUniqueName(file.name, existingNames); + const replaceId = choices.get(file.name) === 'replace' ? replaceIds.get(file.name) : undefined; + const uniqueName = replaceId ? file.name : getUniqueName(file.name, existingNames); existingNames.add(uniqueName); set({ uploadProgress: { name: file.name, loaded: 0, total: file.size, current: i + 1, totalFiles } }); @@ -1120,6 +1173,13 @@ export const useFileStore = create((set, get) => ({ }); if (abortController.signal.aborted) break; set({ uploadProgress: { name: file.name, loaded: file.size, total: file.size, current: i + 1, totalFiles } }); + if (replaceId) { + // New content for the node that is there: same id, same shares. + await client.replaceFileNodeBlob(replaceId, blobId); + report.uploaded++; + void fileHooks.onAfterFileUpload.emit(replacedView(replaceId, file, type, currentPath)); + continue; + } const node = await client.createFileNode(uniqueName, blobId, type || file.type || 'application/octet-stream', file.size, currentParentId); report.uploaded++; void fileHooks.onAfterFileUpload.emit(fileToView(node, currentPath)); @@ -1143,8 +1203,8 @@ export const useFileStore = create((set, get) => ({ } }, - uploadFolder: async (allFiles: File[]) => { - const { client, currentParentId, currentPath } = get(); + uploadFolder: async (allFiles: File[], options?: UploadOptions) => { + const { client, currentParentId, currentPath, resources } = get(); const report: UploadReport = { uploaded: 0, failed: [], canceled: false }; if (!client || allFiles.length === 0) return report; assertCanAddTo(currentParentId); @@ -1167,6 +1227,39 @@ export const useFileStore = create((set, get) => ({ } if (files.length === 0) return report; + // What lands right in the target folder: a dropped file, or the top + // folder of each path. A name that is taken there asks first (pim-10). + const topOf = (file: File): UploadConflict => { + const relativePath = relativePathOf(file); + const slash = relativePath.indexOf('/'); + return slash >= 0 ? { name: relativePath.slice(0, slash), kind: 'folder' } : { name: relativePath, kind: 'file' }; + }; + const choices = await askUploadConflicts(files.map(topOf), resources, options?.resolveConflicts); + if (!choices) { + report.canceled = true; + return report; + } + const kept = files.filter(f => choices.get(topOf(f).name) !== 'skip'); + if (kept.length < files.length) report.skipped = files.length - kept.length; + files.splice(0, files.length, ...kept); + if (files.length === 0) return report; + // Replace: a file gets new content; a folder takes the new content in, + // with its subfolders reused and files of the same name replaced. + const replacing = new Map(resources + .filter(r => !r.isSymlink && choices.get(r.serverName ?? r.name) === 'replace') + .map(r => [`${r.isDirectory ? 'folder' : 'file'}:${r.serverName ?? r.name}`, r.id])); + let tree: FileNode[] | null = null; + const loadTree = async () => { + tree ??= isCrossAccountId(currentParentId) + ? await client.listAllFileNodesAcrossAccounts() + : await client.listAllFileNodes(); + return tree; + }; + const childNamed = async (parentId: string | null, name: string, kind: 'file' | 'directory') => + (await loadTree()).find(n => (n.parentId ?? null) === parentId && n.name === name && fileNodeKind(n) === kind)?.id; + // Folder paths of the upload that are existing folders (merged into). + const mergedDirs = new Set(); + const abortController = new AbortController(); set({ uploadAbortController: abortController }); const totalFiles = files.length; @@ -1220,6 +1313,14 @@ export const useFileStore = create((set, get) => ({ continue; } try { + const existingId = parentPath === '' + ? replacing.get(`folder:${dirName}`) + : mergedDirs.has(parentPath) ? await childNamed(parentId, dirName, 'directory') : undefined; + if (existingId) { + dirIds.set(dir, existingId); + mergedDirs.add(dir); + continue; + } const created = await client.createFileDirectory(dirName, parentId); dirIds.set(dir, created.id); void fileHooks.onDirectoryCreate.emit(fileToView(created, joinPath(currentPath, parentPath))); @@ -1256,6 +1357,15 @@ export const useFileStore = create((set, get) => ({ }); if (abortController.signal.aborted) break; set({ uploadProgress: { name: relativePath, loaded: file.size, total: file.size, current: i + 1, totalFiles } }); + const replaceId = dirPath === '' + ? replacing.get(`file:${file.name}`) + : mergedDirs.has(dirPath) ? await childNamed(parentId, file.name, 'file') : undefined; + if (replaceId) { + await client.replaceFileNodeBlob(replaceId, blobId); + report.uploaded++; + void fileHooks.onAfterFileUpload.emit(replacedView(replaceId, file, type, joinPath(currentPath, dirPath))); + continue; + } const node = await client.createFileNode(file.name, blobId, type || file.type || 'application/octet-stream', file.size, parentId); report.uploaded++; void fileHooks.onAfterFileUpload.emit(fileToView(node, joinPath(currentPath, dirPath)));