Photo: save path, is-local, content bytes, metadata and EXIF getters (closes #141)
check / check (push) Successful in 1m34s

A Photo now has savePath and isLocal, which look only at the disk;
content() and exif(), which may download the original; and modifiedAt,
hash, fileSize and year. PhotoRecord gains modifiedAt, hash and fileSize.
The JPEG EXIF scan moves from metadata-backup.ts to the new src/exif.ts,
so the read surface does not import the backup command.

Model: opus-5-5
This commit is contained in:
2026-10-01 15:09:17 +00:00
parent e6825abcdb
commit e7f75685e5
12 changed files with 533 additions and 86 deletions
+37 -8
View File
@@ -224,6 +224,7 @@ quak/
backup.ts resilient full-account backup with dedup
metadata-backup.ts
backup-metadata: the metadata quak keeps, as JSON
exif.ts EXIF read from a JPEG's bytes
mldata-fetch.ts fetch + decrypt per-file ML data
filename.ts safe file names from server metadata
errors.ts error types shared across layers
@@ -692,16 +693,40 @@ https://git.eeqj.de/sneak/quak/issues/75).
An `Album` exposes its record fields and `album.photos.list()` → `Photo[]`
(newest first). A `Photo` exposes its record fields other than `thumbnailPath`
and `originalPath`, `photo.record()` → `PhotoRecord`, and two content methods:
and `originalPath`, and `photo.year`, the local-time year of `takenAt`, all as
synchronous getters read from RAM; `photo.record()` → `PhotoRecord`. Two more
synchronous getters look at the disk and never touch the network:
- `photo.savePath` → `string | undefined` — where `lib.backup()` writes the
original, `originals/<fileID>.<ext>` under the `downloadDirectory` the library
was opened with, whether or not it is there yet. For a live photo the backup
has stored, it is the image's path, whose extension comes from inside the live
photo and can differ from the title's. `undefined` when the library has no
`downloadDirectory` or no content source.
- `photo.isLocal` → `boolean` — whether the whole original is at `savePath`.
Four async methods may download:
- `await photo.original(opts?)` → `{ path, bytes, videoPath? }` — the
full-resolution file. For a live photo, `path` and `bytes` are its image's and
`videoPath` is its video.
- `await photo.thumbnail(opts?)` → `{ path, bytes }`.
- `await photo.content(opts?)` → `Uint8Array` — the original's bytes, read
through `original()`; for a live photo, its image's.
- `await photo.exif(opts?)` → `PhotoExif` — `make`, `model`, `lensModel`,
`dateTimeOriginal`, `offsetTimeOriginal`, `exposureTime`, `fNumber`, `iso`,
`focalLength`, `orientation`, `gpsLatitude`, `gpsLongitude` and `gpsAltitude`,
each absent when the file lacks it. GPS values are signed decimal degrees and
metres. `dateTimeOriginal` is the camera's clock reading held in the `Date`'s
UTC fields; `offsetTimeOriginal`, when present, is that clock's offset from
UTC. Only a JPEG's EXIF is read: any other original gives `{}`, and a video
gives `{}` without being downloaded.
Both serve from the on-disk content cache when the bytes are present and
otherwise fetch through the pools; `opts.onProgress` reports per-file progress.
They throw when the library was opened without a content source.
They serve from the on-disk content cache, or the backup, when the bytes are
present and otherwise fetch through the pools; `opts.onProgress` reports
per-file progress. They throw when the library was opened without a content
source. An original that `content()` or `exif()` downloads lands in the cache,
which does not make `isLocal` true; only `lib.backup()` does.
Lower-level accessors that return decrypted model objects (which hold key
material) are also available: `listCollections()`, `getCollection(id)`,
@@ -713,10 +738,13 @@ material) are also available: `listCollections()`, `getCollection(id)`,
The GUI-facing records hold no key material and no binary, so they survive
`structuredClone`/JSON across the Electron IPC boundary:
- `PhotoRecord`: `fileID`, `albumIDs`, `title`, `takenAt` (milliseconds),
`fileType`, optional `caption` / `width` / `height` / `latitude` /
`longitude`, `isArchived`, `isHidden`, and `thumbnailPath` / `originalPath`
once the bytes are cached (for a live photo, `originalPath` is its image).
- `PhotoRecord`: `fileID`, `albumIDs`, `title`, `takenAt` and `modifiedAt`
(milliseconds), `fileType`, optional `caption` / `width` / `height` /
`latitude` / `longitude`, optional `hash` (the content hash recorded at
upload; very old files have none) and `fileSize` (the original's size in
bytes, as the server reports it), `isArchived`, `isHidden`, and
`thumbnailPath` / `originalPath` once the bytes are cached (for a live photo,
`originalPath` is its image).
- `AlbumRecord`: `collectionID`, `name`, `type`, `isShared`, `updationTime`, and
`fileIDs` (newest first).
- `LibrarySnapshot`: `{ albums, photos, takenAt }`.
@@ -811,6 +839,7 @@ from a very old client, is stored unchecked.
- `src/library/records.ts`: `PhotoRecord`, `AlbumRecord`, `LibrarySnapshot`,
`LibraryChange`
- `src/library/mlsearch.ts`: `MLDataAPI`, `SimilarResult`
- `src/exif.ts`: `PhotoExif`
- `src/library/pools.ts`: `RequestPools`, `RequestPoolsOptions`, `BoundedPool`
- `src/backup.ts`: `BackupOptions`, `BackupResult`, `BackupError`
- `src/client.ts`: `Client`, `LoginOptions`, `ClientSnapshot`