CLI: quak login + quak backup with dedup symlink layout

bin/quak.ts: commander-based CLI with login (interactive + QUAK_EMAIL/
QUAK_PASSWORD env vars), whoami, logout, backup commands. Session
stored at env-paths('quak').data/session.json (~/Library/Application
Support/quak/ on macOS, XDG on Linux).

src/backup.ts: runBackup downloads all files into originals/<id>.<ext>,
symlinks into collections/<name>/<title>, writes per-collection JSON
metadata at collections/<name>.json. Deduplicates across collections
(each file downloaded once). Skips existing originals on incremental
runs. Never crashes on single-file failure.

4 backup tests + live-tested against real Ente account.
This commit is contained in:
2026-05-13 18:47:06 -07:00
parent 30a13eeeaf
commit 8ee1be1cc2
3 changed files with 348 additions and 27 deletions

View File

@@ -1,6 +1,13 @@
// Stub: see the README "Development workflow" section for TDD policy.
import {
existsSync,
mkdirSync,
statSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { join, relative, extname } from "node:path";
import type { Client } from "./client.js";
import type { EnteFile } from "./model/types.js";
export interface BackupError {
fileID: number;
@@ -17,9 +24,114 @@ export interface BackupResult {
errors: BackupError[];
}
export const runBackup = async (
_client: Client,
_outDir: string,
): Promise<BackupResult> => {
throw new Error("backup.runBackup not implemented");
export type ProgressCallback = (message: string) => void;
const sanitizePath = (name: string): string =>
name.replace(/[/\\:*?"<>|]/g, "_").replace(/^\.+/, "_");
const originalFileName = (file: EnteFile): string => {
const ext = extname(file.metadata.title || "") || ".bin";
return `${file.id}${ext}`;
};
export const runBackup = async (
client: Client,
outDir: string,
onProgress?: ProgressCallback,
): Promise<BackupResult> => {
const log = onProgress ?? (() => {});
mkdirSync(outDir, { recursive: true });
const originalsDir = join(outDir, "originals");
mkdirSync(originalsDir, { recursive: true });
const collectionsDir = join(outDir, "collections");
mkdirSync(collectionsDir, { recursive: true });
log("Fetching collections...");
const collections = await client.listCollections();
const downloadedIDs = new Set<number>();
let totalFiles = 0;
let downloaded = 0;
let skipped = 0;
let failed = 0;
const errors: BackupError[] = [];
for (const col of collections) {
const colDirName = sanitizePath(col.name || `collection-${col.id}`);
const colDir = join(collectionsDir, colDirName);
mkdirSync(colDir, { recursive: true });
log(`[${col.name}] Fetching file list...`);
const files = await client.listFiles(col.id, col.key);
log(`[${col.name}] ${files.length} file(s)`);
const collectionMeta: {
id: number;
name: string;
type: string;
files: { id: number; metadata: EnteFile["metadata"] }[];
} = {
id: col.id,
name: col.name,
type: col.type,
files: [],
};
for (const file of files) {
totalFiles++;
const origName = originalFileName(file);
const origPath = join(originalsDir, origName);
const linkName = sanitizePath(
file.metadata.title || `file-${file.id}`,
);
const linkPath = join(colDir, linkName);
if (!downloadedIDs.has(file.id)) {
if (existsSync(origPath) && statSync(origPath).size > 0) {
skipped++;
downloadedIDs.add(file.id);
} else {
try {
log(`[${col.name}] Downloading ${linkName}...`);
await client.downloadFile(file, origPath);
downloaded++;
downloadedIDs.add(file.id);
} catch (err) {
log(
`[${col.name}] FAILED ${linkName}: ${err instanceof Error ? err.message : err}`,
);
failed++;
errors.push({
fileID: file.id,
title: file.metadata.title,
collection: col.name,
error:
err instanceof Error
? err.message
: String(err),
});
continue;
}
}
}
if (!existsSync(linkPath) && existsSync(origPath)) {
const target = relative(colDir, origPath);
symlinkSync(target, linkPath);
}
collectionMeta.files.push({
id: file.id,
metadata: file.metadata,
});
}
writeFileSync(
join(collectionsDir, `${colDirName}.json`),
JSON.stringify(collectionMeta, null, 2),
);
}
return { totalFiles, downloaded, skipped, failed, errors };
};