Photo: save path, is-local, content bytes, and common metadata and EXIF getters #141

Closed
opened 2026-10-01 16:44:08 +02:00 by clawbot · 3 comments
Collaborator

Follows the answer on #140 (usage examples and the fluent-chain assessment, comment 107484). Ruling recorded in comment 107486.

Owner's words (chat, 2026-10-01 14:42 UTC):

savepath is where it would be written to if downloaded. .IsLocal() should tell if it's downloaded or not. .Content() should return the bytes. add getters for most common metadata fields, including any common ones from exif.

Definition of done:

  • A Photo exposes its save path: the path in the backup tree that lib.backup() would write the original to, whether or not it has been downloaded yet.
  • A Photo says whether it is local: whether the original has been downloaded.
  • A Photo returns its content: the original's bytes themselves, not a path.
  • A Photo has getters for the most common metadata fields, including the common EXIF fields.
  • Names and call style follow the existing quak API conventions. The issue comments state the chosen names and the reading of each point above before implementation starts.
  • Tests cover each new member. README/API docs show the new members. The check gate is green on next.
  • One PR to next, independently reviewed and squash-merged.

Out of scope until the owner rules: a ULID per file, and a dedicated year or date filter (both raised on 140).

model: opus-5-5

Follows the answer on https://git.eeqj.de/sneak/quak/issues/140 (usage examples and the fluent-chain assessment, comment 107484). Ruling recorded in comment 107486. Owner's words (chat, 2026-10-01 14:42 UTC): > savepath is where it would be written to if downloaded. .IsLocal() should tell if it's downloaded or not. .Content() should return the bytes. add getters for most common metadata fields, including any common ones from exif. Definition of done: - A `Photo` exposes its save path: the path in the backup tree that `lib.backup()` would write the original to, whether or not it has been downloaded yet. - A `Photo` says whether it is local: whether the original has been downloaded. - A `Photo` returns its content: the original's bytes themselves, not a path. - A `Photo` has getters for the most common metadata fields, including the common EXIF fields. - Names and call style follow the existing quak API conventions. The issue comments state the chosen names and the reading of each point above before implementation starts. - Tests cover each new member. README/API docs show the new members. The check gate is green on `next`. - One PR to `next`, independently reviewed and squash-merged. Out of scope until the owner rules: a ULID per file, and a dedicated year or date filter (both raised on 140). model: opus-5-5
clawbot self-assigned this 2026-10-01 16:44:08 +02:00
Author
Collaborator

Reading of the ruling, and the API this unit adds to Photo. It follows quak's existing style: data are camelCase getters (photo.title), and anything that may download is an async method (await photo.original()).

  • photo.savePath (getter, string | undefined): the path lib.backup() writes the original to, {downloadDirectory}/originals/{fileID}{ext}, whether or not the file is there yet. The root is the downloadDirectory the library was opened with (Library.open({ client, downloadDirectory })). It is undefined when the library has none, because a Photo cannot know a directory passed only to backup(). The name comes from the file's original name in Ente, not from a rename. For a live photo it is the image's path. Before download, that is the name the backup starts from; the image can land beside it with the extension found inside the live photo.

  • photo.isLocal (getter, boolean): whether the whole original is at savePath. It is checked on disk at each read, never over the network. A copy held only in quak's cache does not count.

  • await photo.content() (async method, Uint8Array): the original's bytes. It goes through original(), so it reads the backup tree or the cache when the file is there and downloads it otherwise. For a live photo it returns the image; the video stays at original().videoPath.

  • Metadata from Ente (synchronous getters, from RAM): the existing title, takenAt, fileType, caption, width, height, latitude and longitude, plus three new ones:

    • modifiedAt: milliseconds.
    • hash: the content hash recorded at upload; very old files have none.
    • year: the local-time year of takenAt. It is a getter, not a filter.

    modifiedAt and hash also go on PhotoRecord.

  • EXIF (await photo.exif(), an async method returning a plain object): EXIF is inside the file, so reading it needs the bytes and may download them. Fields: make, model, lensModel, dateTimeOriginal, offsetTimeOriginal, exposureTime, fNumber, iso, focalLength, orientation, gpsLatitude, gpsLongitude and gpsAltitude. Each one is absent when the file lacks it. It uses the JPEG EXIF reader quak already has, so HEIC, PNG and video originals return no fields.

Not in this unit: a ULID per file, and a year or date filter.

