From f2f3395f94bceb3d50b47ca2b483930300460ce6 Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 1 Oct 2026 20:37:02 +0000 Subject: [PATCH 1/2] Photo: one async method per EXIF field (closes #148) Thirteen methods on Photo, make() through gpsAltitude(), each named after its PhotoExif field and typed by it. Each calls exif() with the same opts and returns that one field, or undefined when the file lacks it. A test checks, on the JPEG fixture and on test/exif.heic, that every field exif() returns has a method giving the same value. README and TODO.md list them. Model: opus-5-5 --- README.md | 8 +++- TODO.md | 8 ++++ src/library/read.ts | 69 ++++++++++++++++++++++++++-- test/library/content-library.test.ts | 30 ++++++++++-- 4 files changed, 105 insertions(+), 10 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..6550ebd 100644 --- a/TODO.md +++ b/TODO.md @@ -25,6 +25,14 @@ 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. A test checks, on the JPEG and the + HEIC, that every field `exif()` returns has a method giving the same value. + - 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..9ea594e 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"; @@ -160,6 +160,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..cce917b 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,31 @@ describe("Photo save path, local copy, content and EXIF", () => { await lib.close(); }); + 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(); }); -- 2.54.0 From 1ede66e5c0e9fec4a7dcd61dd0a03bf48f6a8ae5 Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 1 Oct 2026 21:11:05 +0000 Subject: [PATCH 2/2] Photo implements one method per PhotoExif field Photo now implements a mapped type with one method per PhotoExif field, each taking exif()'s options and giving that field's type, so the build's type check fails when PhotoExif has a field Photo has no method for, whatever the test fixtures hold. The test comment and TODO.md say so. Model: opus-5-5 --- TODO.md | 6 ++++-- src/library/read.ts | 9 ++++++++- test/library/content-library.test.ts | 4 ++++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/TODO.md b/TODO.md index 6550ebd..3d1af8a 100644 --- a/TODO.md +++ b/TODO.md @@ -30,8 +30,10 @@ declares one. `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. A test checks, on the JPEG and the - HEIC, that every field `exif()` returns has a method giving the same value. + 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 diff --git a/src/library/read.ts b/src/library/read.ts index 9ea594e..3aac9ce 100644 --- a/src/library/read.ts +++ b/src/library/read.ts @@ -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, diff --git a/test/library/content-library.test.ts b/test/library/content-library.test.ts index cce917b..ffa0cc7 100644 --- a/test/library/content-library.test.ts +++ b/test/library/content-library.test.ts @@ -686,6 +686,10 @@ 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], -- 2.54.0