This commit is contained in:
104 changed files with 4042 additions and 2794 deletions

View File

@@ -9,7 +9,7 @@
"build:stats": "BUILD_STATS=true vite build",
"package": "svelte-kit package",
"preview": "vite preview",
"check:svelte": "svelte-check --no-tsconfig --fail-on-warnings",
"check:svelte": "svelte-check --no-tsconfig --fail-on-warnings --compiler-warnings 'state_referenced_locally:ignore'",
"check:typescript": "tsc --noEmit",
"check:watch": "pnpm run check:svelte --watch",
"check:code": "pnpm run format && pnpm run lint && pnpm run check:svelte && pnpm run check:typescript",
@@ -98,7 +98,7 @@
"prettier-plugin-sort-json": "^4.1.1",
"prettier-plugin-svelte": "^3.3.3",
"rollup-plugin-visualizer": "^6.0.0",
"svelte": "5.45.2",
"svelte": "5.43.3",
"svelte-check": "^4.1.5",
"svelte-eslint-parser": "^1.3.3",
"tailwindcss": "^4.1.7",

View File

@@ -1,4 +1,3 @@
import { tick } from 'svelte';
import type { Action } from 'svelte/action';
type Parameters = {
@@ -6,14 +5,19 @@ type Parameters = {
value: string; // added to enable reactivity
};
export const autoGrowHeight: Action<HTMLTextAreaElement, Parameters> = (textarea, { height = 'auto' }) => {
const update = () => {
void tick().then(() => {
textarea.style.height = height;
textarea.style.height = `${textarea.scrollHeight}px`;
});
export const autoGrowHeight: Action<HTMLTextAreaElement, Parameters> = (textarea) => {
const resize = () => {
textarea.style.minHeight = '0';
textarea.style.height = 'auto';
textarea.style.height = `${textarea.scrollHeight}px`;
};
update();
return { update };
resize();
textarea.addEventListener('input', resize);
return {
update: resize,
destroy() {
textarea.removeEventListener('input', resize);
},
};
};

View File

@@ -12,12 +12,14 @@
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { handlePromiseError } from '$lib/utils';
import { cancelMultiselect } from '$lib/utils/asset-utils';
import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader';
import type { AlbumResponseDto, SharedLinkResponseDto, UserResponseDto } from '@immich/sdk';
import { IconButton, Logo } from '@immich/ui';
import { mdiDownload, mdiFileImagePlusOutline } from '@mdi/js';
import { mdiDownload, mdiFileImagePlusOutline, mdiPresentationPlay } from '@mdi/js';
import { t } from 'svelte-i18n';
import ControlAppBar from '../shared-components/control-app-bar.svelte';
import ThemeButton from '../shared-components/theme-button.svelte';
@@ -32,7 +34,8 @@
const album = sharedLink.album as AlbumResponseDto;
let { isViewing: showAssetViewer } = assetViewingStore;
let { isViewing: showAssetViewer, setAssetId } = assetViewingStore;
let { slideshowState, slideshowNavigation } = slideshowStore;
const options = $derived({ albumId: album.id, order: album.order });
let timelineManager = $state<TimelineManager>() as TimelineManager;
@@ -45,6 +48,16 @@
dragAndDropFilesStore.set({ isDragging: false, files: [] });
}
});
const handleStartSlideshow = async () => {
const asset =
$slideshowNavigation === SlideshowNavigation.Shuffle
? await timelineManager.getRandomAsset()
: timelineManager.months[0]?.dayGroups[0]?.viewerAssets[0]?.asset;
if (asset) {
handlePromiseError(setAssetId(asset.id).then(() => ($slideshowState = SlideshowState.PlaySlideshow)));
}
};
</script>
<svelte:document
@@ -98,7 +111,7 @@
<ControlAppBar showBackButton={false}>
{#snippet leading()}
<a data-sveltekit-preload-data="hover" class="ms-4" href="/">
<Logo variant="inline" class="min-w-min" />
<Logo variant={mobileDevice.maxMd ? 'icon' : 'inline'} class="min-w-10" />
</a>
{/snippet}
@@ -117,6 +130,14 @@
{/if}
{#if album.assetCount > 0 && sharedLink.allowDownload}
<IconButton
shape="round"
variant="ghost"
color="secondary"
aria-label={$t('slideshow')}
onclick={handleStartSlideshow}
icon={mdiPresentationPlay}
/>
<IconButton
shape="round"
color="secondary"

View File

@@ -2,7 +2,7 @@
import { locale } from '$lib/stores/preferences.store';
import type { ActivityResponseDto } from '@immich/sdk';
import { Icon } from '@immich/ui';
import { mdiCommentOutline, mdiHeart, mdiHeartOutline } from '@mdi/js';
import { mdiCommentOutline, mdiThumbUp, mdiThumbUpOutline } from '@mdi/js';
interface Props {
isLiked: ActivityResponseDto | null;
@@ -19,7 +19,7 @@
<div class="w-full flex p-4 items-center justify-center rounded-full gap-5 bg-subtle border bg-opacity-60">
<button type="button" class={disabled ? 'cursor-not-allowed' : ''} onclick={onFavorite} {disabled}>
<div class="flex gap-2 items-center justify-center">
<Icon icon={isLiked ? mdiHeart : mdiHeartOutline} size="24" class={isLiked ? 'text-red-400' : 'text-fg'} />
<Icon icon={isLiked ? mdiThumbUp : mdiThumbUpOutline} size="24" class={isLiked ? 'text-primary' : 'text-fg'} />
{#if numberOfLikes}
<div class="text-l">{numberOfLikes.toLocaleString($locale)}</div>
{/if}

View File

@@ -13,7 +13,7 @@
import { isTenMinutesApart } from '$lib/utils/timesince';
import { ReactionType, type ActivityResponseDto, type AssetTypeEnum, type UserResponseDto } from '@immich/sdk';
import { Icon, IconButton, LoadingSpinner, toastManager } from '@immich/ui';
import { mdiClose, mdiDeleteOutline, mdiDotsVertical, mdiHeart, mdiSend } from '@mdi/js';
import { mdiClose, mdiDeleteOutline, mdiDotsVertical, mdiSend, mdiThumbUp } from '@mdi/js';
import * as luxon from 'luxon';
import { t } from 'svelte-i18n';
import UserAvatar from '../shared-components/user-avatar.svelte';
@@ -181,7 +181,7 @@
{:else if reaction.type === ReactionType.Like}
<div class="relative">
<div class="flex py-3 ps-3 mt-3 gap-4 items-center text-sm">
<div class="text-red-600"><Icon icon={mdiHeart} size="20" /></div>
<div class="text-primary"><Icon icon={mdiThumbUp} size="20" /></div>
<div class="w-full" title={`${reaction.user.name} (${reaction.user.email})`}>
{$t('user_liked', {
@@ -248,13 +248,14 @@
<textarea
{disabled}
bind:value={message}
use:autoGrowHeight={{ height: '5px', value: message }}
rows="1"
use:autoGrowHeight={{ value: message }}
placeholder={disabled ? $t('comments_are_disabled') : $t('say_something')}
use:shortcut={{
shortcut: { key: 'Enter' },
onShortcut: () => handleSendComment(),
}}
class="h-[18px] {disabled
class="h-4.5 {disabled
? 'cursor-not-allowed'
: ''} w-full max-h-56 pe-2 items-center overflow-y-auto leading-4 outline-none resize-none bg-gray-200"
></textarea>

View File

@@ -114,7 +114,11 @@
return;
}
await modalManager.show(AssetChangeDateModal, { asset: toTimelineAsset(asset), initialDate: dateTime });
await modalManager.show(AssetChangeDateModal, {
asset: toTimelineAsset(asset),
initialDate: dateTime,
initialTimeZone: timeZone,
});
};
</script>

View File

@@ -16,7 +16,7 @@
{#if downloadManager.isDownloading}
<div
transition:fly={{ x: -100, duration: 350 }}
class="fixed bottom-10 start-2 max-h-67.5 w-79 rounded-2xl border dark:border-white/10 p-4 shadow-lg bg-subtle"
class="fixed bottom-10 start-2 max-h-67.5 w-79 z-60 rounded-2xl border dark:border-white/10 p-4 shadow-lg bg-subtle"
>
<Heading size="tiny">{$t('downloading')}</Heading>
<div class="my-2 mb-2 flex max-h-50 flex-col overflow-y-auto text-sm">

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import { cancelImageUrl } from '$lib/utils/sw-messaging';
import { preloadManager } from '$lib/managers/PreloadManager.svelte';
import { Icon } from '@immich/ui';
import { mdiEyeOffOutline } from '@mdi/js';
import type { ActionReturn } from 'svelte/action';
@@ -60,7 +60,7 @@
onComplete?.(false);
}
return {
destroy: () => cancelImageUrl(url),
destroy: () => preloadManager.cancelPreloadUrl(url),
};
}

View File

@@ -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 @@
<ControlAppBar onClose={() => goto(AppRoute.PHOTOS)} backIcon={mdiArrowLeft} showBackButton={false}>
{#snippet leading()}
<a data-sveltekit-preload-data="hover" class="ms-4" href="/">
<Logo variant="inline" />
<Logo variant={mobileDevice.maxMd ? 'icon' : 'inline'} class="min-w-10" />
</a>
{/snippet}

View File

@@ -26,6 +26,7 @@
class="resize-none {className}"
onfocusout={updateContent}
{placeholder}
rows="1"
use:shortcut={{
shortcut: { key: 'Enter', ctrl: true },
onShortcut: (e) => e.currentTarget.blur(),

View File

@@ -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);
};

View File

@@ -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;
}

View File

@@ -0,0 +1,52 @@
import { getAssetInfo, getAssetOcr, type AssetOcrResponseDto, type AssetResponseDto } from '@immich/sdk';
class AsyncCache<V> {
#cache = new Map<string, V>();
async getOrFetch<K>(
params: K,
fetcher: (params: K) => Promise<V>,
keySerializer: (params: K) => string = (params) => JSON.stringify(params),
): Promise<V> {
const cacheKey = keySerializer(params);
const cached = this.#cache.get(cacheKey);
if (cached) {
return cached;
}
const value = await fetcher(params);
if (value) {
this.#cache.set(cacheKey, value);
}
return value;
}
clear() {
this.#cache.clear();
}
}
class AssetCacheManager {
#assetCache = new AsyncCache<AssetResponseDto>();
#ocrCache = new AsyncCache<AssetOcrResponseDto[]>();
async getAsset(assetIdentifier: { key?: string; slug?: string; id: string }) {
return this.#assetCache.getOrFetch(assetIdentifier, getAssetInfo);
}
async getAssetOcr(id: string) {
return this.#ocrCache.getOrFetch({ id }, getAssetOcr, (params) => params.id);
}
clearAssetCache() {
this.#assetCache.clear();
}
clearOcrCache() {
this.#ocrCache.clear();
}
}
export const assetCacheManager = new AssetCacheManager();

View File

@@ -0,0 +1,37 @@
import { getAssetUrl } from '$lib/utils';
import { cancelImageUrl, preloadImageUrl } from '$lib/utils/sw-messaging';
import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
class PreloadManager {
preload(asset: AssetResponseDto | undefined) {
if (!asset) {
return;
}
if (globalThis.isSecureContext) {
preloadImageUrl(getAssetUrl({ asset }));
return;
}
if (asset.type === AssetTypeEnum.Image) {
const img = new Image();
img.src = getAssetUrl({ asset });
}
}
cancel(asset: AssetResponseDto | undefined) {
if (!globalThis.isSecureContext || !asset) {
return;
}
const url = getAssetUrl({ asset });
cancelImageUrl(url);
}
cancelPreloadUrl(url: string) {
if (!globalThis.isSecureContext) {
return;
}
cancelImageUrl(url);
}
}
export const preloadManager = new PreloadManager();

View File

@@ -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<TimelineAsset | undefined> {
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;
}

View File

@@ -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 });
});

