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(); });