From 49387713f6b6218e7f4bec0c30cfcef18ade241b Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 22 Sep 2026 13:32:13 +0000 Subject: [PATCH 1/3] Add read-surface tests (albums, photos, timeline) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TDD red phase for issue #44: the in-process read API served from RAM — lib.albums (list/byName/byID), lib.photos (byID/records), Album/Photo wrappers, and lib.timeline.groups with the PhotoFilter and day/week/month grouping rules. These fail until src/library/read.ts lands. Model: opus-4-8 --- test/library/read.test.ts | 536 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 536 insertions(+) create mode 100644 test/library/read.test.ts diff --git a/test/library/read.test.ts b/test/library/read.test.ts new file mode 100644 index 0000000..d8ad24f --- /dev/null +++ b/test/library/read.test.ts @@ -0,0 +1,536 @@ +/** + * Tests for the in-process read surface (issue #44). + * + * Phase 1 (#43) projected the decrypted store into plain `AlbumRecord` / + * `PhotoRecord` values. This phase adds the read API a CLI or in-process script + * uses, all served from RAM with no network: + * + * - `lib.albums` — `list` / `byName` / `byID`, returning thin `Album` wrappers. + * - `lib.photos` — `byID` (a `Photo` wrapper) and `records` (plain records). + * - `lib.timeline.groups` — photos bucketed by local day / week / month. + * + * Every call takes a single named-argument object; there are no positional + * arguments. The wrapper classes are for in-process callers only (they hold + * object identity, not JSON); the plain records remain the IPC-safe surface. + * Content-fetch methods (`Photo.original` / `thumbnail`) are a later unit and + * deliberately absent here — this surface is read-only. + * + * The detailed cases drive the API factories directly over a hand-built + * projection (`deriveRecords`), which keeps them free of disk and timers. A + * final section opens a real `Library` to prove the namespaces are wired to the + * live store and that a read never touches the client. + */ + +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + deriveRecords, + type DerivedRecords, +} from "../../src/library/records.js"; +import { + Album, + Photo, + makeAlbumsAPI, + makePhotosAPI, + makeTimelineAPI, + type TimelineGroup, +} from "../../src/library/read.js"; +import { Library } from "../../src/library/index.js"; +import { MetadataStore } from "../../src/library/store.js"; +import type { CollectionsPage, FilesPage } from "../../src/client.js"; +import type { Collection, EnteFile } from "../../src/model/types.js"; + +const OWNER = 42; + +// Ente stores times in microseconds; records expose milliseconds. These +// helpers keep the fixtures readable: `ms(...)` picks an epoch-millisecond +// instant, `micros(...)` is what the fixture stores so the derived record's +// `takenAt` comes back as the same millisecond value. +const ms = (epochMillis: number): number => epochMillis; +const micros = (epochMillis: number): number => epochMillis * 1000; + +const collection = ( + id: number, + opts: Partial = {}, +): Collection => ({ + id, + ownerID: OWNER, + key: new Uint8Array([id & 0xff, 1, 2, 3]), + name: `album-${id}`, + type: "album", + updationTime: micros(1_700_000_000_000), + isShared: false, + ...opts, +}); + +const file = ( + id: number, + collectionID: number, + opts: Partial & { + creationTime?: number; + title?: string; + fileType?: EnteFile["metadata"]["fileType"]; + latitude?: number; + longitude?: number; + } = {}, +): EnteFile => { + const { creationTime, title, fileType, latitude, longitude, ...rest } = + opts; + const metadata: EnteFile["metadata"] = { + title: title ?? `file-${id}.jpg`, + fileType: fileType ?? "image", + creationTime: creationTime ?? micros(1_700_000_000_000), + modificationTime: micros(1_700_000_000_000), + }; + if (latitude !== undefined) metadata.latitude = latitude; + if (longitude !== undefined) metadata.longitude = longitude; + return { + id, + collectionID, + ownerID: OWNER, + key: new Uint8Array([id & 0xff, 9, 8, 7]), + metadata, + file: { decryptionHeader: "aGVhZGVy" }, + thumbnail: { decryptionHeader: "dGh1bWI=" }, + updationTime: micros(1_700_000_000_000), + ...rest, + }; +}; + +// Build the three API objects over one fixed projection, the way `Library` +// wires them over its live store. +const apis = (records: DerivedRecords) => { + const derive = () => records; + return { + albums: makeAlbumsAPI(derive), + photos: makePhotosAPI(derive), + timeline: makeTimelineAPI(derive), + }; +}; + +// Every file id present across all timeline groups, in group-then-member order. +const allFileIDs = (groups: TimelineGroup[]): number[] => + groups.flatMap((g) => g.fileIDs); + +describe("lib.albums", () => { + it("lists albums as Album wrappers, newest updated first", () => { + const records = deriveRecords( + [ + collection(1, { updationTime: micros(1_700_000_000_000) }), + collection(2, { updationTime: micros(1_705_000_000_000) }), + ], + [file(10, 1), file(20, 2)], + ); + const albums = apis(records).albums.list(); + expect(albums.every((a) => a instanceof Album)).toBe(true); + // Collection 2 updated later, so it sorts ahead of collection 1. + expect(albums.map((a) => a.collectionID)).toEqual([2, 1]); + }); + + it("exposes album fields and its photos newest first", () => { + const records = deriveRecords( + [ + collection(7, { + name: "Trip", + type: "favorites", + isShared: true, + }), + ], + [ + file(1, 7, { creationTime: micros(1_600_000_000_000) }), + file(2, 7, { creationTime: micros(1_800_000_000_000) }), + file(3, 7, { creationTime: micros(1_700_000_000_000) }), + ], + ); + const album = apis(records).albums.byID({ collectionID: 7 })!; + expect(album.name).toBe("Trip"); + expect(album.type).toBe("favorites"); + expect(album.isShared).toBe(true); + expect(album.fileIDs).toEqual([2, 3, 1]); + + const photos = album.photos.list(); + expect(photos.every((p) => p instanceof Photo)).toBe(true); + expect(photos.map((p) => p.fileID)).toEqual([2, 3, 1]); + + // record() hands back the plain, JSON-safe projection. + expect("key" in album.record()).toBe(false); + }); + + it("finds an album by exact name and returns undefined when absent", () => { + const records = deriveRecords( + [ + collection(1, { name: "Berlin" }), + collection(2, { name: "Paris" }), + ], + [file(10, 1), file(20, 2)], + ); + const { albums } = apis(records); + expect(albums.byName({ albumName: "Paris" })?.collectionID).toBe(2); + expect(albums.byName({ albumName: "paris" })).toBeUndefined(); + expect(albums.byName({ albumName: "Nowhere" })).toBeUndefined(); + }); + + it("returns undefined for an unknown collection id", () => { + const records = deriveRecords([collection(1)], [file(10, 1)]); + expect( + apis(records).albums.byID({ collectionID: 999 }), + ).toBeUndefined(); + }); +}); + +describe("lib.photos", () => { + it("byID returns a Photo wrapper carrying the mapped fields", () => { + const records = deriveRecords( + [collection(1)], + [ + file(1001, 1, { + title: "IMG.jpg", + fileType: "video", + creationTime: micros(1_699_000_000_000), + latitude: 52.52, + longitude: 13.405, + pubMagicMetadata: { caption: "at the lake" }, + }), + ], + ); + const photo = apis(records).photos.byID({ fileID: 1001 })!; + expect(photo).toBeInstanceOf(Photo); + expect(photo.title).toBe("IMG.jpg"); + expect(photo.fileType).toBe("video"); + expect(photo.takenAt).toBe(ms(1_699_000_000_000)); + expect(photo.caption).toBe("at the lake"); + expect(photo.latitude).toBeCloseTo(52.52); + expect(photo.isArchived).toBe(false); + expect(photo.isHidden).toBe(false); + // The wrapper hands back the plain record, IPC-safe. + expect("key" in photo.record()).toBe(false); + }); + + it("byID returns undefined for an unknown file id", () => { + const records = deriveRecords([collection(1)], [file(1, 1)]); + expect(apis(records).photos.byID({ fileID: 999 })).toBeUndefined(); + }); + + it("records() returns plain records in requested order, deduped, skipping unknowns", () => { + const records = deriveRecords( + [collection(1)], + [file(1, 1), file(2, 1), file(3, 1)], + ); + const out = apis(records).photos.records({ + fileIDs: [3, 1, 3, 999, 2], + }); + // Requested order preserved; the repeated 3 appears once; 999 is dropped. + expect(out.map((r) => r.fileID)).toEqual([3, 1, 2]); + // Plain records, not wrappers, and JSON round-trips whole. + expect(out[0]).not.toBeInstanceOf(Photo); + expect(JSON.parse(JSON.stringify(out[0]))).toEqual(out[0]); + }); + + it("emits one record for a file even when it belongs to several albums", () => { + // File 1001 is a member of collections 1 and 2. + const records = deriveRecords( + [collection(1), collection(2)], + [file(1001, 1), file(1001, 2)], + ); + const out = apis(records).photos.records({ fileIDs: [1001, 1001] }); + expect(out).toHaveLength(1); + expect(out[0]!.albumIDs).toEqual([1, 2]); + }); +}); + +describe("lib.timeline grouping", () => { + // Group keys and `startsAt` are computed in local time. Pinning the zone to + // UTC makes the expected values exact and lets the fixtures use `Date.UTC`. + const savedTZ = process.env.TZ; + beforeAll(() => { + process.env.TZ = "UTC"; + }); + afterAll(() => { + if (savedTZ === undefined) delete process.env.TZ; + else process.env.TZ = savedTZ; + }); + + it("buckets by local day, newest group and newest member first", () => { + const records = deriveRecords( + [collection(1)], + [ + file(1, 1, { creationTime: micros(Date.UTC(2024, 0, 15, 9)) }), + file(2, 1, { creationTime: micros(Date.UTC(2024, 0, 15, 18)) }), + file(3, 1, { creationTime: micros(Date.UTC(2024, 0, 16, 12)) }), + file(4, 1, { creationTime: micros(Date.UTC(2024, 1, 1, 12)) }), + ], + ); + const groups = apis(records).timeline.groups({ groupBy: "day" }); + expect(groups.map((g) => g.key)).toEqual([ + "2024-02-01", + "2024-01-16", + "2024-01-15", + ]); + // Group start is local midnight of the day. + expect(groups[2]!.startsAt).toBe(Date.UTC(2024, 0, 15)); + // Within the 2024-01-15 group, the later photo (id 2) is first. + expect(groups[2]!.fileIDs).toEqual([2, 1]); + }); + + it("buckets by week with weeks starting on Monday", () => { + // 2024-01-15 is a Monday; the week runs through Sunday 2024-01-21. + const records = deriveRecords( + [collection(1)], + [ + file(1, 1, { creationTime: micros(Date.UTC(2024, 0, 15, 12)) }), // Mon + file(2, 1, { creationTime: micros(Date.UTC(2024, 0, 17, 12)) }), // Wed + file(3, 1, { creationTime: micros(Date.UTC(2024, 0, 21, 12)) }), // Sun + file(4, 1, { creationTime: micros(Date.UTC(2024, 0, 22, 12)) }), // next Mon + ], + ); + const groups = apis(records).timeline.groups({ groupBy: "week" }); + expect(groups.map((g) => g.key)).toEqual(["2024-01-22", "2024-01-15"]); + const first = groups.find((g) => g.key === "2024-01-15")!; + expect(first.startsAt).toBe(Date.UTC(2024, 0, 15)); + // The Sunday belongs to the Monday-started week, not the next one. + expect(first.fileIDs.sort((a, b) => a - b)).toEqual([1, 2, 3]); + }); + + it("assigns a Sunday to the preceding Monday's week across a month boundary", () => { + // 2024-01-14 is a Sunday; its week started Monday 2024-01-08. + const records = deriveRecords( + [collection(1)], + [file(1, 1, { creationTime: micros(Date.UTC(2024, 0, 14, 12)) })], + ); + const groups = apis(records).timeline.groups({ groupBy: "week" }); + expect(groups.map((g) => g.key)).toEqual(["2024-01-08"]); + expect(groups[0]!.startsAt).toBe(Date.UTC(2024, 0, 8)); + }); + + it("buckets by month", () => { + const records = deriveRecords( + [collection(1)], + [ + file(1, 1, { creationTime: micros(Date.UTC(2024, 0, 3, 12)) }), + file(2, 1, { creationTime: micros(Date.UTC(2024, 0, 28, 12)) }), + file(3, 1, { creationTime: micros(Date.UTC(2024, 1, 9, 12)) }), + ], + ); + const groups = apis(records).timeline.groups({ groupBy: "month" }); + expect(groups.map((g) => g.key)).toEqual(["2024-02", "2024-01"]); + expect(groups[1]!.startsAt).toBe(Date.UTC(2024, 0, 1)); + expect(groups[1]!.fileIDs).toEqual([2, 1]); + }); + + it("lists each file once even when it belongs to several albums", () => { + // File 1001 is in collections 1 and 2 but must appear once in a group. + const records = deriveRecords( + [collection(1), collection(2)], + [ + file(1001, 1, { + creationTime: micros(Date.UTC(2024, 0, 15, 12)), + }), + file(1001, 2, { + creationTime: micros(Date.UTC(2024, 0, 15, 12)), + }), + ], + ); + const groups = apis(records).timeline.groups({ groupBy: "day" }); + expect(allFileIDs(groups)).toEqual([1001]); + }); +}); + +describe("lib.timeline uses local time, not UTC", () => { + const savedTZ = process.env.TZ; + afterAll(() => { + if (savedTZ === undefined) delete process.env.TZ; + else process.env.TZ = savedTZ; + }); + + it("buckets by the viewer's local day", () => { + // Kolkata is UTC+5:30 with no DST. An instant at 2024-01-14T20:00Z is + // 2024-01-15 01:30 local, so it belongs to the local day 2024-01-15. + process.env.TZ = "Asia/Kolkata"; + const records = deriveRecords( + [collection(1)], + [file(1, 1, { creationTime: micros(Date.UTC(2024, 0, 14, 20)) })], + ); + const groups = apis(records).timeline.groups({ groupBy: "day" }); + expect(groups[0]!.key).toBe("2024-01-15"); + // Local midnight of 2024-01-15, which is 2024-01-14T18:30Z. + expect(groups[0]!.startsAt).toBe(new Date(2024, 0, 15).getTime()); + expect(groups[0]!.startsAt).toBe(Date.UTC(2024, 0, 14, 18, 30)); + }); +}); + +describe("PhotoFilter", () => { + // A fixture spanning albums, file types, geotags, captions, and the two + // visibility states, all on the same local day so grouping is incidental. + const day = (h: number): number => micros(Date.UTC(2024, 2, 4, h)); + const records = (): DerivedRecords => + deriveRecords( + [ + collection(1, { name: "Holidays" }), + collection(2, { name: "Work" }), + ], + [ + file(1, 1, { + title: "Beach sunset", + creationTime: day(1), + latitude: 1, + longitude: 2, + }), + file(2, 1, { + title: "clip.mov", + fileType: "video", + creationTime: day(2), + }), + file(3, 2, { + title: "invoice scan", + creationTime: day(3), + pubMagicMetadata: { caption: "SUNSET colours" }, + }), + file(4, 2, { + title: "archived note", + creationTime: day(4), + magicMetadata: { visibility: 1 }, + }), + file(5, 2, { + title: "secret", + creationTime: day(5), + magicMetadata: { visibility: 2 }, + }), + ], + ); + + const idsWith = ( + filter: Parameters< + ReturnType["timeline"]["groups"] + >[0]["filter"], + ): number[] => + allFileIDs( + apis(records()).timeline.groups({ groupBy: "day", filter }), + ).sort((a, b) => a - b); + + it("never includes hidden photos and excludes archived by default", () => { + // No filter: hidden (5) always gone, archived (4) gone unless asked for. + expect(idsWith(undefined)).toEqual([1, 2, 3]); + }); + + it("includes archived photos when includeArchived is set, hidden still never", () => { + expect(idsWith({ includeArchived: true })).toEqual([1, 2, 3, 4]); + }); + + it("filters by album membership", () => { + expect(idsWith({ albumID: 1 })).toEqual([1, 2]); + expect(idsWith({ albumID: 2 })).toEqual([3]); + }); + + it("filters by file type", () => { + expect(idsWith({ fileTypes: ["video"] })).toEqual([2]); + expect(idsWith({ fileTypes: ["image", "video"] })).toEqual([1, 2, 3]); + }); + + it("filters by presence or absence of location", () => { + expect(idsWith({ hasLocation: true })).toEqual([1]); + expect(idsWith({ hasLocation: false })).toEqual([2, 3]); + }); + + it("matches text case-insensitively against title, caption, and album name", () => { + // Title match (case-insensitive): "Beach sunset". + expect(idsWith({ text: "SUNSET" })).toEqual([1, 3]); + // Caption-only match: file 3's caption is "SUNSET colours". + expect(idsWith({ text: "colours" })).toEqual([3]); + // Album-name match: everything in "Holidays". + expect(idsWith({ text: "holiday" })).toEqual([1, 2]); + }); + + it("combines filters", () => { + // Images in album 1 with a location: only file 1. + expect( + idsWith({ albumID: 1, fileTypes: ["image"], hasLocation: true }), + ).toEqual([1]); + }); +}); + +/** + * A minimal mock `Client`, enough for `Library.open` to run its refresh loop. + * The queues are empty, so the background refresh over a seeded cache changes + * nothing; the counters prove that a read never calls the client. + */ +class MockClient { + userID = OWNER; + collectionsCalls = 0; + filesCalls = 0; + + whoami(): { email: string; userID: number } { + return { email: "user@example.com", userID: this.userID }; + } + async collectionsSince(args: { + sinceTime: number; + }): Promise { + this.collectionsCalls++; + return { collections: [], deleted: [], cursor: args.sinceTime }; + } + async filesSince(args: { + collectionID: number; + collectionKey: Uint8Array; + sinceTime: number; + }): Promise { + this.filesCalls++; + return { files: [], deleted: [], cursor: args.sinceTime }; + } +} + +describe("Library exposes the read surface over its live store", () => { + let dir: string; + + beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), "quak-read-")); + }); + afterAll(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it("serves albums, photos, and timeline from RAM without calling the client", async () => { + const cacheDirectory = join(dir, "cache"); + const path = join(cacheDirectory, "metadata.json"); + + // Seed a cache as a prior run left it, so open() serves it at once. + const seed = await MetadataStore.load(path); + seed.userID = OWNER; + seed.collectionsSinceTime = 100; + seed.putCollection(collection(1, { name: "Seeded" })); + seed.putFile( + file(1001, 1, { creationTime: micros(Date.UTC(2024, 5, 1, 12)) }), + ); + await seed.save(); + + const client = new MockClient(); + // A long interval keeps the background timer from firing during the test. + const lib = await Library.open({ + client, + cacheDirectory, + refreshIntervalSeconds: 3600, + }); + try { + const collectionsBefore = client.collectionsCalls; + const filesBefore = client.filesCalls; + + expect(lib.albums.list().map((a) => a.name)).toEqual(["Seeded"]); + expect( + lib.albums.byName({ albumName: "Seeded" })?.collectionID, + ).toBe(1); + expect(lib.photos.byID({ fileID: 1001 })?.fileID).toBe(1001); + expect( + lib.photos.records({ fileIDs: [1001] }).map((r) => r.fileID), + ).toEqual([1001]); + const groups = lib.timeline.groups({ groupBy: "month" }); + expect(allFileIDs(groups)).toEqual([1001]); + + // Reads are answered from RAM: no read called the client. + expect(client.collectionsCalls).toBe(collectionsBefore); + expect(client.filesCalls).toBe(filesBefore); + } finally { + lib.close(); + } + }); +}); -- 2.54.0 From 1df0e52d409f986eb03c1cf27e5f243b4951a85c Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 22 Sep 2026 13:34:44 +0000 Subject: [PATCH 2/3] Add in-process read surface: albums, photos, timeline (closes #44) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serve albums, photos, and a grouped timeline from RAM over the record projection (#43), with no network. Every call takes one named-argument object. `lib.albums` lists and looks albums up by name or id; `lib.photos` resolves a file to a `Photo` wrapper or returns plain deduped records; `lib.timeline.groups` buckets photos by local day/week/month, newest group and newest member first, each file once. `PhotoFilter` narrows the timeline by album, case-insensitive text (title, caption, or album name), file type, and presence of a location; archived photos are excluded unless asked for and hidden photos are never included. Weeks start on Monday and `startsAt` is local-time midnight of the period. Thin `Album`/`Photo` classes wrap the records for in-process callers only; the plain records remain the IPC-safe surface. Content fetch (original, thumbnail) is a later unit — this surface is read-only. Model: opus-4-8 --- src/index.ts | 8 ++ src/library/index.ts | 33 +++++ src/library/read.ts | 319 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 360 insertions(+) create mode 100644 src/library/read.ts diff --git a/src/index.ts b/src/index.ts index 73e5439..9e90b0f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,11 +36,19 @@ export { export { Library, DEFAULT_REFRESH_INTERVAL_SECONDS, + Album, + Photo, type LibraryClient, type LibraryOptions, type LibraryStatus, type RefreshEvent, type RefreshProgressCallback, + type AlbumsAPI, + type PhotosAPI, + type TimelineAPI, + type PhotoFilter, + type TimelineGroup, + type GroupBy, } from "./library/index.js"; export type { AlbumRecord, diff --git a/src/library/index.ts b/src/library/index.ts index 4d06e87..9a1b0fa 100644 --- a/src/library/index.ts +++ b/src/library/index.ts @@ -31,6 +31,25 @@ import { type LibrarySnapshot, type LibraryChange, } from "./records.js"; +import { + makeAlbumsAPI, + makePhotosAPI, + makeTimelineAPI, + type AlbumsAPI, + type PhotosAPI, + type TimelineAPI, +} from "./read.js"; + +export { + Album, + Photo, + type AlbumsAPI, + type PhotosAPI, + type TimelineAPI, + type PhotoFilter, + type TimelineGroup, + type GroupBy, +} from "./read.js"; import type { CollectionsPage, FilesPage } from "../client.js"; import type { Collection, EnteFile } from "../model/types.js"; @@ -88,6 +107,13 @@ export class Library { readonly cacheDirectory: string; readonly downloadDirectory?: string; + // The in-process read surface (issue #44). Each namespace answers + // synchronously from the live record projection; no read touches the + // network. + readonly albums: AlbumsAPI; + readonly photos: PhotosAPI; + readonly timeline: TimelineAPI; + private readonly client: LibraryClient; private readonly store: MetadataStore; private readonly userID: number; @@ -127,6 +153,13 @@ export class Library { this.intervalMs = args.intervalMs; this.onProgress = args.onProgress; this.lastRecords = this.deriveNow(); + + // The read namespaces derive fresh from the store on each call, so they + // always reflect the latest refresh. + const derive = (): DerivedRecords => this.deriveNow(); + this.albums = makeAlbumsAPI(derive); + this.photos = makePhotosAPI(derive); + this.timeline = makeTimelineAPI(derive); } // Load the cache and start the refresh loop. With an empty cache the first diff --git a/src/library/read.ts b/src/library/read.ts new file mode 100644 index 0000000..3af8069 --- /dev/null +++ b/src/library/read.ts @@ -0,0 +1,319 @@ +// The in-process read surface over the local cache (issue #44). +// +// A CLI or an in-process script reads albums, photos, and a grouped timeline +// through `lib.albums`, `lib.photos`, and `lib.timeline`. Every call is +// answered synchronously from the same plain-record projection the GUI reads +// (`deriveRecords`, issue #43); nothing here touches the network. Every method +// takes a single named-argument object. +// +// The `Album` and `Photo` classes are thin, in-process-only wrappers over +// those records: a caller that holds an object reference gets typed field +// access and, for an album, its photos. They are not sent across IPC — the +// plain records are the serializable surface, and `record()` returns one. +// Content-fetch methods (`Photo.original` / `thumbnail`) belong to a later +// unit; this surface is read-only. + +import type { CollectionType, FileType } from "../model/types.js"; +import type { AlbumRecord, PhotoRecord, DerivedRecords } from "./records.js"; + +// Newest first, with fileID as a stable tiebreak so equal-timed files order +// deterministically — the same order the record projection uses. +const byNewest = (a: PhotoRecord, b: PhotoRecord): number => + b.takenAt - a.takenAt || b.fileID - a.fileID; + +// Albums newest updated first, collection id breaking ties. This is the order +// `albums.list` returns and the order `byName` resolves a name collision in. +const byNewestAlbum = (a: AlbumRecord, b: AlbumRecord): number => + b.updationTime - a.updationTime || b.collectionID - a.collectionID; + +// A single photo. Field access mirrors `PhotoRecord`; `record()` returns the +// underlying plain record for callers that need the IPC-safe value. +export class Photo { + constructor(private readonly rec: PhotoRecord) {} + + get fileID(): number { + return this.rec.fileID; + } + get albumIDs(): number[] { + return this.rec.albumIDs; + } + get title(): string { + return this.rec.title; + } + get takenAt(): number { + return this.rec.takenAt; + } + get fileType(): FileType { + return this.rec.fileType; + } + get caption(): string | undefined { + return this.rec.caption; + } + get width(): number | undefined { + return this.rec.width; + } + get height(): number | undefined { + return this.rec.height; + } + get latitude(): number | undefined { + return this.rec.latitude; + } + get longitude(): number | undefined { + return this.rec.longitude; + } + get isArchived(): boolean { + return this.rec.isArchived; + } + get isHidden(): boolean { + return this.rec.isHidden; + } + + record(): PhotoRecord { + return this.rec; + } +} + +// A single album. `photos.list()` returns the album's photos as wrappers, +// newest first (the record already stores `fileIDs` in that order). +export class Album { + constructor( + private readonly rec: AlbumRecord, + private readonly records: DerivedRecords, + ) {} + + get collectionID(): number { + return this.rec.collectionID; + } + get name(): string { + return this.rec.name; + } + get type(): CollectionType { + return this.rec.type; + } + get isShared(): boolean { + return this.rec.isShared; + } + get updationTime(): number { + return this.rec.updationTime; + } + get fileIDs(): number[] { + return this.rec.fileIDs; + } + + get photos(): { list: () => Photo[] } { + return { list: (): Photo[] => this.listPhotos() }; + } + + record(): AlbumRecord { + return this.rec; + } + + private listPhotos(): Photo[] { + const out: Photo[] = []; + for (const id of this.rec.fileIDs) { + const p = this.records.photos.get(id); + if (p) out.push(new Photo(p)); + } + return out; + } +} + +export interface AlbumsAPI { + list(): Album[]; + byName(args: { albumName: string }): Album | undefined; + byID(args: { collectionID: number }): Album | undefined; +} + +export interface PhotosAPI { + byID(args: { fileID: number }): Photo | undefined; + // Plain records for the requested ids, in the order requested, each id at + // most once, unknown ids dropped. + records(args: { fileIDs: number[] }): PhotoRecord[]; +} + +export type GroupBy = "day" | "week" | "month"; + +// A filter over the timeline. All fields are optional and combine with AND. +// Hidden photos are never included, regardless of this filter. +export interface PhotoFilter { + // Keep only photos that belong to this album. + albumID?: number; + // Case-insensitive substring of the title, caption, or any album name the + // photo belongs to. + text?: string; + // Keep only photos of one of these types. + fileTypes?: FileType[]; + // `true` keeps only geotagged photos; `false` keeps only those without a + // location; omitted places no constraint. + hasLocation?: boolean; + // Archived photos are excluded unless this is `true`. Defaults to `false`. + includeArchived?: boolean; +} + +export interface TimelineGroup { + // The period's identity: `YYYY-MM-DD` for day and week (the Monday), and + // `YYYY-MM` for month. + key: string; + // Local-time milliseconds at the start of the period. + startsAt: number; + // The period's files, newest first, each file once. + fileIDs: number[]; +} + +export interface TimelineAPI { + groups(args: { groupBy: GroupBy; filter?: PhotoFilter }): TimelineGroup[]; +} + +export const makeAlbumsAPI = (derive: () => DerivedRecords): AlbumsAPI => ({ + list: (): Album[] => { + const records = derive(); + return [...records.albums.values()] + .sort(byNewestAlbum) + .map((rec) => new Album(rec, records)); + }, + byID: ({ collectionID }): Album | undefined => { + const records = derive(); + const rec = records.albums.get(collectionID); + return rec ? new Album(rec, records) : undefined; + }, + byName: ({ albumName }): Album | undefined => { + const records = derive(); + // Names are not unique in Ente; resolve a collision deterministically + // to the newest-updated album, matching `list` order. + const match = [...records.albums.values()] + .sort(byNewestAlbum) + .find((rec) => rec.name === albumName); + return match ? new Album(match, records) : undefined; + }, +}); + +export const makePhotosAPI = (derive: () => DerivedRecords): PhotosAPI => ({ + byID: ({ fileID }): Photo | undefined => { + const rec = derive().photos.get(fileID); + return rec ? new Photo(rec) : undefined; + }, + records: ({ fileIDs }): PhotoRecord[] => { + const { photos } = derive(); + const seen = new Set(); + const out: PhotoRecord[] = []; + for (const id of fileIDs) { + if (seen.has(id)) continue; + const rec = photos.get(id); + if (rec) { + out.push(rec); + seen.add(id); + } + } + return out; + }, +}); + +export const makeTimelineAPI = (derive: () => DerivedRecords): TimelineAPI => ({ + groups: ({ groupBy, filter }): TimelineGroup[] => { + const records = derive(); + return groupPhotos(filterPhotos(records, filter), groupBy); + }, +}); + +// Apply a `PhotoFilter` to the projection. Hidden photos are always dropped; +// archived photos are dropped unless `includeArchived` asks for them. +const filterPhotos = ( + records: DerivedRecords, + filter?: PhotoFilter, +): PhotoRecord[] => { + const f = filter ?? {}; + const includeArchived = f.includeArchived ?? false; + const needle = f.text?.toLowerCase(); + const out: PhotoRecord[] = []; + for (const rec of records.photos.values()) { + if (rec.isHidden) continue; + if (rec.isArchived && !includeArchived) continue; + if (f.albumID !== undefined && !rec.albumIDs.includes(f.albumID)) + continue; + if (f.fileTypes !== undefined && !f.fileTypes.includes(rec.fileType)) + continue; + if (f.hasLocation !== undefined) { + const has = + rec.latitude !== undefined && rec.longitude !== undefined; + if (has !== f.hasLocation) continue; + } + if (needle !== undefined && !matchesText(rec, needle, records)) + continue; + out.push(rec); + } + return out; +}; + +const matchesText = ( + rec: PhotoRecord, + needle: string, + records: DerivedRecords, +): boolean => { + if (rec.title.toLowerCase().includes(needle)) return true; + if (rec.caption !== undefined && rec.caption.toLowerCase().includes(needle)) + return true; + for (const id of rec.albumIDs) { + const album = records.albums.get(id); + if (album && album.name.toLowerCase().includes(needle)) return true; + } + return false; +}; + +// Bucket photos into periods, groups newest first, members newest first. +const groupPhotos = ( + photos: PhotoRecord[], + groupBy: GroupBy, +): TimelineGroup[] => { + const buckets = new Map< + string, + { startsAt: number; recs: PhotoRecord[] } + >(); + for (const rec of photos) { + const { key, startsAt } = periodOf(rec.takenAt, groupBy); + const bucket = buckets.get(key); + if (bucket) bucket.recs.push(rec); + else buckets.set(key, { startsAt, recs: [rec] }); + } + const groups: TimelineGroup[] = []; + for (const [key, bucket] of buckets) { + bucket.recs.sort(byNewest); + groups.push({ + key, + startsAt: bucket.startsAt, + fileIDs: bucket.recs.map((r) => r.fileID), + }); + } + groups.sort((a, b) => b.startsAt - a.startsAt); + return groups; +}; + +const pad = (n: number): string => String(n).padStart(2, "0"); + +const dateKey = (d: Date): string => + `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; + +// The period a millisecond instant falls in, in local time. Weeks start on +// Monday. `Date` normalizes out-of-range day arguments, so the week's Monday +// is correct across month and year boundaries. +const periodOf = ( + takenAt: number, + groupBy: GroupBy, +): { key: string; startsAt: number } => { + const d = new Date(takenAt); + const year = d.getFullYear(); + const month = d.getMonth(); + const day = d.getDate(); + + if (groupBy === "month") { + const start = new Date(year, month, 1); + return { key: `${year}-${pad(month + 1)}`, startsAt: start.getTime() }; + } + if (groupBy === "week") { + // getDay(): 0=Sunday..6=Saturday; shift so Monday is the week start. + const fromMonday = (d.getDay() + 6) % 7; + const start = new Date(year, month, day - fromMonday); + return { key: dateKey(start), startsAt: start.getTime() }; + } + const start = new Date(year, month, day); + return { key: dateKey(start), startsAt: start.getTime() }; +}; -- 2.54.0 From 555badc0b94ea6811c98166459e5bf1d26b136d7 Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 22 Sep 2026 15:03:43 +0000 Subject: [PATCH 3/3] Key timeline weeks by ISO week, not the Monday date periodOf keyed a week by the Monday's YYYY-MM-DD, which collides with the day-key form. Weeks now key as YYYY-Www (ISO 8601, e.g. 2025-W32); the week-year comes from the week's Thursday, so it stays correct across the January/December boundary. The Monday-start boundary and startsAt are unchanged. Week tests now assert the ISO form. Model: opus-4-8 --- src/library/read.ts | 31 ++++++++++++++++++++++++++++--- test/library/read.test.ts | 7 ++++--- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/library/read.ts b/src/library/read.ts index 3af8069..3c756b4 100644 --- a/src/library/read.ts +++ b/src/library/read.ts @@ -151,8 +151,8 @@ export interface PhotoFilter { } export interface TimelineGroup { - // The period's identity: `YYYY-MM-DD` for day and week (the Monday), and - // `YYYY-MM` for month. + // The period's identity: `YYYY-MM-DD` for day, `YYYY-Www` (ISO 8601 week, + // e.g. `2025-W32`) for week, and `YYYY-MM` for month. key: string; // Local-time milliseconds at the start of the period. startsAt: number; @@ -292,6 +292,31 @@ const pad = (n: number): string => String(n).padStart(2, "0"); const dateKey = (d: Date): string => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +const WEEK_MS = 7 * 24 * 60 * 60 * 1000; + +// The ISO 8601 week key `YYYY-Www` for the week starting at the given Monday. +// The week-year is the year of that week's Thursday, so it can differ from the +// calendar year at the January/December boundary (e.g. 2024-12-30 is 2025-W01). +const isoWeekKey = (monday: Date): string => { + const thursday = new Date( + monday.getFullYear(), + monday.getMonth(), + monday.getDate() + 3, + ); + const isoYear = thursday.getFullYear(); + // Thursday of ISO week 1 is the Thursday of the week containing January 4. + const jan4 = new Date(isoYear, 0, 4); + const week1Thursday = new Date( + isoYear, + 0, + 4 + 3 - ((jan4.getDay() + 6) % 7), + ); + const week = + 1 + + Math.round((thursday.getTime() - week1Thursday.getTime()) / WEEK_MS); + return `${isoYear}-W${pad(week)}`; +}; + // The period a millisecond instant falls in, in local time. Weeks start on // Monday. `Date` normalizes out-of-range day arguments, so the week's Monday // is correct across month and year boundaries. @@ -312,7 +337,7 @@ const periodOf = ( // getDay(): 0=Sunday..6=Saturday; shift so Monday is the week start. const fromMonday = (d.getDay() + 6) % 7; const start = new Date(year, month, day - fromMonday); - return { key: dateKey(start), startsAt: start.getTime() }; + return { key: isoWeekKey(start), startsAt: start.getTime() }; } const start = new Date(year, month, day); return { key: dateKey(start), startsAt: start.getTime() }; diff --git a/test/library/read.test.ts b/test/library/read.test.ts index d8ad24f..102bdee 100644 --- a/test/library/read.test.ts +++ b/test/library/read.test.ts @@ -287,8 +287,9 @@ describe("lib.timeline grouping", () => { ], ); const groups = apis(records).timeline.groups({ groupBy: "week" }); - expect(groups.map((g) => g.key)).toEqual(["2024-01-22", "2024-01-15"]); - const first = groups.find((g) => g.key === "2024-01-15")!; + // ISO week keys: 2024-01-15 is in 2024-W03, the next Monday in 2024-W04. + expect(groups.map((g) => g.key)).toEqual(["2024-W04", "2024-W03"]); + const first = groups.find((g) => g.key === "2024-W03")!; expect(first.startsAt).toBe(Date.UTC(2024, 0, 15)); // The Sunday belongs to the Monday-started week, not the next one. expect(first.fileIDs.sort((a, b) => a - b)).toEqual([1, 2, 3]); @@ -301,7 +302,7 @@ describe("lib.timeline grouping", () => { [file(1, 1, { creationTime: micros(Date.UTC(2024, 0, 14, 12)) })], ); const groups = apis(records).timeline.groups({ groupBy: "week" }); - expect(groups.map((g) => g.key)).toEqual(["2024-01-08"]); + expect(groups.map((g) => g.key)).toEqual(["2024-W02"]); expect(groups[0]!.startsAt).toBe(Date.UTC(2024, 0, 8)); }); -- 2.54.0