View File

@@ -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<TimelineAsset | undefined> {
return await getAssetWithOffset(this, assetDescriptor, interval, 'later');
}
async getEarlierAsset(
assetDescriptor: AssetDescriptor,
assetDescriptor: AssetDescriptor | AssetResponseDto,
interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
): Promise<TimelineAsset | undefined> {
return await getAssetWithOffset(this, assetDescriptor, interval, 'earlier');

View File

@@ -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>): 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);
});
});
});

View File

@@ -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<OcrBoundingBox[]>([]);
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;
}

View File

@@ -1,10 +1,12 @@
import { defaultLang, langs, locales } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { lang } from '$lib/stores/preferences.store';
import { alwaysLoadOriginalFile, lang } from '$lib/stores/preferences.store';
import { isWebCompatibleImage } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import {
AssetJobName,
AssetMediaSize,
AssetTypeEnum,
MemoryType,
QueueName,
finishOAuth,
@@ -17,6 +19,7 @@ import {
linkOAuthAccount,
startOAuth,
unlinkOAuthAccount,
type AssetResponseDto,
type MemoryResponseDto,
type PersonResponseDto,
type ServerVersionResponseDto,
@@ -191,6 +194,37 @@ const createUrl = (path: string, parameters?: Record<string, unknown>) => {
type AssetUrlOptions = { id: string; cacheKey?: string | null };
export const getAssetUrl = ({
asset,
sharedLink,
forceOriginal = false,
}: {
asset: AssetResponseDto;
sharedLink?: SharedLinkResponseDto;
forceOriginal?: boolean;
}) => {
const id = asset.id;
const cacheKey = asset.thumbhash;
if (sharedLink && (!sharedLink.allowDownload || !sharedLink.showMetadata)) {
return getAssetThumbnailUrl({ id, size: AssetMediaSize.Preview, cacheKey });
}
const targetSize = targetImageSize(asset, forceOriginal);
return targetSize === 'original'
? getAssetOriginalUrl({ id, cacheKey })
: getAssetThumbnailUrl({ id, size: targetSize, cacheKey });
};
const forceUseOriginal = (asset: AssetResponseDto) => {
return asset.type === AssetTypeEnum.Image && asset.duration && !asset.duration.includes('0:00:00.000');
};
export const targetImageSize = (asset: AssetResponseDto, forceOriginal: boolean) => {
if (forceOriginal || get(alwaysLoadOriginalFile) || forceUseOriginal(asset)) {
return isWebCompatibleImage(asset) ? 'original' : AssetMediaSize.Fullsize;
}
return AssetMediaSize.Preview;
};
export const getAssetOriginalUrl = (options: string | AssetUrlOptions) => {
if (typeof options === 'string') {
options = { id: options };

View File

@@ -0,0 +1,540 @@
import { CancellableTask } from '$lib/utils/cancellable-task';
describe('CancellableTask', () => {
describe('execute', () => {
it('should execute task successfully and return LOADED', async () => {
const task = new CancellableTask();
const taskFn = vi.fn(async (_: AbortSignal) => {
await new Promise((resolve) => setTimeout(resolve, 10));
});
const result = await task.execute(taskFn, true);
expect(result).toBe('LOADED');
expect(task.executed).toBe(true);
expect(task.loading).toBe(false);
expect(taskFn).toHaveBeenCalledTimes(1);
});
it('should call loadedCallback when task completes successfully', async () => {
const loadedCallback = vi.fn();
const task = new CancellableTask(loadedCallback);
const taskFn = vi.fn(async () => {});
await task.execute(taskFn, true);
expect(loadedCallback).toHaveBeenCalledTimes(1);
});
it('should return DONE if task is already executed', async () => {
const task = new CancellableTask();
const taskFn = vi.fn(async () => {});
await task.execute(taskFn, true);
const result = await task.execute(taskFn, true);
expect(result).toBe('DONE');
expect(taskFn).toHaveBeenCalledTimes(1);
});
it('should wait if task is already running', async () => {
const task = new CancellableTask();
let resolveTask: () => void;
const taskPromise = new Promise<void>((resolve) => {
resolveTask = resolve;
});
const taskFn = vi.fn(async () => {
await taskPromise;
});
const promise1 = task.execute(taskFn, true);
const promise2 = task.execute(taskFn, true);
expect(task.loading).toBe(true);
resolveTask!();
const [result1, result2] = await Promise.all([promise1, promise2]);
expect(result1).toBe('LOADED');
expect(result2).toBe('WAITED');
expect(taskFn).toHaveBeenCalledTimes(1);
});
it('should pass AbortSignal to task function', async () => {
const task = new CancellableTask();
let capturedSignal: AbortSignal | null = null;
const taskFn = async (signal: AbortSignal) => {
await Promise.resolve();
capturedSignal = signal;
};
await task.execute(taskFn, true);
expect(capturedSignal).toBeInstanceOf(AbortSignal);
});
it('should set cancellable flag correctly', async () => {
const task = new CancellableTask();
const taskFn = vi.fn(async () => {});
expect(task.cancellable).toBe(true);
const promise = task.execute(taskFn, false);
expect(task.cancellable).toBe(false);
await promise;
});
it('should not allow transition from prevent cancel to allow cancel when task is running', async () => {
const task = new CancellableTask();
let resolveTask: () => void;
const taskPromise = new Promise<void>((resolve) => {
resolveTask = resolve;
});
const taskFn = vi.fn(async () => {
await taskPromise;
});
const promise1 = task.execute(taskFn, false);
expect(task.cancellable).toBe(false);
const promise2 = task.execute(taskFn, true);
expect(task.cancellable).toBe(false);
resolveTask!();
await Promise.all([promise1, promise2]);
});
});
describe('cancel', () => {
it('should cancel a running task', async () => {
const task = new CancellableTask();
let taskStarted = false;
const taskFn = async (signal: AbortSignal) => {
taskStarted = true;
await new Promise((resolve) => setTimeout(resolve, 100));
if (signal.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
};
const promise = task.execute(taskFn, true);
// Wait a bit to ensure task has started
await new Promise((resolve) => setTimeout(resolve, 10));
expect(taskStarted).toBe(true);
task.cancel();
const result = await promise;
expect(result).toBe('CANCELED');
expect(task.executed).toBe(false);
});
it('should call canceledCallback when task is canceled', async () => {
const canceledCallback = vi.fn();
const task = new CancellableTask(undefined, canceledCallback);
const taskFn = async (signal: AbortSignal) => {
await new Promise((resolve) => setTimeout(resolve, 100));
if (signal.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
};
const promise = task.execute(taskFn, true);
await new Promise((resolve) => setTimeout(resolve, 10));
task.cancel();
await promise;
expect(canceledCallback).toHaveBeenCalledTimes(1);
});
it('should not cancel if task is not cancellable', async () => {
const task = new CancellableTask();
const taskFn = vi.fn(async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
});
const promise = task.execute(taskFn, false);
task.cancel();
const result = await promise;
expect(result).toBe('LOADED');
expect(task.executed).toBe(true);
});
it('should not cancel if task is already executed', async () => {
const task = new CancellableTask();
const taskFn = vi.fn(async () => {});
await task.execute(taskFn, true);
expect(task.executed).toBe(true);
task.cancel();
expect(task.executed).toBe(true);
});
});
describe('reset', () => {
it('should reset task to initial state', async () => {
const task = new CancellableTask();
const taskFn = vi.fn(async () => {});
await task.execute(taskFn, true);
expect(task.executed).toBe(true);
await task.reset();
expect(task.executed).toBe(false);
expect(task.cancelToken).toBe(null);
expect(task.loading).toBe(false);
});
it('should cancel running task before resetting', async () => {
const task = new CancellableTask();
const taskFn = async (signal: AbortSignal) => {
await new Promise((resolve) => setTimeout(resolve, 100));
if (signal.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
};
const promise = task.execute(taskFn, true);
await new Promise((resolve) => setTimeout(resolve, 10));
const resetPromise = task.reset();
await promise;
await resetPromise;
expect(task.executed).toBe(false);
expect(task.loading).toBe(false);
});
it('should allow re-execution after reset', async () => {
const task = new CancellableTask();
const taskFn = vi.fn(async () => {});
await task.execute(taskFn, true);
await task.reset();
const result = await task.execute(taskFn, true);
expect(result).toBe('LOADED');
expect(task.executed).toBe(true);
expect(taskFn).toHaveBeenCalledTimes(2);
});
});
describe('waitUntilCompletion', () => {
it('should return DONE if task is already executed', async () => {
const task = new CancellableTask();
const taskFn = vi.fn(async () => {});
await task.execute(taskFn, true);
const result = await task.waitUntilCompletion();
expect(result).toBe('DONE');
});
it('should return WAITED if task completes while waiting', async () => {
const task = new CancellableTask();
let resolveTask: () => void;
const taskPromise = new Promise<void>((resolve) => {
resolveTask = resolve;
});
const taskFn = async () => {
await taskPromise;
};
const executePromise = task.execute(taskFn, true);
const waitPromise = task.waitUntilCompletion();
resolveTask!();
const [, waitResult] = await Promise.all([executePromise, waitPromise]);
expect(waitResult).toBe('WAITED');
});
it('should return CANCELED if task is canceled', async () => {
const task = new CancellableTask();
const taskFn = async (signal: AbortSignal) => {
await new Promise((resolve) => setTimeout(resolve, 100));
if (signal.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
};
const executePromise = task.execute(taskFn, true);
const waitPromise = task.waitUntilCompletion();
await new Promise((resolve) => setTimeout(resolve, 10));
task.cancel();
const [, waitResult] = await Promise.all([executePromise, waitPromise]);
expect(waitResult).toBe('CANCELED');
});
});
describe('waitUntilExecution', () => {
it('should return DONE if task is already executed', async () => {
const task = new CancellableTask();
const taskFn = vi.fn(async () => {});
await task.execute(taskFn, true);
const result = await task.waitUntilExecution();
expect(result).toBe('DONE');
});
it('should return WAITED if task completes successfully', async () => {
const task = new CancellableTask();
let resolveTask: () => void;
const taskPromise = new Promise<void>((resolve) => {
resolveTask = resolve;
});
const taskFn = async () => {
await taskPromise;
};
const executePromise = task.execute(taskFn, true);
const waitPromise = task.waitUntilExecution();
resolveTask!();
const [, waitResult] = await Promise.all([executePromise, waitPromise]);
expect(waitResult).toBe('WAITED');
});
it('should retry if task is canceled and wait for next execution', async () => {
vi.useFakeTimers();
const task = new CancellableTask();
let attempt = 0;
const taskFn = async (signal: AbortSignal) => {
attempt++;
await new Promise((resolve) => setTimeout(resolve, 100));
if (signal.aborted && attempt === 1) {
throw new DOMException('Aborted', 'AbortError');
}
};
// Start first execution
const executePromise1 = task.execute(taskFn, true);
const waitPromise = task.waitUntilExecution();
// Cancel the first execution
vi.advanceTimersByTime(10);
task.cancel();
vi.advanceTimersByTime(100);
await executePromise1;
// Start second execution
const executePromise2 = task.execute(taskFn, true);
vi.advanceTimersByTime(100);
const [executeResult, waitResult] = await Promise.all([executePromise2, waitPromise]);
expect(executeResult).toBe('LOADED');
expect(waitResult).toBe('WAITED');
expect(attempt).toBe(2);
vi.useRealTimers();
});
});
describe('error handling', () => {
it('should return ERRORED when task throws non-abort error', async () => {
const task = new CancellableTask();
const error = new Error('Task failed');
const taskFn = async () => {
await Promise.resolve();
throw error;
};
const result = await task.execute(taskFn, true);
expect(result).toBe('ERRORED');
expect(task.executed).toBe(false);
});
it('should call errorCallback when task throws non-abort error', async () => {
const errorCallback = vi.fn();
const task = new CancellableTask(undefined, undefined, errorCallback);
const error = new Error('Task failed');
const taskFn = async () => {
await Promise.resolve();
throw error;
};
await task.execute(taskFn, true);
expect(errorCallback).toHaveBeenCalledTimes(1);
expect(errorCallback).toHaveBeenCalledWith(error);
});
it('should return CANCELED when task throws AbortError', async () => {
const task = new CancellableTask();
const taskFn = async () => {
await Promise.resolve();
throw new DOMException('Aborted', 'AbortError');
};
const result = await task.execute(taskFn, true);
expect(result).toBe('CANCELED');
expect(task.executed).toBe(false);
});
it('should allow re-execution after error', async () => {
const task = new CancellableTask();
const taskFn1 = async () => {
await Promise.resolve();
throw new Error('Failed');
};
const taskFn2 = vi.fn(async () => {});
const result1 = await task.execute(taskFn1, true);
expect(result1).toBe('ERRORED');
const result2 = await task.execute(taskFn2, true);
expect(result2).toBe('LOADED');
expect(task.executed).toBe(true);
});
});
describe('loading property', () => {
it('should return true when task is running', async () => {
const task = new CancellableTask();
let resolveTask: () => void;
const taskPromise = new Promise<void>((resolve) => {
resolveTask = resolve;
});
const taskFn = async () => {
await taskPromise;
};
expect(task.loading).toBe(false);
const promise = task.execute(taskFn, true);
expect(task.loading).toBe(true);
resolveTask!();
await promise;
expect(task.loading).toBe(false);
});
});
describe('complete promise', () => {
it('should resolve when task completes successfully', async () => {
const task = new CancellableTask();
const taskFn = vi.fn(async () => {});
const completePromise = task.complete;
await task.execute(taskFn, true);
await expect(completePromise).resolves.toBeUndefined();
});
it('should reject when task is canceled', async () => {
const task = new CancellableTask();
const taskFn = async (signal: AbortSignal) => {
await new Promise((resolve) => setTimeout(resolve, 100));
if (signal.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
};
const completePromise = task.complete;
const promise = task.execute(taskFn, true);
await new Promise((resolve) => setTimeout(resolve, 10));
task.cancel();
await promise;
await expect(completePromise).rejects.toBeUndefined();
});
it('should reject when task errors', async () => {
const task = new CancellableTask();
const taskFn = async () => {
await Promise.resolve();
throw new Error('Failed');
};
const completePromise = task.complete;
await task.execute(taskFn, true);
await expect(completePromise).rejects.toBeUndefined();
});
});
describe('abort signal handling', () => {
it('should automatically call abort() on signal when task is canceled', async () => {
const task = new CancellableTask();
let capturedSignal: AbortSignal | null = null;
const taskFn = async (signal: AbortSignal) => {
capturedSignal = signal;
// Simulate a long-running task
await new Promise((resolve) => setTimeout(resolve, 100));
if (signal.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
};
const promise = task.execute(taskFn, true);
// Wait a bit to ensure task has started
await new Promise((resolve) => setTimeout(resolve, 10));
expect(capturedSignal).not.toBeNull();
expect(capturedSignal!.aborted).toBe(false);
// Cancel the task
task.cancel();
// Verify the signal was aborted
expect(capturedSignal!.aborted).toBe(true);
const result = await promise;
expect(result).toBe('CANCELED');
});
it('should detect if signal was aborted after task completes', async () => {
const task = new CancellableTask();
let controller: AbortController | null = null;
const taskFn = async (_: AbortSignal) => {
// Capture the controller to abort it externally
controller = task.cancelToken;
// Simulate some work
await new Promise((resolve) => setTimeout(resolve, 10));
// Now abort before the function returns
controller?.abort();
};
const result = await task.execute(taskFn, true);
expect(result).toBe('CANCELED');
expect(task.executed).toBe(false);
});
it('should handle abort signal in async operations', async () => {
const task = new CancellableTask();
const taskFn = async (signal: AbortSignal) => {
// Simulate listening to abort signal during async operation
return new Promise<void>((resolve, reject) => {
signal.addEventListener('abort', () => {
reject(new DOMException('Aborted', 'AbortError'));
});
setTimeout(() => resolve(), 100);
});
};
const promise = task.execute(taskFn, true);
await new Promise((resolve) => setTimeout(resolve, 10));
task.cancel();
const result = await promise;
expect(result).toBe('CANCELED');
});
});
});

View File

@@ -15,15 +15,7 @@ export class CancellableTask {
private canceledCallback?: () => void,
private errorCallback?: (error: unknown) => void,
) {
this.complete = new Promise<void>((resolve, reject) => {
this.loadedSignal = resolve;
this.canceledSignal = reject;
}).catch(
() =>
// if no-one waits on complete its rejected a uncaught rejection message is logged.
// prevent this message with an empty reject handler, since waiting on a bucket is optional.
void 0,
);
this.init();
}
get loading() {
@@ -34,11 +26,30 @@ export class CancellableTask {
if (this.executed) {
return 'DONE';
}
// if there is a cancel token, task is currently executing, so wait on the promise. If it
// isn't, then the task is in new state, it hasn't been loaded, nor has it been executed.
// in either case, we wait on the promise.
await this.complete;
return 'WAITED';
// The `complete` promise resolves when executed, rejects when canceled/errored.
try {
const complete = this.complete;
await complete;
return 'WAITED';
} catch {
// ignore
}
return 'CANCELED';
}
async waitUntilExecution() {
// Keep retrying until the task completes successfully (not canceled)
for (;;) {
try {
if (this.executed) {
return 'DONE';
}
await this.complete;
return 'WAITED';
} catch {
continue;
}
}
}
async execute<F extends (abortSignal: AbortSignal) => Promise<void>>(f: F, cancellable: boolean) {
@@ -80,21 +91,14 @@ export class CancellableTask {
}
private init() {
this.cancelToken = null;
this.executed = false;
// create a promise, and store its resolve/reject callbacks. The loadedSignal callback
// will be incoked when a bucket is loaded, fulfilling the promise. The canceledSignal
// callback will be called if the bucket is canceled before it was loaded, rejecting the
// promise.
this.complete = new Promise<void>((resolve, reject) => {
this.cancelToken = null;
this.executed = false;
this.loadedSignal = resolve;
this.canceledSignal = reject;
}).catch(
() =>
// if no-one waits on complete its rejected a uncaught rejection message is logged.
// prevent this message with an empty reject handler, since waiting on a bucket is optional.
void 0,
);
});
// Suppress unhandled rejection warning
this.complete.catch(() => {});
}
// will reset this job back to the initial state (isLoaded=false, no errors, etc)

View File

@@ -1,8 +1,8 @@
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import type { RouteId } from '$app/types';
import { AppRoute } from '$lib/constants';
import { getAssetInfo } from '@immich/sdk';
import type { NavigationTarget } from '@sveltejs/kit';
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
import { get } from 'svelte/store';
export type AssetGridRouteSearchParams = {
@@ -20,11 +20,12 @@ export const isAlbumsRoute = (route?: string | null) => !!route?.startsWith('/(u
export const isPeopleRoute = (route?: string | null) => !!route?.startsWith('/(user)/people/[personId]');
export const isLockedFolderRoute = (route?: string | null) => !!route?.startsWith('/(user)/locked');
export const isAssetViewerRoute = (target?: NavigationTarget | null) =>
!!(target?.route.id?.endsWith('/[[assetId=id]]') && 'assetId' in (target?.params || {}));
export const isAssetViewerRoute = (
target?: { route?: { id?: RouteId | null }; params?: Record<string, string> | null } | null,
) => !!(target?.route?.id?.endsWith('/[[assetId=id]]') && 'assetId' in (target?.params || {}));
export function getAssetInfoFromParam({ assetId, slug, key }: { assetId?: string; key?: string; slug?: string }) {
return assetId ? getAssetInfo({ id: assetId, slug, key }) : undefined;
return assetId ? assetCacheManager.getAsset({ id: assetId, slug, key }) : undefined;
}
function currentUrlWithoutAsset() {

View File

@@ -1,8 +1,39 @@
const broadcast = new BroadcastChannel('immich');
let isLoadedReplyListeners: ((url: string, isUrlCached: boolean) => void)[] = [];
broadcast.addEventListener('message', (event) => {
if (event.data.type == 'isImageUrlCachedReply') {
for (const listener of isLoadedReplyListeners) {
listener(event.data.url, event.data.isImageUrlCached);
}
}
});
export function cancelImageUrl(url: string) {
broadcast.postMessage({ type: 'cancel', url });
}
export function preloadImageUrl(url: string) {
broadcast.postMessage({ type: 'preload', url });
}
export function isImageUrlCached(url: string) {
if (!globalThis.isSecureContext) {
return Promise.resolve(false);
}
return new Promise((resolve) => {
const listener = (urlReply: string, isUrlCached: boolean) => {
if (urlReply === url) {
cleanup(isUrlCached);
}
};
const cleanup = (isUrlCached: boolean) => {
isLoadedReplyListeners = isLoadedReplyListeners.filter((element) => element !== listener);
resolve(isUrlCached);
};
isLoadedReplyListeners.push(listener);
broadcast.postMessage({ type: 'isImageUrlCached', url });
setTimeout(() => cleanup(false), 5000);
});
}

View File

@@ -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];

View File

@@ -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();
};
</script>
<UserPageLayout title={data.meta.title}>
@@ -131,3 +151,45 @@
{/if}
</section>
</UserPageLayout>
<section>
{#if assetInteraction.selectionActive}
<div class="fixed top-0 start-0 w-full">
<AssetSelectControlBar
ownerId={$user.id}
assets={assetInteraction.selectedAssets}
clearSelect={() => assetInteraction.clearMultiselect()}
>
<CreateSharedLink />
<SelectAllAssets {timelineManager} {assetInteraction} />
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
<AddToAlbum />
<AddToAlbum shared />
</ButtonContextMenu>
<FavoriteAction
removeFavorite={assetInteraction.isAllFavorite}
onFavorite={(ids, isFavorite) => timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))}
></FavoriteAction>
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem />
<ChangeDate menuItem />
<ChangeDescription menuItem />
<ChangeLocation menuItem />
<ArchiveAction
menuItem
onArchive={(ids, visibility) => timelineManager.update(ids, (asset) => (asset.visibility = visibility))}
/>
{#if $preferences.tags.enabled}
<TagAction menuItem />
{/if}
<DeleteAssets
menuItem
onAssetDelete={(assetIds) => timelineManager.removeAssets(assetIds)}
onUndoDelete={(assets) => timelineManager.upsertAssets(assets)}
/>
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
</ButtonContextMenu>
</AssetSelectControlBar>
</div>
{/if}
</section>

View File

@@ -1,7 +1,8 @@
import { handleCancel, handlePreload } from './request';
import { handleCancel, handleIsUrlCached, handlePreload } from './request';
export const broadcast = new BroadcastChannel('immich');
export const installBroadcastChannelListener = () => {
const broadcast = new BroadcastChannel('immich');
// eslint-disable-next-line unicorn/prefer-add-event-listener
broadcast.onmessage = (event) => {
if (!event.data) {
@@ -20,6 +21,15 @@ export const installBroadcastChannelListener = () => {
handleCancel(url);
break;
}
case 'isImageUrlCached': {
void handleIsUrlCached(url);
break;
}
}
};
};
export const replyIsImageUrlCached = (url: string, isImageUrlCached: boolean) => {
broadcast.postMessage({ type: 'isImageUrlCachedReply', url, isImageUrlCached });
};

View File

@@ -30,7 +30,11 @@ export const put = async (key: string, response: Response) => {
return;
}
cache.put(key, response.clone());
try {
await cache.put(key, response.clone());
} catch (error) {
console.error('Ignoring error during cache put', error);
}
};
export const prune = async () => {

View File

@@ -1,3 +1,4 @@
import { replyIsImageUrlCached } from './broadcast-channel';
import { get, put } from './cache';
const pendingRequests = new Map<string, AbortController>();
@@ -44,7 +45,7 @@ export const handleRequest = async (request: URL | Request) => {
const response = await fetch(request, { signal: cancelToken.signal });
assertResponse(response);
put(cacheKey, response);
await put(cacheKey, response);
return response;
} catch (error) {
@@ -71,3 +72,9 @@ export const handleCancel = (url: URL) => {
pendingRequest.abort();
pendingRequests.delete(cacheKey);
};
export const handleIsUrlCached = async (url: URL) => {
const cacheKey = getCacheKey(url);
const isImageUrlCached = !!(await get(cacheKey));
replyIsImageUrlCached(url.pathname + url.search + url.hash, isImageUrlCached);
};