feat: duplicate a folder in Files with everything in it

This commit is contained in:
Linus Rath
2026-10-31 04:51:01 +01:00
parent 18c6b03fc1
commit a0f586f9c2
4 changed files with 137 additions and 4 deletions
+47
View File
@@ -30,6 +30,7 @@ import { CAPABILITIES } from '../types';
import { import {
accountSupportsFiles, accountSupportsFiles,
copyFileNode, copyFileNode,
copyFileTree,
createFolder, createFolder,
deleteFileNodes, deleteFileNodes,
fileNodeKind, 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<string, unknown>[] = [];
const destroys: string[][] = [];
mockRequest.mockImplementation(async (calls: [string, Record<string, any>, 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<string, Record<string, unknown>>)[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', () => { describe('fileNodeKind', () => {
it('takes nodeType as Stalwart 0.16.6+ returns it', () => { it('takes nodeType as Stalwart 0.16.6+ returns it', () => {
expect(fileNodeKind({ nodeType: 'file', blobId: 'b1' })).toBe('file'); expect(fileNodeKind({ nodeType: 'file', blobId: 'b1' })).toBe('file');
@@ -34,6 +34,7 @@ import { CAPABILITIES } from '../types';
import type { FileNode } from '../types'; import type { FileNode } from '../types';
import { import {
copyFileNode, copyFileNode,
copyFileTree,
createFolder, createFolder,
deleteFileNodes, deleteFileNodes,
destroyEmptyFolders, 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([]); expect(all.filter((n) => [copy.id, box.id, file.id, inner.id].includes(n.id))).toEqual([]);
}, 90_000); }, 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 () => { it('moves a node to the drive root and back', async () => {
const { uploaded } = await uploadFileBatch([pick('root-trip.txt', 'x')], scratch, []); const { uploaded } = await uploadFileBatch([pick('root-trip.txt', 'x')], scratch, []);
const id = uploaded[0].id; const id = uploaded[0].id;
+60
View File
@@ -723,6 +723,66 @@ export async function copyFileNode(
return createFileNodeFromBlob(newName, node.blobId, node.type, parentId); 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<FileNode> {
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<string, FileNode[]>();
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<string>([node.id]);
const create = async (source: FileNode, name: string, parent: string | null, level: number): Promise<FileNode> => {
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<void> => {
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) ──────────────────────────────────── // ── Sharing (RFC 9670) ────────────────────────────────────
// Add, update, or remove a principal's rights on an owned FileNode (file or // Add, update, or remove a principal's rights on an owned FileNode (file or
+5 -4
View File
@@ -47,7 +47,7 @@ function loadDocumentPicker(): DocumentPickerModule | null {
} }
import { import {
copyFileNode, createFolder, deleteFileNodes, getAllFileNodesAcrossAccounts, copyFileTree, createFolder, deleteFileNodes, getAllFileNodesAcrossAccounts,
getFileNameRules, getFileNodeDownloadUrl, getMaxSizeUpload, isFolder, getFileNameRules, getFileNodeDownloadUrl, getMaxSizeUpload, isFolder,
isSymlink, listFolder, moveFileNode, renameFileNode, supportsSharing, uploadFileBatch, isSymlink, listFolder, moveFileNode, renameFileNode, supportsSharing, uploadFileBatch,
} from '../api/files'; } from '../api/files';
@@ -661,13 +661,14 @@ export default function FilesScreen() {
}, [renameTarget, renameValue, loadFiles, checkName, t]); }, [renameTarget, renameValue, loadFiles, checkName, t]);
const duplicateFile = useCallback(async (row: FileRow) => { 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); setBusyId(row.id);
try { try {
const siblings = new Set( const siblings = new Set(
allNodes.filter((n) => (n.parentId ?? null) === (row.parentId ?? null)).map((n) => n.name), 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'); await loadFiles('refresh');
} catch (e) { } catch (e) {
Alert.alert(t('files.duplicate_error', 'Failed to duplicate'), fileErrorMessage(e, t)); Alert.alert(t('files.duplicate_error', 'Failed to duplicate'), fileErrorMessage(e, t));
@@ -1641,7 +1642,7 @@ function ActionsSheet({
<Text style={styles.actionLabel}>{t('files.move_to', 'Move to…')}</Text> <Text style={styles.actionLabel}>{t('files.move_to', 'Move to…')}</Text>
</Pressable> </Pressable>
) : null} ) : null}
{access.duplicate && isFile ? ( {access.duplicate && !isSymlink(target) ? (
<Pressable style={styles.action} onPress={() => onDuplicate(target)}> <Pressable style={styles.action} onPress={() => onDuplicate(target)}>
<Copy size={18} color={c.text} /> <Copy size={18} color={c.text} />
<Text style={styles.actionLabel}>{t('files.duplicate', 'Duplicate')}</Text> <Text style={styles.actionLabel}>{t('files.duplicate', 'Duplicate')}</Text>