Questions for sneak. Work proceeds on the recommendation, since it lands only on next.

  1. Should isLocal also be true when the original is only in quak's cache and not at savePath? Recommendation: no, so that isLocal always means "the file is at savePath".
  2. Because EXIF needs the bytes, it is one async exif() call rather than synchronous getters. Synchronous EXIF getters would mean quak extracts and stores EXIF for every original it downloads. Recommendation: the async call.
  3. Should reading EXIF from HEIC (iPhone) originals be a follow-up unit? It needs a different reader. Recommendation: yes, a separate issue after this one.

Model: opus-5-5

Reading of the ruling, and the API this unit adds to `Photo`. It follows quak's existing style: data are camelCase getters (`photo.title`), and anything that may download is an async method (`await photo.original()`). - **`photo.savePath`** (getter, `string | undefined`): the path `lib.backup()` writes the original to, `{downloadDirectory}/originals/{fileID}{ext}`, whether or not the file is there yet. The root is the `downloadDirectory` the library was opened with (`Library.open({ client, downloadDirectory })`). It is `undefined` when the library has none, because a `Photo` cannot know a directory passed only to `backup()`. The name comes from the file's original name in Ente, not from a rename. For a live photo it is the image's path. Before download, that is the name the backup starts from; the image can land beside it with the extension found inside the live photo. - **`photo.isLocal`** (getter, `boolean`): whether the whole original is at `savePath`. It is checked on disk at each read, never over the network. A copy held only in quak's cache does not count. - **`await photo.content()`** (async method, `Uint8Array`): the original's bytes. It goes through `original()`, so it reads the backup tree or the cache when the file is there and downloads it otherwise. For a live photo it returns the image; the video stays at `original().videoPath`. - **Metadata from Ente** (synchronous getters, from RAM): the existing `title`, `takenAt`, `fileType`, `caption`, `width`, `height`, `latitude` and `longitude`, plus three new ones: - `modifiedAt`: milliseconds. - `hash`: the content hash recorded at upload; very old files have none. - `year`: the local-time year of `takenAt`. It is a getter, not a filter. `modifiedAt` and `hash` also go on `PhotoRecord`. - **EXIF** (`await photo.exif()`, an async method returning a plain object): EXIF is inside the file, so reading it needs the bytes and may download them. Fields: `make`, `model`, `lensModel`, `dateTimeOriginal`, `offsetTimeOriginal`, `exposureTime`, `fNumber`, `iso`, `focalLength`, `orientation`, `gpsLatitude`, `gpsLongitude` and `gpsAltitude`. Each one is absent when the file lacks it. It uses the JPEG EXIF reader quak already has, so HEIC, PNG and video originals return no fields. Not in this unit: a ULID per file, and a year or date filter. Questions for sneak. Work proceeds on the recommendation, since it lands only on `next`. 1. Should `isLocal` also be true when the original is only in quak's cache and not at `savePath`? Recommendation: no, so that `isLocal` always means "the file is at `savePath`". 2. Because EXIF needs the bytes, it is one async `exif()` call rather than synchronous getters. Synchronous EXIF getters would mean quak extracts and stores EXIF for every original it downloads. Recommendation: the async call. 3. Should reading EXIF from HEIC (iPhone) originals be a follow-up unit? It needs a different reader. Recommendation: yes, a separate issue after this one. Model: opus-5-5
Author
Collaborator

Implementer's brief. This follows the reading in #141 (comment). It adds one getter to that list, fileSize: the original's size in bytes as the server reports it, a common field and already decoded.

Records (src/library/records.ts, toPhotoRecord). Add to PhotoRecord:

  • modifiedAt: number: milliseconds, from metadata.modificationTime, which is in microseconds.
  • hash?: string: from metadata.hash.
  • fileSize?: number: from file.size.

All three are read from the same chosen membership as the existing fields.

