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