diff --git a/web/src/lib/components/share-page/individual-shared-viewer.svelte b/web/src/lib/components/share-page/individual-shared-viewer.svelte
index b7d9e548df..eb25d102fb 100644
--- a/web/src/lib/components/share-page/individual-shared-viewer.svelte
+++ b/web/src/lib/components/share-page/individual-shared-viewer.svelte
@@ -9,6 +9,7 @@
import type { Viewport } from '$lib/managers/timeline-manager/types';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
+ import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { handlePromiseError } from '$lib/utils';
import { cancelMultiselect, downloadArchive } from '$lib/utils/asset-utils';
import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader';
@@ -108,7 +109,7 @@
goto(AppRoute.PHOTOS)} backIcon={mdiArrowLeft} showBackButton={false}>
{#snippet leading()}
-
+
{/snippet}
diff --git a/web/src/lib/components/shared-components/search-bar/search-bar.svelte b/web/src/lib/components/shared-components/search-bar/search-bar.svelte
index a88da2cebf..2218e8bf45 100644
--- a/web/src/lib/components/shared-components/search-bar/search-bar.svelte
+++ b/web/src/lib/components/shared-components/search-bar/search-bar.svelte
@@ -79,10 +79,30 @@
searchStore.isSearchEnabled = false;
};
+ const buildSearchPayload = (term: string): SmartSearchDto | MetadataSearchDto => {
+ const searchType = getSearchType();
+ switch (searchType) {
+ case 'smart': {
+ return { query: term };
+ }
+ case 'metadata': {
+ return { originalFileName: term };
+ }
+ case 'description': {
+ return { description: term };
+ }
+ case 'ocr': {
+ return { ocr: term };
+ }
+ default: {
+ return { query: term };
+ }
+ }
+ };
+
const onHistoryTermClick = async (searchTerm: string) => {
value = searchTerm;
- const searchPayload = { query: searchTerm };
- await handleSearch(searchPayload);
+ await handleSearch(buildSearchPayload(searchTerm));
};
const onFilterClick = async () => {
@@ -112,29 +132,7 @@
};
const onSubmit = () => {
- const searchType = getSearchType();
- let payload = {} as SmartSearchDto | MetadataSearchDto;
-
- switch (searchType) {
- case 'smart': {
- payload = { query: value } as SmartSearchDto;
- break;
- }
- case 'metadata': {
- payload = { originalFileName: value } as MetadataSearchDto;
- break;
- }
- case 'description': {
- payload = { description: value } as MetadataSearchDto;
- break;
- }
- case 'ocr': {
- payload = { ocr: value } as MetadataSearchDto;
- break;
- }
- }
-
- handlePromiseError(handleSearch(payload));
+ handlePromiseError(handleSearch(buildSearchPayload(value)));
saveSearchTerm(value);
};
diff --git a/web/src/lib/components/timeline/Timeline.svelte b/web/src/lib/components/timeline/Timeline.svelte
index ba9cf37bff..6e21479acc 100644
--- a/web/src/lib/components/timeline/Timeline.svelte
+++ b/web/src/lib/components/timeline/Timeline.svelte
@@ -188,7 +188,7 @@
// the performance benefits of deferred layouts while still supporting deep linking
// to assets at the end of the timeline.
timelineManager.isScrollingOnLoad = true;
- const monthGroup = await timelineManager.findMonthGroupForAsset(assetId);
+ const monthGroup = await timelineManager.findMonthGroupForAsset({ id: assetId });
if (!monthGroup) {
return false;
}
diff --git a/web/src/lib/managers/timeline-manager/internal/search-support.svelte.ts b/web/src/lib/managers/timeline-manager/internal/search-support.svelte.ts
index f889456c20..7082673700 100644
--- a/web/src/lib/managers/timeline-manager/internal/search-support.svelte.ts
+++ b/web/src/lib/managers/timeline-manager/internal/search-support.svelte.ts
@@ -1,5 +1,5 @@
import { plainDateTimeCompare, type TimelineYearMonth } from '$lib/utils/timeline-util';
-import { AssetOrder } from '@immich/sdk';
+import { AssetOrder, type AssetResponseDto } from '@immich/sdk';
import { DateTime } from 'luxon';
import type { MonthGroup } from '../month-group.svelte';
import { TimelineManager } from '../timeline-manager.svelte';
@@ -7,12 +7,16 @@ import type { AssetDescriptor, Direction, TimelineAsset } from '../types';
export async function getAssetWithOffset(
timelineManager: TimelineManager,
- assetDescriptor: AssetDescriptor,
+ assetDescriptor: AssetDescriptor | AssetResponseDto,
interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
direction: Direction,
): Promise {
- const { asset, monthGroup } = findMonthGroupForAsset(timelineManager, assetDescriptor.id) ?? {};
- if (!monthGroup || !asset) {
+ const monthGroup = await timelineManager.findMonthGroupForAsset(assetDescriptor);
+ if (!monthGroup) {
+ return;
+ }
+ const asset = monthGroup.findAssetById(assetDescriptor);
+ if (!asset) {
return;
}
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 bb58704214..8e31f28138 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
@@ -524,6 +524,7 @@ describe('TimelineManager', () => {
{ count: 3, timeBucket: '2024-01-01T00:00:00.000Z' },
]);
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssetsResponse[timeBucket]));
+ sdkMock.getAssetInfo.mockRejectedValue(new Error('Asset not found'));
await timelineManager.updateViewport({ width: 1588, height: 1000 });
});
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 feba73a0f8..b6c43480ef 100644
--- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts
+++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts
@@ -16,12 +16,13 @@ import { WebsocketSupport } from '$lib/managers/timeline-manager/internal/websoc
import { CancellableTask } from '$lib/utils/cancellable-task';
import { PersistedLocalStorage } from '$lib/utils/persisted';
import {
+ isAssetResponseDto,
setDifference,
toTimelineAsset,
type TimelineDateTime,
type TimelineYearMonth,
} from '$lib/utils/timeline-util';
-import { AssetOrder, getAssetInfo, getTimeBuckets } from '@immich/sdk';
+import { AssetOrder, getAssetInfo, getTimeBuckets, type AssetResponseDto } from '@immich/sdk';
import { clamp, isEqual } from 'lodash-es';
import { SvelteDate, SvelteSet } from 'svelte/reactivity';
import { DayGroup } from './day-group.svelte';
@@ -343,27 +344,30 @@ export class TimelineManager extends VirtualScrollManager {
this.addAssetsUpsertSegments([...notExcluded]);
}
- async findMonthGroupForAsset(id: string) {
+ async findMonthGroupForAsset(asset: AssetDescriptor | AssetResponseDto) {
if (!this.isInitialized) {
await this.initTask.waitUntilCompletion();
}
+ const { id } = asset;
let { monthGroup } = findMonthGroupForAssetUtil(this, id) ?? {};
if (monthGroup) {
return monthGroup;
}
- const response = await getAssetInfo({ ...authManager.params, id }).catch(() => null);
+ const response = isAssetResponseDto(asset)
+ ? asset
+ : await getAssetInfo({ ...authManager.params, id }).catch(() => null);
if (!response) {
return;
}
- const asset = toTimelineAsset(response);
- if (!asset || this.isExcluded(asset)) {
+ const timelineAsset = toTimelineAsset(response);
+ if (this.isExcluded(timelineAsset)) {
return;
}
- monthGroup = await this.#loadMonthGroupAtTime(asset.localDateTime, { cancelable: false });
+ monthGroup = await this.#loadMonthGroupAtTime(timelineAsset.localDateTime, { cancelable: false });
if (monthGroup?.findAssetById({ id })) {
return monthGroup;
}
@@ -532,14 +536,14 @@ export class TimelineManager extends VirtualScrollManager {
}
async getLaterAsset(
- assetDescriptor: AssetDescriptor,
+ assetDescriptor: AssetDescriptor | AssetResponseDto,
interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
): Promise {
return await getAssetWithOffset(this, assetDescriptor, interval, 'later');
}
async getEarlierAsset(
- assetDescriptor: AssetDescriptor,
+ assetDescriptor: AssetDescriptor | AssetResponseDto,
interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
): Promise {
return await getAssetWithOffset(this, assetDescriptor, interval, 'earlier');
diff --git a/web/src/lib/stores/ocr.svelte.spec.ts b/web/src/lib/stores/ocr.svelte.spec.ts
new file mode 100644
index 0000000000..516e9f9f92
--- /dev/null
+++ b/web/src/lib/stores/ocr.svelte.spec.ts
@@ -0,0 +1,225 @@
+import { ocrManager, type OcrBoundingBox } from '$lib/stores/ocr.svelte';
+import { getAssetOcr } from '@immich/sdk';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+// Mock the SDK
+vi.mock('@immich/sdk', () => ({
+ getAssetOcr: vi.fn(),
+}));
+
+const createMockOcrData = (overrides?: Partial): OcrBoundingBox[] => [
+ {
+ id: '1',
+ assetId: 'asset-123',
+ x1: 0,
+ y1: 0,
+ x2: 100,
+ y2: 0,
+ x3: 100,
+ y3: 50,
+ x4: 0,
+ y4: 50,
+ boxScore: 0.95,
+ textScore: 0.98,
+ text: 'Hello World',
+ ...overrides,
+ },
+];
+
+describe('OcrManager', () => {
+ beforeEach(() => {
+ // Reset the singleton state before each test
+ ocrManager.clear();
+ vi.clearAllMocks();
+ });
+
+ describe('initial state', () => {
+ it('should initialize with empty data', () => {
+ expect(ocrManager.data).toEqual([]);
+ });
+
+ it('should initialize with showOverlay as false', () => {
+ expect(ocrManager.showOverlay).toBe(false);
+ });
+
+ it('should initialize with hasOcrData as false', () => {
+ expect(ocrManager.hasOcrData).toBe(false);
+ });
+ });
+
+ describe('getAssetOcr', () => {
+ it('should load OCR data for an asset', async () => {
+ const mockData = createMockOcrData();
+ vi.mocked(getAssetOcr).mockResolvedValue(mockData);
+
+ await ocrManager.getAssetOcr('asset-123');
+
+ expect(getAssetOcr).toHaveBeenCalledWith({ id: 'asset-123' });
+ expect(ocrManager.data).toEqual(mockData);
+ expect(ocrManager.hasOcrData).toBe(true);
+ });
+
+ it('should handle empty OCR data', async () => {
+ vi.mocked(getAssetOcr).mockResolvedValue([]);
+
+ await ocrManager.getAssetOcr('asset-456');
+
+ expect(ocrManager.data).toEqual([]);
+ expect(ocrManager.hasOcrData).toBe(false);
+ });
+
+ it('should reset the loader when previously cleared', async () => {
+ const mockData = createMockOcrData();
+ vi.mocked(getAssetOcr).mockResolvedValue(mockData);
+
+ // First clear
+ ocrManager.clear();
+ expect(ocrManager.data).toEqual([]);
+
+ // Then load new data
+ await ocrManager.getAssetOcr('asset-789');
+
+ expect(ocrManager.data).toEqual(mockData);
+ expect(ocrManager.hasOcrData).toBe(true);
+ });
+
+ it('should handle concurrent requests safely', async () => {
+ const firstData = createMockOcrData({ id: '1', text: 'First' });
+ const secondData = createMockOcrData({ id: '2', text: 'Second' });
+
+ vi.mocked(getAssetOcr)
+ .mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ setTimeout(() => resolve(firstData), 100);
+ }),
+ )
+ .mockResolvedValueOnce(secondData);
+
+ // Start first request
+ const promise1 = ocrManager.getAssetOcr('asset-1');
+ // Start second request immediately (should wait for first to complete)
+ const promise2 = ocrManager.getAssetOcr('asset-2');
+
+ await Promise.all([promise1, promise2]);
+
+ // CancellableTask waits for first request, so second request is ignored
+ // The data should be from the first request that completed
+ expect(ocrManager.data).toEqual(firstData);
+ });
+
+ it('should handle errors gracefully', async () => {
+ const error = new Error('Network error');
+ vi.mocked(getAssetOcr).mockRejectedValue(error);
+
+ // The error should be handled by CancellableTask
+ await expect(ocrManager.getAssetOcr('asset-error')).resolves.not.toThrow();
+ });
+ });
+
+ describe('clear', () => {
+ it('should clear OCR data', async () => {
+ const mockData = createMockOcrData({ text: 'Test' });
+ vi.mocked(getAssetOcr).mockResolvedValue(mockData);
+ await ocrManager.getAssetOcr('asset-123');
+
+ ocrManager.clear();
+
+ expect(ocrManager.data).toEqual([]);
+ expect(ocrManager.hasOcrData).toBe(false);
+ });
+
+ it('should reset showOverlay to false', () => {
+ ocrManager.showOverlay = true;
+
+ ocrManager.clear();
+
+ expect(ocrManager.showOverlay).toBe(false);
+ });
+
+ it('should mark as cleared for next load', async () => {
+ const mockData = createMockOcrData({ text: 'Test' });
+ vi.mocked(getAssetOcr).mockResolvedValue(mockData);
+
+ ocrManager.clear();
+ await ocrManager.getAssetOcr('asset-123');
+
+ // Should successfully load after clear
+ expect(ocrManager.data).toEqual(mockData);
+ });
+ });
+
+ describe('toggleOcrBoundingBox', () => {
+ it('should toggle showOverlay from false to true', () => {
+ expect(ocrManager.showOverlay).toBe(false);
+
+ ocrManager.toggleOcrBoundingBox();
+
+ expect(ocrManager.showOverlay).toBe(true);
+ });
+
+ it('should toggle showOverlay from true to false', () => {
+ ocrManager.showOverlay = true;
+
+ ocrManager.toggleOcrBoundingBox();
+
+ expect(ocrManager.showOverlay).toBe(false);
+ });
+
+ it('should toggle multiple times', () => {
+ ocrManager.toggleOcrBoundingBox();
+ expect(ocrManager.showOverlay).toBe(true);
+
+ ocrManager.toggleOcrBoundingBox();
+ expect(ocrManager.showOverlay).toBe(false);
+
+ ocrManager.toggleOcrBoundingBox();
+ expect(ocrManager.showOverlay).toBe(true);
+ });
+ });
+
+ describe('hasOcrData derived state', () => {
+ it('should be false when data is empty', () => {
+ expect(ocrManager.hasOcrData).toBe(false);
+ });
+
+ it('should be true when data is present', async () => {
+ const mockData = createMockOcrData({ text: 'Test' });
+ vi.mocked(getAssetOcr).mockResolvedValue(mockData);
+ await ocrManager.getAssetOcr('asset-123');
+
+ expect(ocrManager.hasOcrData).toBe(true);
+ });
+
+ it('should update when data is cleared', async () => {
+ const mockData = createMockOcrData({ text: 'Test' });
+ vi.mocked(getAssetOcr).mockResolvedValue(mockData);
+ await ocrManager.getAssetOcr('asset-123');
+ expect(ocrManager.hasOcrData).toBe(true);
+
+ ocrManager.clear();
+ expect(ocrManager.hasOcrData).toBe(false);
+ });
+ });
+
+ describe('data immutability', () => {
+ it('should return the same reference when data does not change', () => {
+ const firstReference = ocrManager.data;
+ const secondReference = ocrManager.data;
+
+ expect(firstReference).toBe(secondReference);
+ });
+
+ it('should return a new reference when data changes', async () => {
+ const firstReference = ocrManager.data;
+ const mockData = createMockOcrData({ text: 'Test' });
+
+ vi.mocked(getAssetOcr).mockResolvedValue(mockData);
+ await ocrManager.getAssetOcr('asset-123');
+
+ const secondReference = ocrManager.data;
+
+ expect(firstReference).not.toBe(secondReference);
+ });
+ });
+});
diff --git a/web/src/lib/stores/ocr.svelte.ts b/web/src/lib/stores/ocr.svelte.ts
index f9862b1edc..f68e550851 100644
--- a/web/src/lib/stores/ocr.svelte.ts
+++ b/web/src/lib/stores/ocr.svelte.ts
@@ -1,3 +1,4 @@
+import { CancellableTask } from '$lib/utils/cancellable-task';
import { getAssetOcr } from '@immich/sdk';
export type OcrBoundingBox = {
@@ -20,6 +21,8 @@ class OcrManager {
#data = $state([]);
showOverlay = $state(false);
#hasOcrData = $derived(this.#data.length > 0);
+ #ocrLoader = new CancellableTask();
+ #cleared = false;
get data() {
return this.#data;
@@ -30,10 +33,17 @@ class OcrManager {
}
async getAssetOcr(id: string) {
- this.#data = await getAssetOcr({ id });
+ if (this.#cleared) {
+ await this.#ocrLoader.reset();
+ this.#cleared = false;
+ }
+ await this.#ocrLoader.execute(async () => {
+ this.#data = await getAssetOcr({ id });
+ }, false);
}
clear() {
+ this.#cleared = true;
this.#data = [];
this.showOverlay = false;
}
diff --git a/web/src/lib/utils/timeline-util.ts b/web/src/lib/utils/timeline-util.ts
index 3326676f3c..f8fb49b61f 100644
--- a/web/src/lib/utils/timeline-util.ts
+++ b/web/src/lib/utils/timeline-util.ts
@@ -1,4 +1,4 @@
-import type { TimelineAsset, ViewportTopMonth } from '$lib/managers/timeline-manager/types';
+import type { AssetDescriptor, TimelineAsset, ViewportTopMonth } from '$lib/managers/timeline-manager/types';
import { locale } from '$lib/stores/preferences.store';
import { getAssetRatio } from '$lib/utils/asset-utils';
import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
@@ -192,8 +192,13 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset):
};
};
-export const isTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): unknownAsset is TimelineAsset =>
- (unknownAsset as TimelineAsset).ratio !== undefined;
+export const isTimelineAsset = (
+ unknownAsset: AssetDescriptor | AssetResponseDto | TimelineAsset,
+): unknownAsset is TimelineAsset => (unknownAsset as TimelineAsset).ratio !== undefined;
+
+export const isAssetResponseDto = (
+ unknownAsset: AssetDescriptor | AssetResponseDto | TimelineAsset,
+): unknownAsset is AssetResponseDto => (unknownAsset as AssetResponseDto).type !== undefined;
export const isTimelineAssets = (assets: AssetResponseDto[] | TimelineAsset[]): assets is TimelineAsset[] =>
assets.length === 0 || 'ratio' in assets[0];
diff --git a/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte
index 442d3cef6c..7f873aa918 100644
--- a/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -15,9 +15,24 @@
import { joinPaths, TreeNode } from '$lib/utils/tree-utils';
import { deleteTag, getAllTags, type TagResponseDto } from '@immich/sdk';
import { Button, HStack, modalManager, Text } from '@immich/ui';
- import { mdiPencil, mdiPlus, mdiTag, mdiTagMultiple, mdiTrashCanOutline } from '@mdi/js';
+ import { mdiDotsVertical, mdiPencil, mdiPlus, mdiTag, mdiTagMultiple, mdiTrashCanOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
+ import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
+ import AddToAlbum from '$lib/components/timeline/actions/AddToAlbumAction.svelte';
+ import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte';
+ import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte';
+ import ChangeDescription from '$lib/components/timeline/actions/ChangeDescriptionAction.svelte';
+ import ChangeLocation from '$lib/components/timeline/actions/ChangeLocationAction.svelte';
+ import CreateSharedLink from '$lib/components/timeline/actions/CreateSharedLinkAction.svelte';
+ import DeleteAssets from '$lib/components/timeline/actions/DeleteAssetsAction.svelte';
+ import DownloadAction from '$lib/components/timeline/actions/DownloadAction.svelte';
+ import FavoriteAction from '$lib/components/timeline/actions/FavoriteAction.svelte';
+ import SelectAllAssets from '$lib/components/timeline/actions/SelectAllAction.svelte';
+ import SetVisibilityAction from '$lib/components/timeline/actions/SetVisibilityAction.svelte';
+ import TagAction from '$lib/components/timeline/actions/TagAction.svelte';
+ import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
+ import { preferences, user } from '$lib/stores/user.store';
interface Props {
data: PageData;
@@ -79,6 +94,11 @@
// navigate to parent
await navigateToView(tag.parent ? tag.parent.path : '');
};
+
+ const handleSetVisibility = (assetIds: string[]) => {
+ timelineManager.removeAssets(assetIds);
+ assetInteraction.clearMultiselect();
+ };
@@ -131,3 +151,45 @@
{/if}
+
+
+ {#if assetInteraction.selectionActive}
+
+
assetInteraction.clearMultiselect()}
+ >
+
+
+
+
+
+
+ timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))}
+ >
+
+
+
+
+
+ timelineManager.update(ids, (asset) => (asset.visibility = visibility))}
+ />
+ {#if $preferences.tags.enabled}
+
+ {/if}
+ timelineManager.removeAssets(assetIds)}
+ onUndoDelete={(assets) => timelineManager.upsertAssets(assets)}
+ />
+
+
+
+
+ {/if}
+