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:
@@ -33,7 +33,14 @@
|
||||
* is a thin wrapper around this module.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import {
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readlinkSync,
|
||||
rmSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import sodium from "libsodium-wrappers-sumo";
|
||||
@@ -363,13 +370,22 @@ describe("quak backup", () => {
|
||||
expect(result.failed).toBe(0);
|
||||
expect(result.errors).toEqual([]);
|
||||
|
||||
// Files are under <outDir>/<collection-name>/<title>
|
||||
const beach = readFileSync(join(outDir, "Vacation", "beach.jpg"));
|
||||
const sunset = readFileSync(join(outDir, "Vacation", "sunset.jpg"));
|
||||
const diagram = readFileSync(join(outDir, "Work", "diagram.png"));
|
||||
expect(beach.length).toBe(3000);
|
||||
expect(sunset.length).toBe(2000);
|
||||
expect(diagram.length).toBe(1500);
|
||||
// Originals are under <outDir>/originals/<fileID>.<ext>
|
||||
expect(readFileSync(join(outDir, "originals", "100.jpg")).length).toBe(
|
||||
3000,
|
||||
);
|
||||
expect(readFileSync(join(outDir, "originals", "101.jpg")).length).toBe(
|
||||
2000,
|
||||
);
|
||||
expect(readFileSync(join(outDir, "originals", "200.png")).length).toBe(
|
||||
1500,
|
||||
);
|
||||
|
||||
// Collection dirs under collections/ contain symlinks to originals
|
||||
const beachLink = join(outDir, "collections", "Vacation", "beach.jpg");
|
||||
expect(lstatSync(beachLink).isSymbolicLink()).toBe(true);
|
||||
expect(readlinkSync(beachLink)).toContain("originals");
|
||||
expect(readFileSync(beachLink).length).toBe(3000);
|
||||
});
|
||||
|
||||
it("skips files that already exist on disk with matching size", async () => {
|
||||
@@ -411,14 +427,17 @@ describe("quak backup", () => {
|
||||
expect(result.errors[0]!.fileID).toBe(101);
|
||||
expect(result.errors[0]!.title).toBe("sunset.jpg");
|
||||
|
||||
// The two successful files are on disk
|
||||
expect(existsSync(join(outDir, "Vacation", "beach.jpg"))).toBe(true);
|
||||
expect(existsSync(join(outDir, "Work", "diagram.png"))).toBe(true);
|
||||
// The failed file is NOT on disk (no partial write)
|
||||
expect(existsSync(join(outDir, "Vacation", "sunset.jpg"))).toBe(false);
|
||||
// The two successful originals are on disk
|
||||
expect(existsSync(join(outDir, "originals", "100.jpg"))).toBe(true);
|
||||
expect(existsSync(join(outDir, "originals", "200.png"))).toBe(true);
|
||||
// The failed file has no original and no symlink
|
||||
expect(existsSync(join(outDir, "originals", "101.jpg"))).toBe(false);
|
||||
expect(
|
||||
existsSync(join(outDir, "collections", "Vacation", "sunset.jpg")),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("writes metadata.json with collection and file info", async () => {
|
||||
it("writes per-collection JSON metadata", async () => {
|
||||
const outDir = join(testDir, "metadata-check");
|
||||
const client = await Client.login({
|
||||
email: TEST_EMAIL,
|
||||
@@ -428,11 +447,12 @@ describe("quak backup", () => {
|
||||
|
||||
await runBackup(client, outDir);
|
||||
|
||||
const metaPath = join(outDir, "metadata.json");
|
||||
expect(existsSync(metaPath)).toBe(true);
|
||||
const meta = JSON.parse(readFileSync(metaPath, "utf-8"));
|
||||
expect(meta.collections.length).toBe(2);
|
||||
expect(meta.collections[0].files.length).toBeGreaterThan(0);
|
||||
expect(meta.collections[0].files[0].metadata.title).toBeDefined();
|
||||
// Each collection gets a <name>.json next to its image dir
|
||||
const vacationMeta = join(outDir, "collections", "Vacation.json");
|
||||
expect(existsSync(vacationMeta)).toBe(true);
|
||||
const meta = JSON.parse(readFileSync(vacationMeta, "utf-8"));
|
||||
expect(meta.name).toBe("Vacation");
|
||||
expect(meta.files.length).toBeGreaterThan(0);
|
||||
expect(meta.files[0].metadata.title).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user