Photo (src/library/read.ts):

  • Getters modifiedAt, hash and fileSize (from the record), and year, which is new Date(takenAt).getFullYear().
  • get savePath(): string | undefined and get isLocal(): boolean. Both delegate to two new methods on PhotoContent, savePath(fileID) and isLocal(fileID), which ContentCache implements from its downloadDirectory and getFile:
    • savePath: for a live photo whose image is stored, the image's path (storedOriginal(...)?.path). Otherwise join(downloadDirectory, "originals", nameInOriginals(file)). undefined when there is no download directory.
    • isLocal: storedOriginal(join(downloadDirectory, "originals"), file) !== undefined, the same test backup and the cache already use. false when there is no download directory. A copy that is only in the cache does not count.
    • With no content cache: savePath is undefined and isLocal is false; neither throws.
  • async content(opts?: ContentOptions), resolving to a Uint8Array: original(opts), then read its path. For a live photo this returns the image. It throws when there is no content cache, as original() does.
  • async exif(opts?: ContentOptions), resolving to a PhotoExif:
    • For fileType === "video", return {} without downloading anything.
    • Otherwise read the bytes with content(opts) and pick these fields: make, model, lensModel, dateTimeOriginal, offsetTimeOriginal, exposureTime, fNumber, iso, focalLength, orientation, gpsLatitude, gpsLongitude and gpsAltitude.
    • GPS values become signed decimal degrees and metres, with the reference letter or byte applied.
    • Keep dateTimeOriginal as the Date that exif-reader returns, and say in a comment how that library reads it. Confirm each source tag against exif-reader's own types.
    • A missing or unreadable EXIF block returns {}; it does not throw. Only JPEG is read.

EXIF code. Move extractExifFromJpeg out of src/metadata-backup.ts into a new src/exif.ts, together with the field picker. metadata-backup.ts imports it from there. Moving it avoids read.ts importing the backup command, which imports Photo.

Exports and docs. Export PhotoExif from src/index.ts. In README, update "Read surface": the new members, sync vs async for each, and that content() and exif() may download while savePath and isLocal never touch the network. Say that content() fills the cache and does not make isLocal true; lib.backup() does. Add src/exif.ts to "Key types by source file" if it fits there.

Tests. Follow the patterns in test/library/content-library.test.ts and test/cli/metadata-exif.test.ts.

  • modifiedAt, hash, fileSize and year. Use a mid-year date, so the time zone cannot change the year.
  • savePath before any download, and isLocal false.
  • After lib.backup(), isLocal is true and savePath exists.
  • A copy only in the cache leaves isLocal false.
  • With no download directory, savePath is undefined and isLocal is false.
  • A live photo's savePath after backup (use test/live-photo.ts).
  • content() returns the bytes the stub source wrote.
  • exif() on a hand-built JPEG with Make, Model, Orientation and GPS; {} for a non-JPEG; {} for a video, with the source never called.

Existing tests that compare whole records may gain the new fields. Never relax an assertion otherwise.

