diff --git a/src/library/read.ts b/src/library/read.ts index 0e54468..03bc2ee 100644 --- a/src/library/read.ts +++ b/src/library/read.ts @@ -44,8 +44,8 @@ const byNewestAlbum = (a: AlbumRecord, b: AlbumRecord): number => // A single photo. Field access mirrors `PhotoRecord`; `record()` returns the // underlying plain record for callers that need the IPC-safe value. `file` is -// the file the record was made from, so the save path stays known after a -// refresh removes the file from the library. +// the membership the record is read from, so the save path carries the date of +// `takenAt` and stays known after a refresh removes the file from the library. export class Photo { constructor( private readonly rec: PhotoRecord, diff --git a/src/library/records.ts b/src/library/records.ts index 0d0be30..7b3760f 100644 --- a/src/library/records.ts +++ b/src/library/records.ts @@ -86,9 +86,9 @@ export interface LibraryChange { export interface DerivedRecords { albums: Map; photos: Map; - // One membership of each photo's file, for its `Photo`'s save path. It - // holds the file's key, so it stays in this process: no snapshot or change - // carries it. + // The membership each photo's record is read from, for its `Photo`'s save + // path. It holds the file's key, so it stays in this process: no snapshot + // or change carries it. files: Map; } @@ -113,10 +113,20 @@ export const takenAtOf = (file: EnteFile): number => file.metadata.creationTime, ); +// The membership a file's record is read from: the most recently synced, lowest +// collection id to break ties. +const representative = (memberships: EnteFile[]): EnteFile => + memberships.reduce((best, m) => + m.updationTime > best.updationTime || + (m.updationTime === best.updationTime && + m.collectionID < best.collectionID) + ? m + : best, + ); + // 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. +// the same underlying file, so metadata is read from a single representative; +// `albumIDs` gathers them all. const toPhotoRecord = ( fileID: number, memberships: EnteFile[], @@ -124,13 +134,7 @@ const toPhotoRecord = ( 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 rep = representative(memberships); const pub = rep.pubMagicMetadata ?? {}; const priv = rep.magicMetadata ?? {}; @@ -221,7 +225,7 @@ export const deriveRecords = ( record.thumbnailPath = paths.thumbnailPath; } photos.set(fileID, record); - photoFiles.set(fileID, memberships[0]!); + photoFiles.set(fileID, representative(memberships)); takenAtByFile.set(fileID, record.takenAt); } diff --git a/test/library/content-library.test.ts b/test/library/content-library.test.ts index 01a3f60..706c343 100644 --- a/test/library/content-library.test.ts +++ b/test/library/content-library.test.ts @@ -407,6 +407,50 @@ describe("Photo save path, local copy, content and EXIF", () => { await lib.close(); }); + it("dates the save path by the membership its takenAt comes from", async () => { + // One file in two albums. The date edited in Ente has reached album 2's + // copy, synced later, but album 1 still has an earlier edit. + const older = file(1, 1); + older.pubMagicMetadata = { + editedTime: new Date(2026, 1, 1, 12).getTime() * 1000, + }; + const newer = file(1, 2); + newer.updationTime = 2; + newer.pubMagicMetadata = { + editedTime: new Date(2026, 3, 15, 12).getTime() * 1000, + }; + const client = { + whoami: () => ({ email: "u@example.com", userID: USER_ID }), + collectionsSince: async (): Promise => ({ + collections: [collection(1), collection(2)], + deleted: [], + cursor: 1, + }), + filesSince: async (args: { + collectionID: number; + }): Promise => ({ + files: [args.collectionID === 1 ? older : newer], + deleted: [], + cursor: 1, + }), + }; + const lib = await open({ client }); + const photo = lib.photos.byID({ fileID: 1 })!; + + expect(photo.takenAt).toBe(new Date(2026, 3, 15, 12).getTime()); + expect(photo.savePath).toBe( + join( + root, + "backup", + "2026", + "2026-04", + "2026-04-15", + "2026-04-15.1.jpg", + ), + ); + await lib.close(); + }); + it("has a save path, and is not local, without a content source", async () => { const lib = await open({ contentSource: undefined }); const photo = lib.photos.byID({ fileID: 1 })!;