Compare commits

..
1 Commits
Author SHA1 Message Date
sneak 005612ef76 Port the quak CLI to the library API (closes #52)
check / check (push) Successful in 39s
Route every command through Library.open instead of scanning the client.
collections/files read lib.albums; get/get-thumb resolve lib.photos.byID
and copy the cached original/thumbnail to --out, so --collection is now
accepted but ignored. backup-metadata and the thumbnail helpers enumerate
through the library and read originals via photo.original(); ML fetch,
EXIF, and thumbnail upload are unchanged. A new global --cache-dir sets the
cache location; point commands open with the background precache off so a
one-shot command never starts downloading the whole account.

Also addresses #17: fix-missing-thumbnails
now reports a non-JPEG image or a video as skipped (unsupported), distinct
from failed, and only a genuine failure exits non-zero.

Model: opus-4-8
2026-09-22 20:19:18 +00:00
4 changed files with 18 additions and 150 deletions
+18 -27
View File
@@ -15,12 +15,6 @@ import envPaths from "env-paths";
import { Client, type ClientSnapshot } from "../src/client.js"; import { Client, type ClientSnapshot } from "../src/client.js";
import { init } from "../src/crypto/index.js"; import { init } from "../src/crypto/index.js";
import { Library, type LibraryClient } from "../src/library/index.js"; import { Library, type LibraryClient } from "../src/library/index.js";
import {
fileListRow,
fileListLine,
originalName,
thumbnailName,
} from "../src/cli-output.js";
import { runMetadataBackup } from "../src/metadata-backup.js"; import { runMetadataBackup } from "../src/metadata-backup.js";
import { import {
listMissingThumbnails, listMissingThumbnails,
@@ -235,21 +229,24 @@ program
return; return;
} }
// Present each file from its own decrypted metadata, not the const photos = album.photos.list();
// PhotoRecord projection, so the raw title and the microsecond
// creationTime print as the pre-library CLI did (issue #52). The
// album's photo order is kept; only the field source changes.
const files = album.photos.list().flatMap((p) => {
const file = lib.getFile(collectionID, p.fileID);
return file ? [file] : [];
});
if (opts.json) { if (opts.json) {
stdout.write( stdout.write(
JSON.stringify(files.map(fileListRow), null, 2) + "\n", JSON.stringify(
photos.map((p) => ({
id: p.fileID,
title: p.title,
fileType: p.fileType,
creationTime: p.takenAt,
collectionID,
})),
null,
2,
) + "\n",
); );
} else { } else {
for (const file of files) { for (const p of photos) {
stdout.write(fileListLine(file) + "\n"); stdout.write(`${p.fileID}\t${p.fileType}\t${p.title}\n`);
} }
} }
finish(lib, 0); finish(lib, 0);
@@ -272,17 +269,14 @@ program
const lib = await openReadLibrary(client); const lib = await openReadLibrary(client);
const photo = lib.photos.byID({ fileID }); const photo = lib.photos.byID({ fileID });
const file = lib.getFileByID(fileID); if (!photo) {
if (!photo || !file) {
stderr.write(`File ${fileID} not found\n`); stderr.write(`File ${fileID} not found\n`);
finish(lib, 1); finish(lib, 1);
return; return;
} }
const result = await photo.original(); const result = await photo.original();
// Default name is the file's own title, as the pre-library CLI used const outPath = opts.out ?? photo.title;
// (not the editedName-preferring projection title) (issue #52).
const outPath = opts.out ?? originalName(file);
copyFileSync(result.path, outPath); copyFileSync(result.path, outPath);
stderr.write(`${result.bytes} bytes -> ${outPath}\n`); stderr.write(`${result.bytes} bytes -> ${outPath}\n`);
finish(lib, 0); finish(lib, 0);
@@ -305,17 +299,14 @@ program
const lib = await openReadLibrary(client); const lib = await openReadLibrary(client);
const photo = lib.photos.byID({ fileID }); const photo = lib.photos.byID({ fileID });
const file = lib.getFileByID(fileID); if (!photo) {
if (!photo || !file) {
stderr.write(`File ${fileID} not found\n`); stderr.write(`File ${fileID} not found\n`);
finish(lib, 1); finish(lib, 1);
return; return;
} }
const result = await photo.thumbnail(); const result = await photo.thumbnail();
// Default name is thumb_<file's own title>, as the pre-library CLI const outPath = opts.out ?? `thumb_${photo.title}`;
// used (not the projection title) (issue #52).
const outPath = opts.out ?? thumbnailName(file);
copyFileSync(result.path, outPath); copyFileSync(result.path, outPath);
stderr.write(`${result.bytes} bytes -> ${outPath}\n`); stderr.write(`${result.bytes} bytes -> ${outPath}\n`);
finish(lib, 0); finish(lib, 0);
-40
View File
@@ -1,40 +0,0 @@
// How the CLI presents a file's identity in `files`, `get`, and `get-thumb`.
//
// These read the file's own decrypted metadata — the raw title and the
// creationTime in microseconds — rather than the `PhotoRecord` projection the
// rest of the library exposes. The projection prefers `editedName`/`editedTime`
// and reports time in milliseconds, which is right for a photo browser but
// would change the CLI's externally-visible output. The pre-library CLI printed
// `metadata.title` and `metadata.creationTime` and named downloads after
// `metadata.title`, and issue #52 requires that output stay byte-identical, so
// the commands shape their output from the raw `EnteFile` through here.
import type { EnteFile, FileType, Microseconds } from "./model/types.js";
// One row of `quak files --json`.
export interface FileListRow {
id: number;
title: string;
fileType: FileType;
creationTime: Microseconds;
collectionID: number;
}
export const fileListRow = (file: EnteFile): FileListRow => ({
id: file.id,
title: file.metadata.title,
fileType: file.metadata.fileType,
creationTime: file.metadata.creationTime,
collectionID: file.collectionID,
});
// One line of `quak files` in its human, tab-separated form.
export const fileListLine = (file: EnteFile): string =>
`${file.id}\t${file.metadata.fileType}\t${file.metadata.title}`;
// Default output path for `quak get` when `--out` is not given.
export const originalName = (file: EnteFile): string => file.metadata.title;
// Default output path for `quak get-thumb` when `--out` is not given.
export const thumbnailName = (file: EnteFile): string =>
`thumb_${file.metadata.title}`;
-7
View File
@@ -433,13 +433,6 @@ export class Library {
return this.store.getFile(collectionID, fileID); return this.store.getFile(collectionID, fileID);
} }
// Any membership of a file, addressed by file id alone. A file's own
// metadata (title, creationTime) is identical across the collections it
// belongs to, so this serves the point commands that hold only a fileID.
getFileByID(fileID: number): EnteFile | undefined {
return this.store.getFileByID(fileID);
}
// A synchronous, RAM-only projection of the whole library into plain // A synchronous, RAM-only projection of the whole library into plain
// records (no keys), the surface the GUI reads across IPC. Photos are // records (no keys), the surface the GUI reads across IPC. Photos are
// deduplicated to one record per file and ordered newest first. // deduplicated to one record per file and ordered newest first.
-76
View File
@@ -1,76 +0,0 @@
// The CLI presents a file by its own decrypted metadata, not the PhotoRecord
// projection (issue #52). For a renamed file the two disagree: the projection
// prefers `editedName` and reports `editedTime` in milliseconds, while the CLI
// must print the raw `metadata.title` and `metadata.creationTime` (microseconds)
// and name downloads after the raw title, byte-identical to the pre-library CLI.
//
// This locks in that contrast: the shared output helpers emit the raw values,
// and the projection of the same file emits the edited ones — so a regression
// that re-sourced the CLI from the projection would fail here.
import { describe, it, expect } from "vitest";
import {
fileListRow,
fileListLine,
originalName,
thumbnailName,
} from "../../src/cli-output.js";
import { deriveRecords } from "../../src/library/records.js";
import type { EnteFile } from "../../src/model/types.js";
// Microseconds, as Ente stores times.
const RAW_CREATION = 1700000000000000;
const EDITED_TIME = 1710000000000000;
const RAW_TITLE = "IMG_0001.HEIC";
const EDITED_NAME = "Sunset.heic";
// A file the user has renamed and re-dated: basic metadata holds the original
// title and capture time; public magic metadata holds the edits.
const renamedFile: EnteFile = {
id: 100,
collectionID: 10,
ownerID: 42,
key: new Uint8Array(),
metadata: {
title: RAW_TITLE,
fileType: "image",
creationTime: RAW_CREATION,
modificationTime: RAW_CREATION,
},
pubMagicMetadata: { editedName: EDITED_NAME, editedTime: EDITED_TIME },
file: { decryptionHeader: "" },
thumbnail: { decryptionHeader: "" },
updationTime: RAW_CREATION,
};
describe("CLI file output (issue #52)", () => {
it("emits the raw title and microsecond creationTime for --json", () => {
expect(fileListRow(renamedFile)).toEqual({
id: 100,
title: RAW_TITLE,
fileType: "image",
creationTime: RAW_CREATION,
collectionID: 10,
});
});
it("emits the raw title in the human column", () => {
expect(fileListLine(renamedFile)).toBe(`100\timage\t${RAW_TITLE}`);
});
it("names downloads after the raw title", () => {
expect(originalName(renamedFile)).toBe(RAW_TITLE);
expect(thumbnailName(renamedFile)).toBe(`thumb_${RAW_TITLE}`);
});
it("does not use the editedName/editedTime projection", () => {
const record = deriveRecords([], [renamedFile]).photos.get(100);
// The projection prefers the edits and reports milliseconds; the CLI
// helpers above deliberately do not.
expect(record?.title).toBe(EDITED_NAME);
expect(record?.takenAt).toBe(Math.floor(EDITED_TIME / 1000));
expect(fileListRow(renamedFile).title).not.toBe(record?.title);
expect(fileListRow(renamedFile).creationTime).not.toBe(record?.takenAt);
});
});