Add quak backup-metadata: dump all decrypted metadata to plain JSON

New command: quak backup-metadata <dir>

Dumps every piece of decrypted account metadata into a directory tree
of plain JSON files without downloading any file content. Layout:

    <dir>/
        account.json                    { email, userID }
        collections/
            <id>-<name>/
                _collection.json        { id, name, type, pubMagicMetadata?, ... }
                <fileID>.json           { id, metadata, magicMetadata?, pubMagicMetadata? }

Also adds collection-level magic metadata decryption (magicMetadata,
pubMagicMetadata, sharedMagicMetadata) to decryptCollection, which was
previously only done for files. The server sends these for visibility
settings, sort order, cover photo selection, etc.

6 new tests covering: account.json, per-collection dirs with
_collection.json, collection pubMagicMetadata decryption, per-file
JSON with all three metadata layers, graceful handling of files with
no magic metadata, and incremental re-run safety. 116 total.
This commit is contained in:
2026-06-09 12:41:34 -04:00
parent 6729e8bdc3
commit f3958e911d
5 changed files with 567 additions and 0 deletions

77
src/metadata-backup.ts Normal file
View File

@@ -0,0 +1,77 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import type { Client } from "./client.js";
export type ProgressCallback = (message: string) => void;
const sanitizePath = (name: string): string =>
name.replace(/[/\\:*?"<>|]/g, "_").replace(/^\.+/, "_");
export const runMetadataBackup = async (
client: Client,
outDir: string,
onProgress?: ProgressCallback,
): Promise<void> => {
const log = onProgress ?? (() => {});
mkdirSync(outDir, { recursive: true });
mkdirSync(join(outDir, "collections"), { recursive: true });
const { email, userID } = client.whoami();
writeFileSync(
join(outDir, "account.json"),
JSON.stringify({ email, userID }, null, 2),
);
log("Fetching collections...");
const collections = await client.listCollections();
for (const col of collections) {
const dirName = `${col.id}-${sanitizePath(col.name || "unnamed")}`;
const colDir = join(outDir, "collections", dirName);
mkdirSync(colDir, { recursive: true });
const collectionMeta: Record<string, unknown> = {
id: col.id,
name: col.name,
type: col.type,
ownerID: col.ownerID,
isShared: col.isShared,
updationTime: col.updationTime,
};
if (col.magicMetadata) collectionMeta.magicMetadata = col.magicMetadata;
if (col.pubMagicMetadata)
collectionMeta.pubMagicMetadata = col.pubMagicMetadata;
if (col.sharedMagicMetadata)
collectionMeta.sharedMagicMetadata = col.sharedMagicMetadata;
writeFileSync(
join(colDir, "_collection.json"),
JSON.stringify(collectionMeta, null, 2),
);
log(`[${col.name}] Fetching files...`);
const files = await client.listFiles(col.id, col.key);
log(`[${col.name}] ${files.length} file(s)`);
for (const file of files) {
const fileMeta: Record<string, unknown> = {
id: file.id,
collectionID: file.collectionID,
ownerID: file.ownerID,
metadata: file.metadata,
updationTime: file.updationTime,
};
if (file.magicMetadata) fileMeta.magicMetadata = file.magicMetadata;
if (file.pubMagicMetadata)
fileMeta.pubMagicMetadata = file.pubMagicMetadata;
writeFileSync(
join(colDir, `${file.id}.json`),
JSON.stringify(fileMeta, null, 2),
);
}
}
log("Metadata backup complete.");
};