check / check (push) Successful in 28s
Adds Library.fresh(): forces a refresh, awaits its completion and persist, then returns the albums/photos/timeline read namespaces now reflecting a completed server round trip — the caller awaits and is guaranteed current at resolve. Default reads and the background loop are unchanged (immediate-from-cache). Concurrent fresh reads coalesce to one in-flight refresh; a fresh read whose refresh fails rejects rather than answering stale. The CLI adopts fresh reads separately (#52). Model: opus-4-8
389 lines
13 KiB
TypeScript
389 lines
13 KiB
TypeScript
// 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.
|
|
//
|
|
// A `Photo` also fetches its own bytes: `original()` and `thumbnail()` go
|
|
// through the on-disk content cache (issue #46), the one place in this module
|
|
// that is not synchronous and RAM-only. A library opened without a content
|
|
// source leaves that cache absent, and those two methods then throw.
|
|
|
|
import type { CollectionType, FileType } from "../model/types.js";
|
|
import type { ContentOptions, ContentResult, PhotoContent } from "./content.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,
|
|
private readonly content?: PhotoContent,
|
|
) {}
|
|
|
|
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;
|
|
}
|
|
|
|
// Fetch and cache the full-resolution original, returning its on-disk path
|
|
// and byte length. Served from the cache (or the backup download directory)
|
|
// when already present, otherwise fetched through the content pool.
|
|
async original(opts?: ContentOptions): Promise<ContentResult> {
|
|
return this.contentOrThrow().original(this.rec.fileID, opts);
|
|
}
|
|
|
|
// As `original`, for the thumbnail, through the thumbnail pool.
|
|
async thumbnail(opts?: ContentOptions): Promise<ContentResult> {
|
|
return this.contentOrThrow().thumbnail(this.rec.fileID, opts);
|
|
}
|
|
|
|
private contentOrThrow(): PhotoContent {
|
|
if (!this.content) {
|
|
throw new Error(
|
|
"Photo content requires a library opened with a content cache",
|
|
);
|
|
}
|
|
return this.content;
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
private readonly content?: PhotoContent,
|
|
) {}
|
|
|
|
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, this.content));
|
|
}
|
|
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[];
|
|
}
|
|
|
|
// The surface `Library.fresh()` resolves to (issue #75). It is the same three
|
|
// read namespaces as the default `albums`/`photos`/`timeline`, handed back only
|
|
// after a forced refresh has brought the local copy current.
|
|
export interface FreshReads {
|
|
albums: AlbumsAPI;
|
|
photos: PhotosAPI;
|
|
timeline: TimelineAPI;
|
|
}
|
|
|
|
export const makeAlbumsAPI = (
|
|
derive: () => DerivedRecords,
|
|
content?: PhotoContent,
|
|
): AlbumsAPI => ({
|
|
list: (): Album[] => {
|
|
const records = derive();
|
|
return [...records.albums.values()]
|
|
.sort(byNewestAlbum)
|
|
.map((rec) => new Album(rec, records, content));
|
|
},
|
|
byID: ({ collectionID }): Album | undefined => {
|
|
const records = derive();
|
|
const rec = records.albums.get(collectionID);
|
|
return rec ? new Album(rec, records, content) : 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, content) : undefined;
|
|
},
|
|
});
|
|
|
|
export const makePhotosAPI = (
|
|
derive: () => DerivedRecords,
|
|
content?: PhotoContent,
|
|
): PhotosAPI => ({
|
|
byID: ({ fileID }): Photo | undefined => {
|
|
const rec = derive().photos.get(fileID);
|
|
return rec ? new Photo(rec, content) : 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())}`;
|
|
|
|
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() };
|
|
};
|