Process

  • Branch from current next, with one PR whose base is next. Title: Photo: save path, is-local, content bytes, metadata and EXIF getters (closes #141). Label it needs-review and assign clawbot.
  • Gate only with make check, and format only with make fmt. Both run in Docker. Run Docker builds under flock -w 1800 /srv/code/tmp/.docker-build.lock. A timeout there exits 1 without running anything; retry, and never read it as red. Remove every container you start.
  • Pull and rebase onto next again right before pushing, and re-run make check after any conflict resolution.
  • Edit files by hand. No sed -i, perl -pi, awk or scripted replacements.
  • Keep the change small and plain enough for a newcomer to follow in one reading. Coin no new terms.
  • Ask no interactive questions. A point only sneak can settle goes on the PR with the reading you took.
  • Commit and PR body end with a Model: line naming your model id. Never name the company.
  • Stay under 2 GiB of RAM.

Model: opus-5-5

**Implementer's brief.** This follows the reading in https://git.eeqj.de/sneak/quak/issues/141#issuecomment-107489. It adds one getter to that list, `fileSize`: the original's size in bytes as the server reports it, a common field and already decoded. **Records** (`src/library/records.ts`, `toPhotoRecord`). Add to `PhotoRecord`: - `modifiedAt: number`: milliseconds, from `metadata.modificationTime`, which is in microseconds. - `hash?: string`: from `metadata.hash`. - `fileSize?: number`: from `file.size`. All three are read from the same chosen membership as the existing fields. **`Photo`** (`src/library/read.ts`): - Getters `modifiedAt`, `hash` and `fileSize` (from the record), and `year`, which is `new Date(takenAt).getFullYear()`. - `get savePath(): string | undefined` and `get isLocal(): boolean`. Both delegate to two new methods on `PhotoContent`, `savePath(fileID)` and `isLocal(fileID)`, which `ContentCache` implements from its `downloadDirectory` and `getFile`: - `savePath`: for a live photo whose image is stored, the image's path (`storedOriginal(...)?.path`). Otherwise `join(downloadDirectory, "originals", nameInOriginals(file))`. `undefined` when there is no download directory. - `isLocal`: `storedOriginal(join(downloadDirectory, "originals"), file) !== undefined`, the same test backup and the cache already use. `false` when there is no download directory. A copy that is only in the cache does not count. - With no content cache: `savePath` is `undefined` and `isLocal` is `false`; neither throws. - `async content(opts?: ContentOptions)`, resolving to a `Uint8Array`: `original(opts)`, then read its `path`. For a live photo this returns the image. It throws when there is no content cache, as `original()` does. - `async exif(opts?: ContentOptions)`, resolving to a `PhotoExif`: - For `fileType === "video"`, return `{}` without downloading anything. - Otherwise read the bytes with `content(opts)` and pick these fields: `make`, `model`, `lensModel`, `dateTimeOriginal`, `offsetTimeOriginal`, `exposureTime`, `fNumber`, `iso`, `focalLength`, `orientation`, `gpsLatitude`, `gpsLongitude` and `gpsAltitude`. - GPS values become signed decimal degrees and metres, with the reference letter or byte applied. - Keep `dateTimeOriginal` as the `Date` that `exif-reader` returns, and say in a comment how that library reads it. Confirm each source tag against `exif-reader`'s own types. - A missing or unreadable EXIF block returns `{}`; it does not throw. Only JPEG is read. **EXIF code.** Move `extractExifFromJpeg` out of `src/metadata-backup.ts` into a new `src/exif.ts`, together with the field picker. `metadata-backup.ts` imports it from there. Moving it avoids `read.ts` importing the backup command, which imports `Photo`. **Exports and docs.** Export `PhotoExif` from `src/index.ts`. In README, update "Read surface": the new members, sync vs async for each, and that `content()` and `exif()` may download while `savePath` and `isLocal` never touch the network. Say that `content()` fills the cache and does not make `isLocal` true; `lib.backup()` does. Add `src/exif.ts` to "Key types by source file" if it fits there. **Tests.** Follow the patterns in `test/library/content-library.test.ts` and `test/cli/metadata-exif.test.ts`. - `modifiedAt`, `hash`, `fileSize` and `year`. Use a mid-year date, so the time zone cannot change the year. - `savePath` before any download, and `isLocal` false. - After `lib.backup()`, `isLocal` is true and `savePath` exists. - A copy only in the cache leaves `isLocal` false. - With no download directory, `savePath` is `undefined` and `isLocal` is false. - A live photo's `savePath` after backup (use `test/live-photo.ts`). - `content()` returns the bytes the stub source wrote. - `exif()` on a hand-built JPEG with Make, Model, Orientation and GPS; `{}` for a non-JPEG; `{}` for a video, with the source never called. Existing tests that compare whole records may gain the new fields. Never relax an assertion otherwise. **Process** - Branch from current `next`, with one PR whose base is `next`. Title: `Photo: save path, is-local, content bytes, metadata and EXIF getters (closes #141)`. Label it `needs-review` and assign `clawbot`. - Gate only with `make check`, and format only with `make fmt`. Both run in Docker. Run Docker builds under `flock -w 1800 /srv/code/tmp/.docker-build.lock`. A timeout there exits 1 without running anything; retry, and never read it as red. Remove every container you start. - Pull and rebase onto `next` again right before pushing, and re-run `make check` after any conflict resolution. - Edit files by hand. No `sed -i`, `perl -pi`, `awk` or scripted replacements. - Keep the change small and plain enough for a newcomer to follow in one reading. Coin no new terms. - Ask no interactive questions. A point only sneak can settle goes on the PR with the reading you took. - Commit and PR body end with a `Model:` line naming your model id. Never name the company. - Stay under 2 GiB of RAM. Model: opus-5-5
Author
Collaborator

Implemented in #142, as the brief in #141 (comment) describes. One deviation: the test JPEG carries every EXIF field exif() returns, not only Make, Model, Orientation and GPS.

Model: opus-5-5

Implemented in https://git.eeqj.de/sneak/quak/pulls/142, as the brief in https://git.eeqj.de/sneak/quak/issues/141#issuecomment-107492 describes. One deviation: the test JPEG carries every EXIF field `exif()` returns, not only Make, Model, Orientation and GPS. Model: opus-5-5
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/quak#141