merge: remote-tracking branch 'origin/main' into feat/database-restores

This commit is contained in:
izzy
2025-11-20 16:34:53 +00:00
206 changed files with 7510 additions and 2158 deletions

View File

@@ -2,7 +2,7 @@ import { photoZoomState } from '$lib/stores/zoom-image.store';
import { useZoomImageWheel } from '@zoom-image/svelte';
import { get } from 'svelte/store';
export const zoomImageAction = (node: HTMLElement) => {
export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolean }) => {
const { createZoomImage, zoomImageState, setZoomImageState } = useZoomImageWheel();
createZoomImage(node, {
@@ -14,9 +14,32 @@ export const zoomImageAction = (node: HTMLElement) => {
setZoomImageState(state);
}
// Store original event handlers so we can prevent them when disabled
const wheelHandler = (event: WheelEvent) => {
if (options?.disabled) {
event.stopImmediatePropagation();
}
};
const pointerDownHandler = (event: PointerEvent) => {
if (options?.disabled) {
event.stopImmediatePropagation();
}
};
// Add handlers at capture phase with higher priority
node.addEventListener('wheel', wheelHandler, { capture: true });
node.addEventListener('pointerdown', pointerDownHandler, { capture: true });
const unsubscribes = [photoZoomState.subscribe(setZoomImageState), zoomImageState.subscribe(photoZoomState.set)];
return {
update(newOptions?: { disabled?: boolean }) {
options = newOptions;
},
destroy() {
node.removeEventListener('wheel', wheelHandler, { capture: true });
node.removeEventListener('pointerdown', pointerDownHandler, { capture: true });
for (const unsubscribe of unsubscribes) {
unsubscribe();
}

View File

@@ -0,0 +1 @@
<svg width="900" height="600" viewBox="0 0 900 600" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill="transparent" d="M0 0h900v600H0z"/><path d="M718.697 359.789c2.347 69.208-149.828 213.346-331.607 165.169-84.544-22.409-76.298-62.83-139.698-114.488-37.789-30.789-92.638-53.5-106.885-99.138-12.309-39.393-3.044-82.222 20.77-110.466 53.556-63.52 159.542-108.522 260.374-12.465 100.832 96.056 290.968-7.105 297.046 171.388z" fill="url(#a)"/><path fill-rule="evenodd" clip-rule="evenodd" d="M629.602 207.307v-51.154c0-28.251-22.902-51.153-51.154-51.153H322.681c-28.251 0-51.153 22.902-51.153 51.153v127.884" fill="#fff"/><path d="M629.602 207.307v-51.154c0-28.251-22.902-51.153-51.154-51.153H322.681c-28.251 0-51.153 22.902-51.153 51.153v127.884" stroke="#E1E4E5" stroke-width="4.13"/><path fill-rule="evenodd" clip-rule="evenodd" d="M271.528 216.252h165.353a25.578 25.578 0 0 0 21.28-11.382l35.884-53.941a25.575 25.575 0 0 1 21.357-11.407h114.2c28.251 0 51.154 22.902 51.154 51.153v255.767c0 28.252-22.903 51.154-51.154 51.154H271.528c-28.251 0-51.154-22.902-51.154-51.154V267.405c0-28.251 22.903-51.153 51.154-51.153z" fill="#fff" stroke="#E1E4E5" stroke-width="4.13"/><path fill-rule="evenodd" clip-rule="evenodd" d="M320.022 432.016v3.968h3.964A3.028 3.028 0 0 1 327 439a3.028 3.028 0 0 1-3.014 3.016h-3.964v3.968a3.028 3.028 0 0 1-3.014 3.016 3.029 3.029 0 0 1-3.014-3.016v-3.951h-3.98a3.029 3.029 0 0 1-3.014-3.017 3.029 3.029 0 0 1 3.014-3.016h3.964v-3.984a3.031 3.031 0 0 1 3.03-3.016 3.028 3.028 0 0 1 3.014 3.016zm-33.14-27.793v5.554h5.748c2.399 0 4.37 1.905 4.37 4.223 0 2.318-1.971 4.223-4.37 4.223h-5.748v5.554c0 2.318-1.971 4.223-4.37 4.223s-4.37-1.905-4.37-4.223v-5.531h-5.772c-2.399 0-4.37-1.905-4.37-4.223 0-2.318 1.971-4.223 4.37-4.223h5.748v-5.577c0-2.318 1.971-4.223 4.394-4.223 2.399 0 4.37 1.905 4.37 4.223z" fill="#E1E4E5"/><circle cx="451.101" cy="358.294" r="98.899" fill="#aaa"/><rect x="444.142" y="322.427" width="13.918" height="71.734" rx="6.959" fill="#fff"/><rect x="486.968" y="351.335" width="13.918" height="71.734" rx="6.959" transform="rotate(90 486.968 351.335)" fill="#fff"/><ellipse rx="13.917" ry="13.254" transform="matrix(-1 0 0 1 718.227 479.149)" fill="#E1E4E5"/><circle r="4.639" transform="matrix(-1 0 0 1 292.465 519.783)" fill="#E1E4E5"/><circle r="6.627" transform="matrix(-1 0 0 1 566.399 205.929)" fill="#E1E4E5"/><circle r="6.476" transform="scale(1 -1) rotate(-75 -180.786 -314.12)" fill="#E1E4E5"/><circle r="8.615" transform="matrix(-1 0 0 1 217.158 114.719)" fill="#E1E4E5"/><ellipse rx="6.627" ry="5.302" transform="matrix(-1 0 0 1 704.513 233.511)" fill="#E1E4E5"/><path d="M186.177 456.259h.174c1.026 14.545 11.844 14.769 11.844 14.769s-11.929.233-11.929 17.04c0-16.807-11.929-17.04-11.929-17.04s10.814-.224 11.84-14.769zm574.334-165.951h.18c1.067 15.36 12.309 15.596 12.309 15.596s-12.397.246-12.397 17.994c0-17.748-12.396-17.994-12.396-17.994s11.237-.236 12.304-15.596z" fill="#E1E4E5"/><defs><linearGradient id="a" x1="530.485" y1="779.032" x2="277.414" y2="-357.319" gradientUnits="userSpaceOnUse"><stop stop-color="#fff"/><stop offset="1" stop-color="#EEE"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -25,12 +25,12 @@
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { AppRoute } from '$lib/constants';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { handleReplaceAsset } from '$lib/services/asset.service';
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
import { user } from '$lib/stores/user.store';
import { photoZoomState } from '$lib/stores/zoom-image.store';
import { getAssetJobName, getSharedLink } from '$lib/utils';
import { canCopyImageToClipboard } from '$lib/utils/asset-utils';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import {
AssetJobName,
@@ -227,7 +227,7 @@
<ArchiveAction {asset} {onAction} {preAction} />
<MenuOption
icon={mdiUpload}
onClick={() => openFileUploadDialog({ multiple: false, assetId: asset.id })}
onClick={() => handleReplaceAsset(asset.id)}
text={$t('replace_with_upload')}
/>
{#if !asset.isArchived && !asset.isTrashed}

View File

@@ -1,16 +1,19 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { focusTrap } from '$lib/actions/focus-trap';
import type { Action, OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
import MotionPhotoAction from '$lib/components/asset-viewer/actions/motion-photo-action.svelte';
import NextAssetAction from '$lib/components/asset-viewer/actions/next-asset-action.svelte';
import PreviousAssetAction from '$lib/components/asset-viewer/actions/previous-asset-action.svelte';
import AssetViewerNavBar from '$lib/components/asset-viewer/asset-viewer-nav-bar.svelte';
import { AssetAction, ProjectionType } from '$lib/constants';
import OnEvents from '$lib/components/OnEvents.svelte';
import { AppRoute, AssetAction, ProjectionType } from '$lib/constants';
import { activityManager } from '$lib/managers/activity-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { closeEditorCofirm } from '$lib/stores/asset-editor.store';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { ocrManager } from '$lib/stores/ocr.svelte';
import { alwaysLoadOriginalVideo, isShowDetail } from '$lib/stores/preferences.store';
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { user } from '$lib/stores/user.store';
@@ -42,6 +45,7 @@
import CropArea from './editor/crop-tool/crop-area.svelte';
import EditorPanel from './editor/editor-panel.svelte';
import ImagePanoramaViewer from './image-panorama-viewer.svelte';
import OcrButton from './ocr-button.svelte';
import PhotoViewer from './photo-viewer.svelte';
import SlideshowBar from './slideshow-bar.svelte';
import VideoViewer from './video-wrapper-viewer.svelte';
@@ -363,6 +367,15 @@
selectedEditType = type;
};
const handleAssetReplace = async ({ oldAssetId, newAssetId }: { oldAssetId: string; newAssetId: string }) => {
if (oldAssetId !== asset.id) {
return;
}
await new Promise((promise) => setTimeout(promise, 500));
await goto(`${AppRoute.PHOTOS}/${newAssetId}`);
};
let isFullScreen = $derived(fullscreenElement !== null);
$effect(() => {
@@ -381,13 +394,19 @@
handlePromiseError(activityManager.init(album.id, asset.id));
}
});
let currentAssetId = $derived(asset.id);
$effect(() => {
if (asset.id) {
handlePromiseError(handleGetAllAlbums());
if (currentAssetId) {
untrack(() => handlePromiseError(handleGetAllAlbums()));
ocrManager.clear();
handlePromiseError(ocrManager.getAssetOcr(currentAssetId));
}
});
</script>
<OnEvents onAssetReplace={handleAssetReplace} />
<svelte:document bind:fullscreenElement />
<section
@@ -522,6 +541,7 @@
{playOriginalVideo}
/>
{/if}
{#if $slideshowState === SlideshowState.None && isShared && ((album && album.isActivityEnabled) || activityManager.commentCount > 0) && !activityManager.isLoading}
<div class="absolute bottom-0 end-0 mb-20 me-8">
<ActivityStatus
@@ -534,6 +554,12 @@
/>
</div>
{/if}
{#if $slideshowState === SlideshowState.None && asset.type === AssetTypeEnum.Image && !isShowEditor && ocrManager.hasOcrData}
<div class="absolute bottom-0 end-0 mb-6 me-6">
<OcrButton />
</div>
{/if}
{/key}
{/if}
</div>

View File

@@ -503,7 +503,7 @@
{/if}
{#if albums.length > 0}
<section class="px-6 pt-6 dark:text-immich-dark-fg">
<section class="px-6 py-6 dark:text-immich-dark-fg">
<p class="uppercase pb-4 text-sm">{$t('appears_in')}</p>
{#each albums as album (album.id)}
<a href={resolve(`${AppRoute.ALBUMS}/${album.id}`)}>

View File

@@ -0,0 +1,36 @@
<script lang="ts">
import type { OcrBox } from '$lib/utils/ocr-utils';
import { calculateBoundingBoxDimensions } from '$lib/utils/ocr-utils';
type Props = {
ocrBox: OcrBox;
};
let { ocrBox }: Props = $props();
const dimensions = $derived(calculateBoundingBoxDimensions(ocrBox.points));
const transform = $derived(
`translate(${dimensions.minX}px, ${dimensions.minY}px) rotate(${dimensions.rotation}deg) skew(${dimensions.skewX}deg, ${dimensions.skewY}deg)`,
);
const transformOrigin = $derived(
`${dimensions.centerX - dimensions.minX}px ${dimensions.centerY - dimensions.minY}px`,
);
</script>
<div class="absolute group left-0 top-0 pointer-events-none">
<!-- Bounding box with CSS transforms -->
<div
class="absolute border-2 border-blue-500 bg-blue-500/10 cursor-pointer pointer-events-auto transition-all group-hover:bg-blue-500/30 group-hover:border-blue-600 group-hover:border-[3px]"
style="width: {dimensions.width}px; height: {dimensions.height}px; transform: {transform}; transform-origin: {transformOrigin};"
></div>
<!-- Text overlay - always rendered but invisible, allows text selection and copy -->
<div
class="absolute flex items-center justify-center text-transparent text-sm px-2 py-1 pointer-events-auto cursor-text whitespace-pre-wrap wrap-break-word select-text group-hover:text-white group-hover:bg-black/75 group-hover:z-10"
style="width: {dimensions.width}px; height: {dimensions.height}px; transform: {transform}; transform-origin: {transformOrigin};"
>
{ocrBox.text}
</div>
</div>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { ocrManager } from '$lib/stores/ocr.svelte';
import { IconButton } from '@immich/ui';
import { mdiTextRecognition } from '@mdi/js';
import { t } from 'svelte-i18n';
</script>
<IconButton
title={ocrManager.showOverlay ? $t('hide_text_recognition') : $t('show_text_recognition')}
icon={mdiTextRecognition}
class={"dark {ocrStore.showOverlay ? 'bg-immich-primary text-white dark' : 'dark'}"}
color="secondary"
variant="ghost"
shape="round"
aria-label={$t('text_recognition')}
onclick={() => ocrManager.toggleOcrBoundingBox()}
/>

View File

@@ -1,7 +1,7 @@
import { getAnimateMock } from '$lib/__mocks__/animate.mock';
import PhotoViewer from '$lib/components/asset-viewer/photo-viewer.svelte';
import * as utils from '$lib/utils';
import { AssetMediaSize } from '@immich/sdk';
import { AssetMediaSize, AssetTypeEnum } from '@immich/sdk';
import { assetFactory } from '@test-data/factories/asset-factory';
import { sharedLinkFactory } from '@test-data/factories/shared-link-factory';
import { render } from '@testing-library/svelte';
@@ -65,7 +65,11 @@ describe('PhotoViewer component', () => {
});
it('loads the thumbnail', () => {
const asset = assetFactory.build({ originalPath: 'image.jpg', originalMimeType: 'image/jpeg' });
const asset = assetFactory.build({
originalPath: 'image.jpg',
originalMimeType: 'image/jpeg',
type: AssetTypeEnum.Image,
});
render(PhotoViewer, { asset });
expect(getAssetThumbnailUrlSpy).toBeCalledWith({
@@ -76,16 +80,89 @@ describe('PhotoViewer component', () => {
expect(getAssetOriginalUrlSpy).not.toBeCalled();
});
it('loads the original image for gifs', () => {
const asset = assetFactory.build({ originalPath: 'image.gif', originalMimeType: 'image/gif' });
it('loads the thumbnail image for static gifs', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
});
render(PhotoViewer, { asset });
expect(getAssetThumbnailUrlSpy).toBeCalledWith({
id: asset.id,
size: AssetMediaSize.Preview,
cacheKey: asset.thumbhash,
});
expect(getAssetOriginalUrlSpy).not.toBeCalled();
});
it('loads the thumbnail image for static webp images', () => {
const asset = assetFactory.build({
originalPath: 'image.webp',
originalMimeType: 'image/webp',
type: AssetTypeEnum.Image,
});
render(PhotoViewer, { asset });
expect(getAssetThumbnailUrlSpy).toBeCalledWith({
id: asset.id,
size: AssetMediaSize.Preview,
cacheKey: asset.thumbhash,
});
expect(getAssetOriginalUrlSpy).not.toBeCalled();
});
it('loads the original image for animated gifs', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
duration: '2.0',
});
render(PhotoViewer, { asset });
expect(getAssetThumbnailUrlSpy).not.toBeCalled();
expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash });
});
it('loads original for shared link when download permission is true and showMetadata permission is true', () => {
const asset = assetFactory.build({ originalPath: 'image.gif', originalMimeType: 'image/gif' });
it('loads the original image for animated webp images', () => {
const asset = assetFactory.build({
originalPath: 'image.webp',
originalMimeType: 'image/webp',
type: AssetTypeEnum.Image,
duration: '2.0',
});
render(PhotoViewer, { asset });
expect(getAssetThumbnailUrlSpy).not.toBeCalled();
expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash });
});
it('not loads original static image in shared link even when download permission is true and showMetadata permission is true', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
});
const sharedLink = sharedLinkFactory.build({ allowDownload: true, showMetadata: true, assets: [asset] });
render(PhotoViewer, { asset, sharedLink });
expect(getAssetThumbnailUrlSpy).toBeCalledWith({
id: asset.id,
size: AssetMediaSize.Preview,
cacheKey: asset.thumbhash,
});
expect(getAssetOriginalUrlSpy).not.toBeCalled();
});
it('loads original animated image in shared link when download permission is true and showMetadata permission is true', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
duration: '2.0',
});
const sharedLink = sharedLinkFactory.build({ allowDownload: true, showMetadata: true, assets: [asset] });
render(PhotoViewer, { asset, sharedLink });
@@ -93,8 +170,13 @@ describe('PhotoViewer component', () => {
expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash });
});
it('not loads original image when shared link download permission is false', () => {
const asset = assetFactory.build({ originalPath: 'image.gif', originalMimeType: 'image/gif' });
it('not loads original animated image when shared link download permission is false', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
duration: '2.0',
});
const sharedLink = sharedLinkFactory.build({ allowDownload: false, assets: [asset] });
render(PhotoViewer, { asset, sharedLink });
@@ -107,8 +189,13 @@ describe('PhotoViewer component', () => {
expect(getAssetOriginalUrlSpy).not.toBeCalled();
});
it('not loads original image when shared link showMetadata permission is false', () => {
const asset = assetFactory.build({ originalPath: 'image.gif', originalMimeType: 'image/gif' });
it('not loads original animated image when shared link showMetadata permission is false', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
duration: '2.0',
});
const sharedLink = sharedLinkFactory.build({ showMetadata: false, assets: [asset] });
render(PhotoViewer, { asset, sharedLink });

View File

@@ -2,12 +2,14 @@
import { shortcuts } from '$lib/actions/shortcut';
import { zoomImageAction } from '$lib/actions/zoom-image';
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
import OcrBoundingBox from '$lib/components/asset-viewer/ocr-bounding-box.svelte';
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import { assetViewerFadeDuration } from '$lib/constants';
import { castManager } from '$lib/managers/cast-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { ocrManager } from '$lib/stores/ocr.svelte';
import { boundingBoxesArray } from '$lib/stores/people.store';
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
@@ -15,11 +17,12 @@
import { getAssetOriginalUrl, getAssetThumbnailUrl, handlePromiseError } from '$lib/utils';
import { canCopyImageToClipboard, copyImageToClipboard, isWebCompatibleImage } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
import { getBoundingBox } from '$lib/utils/people-utils';
import { cancelImageUrl } from '$lib/utils/sw-messaging';
import { getAltText } from '$lib/utils/thumbnail-util';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetMediaSize, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
import { AssetMediaSize, AssetTypeEnum, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
import { LoadingSpinner, toastManager } from '@immich/ui';
import { onDestroy, onMount } from 'svelte';
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
@@ -71,6 +74,14 @@
$boundingBoxesArray = [];
});
let ocrBoxes = $derived(
ocrManager.showOverlay && $photoViewerImgElement
? getOcrBoundingBoxes(ocrManager.data, $photoZoomState, $photoViewerImgElement)
: [],
);
let isOcrActive = $derived(ocrManager.showOverlay);
const preload = (targetSize: AssetMediaSize | 'original', preloadAssets?: TimelineAsset[]) => {
for (const preloadAsset of preloadAssets || []) {
if (preloadAsset.isImage) {
@@ -130,16 +141,25 @@
if ($photoZoomState.currentZoom > 1) {
return;
}
if (ocrManager.showOverlay) {
return;
}
if (onNextAsset && event.detail.direction === 'left') {
onNextAsset();
}
if (onPreviousAsset && event.detail.direction === 'right') {
onPreviousAsset();
}
};
// when true, will force loading of the original image
let forceUseOriginal: boolean = $derived(asset.originalMimeType === 'image/gif' || $photoZoomState.currentZoom > 1);
let forceUseOriginal: boolean = $derived(
(asset.type === AssetTypeEnum.Image && asset.duration && !asset.duration.includes('0:00:00.000')) ||
$photoZoomState.currentZoom > 1,
);
const targetImageSize = $derived.by(() => {
if ($alwaysLoadOriginalFile || forceUseOriginal || originalImageLoaded) {
@@ -232,7 +252,7 @@
</div>
{:else if !imageError}
<div
use:zoomImageAction
use:zoomImageAction={{ disabled: isOcrActive }}
{...useSwipe(onSwipe)}
class="h-full w-full"
transition:fade={{ duration: haveFadeTransition ? assetViewerFadeDuration : 0 }}
@@ -261,6 +281,10 @@
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
></div>
{/each}
{#each ocrBoxes as ocrBox (ocrBox.id)}
<OcrBoundingBox {ocrBox} />
{/each}
</div>
{#if isFaceEditMode.value}

View File

@@ -282,7 +282,7 @@
</div>
{/if}
{#if asset.isImage && asset.duration}
{#if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000')}
<div class="absolute end-0 top-0 flex place-items-center gap-1 text-xs font-medium text-white">
<span class="pe-2 pt-2">
<Icon icon={mdiFileGifBox} size="24" />
@@ -351,7 +351,7 @@
playbackOnIconHover={!$playVideoThumbnailOnHover}
/>
</div>
{:else if asset.isImage && asset.duration && mouseOver}
{:else if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000') && mouseOver}
<!-- GIF -->
<div class="absolute top-0 h-full w-full pointer-events-none">
<div class="absolute h-full w-full bg-linear-to-b from-black/25 via-[transparent_25%]"></div>

View File

@@ -1,207 +0,0 @@
<script lang="ts">
import LibraryImportPathModal from '$lib/modals/LibraryImportPathModal.svelte';
import { handleError } from '$lib/utils/handle-error';
import type { ValidateLibraryImportPathResponseDto } from '@immich/sdk';
import { validate, type LibraryResponseDto } from '@immich/sdk';
import { Button, Icon, IconButton, modalManager, toastManager } from '@immich/ui';
import { mdiAlertOutline, mdiCheckCircleOutline, mdiPencilOutline, mdiRefresh } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
interface Props {
library: LibraryResponseDto;
onCancel: () => void;
onSubmit: (library: LibraryResponseDto) => void;
}
let { library = $bindable(), onCancel, onSubmit }: Props = $props();
let validatedPaths: ValidateLibraryImportPathResponseDto[] = $state([]);
let importPaths = $derived(validatedPaths.map((validatedPath) => validatedPath.importPath));
onMount(async () => {
if (library.importPaths) {
await handleValidation();
} else {
library.importPaths = [];
}
});
const handleValidation = async () => {
if (library.importPaths) {
const validation = await validate({
id: library.id,
validateLibraryDto: { importPaths: library.importPaths },
});
validatedPaths = validation.importPaths ?? [];
}
};
const revalidate = async (notifyIfSuccessful = true) => {
await handleValidation();
let failedPaths = 0;
for (const validatedPath of validatedPaths) {
if (!validatedPath.isValid) {
failedPaths++;
}
}
if (failedPaths === 0) {
if (notifyIfSuccessful) {
toastManager.success($t('admin.paths_validated_successfully'));
}
} else {
toastManager.warning($t('errors.paths_validation_failed', { values: { paths: failedPaths } }));
}
};
const handleAddImportPath = async (importPathToAdd: string | null) => {
if (!importPathToAdd) {
return;
}
if (!library.importPaths) {
library.importPaths = [];
}
try {
// Check so that import path isn't duplicated
if (!library.importPaths.includes(importPathToAdd)) {
library.importPaths.push(importPathToAdd);
await revalidate(false);
}
} catch (error) {
handleError(error, $t('errors.unable_to_add_import_path'));
}
};
const handleEditImportPath = async (editedImportPath: string | null, pathIndexToEdit: number) => {
if (editedImportPath === null) {
return;
}
if (!library.importPaths) {
library.importPaths = [];
}
try {
// Check so that import path isn't duplicated
if (!library.importPaths.includes(editedImportPath)) {
// Update import path
library.importPaths[pathIndexToEdit] = editedImportPath;
await revalidate(false);
}
} catch (error) {
handleError(error, $t('errors.unable_to_edit_import_path'));
}
};
const handleDeleteImportPath = async (pathIndexToDelete?: number) => {
if (pathIndexToDelete === undefined) {
return;
}
try {
if (!library.importPaths) {
library.importPaths = [];
}
const pathToDelete = library.importPaths[pathIndexToDelete];
library.importPaths = library.importPaths.filter((path) => path != pathToDelete);
await handleValidation();
} catch (error) {
handleError(error, $t('errors.unable_to_delete_import_path'));
}
};
const onEditImportPath = async (pathIndexToEdit?: number) => {
const result = await modalManager.show(LibraryImportPathModal, {
title: pathIndexToEdit === undefined ? $t('add_import_path') : $t('edit_import_path'),
submitText: pathIndexToEdit === undefined ? $t('add') : $t('save'),
isEditing: pathIndexToEdit !== undefined,
importPath: pathIndexToEdit === undefined ? null : library.importPaths[pathIndexToEdit],
importPaths: library.importPaths,
});
if (!result) {
return;
}
switch (result.action) {
case 'submit': {
// eslint-disable-next-line unicorn/prefer-ternary
if (pathIndexToEdit === undefined) {
await handleAddImportPath(result.importPath);
} else {
await handleEditImportPath(result.importPath, pathIndexToEdit);
}
break;
}
case 'delete': {
await handleDeleteImportPath(pathIndexToEdit);
break;
}
}
};
const onsubmit = (event: Event) => {
event.preventDefault();
onSubmit({ ...library });
};
</script>
<form {onsubmit} autocomplete="off" class="m-4 flex flex-col gap-4">
<table class="text-start">
<tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
{#each validatedPaths as validatedPath, listIndex (validatedPath.importPath)}
<tr
class="flex h-20 w-full place-items-center text-center dark:text-immich-dark-fg even:bg-subtle/20 odd:bg-subtle/80"
>
<td class="w-1/8 text-ellipsis ps-8 text-sm">
{#if validatedPath.isValid}
<Icon icon={mdiCheckCircleOutline} size="24" title={validatedPath.message} class="text-success" />
{:else}
<Icon icon={mdiAlertOutline} size="24" title={validatedPath.message} class="text-warning" />
{/if}
</td>
<td class="w-4/5 text-ellipsis px-4 text-sm">{validatedPath.importPath}</td>
<td class="w-1/5 text-ellipsis flex justify-center">
<IconButton
shape="round"
color="primary"
icon={mdiPencilOutline}
aria-label={$t('edit_import_path')}
onclick={() => onEditImportPath(listIndex)}
size="small"
/>
</td>
</tr>
{/each}
<tr
class="flex h-20 w-full place-items-center text-center dark:text-immich-dark-fg even:bg-subtle/20 odd:bg-subtle/80"
>
<td class="w-4/5 text-ellipsis px-4 text-sm">
{#if importPaths.length === 0}
{$t('admin.no_paths_added')}
{/if}</td
>
<td class="w-1/5 text-ellipsis px-4 text-sm">
<Button shape="round" size="small" onclick={() => onEditImportPath()}>{$t('add_path')}</Button>
</td>
</tr>
</tbody>
</table>
<div class="flex justify-between w-full">
<div class="justify-end gap-2">
<Button shape="round" leadingIcon={mdiRefresh} size="small" color="secondary" onclick={() => revalidate()}
>{$t('validate')}</Button
>
</div>
<div class="flex justify-end gap-2">
<Button shape="round" size="small" color="secondary" onclick={onCancel}>{$t('cancel')}</Button>
<Button shape="round" size="small" type="submit">{$t('save')}</Button>
</div>
</div>
</form>

View File

@@ -1,151 +0,0 @@
<script lang="ts">
import LibraryExclusionPatternModal from '$lib/modals/LibraryExclusionPatternModal.svelte';
import { handleError } from '$lib/utils/handle-error';
import { type LibraryResponseDto } from '@immich/sdk';
import { Button, IconButton, modalManager } from '@immich/ui';
import { mdiPencilOutline } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
interface Props {
library: Partial<LibraryResponseDto>;
onCancel: () => void;
onSubmit: (library: Partial<LibraryResponseDto>) => void;
}
let { library = $bindable(), onCancel, onSubmit }: Props = $props();
let exclusionPatterns: string[] = $state([]);
onMount(() => {
if (library.exclusionPatterns) {
exclusionPatterns = library.exclusionPatterns;
} else {
library.exclusionPatterns = [];
}
});
const handleAddExclusionPattern = (exclusionPatternToAdd: string) => {
if (!library.exclusionPatterns) {
library.exclusionPatterns = [];
}
try {
// Check so that exclusion pattern isn't duplicated
if (!library.exclusionPatterns.includes(exclusionPatternToAdd)) {
library.exclusionPatterns.push(exclusionPatternToAdd);
exclusionPatterns = library.exclusionPatterns;
}
} catch (error) {
handleError(error, $t('errors.unable_to_add_exclusion_pattern'));
}
};
const handleEditExclusionPattern = (editedExclusionPattern: string, patternIndex: number) => {
if (!library.exclusionPatterns) {
library.exclusionPatterns = [];
}
try {
library.exclusionPatterns[patternIndex] = editedExclusionPattern;
exclusionPatterns = library.exclusionPatterns;
} catch (error) {
handleError(error, $t('errors.unable_to_edit_exclusion_pattern'));
}
};
const handleDeleteExclusionPattern = (patternIndexToDelete?: number) => {
if (patternIndexToDelete === undefined) {
return;
}
try {
if (!library.exclusionPatterns) {
library.exclusionPatterns = [];
}
const patternToDelete = library.exclusionPatterns[patternIndexToDelete];
library.exclusionPatterns = library.exclusionPatterns.filter((path) => path != patternToDelete);
exclusionPatterns = library.exclusionPatterns;
} catch (error) {
handleError(error, $t('errors.unable_to_delete_exclusion_pattern'));
}
};
const onEditExclusionPattern = async (patternIndexToEdit?: number) => {
const result = await modalManager.show(LibraryExclusionPatternModal, {
submitText: patternIndexToEdit === undefined ? $t('add') : $t('save'),
isEditing: patternIndexToEdit !== undefined,
exclusionPattern: patternIndexToEdit === undefined ? '' : exclusionPatterns[patternIndexToEdit],
exclusionPatterns,
});
if (!result) {
return;
}
switch (result.action) {
case 'submit': {
if (patternIndexToEdit === undefined) {
handleAddExclusionPattern(result.exclusionPattern);
} else {
handleEditExclusionPattern(result.exclusionPattern, patternIndexToEdit);
}
break;
}
case 'delete': {
handleDeleteExclusionPattern(patternIndexToEdit);
break;
}
}
};
const onsubmit = (event: Event) => {
event.preventDefault();
onSubmit(library);
};
</script>
<form {onsubmit} autocomplete="off" class="m-4 flex flex-col gap-4">
<table class="w-full text-start">
<tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
{#each exclusionPatterns as exclusionPattern, listIndex (exclusionPattern)}
<tr
class="flex h-20 w-full place-items-center text-center dark:text-immich-dark-fg even:bg-subtle/20 odd:bg-subtle/80"
>
<td class="w-3/4 text-ellipsis px-4 text-sm">{exclusionPattern}</td>
<td class="w-1/4 text-ellipsis flex justify-center">
<IconButton
shape="round"
color="primary"
icon={mdiPencilOutline}
title={$t('edit_exclusion_pattern')}
onclick={() => onEditExclusionPattern(listIndex)}
aria-label={$t('edit_exclusion_pattern')}
size="small"
/>
</td>
</tr>
{/each}
<tr
class="flex h-20 w-full place-items-center text-center dark:text-immich-dark-fg even:bg-subtle/20 odd:bg-subtle/80"
>
<td class="w-3/4 text-ellipsis px-4 text-sm">
{#if exclusionPatterns.length === 0}
{$t('admin.no_pattern_added')}
{/if}
</td>
<td class="w-1/4 text-ellipsis px-4 text-sm flex justify-center">
<Button size="small" shape="round" onclick={() => onEditExclusionPattern()}>
{$t('add_exclusion_pattern')}
</Button>
</td>
</tr>
</tbody>
</table>
<div class="flex w-full justify-end gap-2">
<Button size="small" shape="round" color="secondary" onclick={onCancel}>{$t('cancel')}</Button>
<Button size="small" shape="round" type="submit">{$t('save')}</Button>
</div>
</form>

View File

@@ -7,9 +7,10 @@
fullWidth?: boolean;
src?: string;
title?: string;
class?: string;
}
let { onClick = undefined, text, fullWidth = false, src = empty1Url, title }: Props = $props();
let { onClick = undefined, text, fullWidth = false, src = empty1Url, title, class: className }: Props = $props();
let width = $derived(fullWidth ? 'w-full' : 'w-1/2');
@@ -22,7 +23,7 @@
<svelte:element
this={onClick ? 'button' : 'div'}
onclick={onClick}
class="{width} m-auto mt-10 flex flex-col place-content-center place-items-center rounded-3xl bg-gray-50 p-5 dark:bg-immich-dark-gray {hoverClasses}"
class="{width} {className} flex flex-col place-content-center place-items-center rounded-3xl bg-gray-50 p-5 dark:bg-immich-dark-gray {hoverClasses}"
>
<img {src} alt="" width="500" draggable="false" />

View File

@@ -74,10 +74,10 @@
{#if $isPurchased && $preferences.purchase.showSupportBadge}
<button
onclick={() => goto(`${AppRoute.USER_SETTINGS}?isOpen=user-purchase-settings`)}
class="w-full"
class="w-full mt-2"
type="button"
>
<SupporterBadge />
<SupporterBadge size="small" effect="always" />
</button>
{:else if !$isPurchased && showBuyButton && getAccountAge() > 14}
<button

View File

@@ -1,8 +1,10 @@
<script lang="ts">
import { releaseManager } from '$lib/managers/release-manager.svelte';
import ServerAboutModal from '$lib/modals/ServerAboutModal.svelte';
import { user } from '$lib/stores/user.store';
import { userInteraction } from '$lib/stores/user.svelte';
import { websocketStore } from '$lib/stores/websocket';
import type { ReleaseEvent } from '$lib/types';
import { semverToName } from '$lib/utils';
import { requestServerInfo } from '$lib/utils/auth';
import {
@@ -16,7 +18,7 @@
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
const { serverVersion, connected, release } = websocketStore;
const { serverVersion, connected } = websocketStore;
let info: ServerAboutResponseDto | undefined = $state();
let versions: ServerVersionHistoryResponseDto[] = $state([]);
@@ -37,20 +39,22 @@
$serverVersion ? `v${$serverVersion.major}.${$serverVersion.minor}.${$serverVersion.patch}` : null,
);
const releaseInfo = $derived.by(() => {
if ($release == undefined || $release?.isAvailable || !$user.isAdmin) {
const getReleaseInfo = (release?: ReleaseEvent) => {
if (!release || !release?.isAvailable || !$user.isAdmin) {
return;
}
const availableVersion = semverToName($release.releaseVersion);
const serverVersion = semverToName($release.serverVersion);
const availableVersion = semverToName(release.releaseVersion);
const serverVersion = semverToName(release.serverVersion);
if (serverVersion === availableVersion) {
return;
}
return { availableVersion, releaseUrl: `https://github.com/immich-app/immich/releases/tag/${availableVersion}` };
});
};
const releaseInfo = $derived(getReleaseInfo(releaseManager.value));
</script>
<div

View File

@@ -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(

View File

@@ -2,7 +2,7 @@
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { assetSnapshot, assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';

View File

@@ -24,12 +24,12 @@
const { clearSelect, getOwnedAssets } = getAssetControlContext();
const handleArchive = async () => {
const isArchived = unarchive ? AssetVisibility.Timeline : AssetVisibility.Archive;
const assets = [...getOwnedAssets()].filter((asset) => asset.visibility !== isArchived);
const visibility = unarchive ? AssetVisibility.Timeline : AssetVisibility.Archive;
const assets = [...getOwnedAssets()].filter((asset) => asset.visibility !== visibility);
loading = true;
const ids = await archiveAssets(assets, isArchived as AssetVisibility);
const ids = await archiveAssets(assets, visibility as AssetVisibility);
if (ids) {
onArchive?.(ids, isArchived ? AssetVisibility.Archive : AssetVisibility.Timeline);
onArchive?.(ids, visibility);
clearSelect();
}
loading = false;

View File

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

View File

@@ -12,6 +12,7 @@
mdiClock,
mdiFile,
mdiFitToScreen,
mdiFolderOutline,
mdiHeart,
mdiImageMultipleOutline,
mdiImageOutline,
@@ -51,6 +52,7 @@
fileName: isDifferent((a) => a.originalFileName),
fileSize: isDifferent((a) => getFileSize(a)),
resolution: isDifferent((a) => getAssetResolution(a)),
originalPath: isDifferent((a) => a.originalPath ?? $t('unknown')),
date: isDifferent((a) => {
const tz = a.exifInfo?.timeZone;
const dt =
@@ -79,6 +81,24 @@
(a) => [a.exifInfo?.city, a.exifInfo?.state, a.exifInfo?.country].filter(Boolean).join(', ') || 'unknown',
),
});
const getBasePath = (fullpath: string, fileName: string): string => {
if (fileName && fullpath.endsWith(fileName)) {
return fullpath.slice(0, -(fileName.length + 1));
}
return fullpath;
};
function truncateMiddle(path: string, maxLength: number = 50): string {
if (path.length <= maxLength) {
return path;
}
const start = Math.floor(maxLength / 2) - 2;
const end = Math.floor(maxLength / 2) - 2;
return path.slice(0, Math.max(0, start)) + '...' + path.slice(Math.max(0, path.length - end));
}
</script>
<div class="min-w-60 transition-colors border rounded-lg">
@@ -152,6 +172,14 @@
{asset.originalFileName}
</InfoRow>
<InfoRow
icon={mdiFolderOutline}
highlight={hasDifferentValues.originalPath}
title={$t('full_path', { values: { path: asset.originalPath } })}
>
{truncateMiddle(getBasePath(asset.originalPath, asset.originalFileName)) || $t('unknown')}
</InfoRow>
<InfoRow icon={mdiFile} highlight={hasDifferentValues.fileSize} title={$t('file_size')}>
{getFileSize(asset)}
</InfoRow>

View File

@@ -301,6 +301,7 @@ export const langs: Lang[] = [
{ name: 'Bulgarian', code: 'bg', loader: () => import('$i18n/bg.json') },
{ name: 'Bislama', code: 'bi', loader: () => import('$i18n/bi.json') },
{ name: 'Bengali', code: 'bn', loader: () => import('$i18n/bn.json') },
{ name: 'Breton', code: 'br', loader: () => import('$i18n/br.json') },
{ name: 'Catalan', code: 'ca', loader: () => import('$i18n/ca.json') },
{ name: 'Czech', code: 'cs', loader: () => import('$i18n/cs.json') },
{ name: 'Chuvash', code: 'cv', loader: () => import('$i18n/cv.json') },
@@ -308,6 +309,7 @@ export const langs: Lang[] = [
{ name: 'German', code: 'de', loader: () => import('$i18n/de.json') },
defaultLang,
{ name: 'Greek', code: 'el', loader: () => import('$i18n/el.json') },
{ name: 'Esperanto', code: 'eo', loader: () => import('$i18n/eo.json') },
{ name: 'Spanish', code: 'es', loader: () => import('$i18n/es.json') },
{ name: 'Estonian', code: 'et', loader: () => import('$i18n/et.json') },
{ name: 'Basque', code: 'eu', loader: () => import('$i18n/eu.json') },
@@ -315,17 +317,22 @@ export const langs: Lang[] = [
{ name: 'Finnish', code: 'fi', loader: () => import('$i18n/fi.json') },
{ name: 'Filipino', code: 'fil', loader: () => import('$i18n/fil.json') },
{ name: 'French', code: 'fr', loader: () => import('$i18n/fr.json') },
{ name: 'Irish', code: 'ga', loader: () => import('$i18n/ga.json') },
{ name: 'Galician', code: 'gl', loader: () => import('$i18n/gl.json') },
{ name: 'Alemannic', code: 'gsw', loader: () => import('$i18n/gsw.json') },
{ name: 'Gujarati', code: 'gu', loader: () => import('$i18n/gu.json') },
{ name: 'Hebrew', code: 'he', loader: () => import('$i18n/he.json'), rtl: true },
{ name: 'Hindi', code: 'hi', loader: () => import('$i18n/hi.json') },
{ name: 'Croatian', code: 'hr', loader: () => import('$i18n/hr.json') },
{ name: 'Hungarian', code: 'hu', loader: () => import('$i18n/hu.json') },
{ name: 'Armenian', code: 'hy', loader: () => import('$i18n/hy.json') },
{ name: 'Indonesian', code: 'id', loader: () => import('$i18n/id.json') },
{ name: 'Icelandic', code: 'is', loader: () => import('$i18n/is.json') },
{ name: 'Italian', code: 'it', loader: () => import('$i18n/it.json') },
{ name: 'Japanese', code: 'ja', loader: () => import('$i18n/ja.json') },
{ name: 'Georgian', code: 'ka', loader: () => import('$i18n/ka.json') },
{ name: 'Kazakh', code: 'kk', loader: () => import('$i18n/kk.json') },
{ name: 'Khmer (Central)', code: 'km', loader: () => import('$i18n/km.json') },
{ name: 'Kurdish (Northern)', code: 'kmr', loader: () => import('$i18n/kmr.json'), rtl: true },
{ name: 'Kannada', code: 'kn', loader: () => import('$i18n/kn.json') },
{ name: 'Korean', code: 'ko', loader: () => import('$i18n/ko.json') },
@@ -347,6 +354,7 @@ export const langs: Lang[] = [
{ name: 'Portuguese (Brazil) ', code: 'pt-BR', weblateCode: 'pt_BR', loader: () => import('$i18n/pt_BR.json') },
{ name: 'Romanian', code: 'ro', loader: () => import('$i18n/ro.json') },
{ name: 'Russian', code: 'ru', loader: () => import('$i18n/ru.json') },
{ name: 'Sinhala', code: 'si', loader: () => import('$i18n/si.json') },
{ name: 'Slovak', code: 'sk', loader: () => import('$i18n/sk.json') },
{ name: 'Slovenian', code: 'sl', loader: () => import('$i18n/sl.json') },
{ name: 'Albanian', code: 'sq', loader: () => import('$i18n/sq.json') },
@@ -364,7 +372,9 @@ export const langs: Lang[] = [
{ name: 'Turkish', code: 'tr', loader: () => import('$i18n/tr.json') },
{ name: 'Ukrainian', code: 'uk', loader: () => import('$i18n/uk.json') },
{ name: 'Urdu', code: 'ur', loader: () => import('$i18n/ur.json'), rtl: true },
{ name: 'Uzbek', code: 'uz', loader: () => import('$i18n/uz.json') },
{ name: 'Vietnamese', code: 'vi', loader: () => import('$i18n/vi.json') },
{ name: 'Cantonese (Traditional Han script)', code: 'yue_Hant', loader: () => import('$i18n/yue_Hant.json') },
{
name: 'Chinese (Traditional)',
code: 'zh-TW',

View File

@@ -1,6 +1,8 @@
import type { ThemeSetting } from '$lib/managers/theme-manager.svelte';
import type { ReleaseEvent } from '$lib/types';
import type {
AlbumResponseDto,
LibraryResponseDto,
LoginResponseDto,
SharedLinkResponseDto,
SystemConfigDto,
@@ -15,6 +17,8 @@ export type Events = {
LanguageChange: [{ name: string; code: string; rtl?: boolean }];
ThemeChange: [ThemeSetting];
AssetReplace: [{ oldAssetId: string; newAssetId: string }];
AlbumDelete: [AlbumResponseDto];
SharedLinkCreate: [SharedLinkResponseDto];
@@ -23,10 +27,19 @@ export type Events = {
UserAdminCreate: [UserAdminResponseDto];
UserAdminUpdate: [UserAdminResponseDto];
UserAdminDelete: [UserAdminResponseDto];
UserAdminRestore: [UserAdminResponseDto];
// soft deleted
UserAdminDelete: [UserAdminResponseDto];
// confirmed permanently deleted from server
UserAdminDeleted: [{ id: string }];
SystemConfigUpdate: [SystemConfigDto];
LibraryCreate: [LibraryResponseDto];
LibraryUpdate: [LibraryResponseDto];
LibraryDelete: [{ id: string }];
ReleaseEvent: [ReleaseEvent];
};
type Listener<EventMap extends Record<string, unknown[]>, K extends keyof EventMap> = (...params: EventMap[K]) => void;

View File

@@ -0,0 +1,12 @@
import { eventManager } from '$lib/managers/event-manager.svelte';
import { type ReleaseEvent } from '$lib/types';
class ReleaseManager {
value = $state<ReleaseEvent | undefined>();
constructor() {
eventManager.on('ReleaseEvent', (event) => (this.value = event));
}
}
export const releaseManager = new ReleaseManager();

View File

@@ -1,6 +1,6 @@
import { TUNABLES } from '$lib/utils/tunables';
import type { MonthGroup } from '../month-group.svelte';
import type { TimelineManager } from '../timeline-manager.svelte';
import { TimelineManager } from '../timeline-manager.svelte';
const {
TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },

View File

@@ -1,5 +1,5 @@
import type { MonthGroup } from '../month-group.svelte';
import type { TimelineManager } from '../timeline-manager.svelte';
import { TimelineManager } from '../timeline-manager.svelte';
import type { UpdateGeometryOptions } from '../types';
export function updateGeometry(timelineManager: TimelineManager, month: MonthGroup, options: UpdateGeometryOptions) {

View File

@@ -2,7 +2,7 @@ import { authManager } from '$lib/managers/auth-manager.svelte';
import { toISOYearMonthUTC } from '$lib/utils/timeline-util';
import { getTimeBucket } from '@immich/sdk';
import type { MonthGroup } from '../month-group.svelte';
import type { TimelineManager } from '../timeline-manager.svelte';
import { TimelineManager } from '../timeline-manager.svelte';
import type { TimelineManagerOptions } from '../types';
export async function loadFromTimeBuckets(

View File

@@ -2,7 +2,7 @@ import { plainDateTimeCompare, type TimelineYearMonth } from '$lib/utils/timelin
import { AssetOrder } from '@immich/sdk';
import { DateTime } from 'luxon';
import type { MonthGroup } from '../month-group.svelte';
import type { TimelineManager } from '../timeline-manager.svelte';
import { TimelineManager } from '../timeline-manager.svelte';
import type { AssetDescriptor, Direction, TimelineAsset } from '../types';
export async function getAssetWithOffset(

View File

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

View File

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

View File

@@ -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<string, TimelineAsset>(assets.map((asset) => [asset.id, asset]));
const { unprocessedIds } = runAssetOperation(
this,

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import { handleAddLibraryExclusionPattern } from '$lib/services/library.service';
import type { LibraryResponseDto } from '@immich/sdk';
import { Button, Field, HStack, Input, Modal, ModalBody, ModalFooter, Text } from '@immich/ui';
import { mdiFolderSync } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
library: LibraryResponseDto;
onClose: () => void;
};
const { library, onClose }: Props = $props();
let exclusionPattern = $state('');
const onsubmit = async () => {
const success = await handleAddLibraryExclusionPattern(library, exclusionPattern);
if (success) {
onClose();
}
};
</script>
<Modal title={$t('add_exclusion_pattern')} icon={mdiFolderSync} {onClose} size="small">
<ModalBody>
<form {onsubmit} autocomplete="off" id="library-exclusion-pattern-form">
<Text size="small" class="mb-4">{$t('admin.exclusion_pattern_description')}</Text>
<Field label={$t('pattern')}>
<Input bind:value={exclusionPattern} />
</Field>
</form>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
<Button shape="round" type="submit" fullWidth form="library-exclusion-pattern-form">
{$t('add')}
</Button>
</HStack>
</ModalFooter>
</Modal>

View File

@@ -0,0 +1,45 @@
<script lang="ts">
import { handleEditExclusionPattern } from '$lib/services/library.service';
import type { LibraryResponseDto } from '@immich/sdk';
import { Button, Field, HStack, Input, Modal, ModalBody, ModalFooter, Text } from '@immich/ui';
import { mdiFolderSync } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
library: LibraryResponseDto;
exclusionPattern: string;
onClose: () => void;
};
const { library, exclusionPattern, onClose }: Props = $props();
let newExclusionPattern = $state(exclusionPattern);
const onsubmit = async () => {
const success = await handleEditExclusionPattern(library, exclusionPattern, newExclusionPattern);
if (success) {
onClose();
}
};
</script>
<Modal title={$t('edit_exclusion_pattern')} icon={mdiFolderSync} {onClose} size="small">
<ModalBody>
<form {onsubmit} autocomplete="off" id="library-exclusion-pattern-form">
<Text size="small" class="mb-4">{$t('admin.exclusion_pattern_description')}</Text>
<Field label={$t('pattern')}>
<Input bind:value={newExclusionPattern} />
</Field>
</form>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
<Button shape="round" type="submit" fullWidth form="library-exclusion-pattern-form">
{$t('save')}
</Button>
</HStack>
</ModalFooter>
</Modal>

View File

@@ -1,78 +0,0 @@
<script lang="ts">
import { Button, HStack, Modal, ModalBody, ModalFooter } from '@immich/ui';
import { mdiFolderRemove } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
interface Props {
exclusionPattern: string;
exclusionPatterns?: string[];
isEditing?: boolean;
submitText?: string;
onClose: (data?: { action: 'delete' } | { action: 'submit'; exclusionPattern: string }) => void;
}
let {
exclusionPattern = $bindable(),
exclusionPatterns = $bindable([]),
isEditing = false,
submitText = $t('submit'),
onClose,
}: Props = $props();
onMount(() => {
if (isEditing) {
exclusionPatterns = exclusionPatterns.filter((pattern) => pattern !== exclusionPattern);
}
});
let isDuplicate = $derived(exclusionPattern !== null && exclusionPatterns.includes(exclusionPattern));
let canSubmit = $derived(exclusionPattern && !exclusionPatterns.includes(exclusionPattern));
const onsubmit = (event: Event) => {
event.preventDefault();
if (canSubmit) {
onClose({ action: 'submit', exclusionPattern });
}
};
</script>
<Modal size="small" title={$t('add_exclusion_pattern')} icon={mdiFolderRemove} {onClose}>
<ModalBody>
<form {onsubmit} autocomplete="off" id="add-exclusion-pattern-form">
<p class="py-5 text-sm">
{$t('admin.exclusion_pattern_description')}
<br /><br />
{$t('admin.add_exclusion_pattern_description')}
</p>
<div class="my-4 flex flex-col gap-2">
<label class="immich-form-label" for="exclusionPattern">{$t('pattern')}</label>
<input
class="immich-form-input"
id="exclusionPattern"
name="exclusionPattern"
type="text"
bind:value={exclusionPattern}
/>
</div>
<div class="mt-8 flex w-full gap-4">
{#if isDuplicate}
<p class="text-red-500 text-sm">{$t('errors.exclusion_pattern_already_exists')}</p>
{/if}
</div>
</form>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
{#if isEditing}
<Button shape="round" color="danger" fullWidth onclick={() => onClose({ action: 'delete' })}
>{$t('delete')}</Button
>
{/if}
<Button shape="round" type="submit" disabled={!canSubmit} fullWidth form="add-exclusion-pattern-form">
{submitText}
</Button>
</HStack>
</ModalFooter>
</Modal>

View File

@@ -0,0 +1,44 @@
<script lang="ts">
import { handleAddLibraryFolder } from '$lib/services/library.service';
import type { LibraryResponseDto } from '@immich/sdk';
import { Button, Field, HStack, Input, Modal, ModalBody, ModalFooter, Text } from '@immich/ui';
import { mdiFolderSync } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
library: LibraryResponseDto;
onClose: () => void;
};
const { library, onClose }: Props = $props();
let folder = $state('');
const onsubmit = async () => {
const success = await handleAddLibraryFolder(library, folder);
if (success) {
onClose();
}
};
</script>
<Modal title={$t('library_add_folder')} icon={mdiFolderSync} {onClose} size="small">
<ModalBody>
<form {onsubmit} autocomplete="off" id="library-import-path-form">
<Text size="small" class="mb-4">{$t('admin.library_folder_description')}</Text>
<Field label={$t('path')}>
<Input bind:value={folder} />
</Field>
</form>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
<Button shape="round" type="submit" fullWidth form="library-import-path-form">
{$t('add')}
</Button>
</HStack>
</ModalFooter>
</Modal>

View File

@@ -0,0 +1,45 @@
<script lang="ts">
import { handleEditLibraryFolder } from '$lib/services/library.service';
import type { LibraryResponseDto } from '@immich/sdk';
import { Button, Field, HStack, Input, Modal, ModalBody, ModalFooter, Text } from '@immich/ui';
import { mdiFolderSync } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
library: LibraryResponseDto;
folder: string;
onClose: () => void;
};
const { library, folder, onClose }: Props = $props();
let newFolder = $state(folder);
const onsubmit = async () => {
const success = await handleEditLibraryFolder(library, folder, newFolder);
if (success) {
onClose();
}
};
</script>
<Modal title={$t('library_edit_folder')} icon={mdiFolderSync} {onClose} size="small">
<ModalBody>
<form {onsubmit} autocomplete="off" id="library-import-path-form">
<Text size="small" class="mb-4">{$t('admin.library_folder_description')}</Text>
<Field label={$t('path')}>
<Input bind:value={newFolder} />
</Field>
</form>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
<Button shape="round" type="submit" fullWidth form="library-import-path-form">
{$t('save')}
</Button>
</HStack>
</ModalFooter>
</Modal>

View File

@@ -1,75 +0,0 @@
<script lang="ts">
import { Button, HStack, Modal, ModalBody, ModalFooter } from '@immich/ui';
import { mdiFolderSync } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
interface Props {
importPath: string | null;
importPaths?: string[];
title?: string;
cancelText?: string;
submitText?: string;
isEditing?: boolean;
onClose: (data?: { action: 'delete' } | { action: 'submit'; importPath: string | null }) => void;
}
let {
importPath = $bindable(),
importPaths = $bindable([]),
title = $t('import_path'),
cancelText = $t('cancel'),
submitText = $t('save'),
isEditing = false,
onClose,
}: Props = $props();
onMount(() => {
if (isEditing) {
importPaths = importPaths.filter((path) => path !== importPath);
}
});
let isDuplicate = $derived(importPath !== null && importPaths.includes(importPath));
let canSubmit = $derived(importPath !== '' && importPath !== null && !importPaths.includes(importPath));
const onsubmit = (event: Event) => {
event.preventDefault();
if (canSubmit) {
onClose({ action: 'submit', importPath });
}
};
</script>
<Modal {title} icon={mdiFolderSync} {onClose} size="small">
<ModalBody>
<form {onsubmit} autocomplete="off" id="library-import-path-form">
<p class="py-5 text-sm">{$t('admin.library_import_path_description')}</p>
<div class="my-4 flex flex-col gap-2">
<label class="immich-form-label" for="path">{$t('path')}</label>
<input class="immich-form-input" id="path" name="path" type="text" bind:value={importPath} />
</div>
<div class="mt-8 flex w-full gap-4">
{#if isDuplicate}
<p class="text-red-500 text-sm">{$t('errors.import_path_already_exists')}</p>
{/if}
</div>
</form>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{cancelText}</Button>
{#if isEditing}
<Button shape="round" color="danger" fullWidth onclick={() => onClose({ action: 'delete' })}>
{$t('delete')}
</Button>
{/if}
<Button shape="round" type="submit" disabled={!canSubmit} fullWidth form="library-import-path-form">
{submitText}
</Button>
</HStack>
</ModalFooter>
</Modal>

View File

@@ -1,21 +1,25 @@
<script lang="ts">
import { handleRenameLibrary } from '$lib/services/library.service';
import type { LibraryResponseDto } from '@immich/sdk';
import { Button, Field, HStack, Input, Modal, ModalBody, ModalFooter } from '@immich/ui';
import { mdiRenameOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
library: Partial<LibraryResponseDto>;
onClose: (library?: Partial<LibraryResponseDto>) => void;
}
type Props = {
library: LibraryResponseDto;
onClose: () => void;
};
let { library, onClose }: Props = $props();
let newName = $state(library.name);
const onsubmit = (event: Event) => {
event.preventDefault();
onClose({ ...library, name: newName });
const onsubmit = async () => {
const success = await handleRenameLibrary(library, newName);
if (success) {
onClose();
}
};
</script>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import DateInput from '$lib/elements/DateInput.svelte';
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { getPreferredTimeZone, getTimezones, toDatetime, type ZoneOption } from '$lib/modals/timezone-utils';
import { Button, HStack, Modal, ModalBody, ModalFooter, VStack } from '@immich/ui';

View File

@@ -0,0 +1,11 @@
import { eventManager } from '$lib/managers/event-manager.svelte';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { copyAsset, deleteAssets } from '@immich/sdk';
export const handleReplaceAsset = async (oldAssetId: string) => {
const [newAssetId] = await openFileUploadDialog({ multiple: false });
await copyAsset({ assetCopyDto: { sourceId: oldAssetId, targetId: newAssetId } });
await deleteAssets({ assetBulkDeleteDto: { ids: [oldAssetId], force: true } });
eventManager.emit('AssetReplace', { oldAssetId, newAssetId });
};

View File

@@ -0,0 +1,352 @@
import { goto } from '$app/navigation';
import { AppRoute } from '$lib/constants';
import { eventManager } from '$lib/managers/event-manager.svelte';
import LibraryExclusionPatternAddModal from '$lib/modals/LibraryExclusionPatternAddModal.svelte';
import LibraryExclusionPatternEditModal from '$lib/modals/LibraryExclusionPatternEditModal.svelte';
import LibraryFolderAddModal from '$lib/modals/LibraryFolderAddModal.svelte';
import LibraryFolderEditModal from '$lib/modals/LibraryFolderEditModal.svelte';
import LibraryRenameModal from '$lib/modals/LibraryRenameModal.svelte';
import LibraryUserPickerModal from '$lib/modals/LibraryUserPickerModal.svelte';
import type { ActionItem } from '$lib/types';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
import {
createLibrary,
deleteLibrary,
QueueCommand,
QueueName,
runQueueCommandLegacy,
scanLibrary,
updateLibrary,
type LibraryResponseDto,
} from '@immich/sdk';
import { modalManager, toastManager } from '@immich/ui';
import { mdiPencilOutline, mdiPlusBoxOutline, mdiSync, mdiTrashCanOutline } from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
export const getLibrariesActions = ($t: MessageFormatter) => {
const ScanAll: ActionItem = {
title: $t('scan_all_libraries'),
icon: mdiSync,
onSelect: () => void handleScanAllLibraries(),
};
const Create: ActionItem = {
title: $t('create_library'),
icon: mdiPlusBoxOutline,
onSelect: () => void handleCreateLibrary(),
};
return { ScanAll, Create };
};
export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponseDto) => {
const Rename: ActionItem = {
icon: mdiPencilOutline,
title: $t('rename'),
onSelect: () => void modalManager.show(LibraryRenameModal, { library }),
};
const Delete: ActionItem = {
icon: mdiTrashCanOutline,
title: $t('delete'),
color: 'danger',
onSelect: () => void handleDeleteLibrary(library),
};
const AddFolder: ActionItem = {
icon: mdiPlusBoxOutline,
title: $t('add'),
onSelect: () => void modalManager.show(LibraryFolderAddModal, { library }),
};
const AddExclusionPattern: ActionItem = {
icon: mdiPlusBoxOutline,
title: $t('add'),
onSelect: () => void modalManager.show(LibraryExclusionPatternAddModal, { library }),
};
const Scan: ActionItem = {
icon: mdiSync,
title: $t('scan_library'),
onSelect: () => void handleScanLibrary(library),
};
return { Rename, Delete, AddFolder, AddExclusionPattern, Scan };
};
export const getLibraryFolderActions = ($t: MessageFormatter, library: LibraryResponseDto, folder: string) => {
const Edit: ActionItem = {
icon: mdiPencilOutline,
title: $t('edit'),
onSelect: () => void modalManager.show(LibraryFolderEditModal, { folder, library }),
};
const Delete: ActionItem = {
icon: mdiTrashCanOutline,
title: $t('delete'),
onSelect: () => void handleDeleteLibraryFolder(library, folder),
};
return { Edit, Delete };
};
export const getLibraryExclusionPatternActions = (
$t: MessageFormatter,
library: LibraryResponseDto,
exclusionPattern: string,
) => {
const Edit: ActionItem = {
icon: mdiPencilOutline,
title: $t('edit'),
onSelect: () => void modalManager.show(LibraryExclusionPatternEditModal, { exclusionPattern, library }),
};
const Delete: ActionItem = {
icon: mdiTrashCanOutline,
title: $t('delete'),
onSelect: () => void handleDeleteExclusionPattern(library, exclusionPattern),
};
return { Edit, Delete };
};
const handleScanAllLibraries = async () => {
const $t = await getFormatter();
try {
await runQueueCommandLegacy({ name: QueueName.Library, queueCommandDto: { command: QueueCommand.Start } });
toastManager.info($t('admin.refreshing_all_libraries'));
} catch (error) {
handleError(error, $t('errors.unable_to_scan_libraries'));
}
};
const handleScanLibrary = async (library: LibraryResponseDto) => {
const $t = await getFormatter();
try {
await scanLibrary({ id: library.id });
toastManager.info($t('admin.scanning_library'));
} catch (error) {
handleError(error, $t('errors.unable_to_scan_library'));
}
};
export const handleViewLibrary = async (library: LibraryResponseDto) => {
await goto(`${AppRoute.ADMIN_LIBRARY_MANAGEMENT}/${library.id}`);
};
export const handleCreateLibrary = async () => {
const $t = await getFormatter();
const ownerId = await modalManager.show(LibraryUserPickerModal, {});
if (!ownerId) {
return;
}
try {
const createdLibrary = await createLibrary({ createLibraryDto: { ownerId } });
eventManager.emit('LibraryCreate', createdLibrary);
toastManager.success($t('admin.library_created', { values: { library: createdLibrary.name } }));
} catch (error) {
handleError(error, $t('errors.unable_to_create_library'));
}
};
export const handleRenameLibrary = async (library: { id: string }, name?: string) => {
const $t = await getFormatter();
if (!name) {
return false;
}
try {
const updatedLibrary = await updateLibrary({
id: library.id,
updateLibraryDto: { name },
});
eventManager.emit('LibraryUpdate', updatedLibrary);
toastManager.success($t('admin.library_updated'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_library'));
return false;
}
return true;
};
const handleDeleteLibrary = async (library: LibraryResponseDto) => {
const $t = await getFormatter();
const confirmed = await modalManager.showDialog({
prompt: $t('admin.confirm_delete_library', { values: { library: library.name } }),
});
if (!confirmed) {
return;
}
if (library.assetCount > 0) {
const isConfirmed = await modalManager.showDialog({
prompt: $t('admin.confirm_delete_library_assets', { values: { count: library.assetCount } }),
});
if (!isConfirmed) {
return;
}
}
try {
await deleteLibrary({ id: library.id });
eventManager.emit('LibraryDelete', { id: library.id });
toastManager.success($t('admin.library_deleted'));
} catch (error) {
handleError(error, $t('errors.unable_to_remove_library'));
}
};
export const handleAddLibraryFolder = async (library: LibraryResponseDto, folder: string) => {
const $t = await getFormatter();
if (library.importPaths.includes(folder)) {
toastManager.danger($t('errors.library_folder_already_exists'));
return false;
}
try {
const updatedLibrary = await updateLibrary({
id: library.id,
updateLibraryDto: { importPaths: [...library.importPaths, folder] },
});
eventManager.emit('LibraryUpdate', updatedLibrary);
toastManager.success($t('admin.library_updated'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_library'));
return false;
}
return true;
};
export const handleEditLibraryFolder = async (library: LibraryResponseDto, oldFolder: string, newFolder: string) => {
const $t = await getFormatter();
if (oldFolder === newFolder) {
return true;
}
const importPaths = library.importPaths.map((path) => (path === oldFolder ? newFolder : path));
try {
const updatedLibrary = await updateLibrary({ id: library.id, updateLibraryDto: { importPaths } });
eventManager.emit('LibraryUpdate', updatedLibrary);
toastManager.success($t('admin.library_updated'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_library'));
return false;
}
return true;
};
const handleDeleteLibraryFolder = async (library: LibraryResponseDto, folder: string) => {
const $t = await getFormatter();
const confirmed = await modalManager.showDialog({
prompt: $t('admin.library_remove_folder_prompt'),
confirmText: $t('remove'),
});
if (!confirmed) {
return false;
}
try {
const updatedLibrary = await updateLibrary({
id: library.id,
updateLibraryDto: { importPaths: library.importPaths.filter((path) => path !== folder) },
});
eventManager.emit('LibraryUpdate', updatedLibrary);
toastManager.success($t('admin.library_updated'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_library'));
return false;
}
return true;
};
export const handleAddLibraryExclusionPattern = async (library: LibraryResponseDto, exclusionPattern: string) => {
const $t = await getFormatter();
if (library.exclusionPatterns.includes(exclusionPattern)) {
toastManager.danger($t('errors.exclusion_pattern_already_exists'));
return false;
}
try {
const updatedLibrary = await updateLibrary({
id: library.id,
updateLibraryDto: { exclusionPatterns: [...library.exclusionPatterns, exclusionPattern] },
});
eventManager.emit('LibraryUpdate', updatedLibrary);
toastManager.success($t('admin.library_updated'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_library'));
return false;
}
return true;
};
export const handleEditExclusionPattern = async (
library: LibraryResponseDto,
oldExclusionPattern: string,
newExclusionPattern: string,
) => {
const $t = await getFormatter();
if (oldExclusionPattern === newExclusionPattern) {
return true;
}
const exclusionPatterns = library.exclusionPatterns.map((pattern) =>
pattern === oldExclusionPattern ? newExclusionPattern : pattern,
);
try {
const updatedLibrary = await updateLibrary({ id: library.id, updateLibraryDto: { exclusionPatterns } });
eventManager.emit('LibraryUpdate', updatedLibrary);
toastManager.success($t('admin.library_updated'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_library'));
return false;
}
return true;
};
const handleDeleteExclusionPattern = async (library: LibraryResponseDto, exclusionPattern: string) => {
const $t = await getFormatter();
const confirmed = await modalManager.showDialog({ prompt: $t('admin.library_remove_exclusion_pattern_prompt') });
if (!confirmed) {
return false;
}
try {
const updatedLibrary = await updateLibrary({
id: library.id,
updateLibraryDto: {
exclusionPatterns: library.exclusionPatterns.filter((pattern) => pattern !== exclusionPattern),
},
});
eventManager.emit('LibraryUpdate', updatedLibrary);
toastManager.success($t('admin.library_updated'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_library'));
return false;
}
return true;
};

View File

@@ -0,0 +1,44 @@
import { getAssetOcr } from '@immich/sdk';
export type OcrBoundingBox = {
id: string;
assetId: string;
x1: number;
y1: number;
x2: number;
y2: number;
x3: number;
y3: number;
x4: number;
y4: number;
boxScore: number;
textScore: number;
text: string;
};
class OcrManager {
#data = $state<OcrBoundingBox[]>([]);
showOverlay = $state(false);
hasOcrData = $state(false);
get data() {
return this.#data;
}
async getAssetOcr(id: string) {
this.#data = await getAssetOcr({ id });
this.hasOcrData = this.#data.length > 0;
}
clear() {
this.#data = [];
this.showOverlay = false;
this.hasOcrData = false;
}
toggleOcrBoundingBox() {
this.showOverlay = !this.showOverlay;
}
}
export const ocrManager = new OcrManager();

View File

@@ -1,8 +1,10 @@
import { page } from '$app/state';
import { AppRoute } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { maintenanceStore } from '$lib/stores/maintenance.store';
import { notificationManager } from '$lib/stores/notification-manager.svelte';
import type { ReleaseEvent } from '$lib/types';
import { createEventEmitter } from '$lib/utils/eventemitter';
import {
MaintenanceAction,
@@ -15,14 +17,6 @@ import { io, type Socket } from 'socket.io-client';
import { get, writable } from 'svelte/store';
import { user } from './user.store';
export interface ReleaseEvent {
isAvailable: boolean;
/** ISO8601 */
checkedAt: string;
serverVersion: ServerVersionResponseDto;
releaseVersion: ServerVersionResponseDto;
}
interface AppRestartEvent {
isMaintenanceMode: boolean;
}
@@ -39,7 +33,7 @@ export interface Events {
on_person_thumbnail: (personId: string) => void;
on_server_version: (serverVersion: ServerVersionResponseDto) => void;
on_config_update: () => void;
on_new_release: (newRelease: ReleaseEvent) => void;
on_new_release: (event: ReleaseEvent) => void;
on_session_delete: (sessionId: string) => void;
on_notification: (notification: NotificationDto) => void;
@@ -59,7 +53,6 @@ const websocket: Socket<Events> = io({
export const websocketStore = {
connected: writable<boolean>(false),
serverVersion: writable<ServerVersionResponseDto>(),
release: writable<ReleaseEvent>(),
serverRestarting: writable<undefined | AppRestartEvent>(),
};
@@ -79,8 +72,9 @@ websocket
});
}
})
.on('on_new_release', (releaseVersion) => websocketStore.release.set(releaseVersion))
.on('on_new_release', (event) => eventManager.emit('ReleaseEvent', event))
.on('on_session_delete', () => authManager.logout())
.on('on_user_delete', (id) => eventManager.emit('UserAdminDeleted', { id }))
.on('on_notification', () => notificationManager.refresh())
.on('connect_error', (e) => console.log('Websocket Connect Error', e));

View File

@@ -1,4 +1,13 @@
import type { ServerVersionResponseDto } from '@immich/sdk';
import type { MenuItem } from '@immich/ui';
import type { HTMLAttributes } from 'svelte/elements';
export type ActionItem = MenuItem & { props?: Omit<HTMLAttributes<HTMLElement>, 'color'> };
export interface ReleaseEvent {
isAvailable: boolean;
/** ISO8601 */
checkedAt: string;
serverVersion: ServerVersionResponseDto;
releaseVersion: ServerVersionResponseDto;
}

View File

@@ -109,5 +109,5 @@ export function updateUnstackedAssetInTimeline(timelineManager: TimelineManager,
},
);
timelineManager.addAssets(assets);
timelineManager.upsertAssets(assets);
}

View File

@@ -3,7 +3,7 @@ import ToastAction from '$lib/components/ToastAction.svelte';
import { AppRoute } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { downloadManager } from '$lib/managers/download-manager.svelte';
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';

View File

@@ -12,7 +12,6 @@ import {
AssetMediaStatus,
AssetVisibility,
checkBulkUpload,
getAssetOriginalPath,
getBaseUrl,
type AssetMediaResponseDto,
} from '@immich/sdk';
@@ -44,12 +43,10 @@ export const addDummyItems = () => {
export const uploadExecutionQueue = new ExecutorQueue({ concurrency: 2 });
type FileUploadParam = { multiple?: boolean } & (
| { albumId?: string; assetId?: never }
| { albumId?: never; assetId?: string }
);
type FileUploadParam = { multiple?: boolean; albumId?: string };
export const openFileUploadDialog = async (options: FileUploadParam = {}) => {
const { albumId, multiple = true, assetId } = options;
const { albumId, multiple = true } = options;
const extensions = uploadManager.getExtensions();
return new Promise<string[]>((resolve, reject) => {
@@ -68,7 +65,7 @@ export const openFileUploadDialog = async (options: FileUploadParam = {}) => {
}
const files = Array.from(target.files);
resolve(fileUploadHandler({ files, albumId, replaceAssetId: assetId }));
resolve(fileUploadHandler({ files, albumId }));
},
{ passive: true },
);
@@ -88,7 +85,6 @@ type FileUploadHandlerParams = Omit<FileUploaderParams, 'deviceAssetId' | 'asset
export const fileUploadHandler = async ({
files,
albumId,
replaceAssetId,
isLockedAssets = false,
}: FileUploadHandlerParams): Promise<string[]> => {
const extensions = uploadManager.getExtensions();
@@ -99,9 +95,7 @@ export const fileUploadHandler = async ({
const deviceAssetId = getDeviceAssetId(file);
uploadAssetsStore.addItem({ id: deviceAssetId, file, albumId });
promises.push(
uploadExecutionQueue.addTask(() =>
fileUploader({ assetFile: file, deviceAssetId, albumId, replaceAssetId, isLockedAssets }),
),
uploadExecutionQueue.addTask(() => fileUploader({ assetFile: file, deviceAssetId, albumId, isLockedAssets })),
);
}
}
@@ -127,7 +121,6 @@ async function fileUploader({
assetFile,
deviceAssetId,
albumId,
replaceAssetId,
isLockedAssets = false,
}: FileUploaderParams): Promise<string | undefined> {
const fileCreatedAt = new Date(assetFile.lastModified).toISOString();
@@ -183,27 +176,17 @@ async function fileUploader({
const queryParams = asQueryString(authManager.params);
uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_uploading') });
if (replaceAssetId) {
const response = await uploadRequest<AssetMediaResponseDto>({
url: getBaseUrl() + getAssetOriginalPath(replaceAssetId) + (queryParams ? `?${queryParams}` : ''),
method: 'PUT',
data: formData,
onUploadProgress: (event) => uploadAssetsStore.updateProgress(deviceAssetId, event.loaded, event.total),
});
responseData = response.data;
} else {
const response = await uploadRequest<AssetMediaResponseDto>({
url: getBaseUrl() + '/assets' + (queryParams ? `?${queryParams}` : ''),
data: formData,
onUploadProgress: (event) => uploadAssetsStore.updateProgress(deviceAssetId, event.loaded, event.total),
});
const response = await uploadRequest<AssetMediaResponseDto>({
url: getBaseUrl() + '/assets' + (queryParams ? `?${queryParams}` : ''),
data: formData,
onUploadProgress: (event) => uploadAssetsStore.updateProgress(deviceAssetId, event.loaded, event.total),
});
if (![200, 201].includes(response.status)) {
throw new Error($t('errors.unable_to_upload_file'));
}
responseData = response.data;
if (![200, 201].includes(response.status)) {
throw new Error($t('errors.unable_to_upload_file'));
}
responseData = response.data;
}
if (responseData.status === AssetMediaStatus.Duplicate) {

View File

@@ -0,0 +1,131 @@
import type { OcrBoundingBox } from '$lib/stores/ocr.svelte';
import type { ZoomImageWheelState } from '@zoom-image/core';
const getContainedSize = (img: HTMLImageElement): { width: number; height: number } => {
const ratio = img.naturalWidth / img.naturalHeight;
let width = img.height * ratio;
let height = img.height;
if (width > img.width) {
width = img.width;
height = img.width / ratio;
}
return { width, height };
};
export interface OcrBox {
id: string;
points: { x: number; y: number }[];
text: string;
confidence: number;
}
export interface BoundingBoxDimensions {
minX: number;
maxX: number;
minY: number;
maxY: number;
width: number;
height: number;
centerX: number;
centerY: number;
rotation: number;
skewX: number;
skewY: number;
}
/**
* Calculate bounding box dimensions and properties from OCR points
* @param points - Array of 4 corner points of the bounding box
* @returns Dimensions, rotation, and skew values for the bounding box
*/
export const calculateBoundingBoxDimensions = (points: { x: number; y: number }[]): BoundingBoxDimensions => {
const [topLeft, topRight, bottomRight, bottomLeft] = points;
const minX = Math.min(...points.map(({ x }) => x));
const maxX = Math.max(...points.map(({ x }) => x));
const minY = Math.min(...points.map(({ y }) => y));
const maxY = Math.max(...points.map(({ y }) => y));
const width = maxX - minX;
const height = maxY - minY;
const centerX = (minX + maxX) / 2;
const centerY = (minY + maxY) / 2;
// Calculate rotation angle from the bottom edge (bottomLeft to bottomRight)
const rotation = Math.atan2(bottomRight.y - bottomLeft.y, bottomRight.x - bottomLeft.x) * (180 / Math.PI);
// Calculate skew angles to handle perspective distortion
// SkewX: compare left and right edges
const leftEdgeAngle = Math.atan2(bottomLeft.y - topLeft.y, bottomLeft.x - topLeft.x);
const rightEdgeAngle = Math.atan2(bottomRight.y - topRight.y, bottomRight.x - topRight.x);
const skewX = (rightEdgeAngle - leftEdgeAngle) * (180 / Math.PI);
// SkewY: compare top and bottom edges
const topEdgeAngle = Math.atan2(topRight.y - topLeft.y, topRight.x - topLeft.x);
const bottomEdgeAngle = Math.atan2(bottomRight.y - bottomLeft.y, bottomRight.x - bottomLeft.x);
const skewY = (bottomEdgeAngle - topEdgeAngle) * (180 / Math.PI);
return {
minX,
maxX,
minY,
maxY,
width,
height,
centerX,
centerY,
rotation,
skewX,
skewY,
};
};
/**
* Convert normalized OCR coordinates to screen coordinates
* OCR coordinates are normalized (0-1) and represent the 4 corners of a rotated rectangle
*/
export const getOcrBoundingBoxes = (
ocrData: OcrBoundingBox[],
zoom: ZoomImageWheelState,
photoViewer: HTMLImageElement | null,
): OcrBox[] => {
const boxes: OcrBox[] = [];
if (photoViewer === null || !photoViewer.naturalWidth || !photoViewer.naturalHeight) {
return boxes;
}
const clientHeight = photoViewer.clientHeight;
const clientWidth = photoViewer.clientWidth;
const { width, height } = getContainedSize(photoViewer);
const imageWidth = photoViewer.naturalWidth;
const imageHeight = photoViewer.naturalHeight;
for (const ocr of ocrData) {
// Convert normalized coordinates (0-1) to actual pixel positions
// OCR provides 4 corners of a potentially rotated rectangle
const points = [
{ x: ocr.x1, y: ocr.y1 },
{ x: ocr.x2, y: ocr.y2 },
{ x: ocr.x3, y: ocr.y3 },
{ x: ocr.x4, y: ocr.y4 },
].map((point) => ({
x:
(width / imageWidth) * zoom.currentZoom * point.x * imageWidth +
((clientWidth - width) / 2) * zoom.currentZoom +
zoom.currentPositionX,
y:
(height / imageHeight) * zoom.currentZoom * point.y * imageHeight +
((clientHeight - height) / 2) * zoom.currentZoom +
zoom.currentPositionY,
}));
boxes.push({
id: ocr.id,
points,
text: ocr.text,
confidence: ocr.textScore,
});
}
return boxes;
};