// Plain records projected from the decrypted store, and the diff between two // projections. These are the library's GUI-facing surface: they hold no key // material and no binary, so they survive `structuredClone`/JSON across the // Electron IPC boundary where methods and file keys cannot go (design #36, // owner ruling 5). The decrypted `Collection`/`EnteFile` objects stay in RAM in // the main process; the window only ever sees these records. // // Ente holds edited/basic times in microseconds; records expose `takenAt` in // milliseconds. The magic-metadata field names below are the ones the Ente // clients write, confirmed against the repo's own fixtures: `w`/`h` in // test/cli/metadata-backup.test.ts, `visibility` in test/library/store.test.ts. import type { Collection, CollectionType, EnteFile, FileType, } from "../model/types.js"; // Ente private-magic-metadata visibility values. const VISIBILITY_ARCHIVED = 1; const VISIBILITY_HIDDEN = 2; // A single photo, deduplicated across the collections it belongs to. No key, // no binary: safe to send to a window. export interface PhotoRecord { fileID: number; // Every collection this file is a member of, ascending. albumIDs: number[]; // `pubMagicMetadata.editedName` when the user renamed the file, else the // basic-metadata title. title: string; // Milliseconds. `pubMagicMetadata.editedTime` when the user edited the // date, else basic-metadata `creationTime`. takenAt: number; fileType: FileType; caption?: string; width?: number; height?: number; latitude?: number; longitude?: number; isArchived: boolean; isHidden: boolean; // Local cache paths, set once a later phase caches the bytes; unset here. thumbnailPath?: string; originalPath?: string; } export interface AlbumRecord { collectionID: number; name: string; // `favorites` identifies the account's favorites album. type: CollectionType; isShared: boolean; updationTime: number; // The album's files, newest first. fileIDs: number[]; } export interface LibrarySnapshot { albums: AlbumRecord[]; photos: PhotoRecord[]; // Wall-clock milliseconds when the snapshot was taken. takenAt: number; } export interface LibraryChange { // Full records for albums/photos added or changed by the refresh. albumsChanged: AlbumRecord[]; photosChanged: PhotoRecord[]; fileIDsRemoved: number[]; albumIDsRemoved: number[]; // Wall-clock milliseconds of the refresh that produced this change. refreshedAt: number; } // The by-id projection of the store at one moment; the source for both // `snapshotFrom` (sorted arrays for the GUI) and `diffRecords` (change sets). export interface DerivedRecords { albums: Map; photos: Map; } const asString = (v: unknown): string | undefined => typeof v === "string" && v.length > 0 ? v : undefined; const asNumber = (v: unknown): number | undefined => typeof v === "number" && Number.isFinite(v) ? v : undefined; const microsToMillis = (micros: number): number => Math.floor(micros / 1000); // Newest first, with fileID as a stable tiebreak so equal-timed files order // deterministically. const byNewestPhoto = (a: PhotoRecord, b: PhotoRecord): number => b.takenAt - a.takenAt || b.fileID - a.fileID; // Build one PhotoRecord from every membership of a file. The memberships share // the same underlying file, so metadata is read from a single representative // (the most recently synced, lowest collection id to break ties); `albumIDs` // gathers them all. const toPhotoRecord = ( fileID: number, memberships: EnteFile[], ): PhotoRecord => { const albumIDs = memberships .map((m) => m.collectionID) .sort((a, b) => a - b); const rep = memberships.reduce((best, m) => m.updationTime > best.updationTime || (m.updationTime === best.updationTime && m.collectionID < best.collectionID) ? m : best, ); const pub = rep.pubMagicMetadata ?? {}; const priv = rep.magicMetadata ?? {}; const takenAtMicros = asNumber(pub.editedTime) ?? rep.metadata.creationTime; const visibility = asNumber(priv.visibility); const record: PhotoRecord = { fileID, albumIDs, title: asString(pub.editedName) ?? rep.metadata.title, takenAt: microsToMillis(takenAtMicros), fileType: rep.metadata.fileType, isArchived: visibility === VISIBILITY_ARCHIVED, isHidden: visibility === VISIBILITY_HIDDEN, }; const caption = asString(pub.caption); if (caption !== undefined) record.caption = caption; const width = asNumber(pub.w); if (width !== undefined) record.width = width; const height = asNumber(pub.h); if (height !== undefined) record.height = height; if (rep.metadata.latitude !== undefined) record.latitude = rep.metadata.latitude; if (rep.metadata.longitude !== undefined) record.longitude = rep.metadata.longitude; return record; }; const toAlbumRecord = ( collection: Collection, files: EnteFile[], takenAtByFile: Map, ): AlbumRecord => { const fileIDs = files .filter((f) => f.collectionID === collection.id) .map((f) => f.id) .sort( (a, b) => (takenAtByFile.get(b) ?? 0) - (takenAtByFile.get(a) ?? 0) || b - a, ); return { collectionID: collection.id, name: collection.name, type: collection.type, isShared: collection.isShared, updationTime: collection.updationTime, fileIDs, }; }; // Project the decrypted collections and file memberships into by-id records. // `files` is every membership (a file appears once per collection it is in). export const deriveRecords = ( collections: Collection[], files: EnteFile[], ): DerivedRecords => { const byFileID = new Map(); for (const f of files) { const arr = byFileID.get(f.id); if (arr) arr.push(f); else byFileID.set(f.id, [f]); } const photos = new Map(); const takenAtByFile = new Map(); for (const [fileID, memberships] of byFileID) { const record = toPhotoRecord(fileID, memberships); photos.set(fileID, record); takenAtByFile.set(fileID, record.takenAt); } const albums = new Map(); for (const c of collections) { albums.set(c.id, toAlbumRecord(c, files, takenAtByFile)); } return { albums, photos }; }; // Sorted, GUI-ready arrays: albums newest updated first, photos newest first. export const snapshotFrom = ( records: DerivedRecords, takenAt: number, ): LibrarySnapshot => ({ albums: [...records.albums.values()].sort( (a, b) => b.updationTime - a.updationTime || b.collectionID - a.collectionID, ), photos: [...records.photos.values()].sort(byNewestPhoto), takenAt, }); // Records compare by value; they are plain and built with a fixed key order, so // a serialized form is a sound equality key. const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b); const diffMap = ( prev: Map, next: Map, ): { changed: T[]; removed: number[] } => { const changed: T[] = []; for (const [id, record] of next) { const before = prev.get(id); if (before === undefined || !same(before, record)) changed.push(record); } const removed: number[] = []; for (const id of prev.keys()) if (!next.has(id)) removed.push(id); removed.sort((a, b) => a - b); return { changed, removed }; }; // The change between two projections, or undefined when nothing changed. export const diffRecords = ( prev: DerivedRecords, next: DerivedRecords, refreshedAt: number, ): LibraryChange | undefined => { const albums = diffMap(prev.albums, next.albums); const photos = diffMap(prev.photos, next.photos); if ( albums.changed.length === 0 && albums.removed.length === 0 && photos.changed.length === 0 && photos.removed.length === 0 ) { return undefined; } return { albumsChanged: albums.changed, photosChanged: photos.changed, fileIDsRemoved: photos.removed, albumIDsRemoved: albums.removed, refreshedAt, }; };