+
+
+
+ {#if isExpired}
+ {$t('expired')}
+ {:else if expiresAt}
{$t('expires_date', { values: { date: getCountDownExpirationDate(expiresAt, now) } })}
-
- {:else}
- {$t('expires_date', { values: { date: '∞' } })}
- {/if}
+ {:else}
+ {$t('expires_date', { values: { date: '∞' } })}
+ {/if}
+
- {#if sharedLink.slug}
- {$t('custom_url')}
- {/if}
+
+ {#if sharedLink.type === SharedLinkType.Album}
+ {sharedLink.album?.albumName}
+ {:else if sharedLink.type === SharedLinkType.Individual}
+ {$t('individual_share')}
+ {/if}
+
- {#if sharedLink.allowUpload}
- {$t('upload')}
- {/if}
-
- {#if sharedLink.showMetadata && sharedLink.allowDownload}
- {$t('download')}
- {/if}
-
- {#if sharedLink.showMetadata}
- {$t('exif')}
- {/if}
-
- {#if sharedLink.password}
- {$t('password')}
+ {#if sharedLink.description}
+ {sharedLink.description}
{/if}
- {#if sharedLink.description}
-
{sharedLink.description}
- {/if}
+
+ {#each capabilities as capability, index (index)}
+
+ {capability}
+
+ {#if index < capabilities.length - 1}
+ •
+ {/if}
+ {/each}
+
diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts
index 5e74214e78..c389ebf2ef 100644
--- a/web/src/lib/constants.ts
+++ b/web/src/lib/constants.ts
@@ -306,6 +306,7 @@ export const langs: Lang[] = [
{ name: 'Chuvash', code: 'cv', loader: () => import('$i18n/cv.json') },
{ name: 'Danish', code: 'da', loader: () => import('$i18n/da.json') },
{ name: 'German', code: 'de', loader: () => import('$i18n/de.json') },
+ { name: 'German (Switzerland)', code: 'de-CH', weblateCode: 'de_CH', loader: () => import('$i18n/de_CH.json') },
defaultLang,
{ name: 'Greek', code: 'el', loader: () => import('$i18n/el.json') },
{ name: 'Esperanto', code: 'eo', loader: () => import('$i18n/eo.json') },
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/routes/(user)/shared-links/[[id=id]]/+page.svelte b/web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte
index d8b35204dc..cc9afd4f64 100644
--- a/web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte
+++ b/web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte
@@ -3,7 +3,7 @@
import { page } from '$app/state';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
- import SharedLinkCard from '$lib/components/sharedlinks-page/shared-link-card.svelte';
+ import SharedLinkCard from '$lib/components/sharedlinks-page/SharedLinkCard.svelte';
import { AppRoute } from '$lib/constants';
import GroupTab from '$lib/elements/GroupTab.svelte';
import SharedLinkUpdateModal from '$lib/modals/SharedLinkUpdateModal.svelte';