Add in-process read surface: albums, photos, timeline (closes #44)
check / check (push) Successful in 28s
check / check (push) Successful in 28s
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
This commit is contained in:
@@ -36,11 +36,19 @@ export {
|
|||||||
export {
|
export {
|
||||||
Library,
|
Library,
|
||||||
DEFAULT_REFRESH_INTERVAL_SECONDS,
|
DEFAULT_REFRESH_INTERVAL_SECONDS,
|
||||||
|
Album,
|
||||||
|
Photo,
|
||||||
type LibraryClient,
|
type LibraryClient,
|
||||||
type LibraryOptions,
|
type LibraryOptions,
|
||||||
type LibraryStatus,
|
type LibraryStatus,
|
||||||
type RefreshEvent,
|
type RefreshEvent,
|
||||||
type RefreshProgressCallback,
|
type RefreshProgressCallback,
|
||||||
|
type AlbumsAPI,
|
||||||
|
type PhotosAPI,
|
||||||
|
type TimelineAPI,
|
||||||
|
type PhotoFilter,
|
||||||
|
type TimelineGroup,
|
||||||
|
type GroupBy,
|
||||||
} from "./library/index.js";
|
} from "./library/index.js";
|
||||||
export type {
|
export type {
|
||||||
AlbumRecord,
|
AlbumRecord,
|
||||||
|
|||||||
@@ -31,6 +31,25 @@ import {
|
|||||||
type LibrarySnapshot,
|
type LibrarySnapshot,
|
||||||
type LibraryChange,
|
type LibraryChange,
|
||||||
} from "./records.js";
|
} 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 { CollectionsPage, FilesPage } from "../client.js";
|
||||||
import type { Collection, EnteFile } from "../model/types.js";
|
import type { Collection, EnteFile } from "../model/types.js";
|
||||||
|
|
||||||
@@ -88,6 +107,13 @@ export class Library {
|
|||||||
readonly cacheDirectory: string;
|
readonly cacheDirectory: string;
|
||||||
readonly downloadDirectory?: 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 client: LibraryClient;
|
||||||
private readonly store: MetadataStore;
|
private readonly store: MetadataStore;
|
||||||
private readonly userID: number;
|
private readonly userID: number;
|
||||||
@@ -127,6 +153,13 @@ export class Library {
|
|||||||
this.intervalMs = args.intervalMs;
|
this.intervalMs = args.intervalMs;
|
||||||
this.onProgress = args.onProgress;
|
this.onProgress = args.onProgress;
|
||||||
this.lastRecords = this.deriveNow();
|
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
|
// Load the cache and start the refresh loop. With an empty cache the first
|
||||||
|
|||||||
@@ -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<number>();
|
||||||
|
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() };
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user