Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
263b6afa19 | ||
|
|
c5c1f387df |
@@ -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,
|
||||
|
||||
@@ -33,6 +33,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 { MLDATA_BATCH_SIZE, type MLData } from "../mldata-fetch.js";
|
||||
import type { Collection, EnteFile } from "../model/types.js";
|
||||
@@ -113,6 +132,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;
|
||||
@@ -164,6 +190,13 @@ export class Library {
|
||||
this.pools = args.pools;
|
||||
this.mldata = args.mldata;
|
||||
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
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
// 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<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() };
|
||||
};
|
||||
+29
-14
@@ -321,8 +321,13 @@ describe("Library ML-data fetch on refresh", () => {
|
||||
refreshIntervalSeconds: FAST_INTERVAL,
|
||||
});
|
||||
try {
|
||||
// Wait on `lastMLFetchAt`, set only once the pass has persisted the
|
||||
// index and payloads — not on the in-RAM counts, which advance
|
||||
// before `storeFetched` writes to disk, so the reopen below reads
|
||||
// the committed index rather than racing the write.
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(lib.status().lastMLFetchAt).toBeGreaterThan(0);
|
||||
expect(lib.status().mlIndexed).toBe(2);
|
||||
expect(lib.status().mlStored).toBe(2);
|
||||
},
|
||||
@@ -369,10 +374,13 @@ describe("Library ML-data fetch on refresh", () => {
|
||||
refreshIntervalSeconds: FAST_INTERVAL,
|
||||
});
|
||||
try {
|
||||
await vi.waitFor(() => expect(lib.status().mlIndexed).toBe(1), {
|
||||
timeout: 2000,
|
||||
interval: 5,
|
||||
});
|
||||
// Wait on `lastMLFetchAt`, set only after the first pass has
|
||||
// persisted, not on `mlIndexed`, which is bumped in RAM before the
|
||||
// write lands.
|
||||
await vi.waitFor(
|
||||
() => expect(lib.status().lastMLFetchAt).toBeGreaterThan(0),
|
||||
{ timeout: 2000, interval: 5 },
|
||||
);
|
||||
const callsBefore = client.mlFetchCalls.length;
|
||||
|
||||
// The file changes on the server (updationTime advances) with a new
|
||||
@@ -389,20 +397,27 @@ describe("Library ML-data fetch on refresh", () => {
|
||||
cursor: 190,
|
||||
});
|
||||
|
||||
// Poll the persisted index itself, not the fetch-call log: a call
|
||||
// is recorded the instant the mock is entered, but `storeFetched`
|
||||
// rewrites `clip.f32` only after it resolves, so an earlier reopen
|
||||
// would read the pre-refetch vector. Reopening reads only committed
|
||||
// (atomically renamed) files, so this sees the new embedding once —
|
||||
// and only once — the store has written it.
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(client.mlFetchCalls.length).toBeGreaterThan(
|
||||
callsBefore,
|
||||
);
|
||||
expect(client.mlFetchCalls.flat()).toContain(1001);
|
||||
},
|
||||
{ timeout: 2000, interval: 5 },
|
||||
);
|
||||
|
||||
async () => {
|
||||
const reopened = await MLDataStore.open(
|
||||
join(cacheDirectory, "mldata"),
|
||||
);
|
||||
expect([...reopened.getIndex().embeddings]).toEqual([9, 9, 9]);
|
||||
expect([...reopened.getIndex().embeddings]).toEqual([
|
||||
9, 9, 9,
|
||||
]);
|
||||
},
|
||||
{ timeout: 2000, interval: 20 },
|
||||
);
|
||||
|
||||
// The refetch really went back to the server for 1001.
|
||||
expect(client.mlFetchCalls.length).toBeGreaterThan(callsBefore);
|
||||
expect(client.mlFetchCalls.flat()).toContain(1001);
|
||||
} finally {
|
||||
lib.close();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,537 @@
|
||||
/**
|
||||
* 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> = {},
|
||||
): 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<EnteFile> & {
|
||||
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" });
|
||||
// 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]);
|
||||
});
|
||||
|
||||
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-W02"]);
|
||||
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<typeof apis>["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<CollectionsPage> {
|
||||
this.collectionsCalls++;
|
||||
return { collections: [], deleted: [], cursor: args.sinceTime };
|
||||
}
|
||||
async filesSince(args: {
|
||||
collectionID: number;
|
||||
collectionKey: Uint8Array;
|
||||
sinceTime: number;
|
||||
}): Promise<FilesPage> {
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user