Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2269b413a4 |
+27
-18
@@ -15,6 +15,12 @@ import envPaths from "env-paths";
|
||||
import { Client, type ClientSnapshot } from "../src/client.js";
|
||||
import { init } from "../src/crypto/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 {
|
||||
listMissingThumbnails,
|
||||
@@ -229,24 +235,21 @@ program
|
||||
return;
|
||||
}
|
||||
|
||||
const photos = album.photos.list();
|
||||
// Present each file from its own decrypted metadata, not the
|
||||
// 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) {
|
||||
stdout.write(
|
||||
JSON.stringify(
|
||||
photos.map((p) => ({
|
||||
id: p.fileID,
|
||||
title: p.title,
|
||||
fileType: p.fileType,
|
||||
creationTime: p.takenAt,
|
||||
collectionID,
|
||||
})),
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
JSON.stringify(files.map(fileListRow), null, 2) + "\n",
|
||||
);
|
||||
} else {
|
||||
for (const p of photos) {
|
||||
stdout.write(`${p.fileID}\t${p.fileType}\t${p.title}\n`);
|
||||
for (const file of files) {
|
||||
stdout.write(fileListLine(file) + "\n");
|
||||
}
|
||||
}
|
||||
finish(lib, 0);
|
||||
@@ -269,14 +272,17 @@ program
|
||||
|
||||
const lib = await openReadLibrary(client);
|
||||
const photo = lib.photos.byID({ fileID });
|
||||
if (!photo) {
|
||||
const file = lib.getFileByID(fileID);
|
||||
if (!photo || !file) {
|
||||
stderr.write(`File ${fileID} not found\n`);
|
||||
finish(lib, 1);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await photo.original();
|
||||
const outPath = opts.out ?? photo.title;
|
||||
// Default name is the file's own title, as the pre-library CLI used
|
||||
// (not the editedName-preferring projection title) (issue #52).
|
||||
const outPath = opts.out ?? originalName(file);
|
||||
copyFileSync(result.path, outPath);
|
||||
stderr.write(`${result.bytes} bytes -> ${outPath}\n`);
|
||||
finish(lib, 0);
|
||||
@@ -299,14 +305,17 @@ program
|
||||
|
||||
const lib = await openReadLibrary(client);
|
||||
const photo = lib.photos.byID({ fileID });
|
||||
if (!photo) {
|
||||
const file = lib.getFileByID(fileID);
|
||||
if (!photo || !file) {
|
||||
stderr.write(`File ${fileID} not found\n`);
|
||||
finish(lib, 1);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await photo.thumbnail();
|
||||
const outPath = opts.out ?? `thumb_${photo.title}`;
|
||||
// Default name is thumb_<file's own title>, as the pre-library CLI
|
||||
// used (not the projection title) (issue #52).
|
||||
const outPath = opts.out ?? thumbnailName(file);
|
||||
copyFileSync(result.path, outPath);
|
||||
stderr.write(`${result.bytes} bytes -> ${outPath}\n`);
|
||||
finish(lib, 0);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// 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}`;
|
||||
@@ -433,6 +433,13 @@ export class Library {
|
||||
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
|
||||
// records (no keys), the surface the GUI reads across IPC. Photos are
|
||||
// deduplicated to one record per file and ordered newest first.
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user