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:
77
src/metadata-backup.ts
Normal file
77
src/metadata-backup.ts
Normal 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.");
|
||||
};
|
||||
Reference in New Issue
Block a user