Date a Photo's save path by the membership its record is read from
check / check (push) Successful in 1m21s

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
This commit is contained in:
2026-10-01 19:36:36 +00:00
förälder e2d54450e8
incheckning 4b70b2889f
3 ändrade filer med 64 tillägg och 16 borttagningar
+2 -2
Visa fil
@@ -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,
+18 -14
Visa fil
@@ -86,9 +86,9 @@ export interface LibraryChange {
export interface DerivedRecords {
albums: Map<number, AlbumRecord>;
photos: Map<number, PhotoRecord>;
// 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<number, EnteFile>;
}
@@ -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);
}
+44
Visa fil
@@ -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<CollectionsPage> => ({
collections: [collection(1), collection(2)],
deleted: [],
cursor: 1,
}),
filesSince: async (args: {
collectionID: number;
}): Promise<FilesPage> => ({
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 })!;