From 4b70b2889f24a6b9a445a492ab8db774cb49a771 Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 1 Oct 2026 19:36:36 +0000 Subject: [PATCH] Date a Photo's save path by the membership its record is read from A Photo kept the first membership of its file, while its record (and so takenAt and year) comes from the most recently synced one. The record projection now keeps that same membership for the Photo, so savePath and isLocal carry the date of takenAt. A test covers one file in two albums whose edited times differ. Model: opus-5-5 --- src/library/read.ts | 4 +-- src/library/records.ts | 32 +++++++++++--------- test/library/content-library.test.ts | 44 ++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 16 deletions(-) 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 })!;