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:
126
src/backup.ts
126
src/backup.ts
@@ -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 };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user