From d40f339c0b2befe349f288ef3827ba1f2d1f2dbc Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 2 Oct 2026 00:09:24 +0200 Subject: [PATCH] Photo: one async method per EXIF field (closes #148) `Photo` gains thirteen async methods, one per `PhotoExif` field and named after it: `make()`, `model()`, `lensModel()`, `dateTimeOriginal()`, `offsetTimeOriginal()`, `exposureTime()`, `fNumber()`, `iso()`, `focalLength()`, `orientation()`, `gpsLatitude()`, `gpsLongitude()` and `gpsAltitude()`. Each calls `exif()` and returns its one field, or `undefined` when the file lacks it. `exif()` is unchanged. `Photo` implements a type built from `PhotoExif`'s keys, so the type check fails when a field has no method. Each call reads the original again; a caller that wants several fields calls `exif()` once. Model: opus-5-5 --- README.md | 8 ++- TODO.md | 10 ++++ src/library/read.ts | 78 +++++++++++++++++++++++++--- test/library/content-library.test.ts | 34 ++++++++++-- 4 files changed, 119 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index bb365bc..1515a67 100644 --- a/README.md +++ b/README.md @@ -726,7 +726,7 @@ synchronous getters look at the disk and never touch the network: - `photo.isLocal` → `boolean` — whether the whole original is at `savePath`. A copy only in the cache does not count. -Five async methods may download: +These 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 @@ -748,6 +748,12 @@ Five async methods may download: UTC. EXIF is read from any image format exifreader reads (such as JPEG, HEIC/HEIF, AVIF, PNG, WebP and TIFF), a live photo's image included. Any other original gives `{}`, and a video gives `{}` without being downloaded. +- `await photo.make(opts?)`, and likewise `model()`, `lensModel()`, + `dateTimeOriginal()`, `offsetTimeOriginal()`, `exposureTime()`, `fNumber()`, + `iso()`, `focalLength()`, `orientation()`, `gpsLatitude()`, `gpsLongitude()` + and `gpsAltitude()` → one field of `exif()` each, typed as in `PhotoExif`, or + `undefined` when the file lacks it. Each calls `exif()` with its `opts`, so + each call reads the original again. They serve from the on-disk content cache when the bytes are present and otherwise fetch through the pools; `original()`, `content()` and `exif()` also diff --git a/TODO.md b/TODO.md index acc99ef..3d1af8a 100644 --- a/TODO.md +++ b/TODO.md @@ -25,6 +25,16 @@ declares one. # Completed Steps +- 2026-10-01: A `Photo` has one async method for each field of `exif()`, named + and typed as in `PhotoExif`: `make()`, `model()`, `lensModel()`, + `dateTimeOriginal()`, `offsetTimeOriginal()`, `exposureTime()`, `fNumber()`, + `iso()`, `focalLength()`, `orientation()`, `gpsLatitude()`, `gpsLongitude()` + and `gpsAltitude()` (issue 148). Each calls `exif()` and returns its one + field, or undefined when the file lacks it. `Photo` implements a type with one + method per `PhotoExif` field, so the build's type check fails when a field has + no method. A test checks, on the JPEG and the HEIC, that each method gives the + same value as `exif()`. + - 2026-10-01: Each original's save path is `YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD..` under the library's download directory, which defaults to `photos` in the working directory (issue diff --git a/src/library/read.ts b/src/library/read.ts index ae040cb..3aac9ce 100644 --- a/src/library/read.ts +++ b/src/library/read.ts @@ -12,11 +12,11 @@ // plain records are the serializable surface, and `record()` returns one. // // A `Photo` also fetches its own bytes: `original()`, `thumbnail()`, -// `download()`, `content()` and `exif()` go through the on-disk content cache -// (issue #46), and are the one place in this module that may touch the -// network. A library opened without a content source leaves that cache absent, -// and those methods then throw. `savePath` and `isLocal` look only at the disk -// and need no cache. +// `download()`, `content()`, `exif()` and the methods that each return one +// field of `exif()` go through the on-disk content cache (issue #46), and are +// the one place in this module that may touch the network. A library opened +// without a content source leaves that cache absent, and those methods then +// throw. `savePath` and `isLocal` look only at the disk and need no cache. import { readFile } from "node:fs/promises"; @@ -42,11 +42,18 @@ const byNewest = (a: PhotoRecord, b: PhotoRecord): number => const byNewestAlbum = (a: AlbumRecord, b: AlbumRecord): number => b.updationTime - a.updationTime || b.collectionID - a.collectionID; +// A method for each `PhotoExif` field, named after it, taking the options +// `exif()` takes and giving that field. `Photo` implements it, so the build's +// type check fails when `PhotoExif` has a field `Photo` has no method for. +type PhotoExifMethods = { + [K in keyof PhotoExif]-?: (opts?: ContentOptions) => Promise; +}; + // A single photo. Field access mirrors `PhotoRecord`; `record()` returns the // underlying plain record for callers that need the IPC-safe value. `file` is // the membership the record is read from, so the save path carries the date of // `takenAt` and stays known after a refresh removes the file from the library. -export class Photo { +export class Photo implements PhotoExifMethods { constructor( private readonly rec: PhotoRecord, private readonly file: EnteFile, @@ -160,6 +167,65 @@ export class Photo { return readPhotoExif(await this.content(opts)); } + // One field of `exif()` each, named and typed as in `PhotoExif`, and + // undefined when the file lacks it. Each call runs `exif()`, which reads + // the original again. + async make(opts?: ContentOptions): Promise { + return (await this.exif(opts)).make; + } + async model(opts?: ContentOptions): Promise { + return (await this.exif(opts)).model; + } + async lensModel(opts?: ContentOptions): Promise { + return (await this.exif(opts)).lensModel; + } + async dateTimeOriginal( + opts?: ContentOptions, + ): Promise { + return (await this.exif(opts)).dateTimeOriginal; + } + async offsetTimeOriginal( + opts?: ContentOptions, + ): Promise { + return (await this.exif(opts)).offsetTimeOriginal; + } + async exposureTime( + opts?: ContentOptions, + ): Promise { + return (await this.exif(opts)).exposureTime; + } + async fNumber(opts?: ContentOptions): Promise { + return (await this.exif(opts)).fNumber; + } + async iso(opts?: ContentOptions): Promise { + return (await this.exif(opts)).iso; + } + async focalLength( + opts?: ContentOptions, + ): Promise { + return (await this.exif(opts)).focalLength; + } + async orientation( + opts?: ContentOptions, + ): Promise { + return (await this.exif(opts)).orientation; + } + async gpsLatitude( + opts?: ContentOptions, + ): Promise { + return (await this.exif(opts)).gpsLatitude; + } + async gpsLongitude( + opts?: ContentOptions, + ): Promise { + return (await this.exif(opts)).gpsLongitude; + } + async gpsAltitude( + opts?: ContentOptions, + ): Promise { + return (await this.exif(opts)).gpsAltitude; + } + private cacheOrThrow(): PhotoContent { if (!this.cache) { throw new Error( diff --git a/test/library/content-library.test.ts b/test/library/content-library.test.ts index 198afd9..ffa0cc7 100644 --- a/test/library/content-library.test.ts +++ b/test/library/content-library.test.ts @@ -7,7 +7,7 @@ * a cached path shows up on the projected record. A library opened without a * content source leaves those methods throwing rather than silently doing * nothing. It also covers a `Photo`'s `savePath`, `isLocal`, `download()`, - * `content()` and `exif()`. + * `content()`, `exif()` and the methods that each return one field of `exif()`. */ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; @@ -644,8 +644,8 @@ describe("Photo save path, local copy, content and EXIF", () => { await lib.close(); }); - // What exif() returns for HEIC_WITH_EXIF, which holds the same values as - // JPEG_WITH_EXIF. + // What exif() returns for HEIC_WITH_EXIF, and for JPEG_WITH_EXIF, which + // holds the same values. const heicFields: PhotoExif = { make: "Canon", model: "EOS R5", @@ -686,9 +686,35 @@ describe("Photo save path, local copy, content and EXIF", () => { await lib.close(); }); + // The build's type check, not this test, makes sure `Photo` has a method + // for every `PhotoExif` field, whatever the fixtures hold: `Photo` + // implements a type with one method per field. This test checks that each + // method gives the same value as exif(). + it.each([ + ["JPEG", JPEG_WITH_EXIF], + ["HEIC", HEIC_WITH_EXIF], + ])( + "has a method for each field exif() returns, giving the same value, for a %s", + async (_, bytes) => { + const lib = await open({ contentSource: stubSource(bytes) }); + const photo = lib.photos.byID({ fileID: 1 })!; + const exif = await photo.exif(); + // The file holds every field, so every method is checked. + expect(exif).toStrictEqual(heicFields); + for (const [field, value] of Object.entries(exif)) { + expect(await photo[field as keyof PhotoExif]()).toStrictEqual( + value, + ); + } + await lib.close(); + }, + ); + it("returns no EXIF fields for an original that is not an image", async () => { const lib = await open(); - expect(await lib.photos.byID({ fileID: 1 })!.exif()).toStrictEqual({}); + const photo = lib.photos.byID({ fileID: 1 })!; + expect(await photo.exif()).toStrictEqual({}); + expect(await photo.dateTimeOriginal()).toBeUndefined(); await lib.close(); });