From 11cec56e8059ef5779c0a0dcb68defafc02859f1 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Wed, 19 Nov 2025 09:48:16 -0500 Subject: [PATCH] refactor(web): consolidate timeline API - merge addAssets/updateAssets into upsertAssets (#23985) --- .../timeline/TimelineAssetViewer.svelte | 10 +- .../actions/TimelineKeyboardActions.svelte | 2 +- .../internal/websocket-support.svelte.ts | 4 +- .../timeline-manager.svelte.spec.ts | 113 +++++++++++++----- .../timeline-manager.svelte.ts | 10 +- web/src/lib/utils/actions.ts | 2 +- .../[[assetId=id]]/+page.svelte | 2 +- .../[[assetId=id]]/+page.svelte | 2 +- .../[[assetId=id]]/+page.svelte | 2 +- .../(user)/photos/[[assetId=id]]/+page.svelte | 8 +- .../(user)/utilities/geolocation/+page.svelte | 2 +- 11 files changed, 103 insertions(+), 54 deletions(-) diff --git a/web/src/lib/components/timeline/TimelineAssetViewer.svelte b/web/src/lib/components/timeline/TimelineAssetViewer.svelte index ccdd8bd5b4..a121bd1938 100644 --- a/web/src/lib/components/timeline/TimelineAssetViewer.svelte +++ b/web/src/lib/components/timeline/TimelineAssetViewer.svelte @@ -110,13 +110,9 @@ case AssetAction.ARCHIVE: case AssetAction.UNARCHIVE: case AssetAction.FAVORITE: - case AssetAction.UNFAVORITE: { - timelineManager.updateAssets([action.asset]); - break; - } - + case AssetAction.UNFAVORITE: case AssetAction.ADD: { - timelineManager.addAssets([action.asset]); + timelineManager.upsertAssets([action.asset]); break; } @@ -135,7 +131,7 @@ break; } case AssetAction.REMOVE_ASSET_FROM_STACK: { - timelineManager.addAssets([toTimelineAsset(action.asset)]); + timelineManager.upsertAssets([toTimelineAsset(action.asset)]); if (action.stack) { //Have to unstack then restack assets in timeline in order to update the stack count in the timeline. updateUnstackedAssetInTimeline( diff --git a/web/src/lib/components/timeline/actions/TimelineKeyboardActions.svelte b/web/src/lib/components/timeline/actions/TimelineKeyboardActions.svelte index 74b058a74f..d5b1d2ecf6 100644 --- a/web/src/lib/components/timeline/actions/TimelineKeyboardActions.svelte +++ b/web/src/lib/components/timeline/actions/TimelineKeyboardActions.svelte @@ -46,7 +46,7 @@ !(isTrashEnabled && !force), (assetIds) => timelineManager.removeAssets(assetIds), assetInteraction.selectedAssets, - !isTrashEnabled || force ? undefined : (assets) => timelineManager.addAssets(assets), + !isTrashEnabled || force ? undefined : (assets) => timelineManager.upsertAssets(assets), ); assetInteraction.clearMultiselect(); }; diff --git a/web/src/lib/managers/timeline-manager/internal/websocket-support.svelte.ts b/web/src/lib/managers/timeline-manager/internal/websocket-support.svelte.ts index 4ba237c50c..bff2f15cb9 100644 --- a/web/src/lib/managers/timeline-manager/internal/websocket-support.svelte.ts +++ b/web/src/lib/managers/timeline-manager/internal/websocket-support.svelte.ts @@ -13,10 +13,10 @@ export class WebsocketSupport { #processPendingChanges = throttle(() => { const { add, update, remove } = this.#getPendingChangeBatches(); if (add.length > 0) { - this.#timelineManager.addAssets(add); + this.#timelineManager.upsertAssets(add); } if (update.length > 0) { - this.#timelineManager.updateAssets(update); + this.#timelineManager.upsertAssets(update); } if (remove.length > 0) { this.#timelineManager.removeAssets(remove); diff --git a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts index e6eddef9b6..62053f7a0d 100644 --- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts +++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts @@ -2,7 +2,7 @@ import { sdkMock } from '$lib/__mocks__/sdk.mock'; import { getMonthGroupByDate } from '$lib/managers/timeline-manager/internal/search-support.svelte'; import { AbortError } from '$lib/utils'; import { fromISODateTimeUTCToObject } from '$lib/utils/timeline-util'; -import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk'; +import { AssetVisibility, type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk'; import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory'; import { tick } from 'svelte'; import { TimelineManager } from './timeline-manager.svelte'; @@ -175,7 +175,7 @@ describe('TimelineManager', () => { }); }); - describe('addAssets', () => { + describe('upsertAssets', () => { let timelineManager: TimelineManager; beforeEach(async () => { @@ -196,7 +196,7 @@ describe('TimelineManager', () => { fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'), }), ); - timelineManager.addAssets([asset]); + timelineManager.upsertAssets([asset]); expect(timelineManager.months.length).toEqual(1); expect(timelineManager.assetCount).toEqual(1); @@ -212,8 +212,8 @@ describe('TimelineManager', () => { fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'), }) .map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset)); - timelineManager.addAssets([assetOne]); - timelineManager.addAssets([assetTwo]); + timelineManager.upsertAssets([assetOne]); + timelineManager.upsertAssets([assetTwo]); expect(timelineManager.months.length).toEqual(1); expect(timelineManager.assetCount).toEqual(2); @@ -238,7 +238,7 @@ describe('TimelineManager', () => { fileCreatedAt: fromISODateTimeUTCToObject('2024-01-16T12:00:00.000Z'), }), ); - timelineManager.addAssets([assetOne, assetTwo, assetThree]); + timelineManager.upsertAssets([assetOne, assetTwo, assetThree]); const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 }); expect(month).not.toBeNull(); @@ -264,7 +264,7 @@ describe('TimelineManager', () => { fileCreatedAt: fromISODateTimeUTCToObject('2023-01-20T12:00:00.000Z'), }), ); - timelineManager.addAssets([assetOne, assetTwo, assetThree]); + timelineManager.upsertAssets([assetOne, assetTwo, assetThree]); expect(timelineManager.months.length).toEqual(3); expect(timelineManager.months[0].yearMonth.year).toEqual(2024); @@ -278,12 +278,10 @@ describe('TimelineManager', () => { }); it('updates existing asset', () => { - const updateAssetsSpy = vi.spyOn(timelineManager, 'updateAssets'); const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build()); - timelineManager.addAssets([asset]); + timelineManager.upsertAssets([asset]); - timelineManager.addAssets([asset]); - expect(updateAssetsSpy).toBeCalledWith([asset]); + timelineManager.upsertAssets([asset]); expect(timelineManager.assetCount).toEqual(1); }); @@ -294,12 +292,12 @@ describe('TimelineManager', () => { const timelineManager = new TimelineManager(); await timelineManager.updateOptions({ isTrashed: true }); - timelineManager.addAssets([asset, trashedAsset]); + timelineManager.upsertAssets([asset, trashedAsset]); expect(await getAssets(timelineManager)).toEqual([trashedAsset]); }); }); - describe('updateAssets', () => { + describe('upsertAssets - updating existing', () => { let timelineManager: TimelineManager; beforeEach(async () => { @@ -309,22 +307,15 @@ describe('TimelineManager', () => { await timelineManager.updateViewport({ width: 1588, height: 1000 }); }); - it('ignores non-existing assets', () => { - timelineManager.updateAssets([deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build())]); - - expect(timelineManager.months.length).toEqual(0); - expect(timelineManager.assetCount).toEqual(0); - }); - it('updates an asset', () => { const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build({ isFavorite: false })); const updatedAsset = { ...asset, isFavorite: true }; - timelineManager.addAssets([asset]); + timelineManager.upsertAssets([asset]); expect(timelineManager.assetCount).toEqual(1); expect(timelineManager.months[0].getFirstAsset().isFavorite).toEqual(false); - timelineManager.updateAssets([updatedAsset]); + timelineManager.upsertAssets([updatedAsset]); expect(timelineManager.assetCount).toEqual(1); expect(timelineManager.months[0].getFirstAsset().isFavorite).toEqual(true); }); @@ -340,18 +331,80 @@ describe('TimelineManager', () => { fileCreatedAt: fromISODateTimeUTCToObject('2024-03-20T12:00:00.000Z'), }); - timelineManager.addAssets([asset]); + timelineManager.upsertAssets([asset]); expect(timelineManager.months.length).toEqual(1); expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined(); expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(1); - timelineManager.updateAssets([updatedAsset]); + timelineManager.upsertAssets([updatedAsset]); expect(timelineManager.months.length).toEqual(2); expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined(); expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(0); expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })).not.toBeUndefined(); expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })?.getAssets().length).toEqual(1); }); + it('asset is removed during upsert when TimelineManager if visibility changes', async () => { + await timelineManager.updateOptions({ + visibility: AssetVisibility.Archive, + }); + const fixture = deriveLocalDateTimeFromFileCreatedAt( + timelineAssetFactory.build({ + visibility: AssetVisibility.Archive, + }), + ); + + timelineManager.upsertAssets([fixture]); + expect(timelineManager.assetCount).toEqual(1); + + const updated = Object.freeze({ ...fixture, visibility: AssetVisibility.Timeline }); + timelineManager.upsertAssets([updated]); + expect(timelineManager.assetCount).toEqual(0); + + timelineManager.upsertAssets([{ ...fixture, visibility: AssetVisibility.Archive }]); + expect(timelineManager.assetCount).toEqual(1); + }); + + it('asset is removed during upsert when TimelineManager if isFavorite changes', async () => { + await timelineManager.updateOptions({ + isFavorite: true, + }); + const fixture = deriveLocalDateTimeFromFileCreatedAt( + timelineAssetFactory.build({ + isFavorite: true, + }), + ); + + timelineManager.upsertAssets([fixture]); + expect(timelineManager.assetCount).toEqual(1); + + const updated = Object.freeze({ ...fixture, isFavorite: false }); + timelineManager.upsertAssets([updated]); + expect(timelineManager.assetCount).toEqual(0); + + timelineManager.upsertAssets([{ ...fixture, isFavorite: true }]); + expect(timelineManager.assetCount).toEqual(1); + }); + + it('asset is removed during upsert when TimelineManager if isTrashed changes', async () => { + await timelineManager.updateOptions({ + isTrashed: true, + }); + const fixture = deriveLocalDateTimeFromFileCreatedAt( + timelineAssetFactory.build({ + isTrashed: true, + }), + ); + + timelineManager.upsertAssets([fixture]); + expect(timelineManager.assetCount).toEqual(1); + + const updated = Object.freeze({ ...fixture, isTrashed: false }); + timelineManager.upsertAssets([updated]); + expect(timelineManager.assetCount).toEqual(0); + + timelineManager.upsertAssets([{ ...fixture, isTrashed: true }]); + expect(timelineManager.assetCount).toEqual(1); + }); }); describe('removeAssets', () => { @@ -365,7 +418,7 @@ describe('TimelineManager', () => { }); it('ignores invalid IDs', () => { - timelineManager.addAssets( + timelineManager.upsertAssets( timelineAssetFactory .buildList(2, { fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'), @@ -385,7 +438,7 @@ describe('TimelineManager', () => { fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'), }) .map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset)); - timelineManager.addAssets([assetOne, assetTwo]); + timelineManager.upsertAssets([assetOne, assetTwo]); timelineManager.removeAssets([assetOne.id]); expect(timelineManager.assetCount).toEqual(1); @@ -399,7 +452,7 @@ describe('TimelineManager', () => { fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'), }) .map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset)); - timelineManager.addAssets(assets); + timelineManager.upsertAssets(assets); timelineManager.removeAssets(assets.map((asset) => asset.id)); expect(timelineManager.assetCount).toEqual(0); @@ -431,7 +484,7 @@ describe('TimelineManager', () => { fileCreatedAt: fromISODateTimeUTCToObject('2024-01-15T12:00:00.000Z'), }), ); - timelineManager.addAssets([assetOne, assetTwo]); + timelineManager.upsertAssets([assetOne, assetTwo]); expect(timelineManager.getFirstAsset()).toEqual(assetOne); }); }); @@ -556,7 +609,7 @@ describe('TimelineManager', () => { fileCreatedAt: fromISODateTimeUTCToObject('2024-02-15T12:00:00.000Z'), }), ); - timelineManager.addAssets([assetOne, assetTwo]); + timelineManager.upsertAssets([assetOne, assetTwo]); expect(timelineManager.getMonthGroupByAssetId(assetTwo.id)?.yearMonth.year).toEqual(2024); expect(timelineManager.getMonthGroupByAssetId(assetTwo.id)?.yearMonth.month).toEqual(2); @@ -575,7 +628,7 @@ describe('TimelineManager', () => { fileCreatedAt: fromISODateTimeUTCToObject('2024-02-15T12:00:00.000Z'), }), ); - timelineManager.addAssets([assetOne, assetTwo]); + timelineManager.upsertAssets([assetOne, assetTwo]); timelineManager.removeAssets([assetTwo.id]); expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.year).toEqual(2024); diff --git a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts index 24523ce9e7..e3327663b4 100644 --- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts +++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts @@ -320,10 +320,10 @@ export class TimelineManager extends VirtualScrollManager { } } - addAssets(assets: TimelineAsset[]) { - const assetsToUpdate = assets.filter((asset) => !this.isExcluded(asset)); - const notUpdated = this.updateAssets(assetsToUpdate); - addAssetsToMonthGroups(this, [...notUpdated], { order: this.#options.order ?? AssetOrder.Desc }); + upsertAssets(assets: TimelineAsset[]) { + const notUpdated = this.#updateAssets(assets); + const notExcluded = notUpdated.filter((asset) => !this.isExcluded(asset)); + addAssetsToMonthGroups(this, [...notExcluded], { order: this.#options.order ?? AssetOrder.Desc }); } async findMonthGroupForAsset(id: string) { @@ -404,7 +404,7 @@ export class TimelineManager extends VirtualScrollManager { runAssetOperation(this, new SvelteSet(ids), operation, { order: this.#options.order ?? AssetOrder.Desc }); } - updateAssets(assets: TimelineAsset[]) { + #updateAssets(assets: TimelineAsset[]) { const lookup = new SvelteMap(assets.map((asset) => [asset.id, asset])); const { unprocessedIds } = runAssetOperation( this, diff --git a/web/src/lib/utils/actions.ts b/web/src/lib/utils/actions.ts index af79527841..2eb081a490 100644 --- a/web/src/lib/utils/actions.ts +++ b/web/src/lib/utils/actions.ts @@ -109,5 +109,5 @@ export function updateUnstackedAssetInTimeline(timelineManager: TimelineManager, }, ); - timelineManager.addAssets(assets); + timelineManager.upsertAssets(assets); } diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index f3c48b99a4..dbaa8c085a 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -244,7 +244,7 @@ }; const handleUndoRemoveAssets = async (assets: TimelineAsset[]) => { - timelineManager.addAssets(assets); + timelineManager.upsertAssets(assets); await refreshAlbum(); }; diff --git a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte index 4eebc59146..4770ba1638 100644 --- a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -94,7 +94,7 @@ timelineManager.removeAssets(assetIds)} - onUndoDelete={(assets) => timelineManager.addAssets(assets)} + onUndoDelete={(assets) => timelineManager.upsertAssets(assets)} /> diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index b29c54607a..5abcc6f5a3 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -339,7 +339,7 @@ }; const handleUndoDeleteAssets = async (assets: TimelineAsset[]) => { - timelineManager.addAssets(assets); + timelineManager.upsertAssets(assets); await updateAssetCount(); }; diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte index fde2aeda28..e2fffa37c4 100644 --- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte @@ -69,12 +69,12 @@ const handleLink: OnLink = ({ still, motion }) => { timelineManager.removeAssets([motion.id]); - timelineManager.updateAssets([still]); + timelineManager.upsertAssets([still]); }; const handleUnlink: OnUnlink = ({ still, motion }) => { - timelineManager.addAssets([motion]); - timelineManager.updateAssets([still]); + timelineManager.upsertAssets([motion]); + timelineManager.upsertAssets([still]); }; const handleSetVisibility = (assetIds: string[]) => { @@ -153,7 +153,7 @@ timelineManager.removeAssets(assetIds)} - onUndoDelete={(assets) => timelineManager.addAssets(assets)} + onUndoDelete={(assets) => timelineManager.upsertAssets(assets)} />
diff --git a/web/src/routes/(user)/utilities/geolocation/+page.svelte b/web/src/routes/(user)/utilities/geolocation/+page.svelte index a90c0b5632..4bd1a29fe5 100644 --- a/web/src/routes/(user)/utilities/geolocation/+page.svelte +++ b/web/src/routes/(user)/utilities/geolocation/+page.svelte @@ -63,7 +63,7 @@ }), ); - timelineManager.updateAssets(updatedAssets); + timelineManager.upsertAssets(updatedAssets); handleDeselectAll(); };