// 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, `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; // 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())}`; 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. 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: isoWeekKey(start), startsAt: start.getTime() }; } const start = new Date(year, month, day); return { key: dateKey(start), startsAt: start.getTime() }; };