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 {
|
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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user