feat: migrate legacy flat-named Files into real hierarchy on load #379

This commit is contained in:
Linus Rath
2026-06-03 21:20:25 +02:00
parent 568abb0e33
commit d564c874a3
7 changed files with 253 additions and 4 deletions
+68
View File
@@ -37,6 +37,14 @@ function makeMockClient(initial: FileNode[] = []) {
const node = nodes.find(n => n.id === id);
if (node) Object.assign(node, updates);
},
async updateFileNodes(updates: Record<string, Partial<Pick<FileNode, 'name' | 'parentId'>>>) {
const updated: string[] = [];
for (const [id, patch] of Object.entries(updates)) {
const node = nodes.find(n => n.id === id);
if (node) { Object.assign(node, patch); updated.push(id); }
}
return { updated, notUpdated: {} as Record<string, string> };
},
async destroyFileNodes(ids: string[]) {
// Emulate Stalwart's onDestroyRemoveChildren: removing a directory also
// removes its whole subtree.
@@ -172,6 +180,66 @@ describe('file-store hierarchy (issue #379)', () => {
expect(node.parentId).toBeNull();
});
it('migrates legacy flat path-encoded nodes into the real hierarchy', async () => {
const SEP = '';
// Exactly the broken shape from issue #379: everything flat at the root
// with the Unicode separator baked into the names.
const client = makeMockClient([
dir('stuff', 'Stuff', null),
dir('nonsense', `Stuff${SEP}Nonsense`, null),
file('notes', `Stuff${SEP}Nonsense${SEP}Notes.md`, null),
file('other', `Stuff${SEP}Nonsense${SEP}Other.md`, null),
]);
useFileStore.getState().initClient(client);
const migrated = await useFileStore.getState().migrateLegacyFlatNodes();
expect(migrated).toBe(true);
const byId = Object.fromEntries(client._nodes().map(n => [n.id, n]));
expect(byId.stuff).toMatchObject({ name: 'Stuff', parentId: null });
expect(byId.nonsense).toMatchObject({ name: 'Nonsense', parentId: 'stuff' });
expect(byId.notes).toMatchObject({ name: 'Notes.md', parentId: 'nonsense' });
expect(byId.other).toMatchObject({ name: 'Other.md', parentId: 'nonsense' });
// Browsing now reflects the real tree.
await useFileStore.getState().navigate(null);
expect(useFileStore.getState().resources.map(r => r.name)).toEqual(['Stuff']);
await useFileStore.getState().navigate('nonsense', 'Nonsense');
expect(useFileStore.getState().resources.map(r => r.name).sort()).toEqual(['Notes.md', 'Other.md']);
});
it('migrates legacy nodes that used a plain "/" separator (issue #379 data)', async () => {
// A folder upload of a git checkout, stored flat with "/" in the names.
const client = makeMockClient([
dir('root', 'night-medieval-castle-lake', null),
dir('branding', 'night-medieval-castle-lake/branding', null),
dir('git', 'night-medieval-castle-lake/branding/.git', null),
file('cfg', 'night-medieval-castle-lake/branding/.git/config', null),
file('logo', 'kunden/logo.svg', null),
dir('kunden', 'kunden', null),
]);
useFileStore.getState().initClient(client);
expect(await useFileStore.getState().migrateLegacyFlatNodes()).toBe(true);
const byId = Object.fromEntries(client._nodes().map(n => [n.id, n]));
expect(byId.root).toMatchObject({ name: 'night-medieval-castle-lake', parentId: null });
expect(byId.branding).toMatchObject({ name: 'branding', parentId: 'root' });
expect(byId.git).toMatchObject({ name: '.git', parentId: 'branding' });
expect(byId.cfg).toMatchObject({ name: 'config', parentId: 'git' });
expect(byId.logo).toMatchObject({ name: 'logo.svg', parentId: 'kunden' });
await useFileStore.getState().navigate(null);
expect(useFileStore.getState().resources.map(r => r.name).sort())
.toEqual(['kunden', 'night-medieval-castle-lake']);
});
it('migration is a no-op when no legacy nodes exist', async () => {
const client = makeMockClient([dir('a', 'A', null), file('f', 'f.txt', 'a')]);
useFileStore.getState().initClient(client);
expect(await useFileStore.getState().migrateLegacyFlatNodes()).toBe(false);
});
it('deletes a folder and the server removes its subtree', async () => {
const client = makeMockClient([
dir('stuff', 'Stuff', null),
+102
View File
@@ -47,6 +47,8 @@ interface FileState {
supportsFiles: boolean | null;
selectedResources: Set<string>;
uploadProgress: UploadProgress | null;
/** Progress of the one-time legacy flat-node migration; null when idle. */
migrationProgress: { current: number; total: number } | null;
client: IJMAPClient | null;
/** Which connected account's files are being browsed. Pro shell only - null in single-account contexts. */
currentAccountId: string | null;
@@ -61,6 +63,13 @@ interface FileState {
/** Detach the current client and reset browse state. Used by the Pro shell to return to the cross-account picker. */
clearClient: () => void;
checkSupport: () => Promise<boolean>;
/**
* One-time upgrade of files created by older Bulwark builds, which encoded
* the folder tree into flat node names with a Unicode separator. Reparents
* those nodes into the real FileNode hierarchy. No-op once migrated.
* Returns true if any node was migrated.
*/
migrateLegacyFlatNodes: () => Promise<boolean>;
navigate: (parentId: string | null, name?: string) => Promise<void>;
navigateByPath: (path: string) => Promise<void>;
navigateUp: () => Promise<void>;
@@ -98,6 +107,22 @@ interface FileState {
const DIRECTORY_TYPES = new Set(['d', 'application/x-directory', 'text/directory', 'httpd/unix-directory', 'inode/directory']);
// Legacy builds encoded the folder hierarchy into flat node names using a path
// separator. Depending on the build / how the data was created (folder upload,
// WebDAV) this is either a plain "/" or the Unicode DIVISION SLASH (U+2215) that
// older webmail used to dodge Stalwart's "/" rejection. We accept both so the
// one-time migration into the real parentId hierarchy can't miss data (#379).
const LEGACY_PATH_SEPS = ['', '/', '', ''];
function lastLegacySepIndex(name: string): number {
let idx = -1;
for (const sep of LEGACY_PATH_SEPS) {
const i = name.lastIndexOf(sep);
if (i > idx) idx = i;
}
return idx;
}
function isDirectoryType(type: string | undefined): boolean {
if (!type) return false;
return DIRECTORY_TYPES.has(type) || type.includes('directory');
@@ -171,6 +196,7 @@ export const useFileStore = create<FileState>((set, get) => ({
supportsFiles: null,
selectedResources: new Set<string>(),
uploadProgress: null,
migrationProgress: null,
client: null,
currentAccountId: null,
clipboard: null,
@@ -219,6 +245,82 @@ export const useFileStore = create<FileState>((set, get) => ({
return supported;
},
migrateLegacyFlatNodes: async () => {
const { client } = get();
if (!client) return false;
let allNodes: FileNode[];
try {
allNodes = await client.listAllFileNodes();
} catch {
return false;
}
const legacy = allNodes.filter(n => lastLegacySepIndex(n.name) >= 0);
if (legacy.length === 0) return false;
// Resolve parents by their original encoded name. Building the map up front
// means the order in which we rewrite nodes does not matter: each node's
// parent is located by id, captured before any rename happens.
const idByEncodedName = new Map<string, string>();
for (const n of allNodes) idByEncodedName.set(n.name, n.id);
// Build the per-node patches (new leaf name + resolved parentId).
const patches: { id: string; name: string; parentId: string | null }[] = [];
let skipped = 0;
for (const node of legacy) {
const idx = lastLegacySepIndex(node.name);
const parentName = node.name.slice(0, idx);
const leafName = node.name.slice(idx + 1);
const parentId = idByEncodedName.get(parentName);
// Skip orphans (missing parent) to avoid name collisions at the root;
// they keep their encoded name and remain accessible.
if (!parentId) {
skipped++;
console.warn('[Files] migration: no parent for', JSON.stringify(node.name), '(expected', JSON.stringify(parentName) + ')');
continue;
}
patches.push({ id: node.id, name: leafName, parentId });
}
set({ migrationProgress: { current: 0, total: patches.length } });
let migrated = 0;
let firstError: string | null = null;
const CHUNK = 100;
try {
for (let i = 0; i < patches.length; i += CHUNK) {
const slice = patches.slice(i, i + CHUNK);
const updates: Record<string, { name: string; parentId: string | null }> = {};
for (const p of slice) updates[p.id] = { name: p.name, parentId: p.parentId };
try {
const { updated, notUpdated } = await client.updateFileNodes(updates);
migrated += updated.length;
const failedIds = Object.keys(notUpdated);
if (failedIds.length > 0 && !firstError) firstError = notUpdated[failedIds[0]];
for (const id of failedIds) {
console.error('[Files] migration: server rejected node', id, '→', notUpdated[id]);
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (!firstError) firstError = msg;
console.error('[Files] migration: batch failed →', msg);
}
set({ migrationProgress: { current: Math.min(i + CHUNK, patches.length), total: patches.length } });
}
} finally {
set({ migrationProgress: null });
}
if (migrated === 0) {
console.error(`[Files] migration found ${legacy.length} legacy node(s) but reparented none ` +
`(skipped ${skipped}, first error: ${firstError ?? 'none'}).`);
} else {
console.info(`[Files] migration reparented ${migrated}/${legacy.length} legacy node(s).`);
}
return migrated > 0;
},
navigate: async (parentId: string | null, name?: string) => {
const { client, pathStack } = get();
if (!client) return;