mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +00:00
feat: duplicate a folder in Files with everything in it
This commit is contained in:
@@ -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<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', () => {
|
||||
it('takes nodeType as Stalwart 0.16.6+ returns it', () => {
|
||||
expect(fileNodeKind({ nodeType: 'file', blobId: 'b1' })).toBe('file');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<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) ────────────────────────────────────
|
||||
|
||||
// Add, update, or remove a principal's rights on an owned FileNode (file or
|
||||
|
||||
@@ -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({
|
||||
<Text style={styles.actionLabel}>{t('files.move_to', 'Move to…')}</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{access.duplicate && isFile ? (
|
||||
{access.duplicate && !isSymlink(target) ? (
|
||||
<Pressable style={styles.action} onPress={() => onDuplicate(target)}>
|
||||
<Copy size={18} color={c.text} />
|
||||
<Text style={styles.actionLabel}>{t('files.duplicate', 'Duplicate')}</Text>
|
||||
|
||||
Reference in New Issue
Block a user