diff --git a/src/api/__tests__/files.test.ts b/src/api/__tests__/files.test.ts index d49bac33..5bcad0d8 100644 --- a/src/api/__tests__/files.test.ts +++ b/src/api/__tests__/files.test.ts @@ -30,6 +30,7 @@ import { CAPABILITIES } from '../types'; import { accountSupportsFiles, copyFileNode, + copyFileTree, createFolder, deleteFileNodes, fileNodeKind, @@ -665,6 +666,52 @@ describe('copyFileNode', () => { }); }); +describe('copyFileTree', () => { + const tree = [ + { id: 'src', name: 'Docs', type: '', blobId: null, parentId: null }, + { id: 'sub', name: 'Old', type: '', blobId: null, parentId: 'src' }, + { id: 'f1', name: 'a.txt', type: 'text/plain', blobId: 'b1', parentId: 'src' }, + { id: 'f2', name: 'b.txt', type: 'text/plain', blobId: 'b2', parentId: 'sub' }, + { id: 'other', name: 'c.txt', type: 'text/plain', blobId: 'b3', parentId: null }, + ]; + /** Answers each FileNode/set create with a new id, or fails the call for `failOn`. */ + function serve(failOn?: string) { + let n = 0; + const creates: Record[] = []; + const destroys: string[][] = []; + mockRequest.mockImplementation(async (calls: [string, Record, string][]) => { + const [, args] = calls[0]; + if (args.destroy) { + destroys.push(args.destroy); + return { methodResponses: [['FileNode/set', { destroyed: args.destroy }, '0']] }; + } + const [key, props] = Object.entries(args.create as Record>)[0]; + if (props.name === failOn) return { methodResponses: [['FileNode/set', { notCreated: { [key]: { type: 'forbidden' } } }, '0']] }; + creates.push(props); + return { methodResponses: [['FileNode/set', { created: { [key]: { id: `new-${++n}` } } }, '0']] }; + }); + return { creates, destroys }; + } + + it('makes the folder again with its folders and files, the files on the same blobs', async () => { + const { creates } = serve(); + const copy = await copyFileTree(tree[0], tree, null, 'Docs (1)'); + expect(copy.id).toBe('new-1'); + expect(creates).toEqual([ + { name: 'Docs (1)' }, + { name: 'Old', parentId: 'new-1' }, + { name: 'b.txt', type: 'text/plain', blobId: 'b2', parentId: 'new-2' }, + { name: 'a.txt', type: 'text/plain', blobId: 'b1', parentId: 'new-1' }, + ]); + }); + + it('takes a half-made copy back, the deepest level first', async () => { + const { destroys } = serve('a.txt'); + await expect(copyFileTree(tree[0], tree, null, 'Docs (1)')).rejects.toThrow(); + expect(destroys).toEqual([['new-3'], ['new-2'], ['new-1']]); + }); +}); + describe('fileNodeKind', () => { it('takes nodeType as Stalwart 0.16.6+ returns it', () => { expect(fileNodeKind({ nodeType: 'file', blobId: 'b1' })).toBe('file'); diff --git a/src/api/__tests__/live-stalwart-files.test.ts b/src/api/__tests__/live-stalwart-files.test.ts index 7c4d7d4e..3a9a2c41 100644 --- a/src/api/__tests__/live-stalwart-files.test.ts +++ b/src/api/__tests__/live-stalwart-files.test.ts @@ -34,6 +34,7 @@ import { CAPABILITIES } from '../types'; import type { FileNode } from '../types'; import { copyFileNode, + copyFileTree, createFolder, deleteFileNodes, destroyEmptyFolders, @@ -161,6 +162,30 @@ live('live Stalwart files', () => { expect(all.filter((n) => [copy.id, box.id, file.id, inner.id].includes(n.id))).toEqual([]); }, 90_000); + it('copies a folder with the folders and files in it', async () => { + const source = await createFolder('Copy me', scratch); + const sub = await createFolder('Inside', source.id); + await uploadFileBatch([pick('top.txt', 'top')], source.id, []); + await uploadFileBatch([pick('deep.txt', 'deeper')], sub.id, []); + const all = await nodes(); + + const copy = await copyFileTree(all.find((n) => n.id === source.id)!, all, scratch, 'Copy me (1)'); + const after = await nodes(); + const copied = after.find((n) => n.id === copy.id)!; + expect(copied).toMatchObject({ name: 'Copy me (1)', parentId: scratch }); + expect(fileNodeKind(copied)).toBe('directory'); + const inCopy = after.filter((n) => n.parentId === copy.id); + expect(inCopy.map((n) => n.name).sort()).toEqual(['Inside', 'top.txt']); + const copiedSub = inCopy.find((n) => n.name === 'Inside')!; + expect(fileNodeKind(copiedSub)).toBe('directory'); + const deep = after.find((n) => n.parentId === copiedSub.id)!; + expect(deep).toMatchObject({ name: 'deep.txt', size: 6 }); + const res = await fetch(getFileNodeDownloadUrl(deep), { headers: { Authorization: client.authHeader } }); + expect(await res.text()).toBe('deeper'); + // The source is untouched. + expect(after.filter((n) => n.parentId === source.id).map((n) => n.name).sort()).toEqual(['Inside', 'top.txt']); + }, 90_000); + it('moves a node to the drive root and back', async () => { const { uploaded } = await uploadFileBatch([pick('root-trip.txt', 'x')], scratch, []); const id = uploaded[0].id; diff --git a/src/api/files.ts b/src/api/files.ts index 8907d2e2..64c9c02a 100644 --- a/src/api/files.ts +++ b/src/api/files.ts @@ -723,6 +723,66 @@ export async function copyFileNode( return createFileNodeFromBlob(newName, node.blobId, node.type, parentId); } +/** + * Copy a file, or a folder with everything in it, into `parentId` (webmail + * copyFileNode). A folder node carries no content, so the subtree is made + * again level by level from `tree`, the listing the ids of `node` and + * `parentId` come from; the files in it point at the same blobs. A copy that + * fails part-way is taken back, the deepest level first, without + * onDestroyRemoveChildren: whatever someone else put into its folders + * meanwhile stays. + */ +export async function copyFileTree( + node: FileNode, + tree: FileNode[], + parentId: string | null, + newName: string = node.name, +): Promise { + const kind = fileNodeKind(node); + if (kind === 'symlink') throw new Error('Symbolic links cannot be copied'); + if (kind === 'file') return copyFileNode(node, parentId, newName); + + const childrenOf = new Map(); + for (const entry of tree) { + if (entry.parentId == null) continue; + const siblings = childrenOf.get(entry.parentId); + if (siblings) siblings.push(entry); + else childrenOf.set(entry.parentId, [entry]); + } + // The ids this copy creates, by level below the copy (0 is the copy itself). + const made: string[][] = []; + const seen = new Set([node.id]); + const create = async (source: FileNode, name: string, parent: string | null, level: number): Promise => { + const created = fileNodeKind(source) === 'directory' + ? await createFolder(name, parent) + : await createFileNodeFromBlob(name, source.blobId!, source.type, parent); + (made[level] ??= []).push(created.id); + return created; + }; + const copyChildren = async (folderId: string, copyId: string, level: number): Promise => { + for (const child of childrenOf.get(folderId) ?? []) { + // Symlinks have nothing to copy; a copy inside the source is not copied again. + if (seen.has(child.id) || fileNodeKind(child) === 'symlink') continue; + if (fileNodeKind(child) === 'file' && !child.blobId) continue; + seen.add(child.id); + const created = await create(child, child.name, copyId, level); + if (fileNodeKind(child) === 'directory') await copyChildren(child.id, created.id, level + 1); + } + }; + + const root = await create(node, newName, parentId, 0); + seen.add(root.id); + try { + await copyChildren(node.id, root.id, 1); + } catch (err) { + for (let level = made.length - 1; level >= 0; level--) { + await destroyEmptyFolders(made[level] ?? []).catch(() => []); + } + throw err; + } + return root; +} + // ── Sharing (RFC 9670) ──────────────────────────────────── // Add, update, or remove a principal's rights on an owned FileNode (file or diff --git a/src/screens/FilesScreen.tsx b/src/screens/FilesScreen.tsx index f3e764cd..1be3045b 100644 --- a/src/screens/FilesScreen.tsx +++ b/src/screens/FilesScreen.tsx @@ -47,7 +47,7 @@ function loadDocumentPicker(): DocumentPickerModule | null { } import { - copyFileNode, createFolder, deleteFileNodes, getAllFileNodesAcrossAccounts, + copyFileTree, createFolder, deleteFileNodes, getAllFileNodesAcrossAccounts, getFileNameRules, getFileNodeDownloadUrl, getMaxSizeUpload, isFolder, isSymlink, listFolder, moveFileNode, renameFileNode, supportsSharing, uploadFileBatch, } from '../api/files'; @@ -661,13 +661,14 @@ export default function FilesScreen() { }, [renameTarget, renameValue, loadFiles, checkName, t]); const duplicateFile = useCallback(async (row: FileRow) => { - if (isFolder(row) || isSymlink(row) || !fileNodeAccess(row, allNodes).duplicate) return; + // A folder is copied with everything in it (webmail copyFileNode). + if (isSymlink(row) || !fileNodeAccess(row, allNodes).duplicate) return; setBusyId(row.id); try { const siblings = new Set( allNodes.filter((n) => (n.parentId ?? null) === (row.parentId ?? null)).map((n) => n.name), ); - await copyFileNode(row, row.parentId ?? null, getUniqueName(row.name, siblings)); + await copyFileTree(row, allNodes, row.parentId ?? null, getUniqueName(row.name, siblings)); await loadFiles('refresh'); } catch (e) { Alert.alert(t('files.duplicate_error', 'Failed to duplicate'), fileErrorMessage(e, t)); @@ -1641,7 +1642,7 @@ function ActionsSheet({ {t('files.move_to', 'Move to…')} ) : null} - {access.duplicate && isFile ? ( + {access.duplicate && !isSymlink(target) ? ( onDuplicate(target)}> {t('files.duplicate', 'Duplicate')}