Compare commits

...

3 Commits

Author SHA1 Message Date
midzelis 1d9308eb40 fix(web): preserve stacked asset selection when tagging faces
Change-Id: Iec1507560f99f2e9433bd5cf6b460b176a6a6964
2026-05-04 13:48:10 +00:00
Michel Heusschen 2015f95ff5 fix(web): correct timeline yesterday label across month boundaries (#28183) 2026-05-04 13:46:11 +02:00
Timon d4f29ab6ac fix(server): validate duplicate group ownership before dismissal (#28221) 2026-05-04 12:51:54 +02:00
14 changed files with 124 additions and 58 deletions
+1 -1
View File
@@ -146,7 +146,7 @@ Class | Method | HTTP request | Description
*DeprecatedApi* | [**runQueueCommandLegacy**](doc//DeprecatedApi.md#runqueuecommandlegacy) | **PUT** /jobs/{name} | Run jobs
*DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive | Download asset archive
*DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info | Retrieve download information
*DuplicatesApi* | [**deleteDuplicate**](doc//DuplicatesApi.md#deleteduplicate) | **DELETE** /duplicates/{id} | Delete a duplicate
*DuplicatesApi* | [**deleteDuplicate**](doc//DuplicatesApi.md#deleteduplicate) | **DELETE** /duplicates/{id} | Dismiss a duplicate group
*DuplicatesApi* | [**deleteDuplicates**](doc//DuplicatesApi.md#deleteduplicates) | **DELETE** /duplicates | Delete duplicates
*DuplicatesApi* | [**getAssetDuplicates**](doc//DuplicatesApi.md#getassetduplicates) | **GET** /duplicates | Retrieve duplicates
*DuplicatesApi* | [**resolveDuplicates**](doc//DuplicatesApi.md#resolveduplicates) | **POST** /duplicates/resolve | Resolve duplicate groups
+4 -4
View File
@@ -16,9 +16,9 @@ class DuplicatesApi {
final ApiClient apiClient;
/// Delete a duplicate
/// Dismiss a duplicate group
///
/// Delete a single duplicate asset specified by its ID.
/// Dismiss a duplicate group by its ID, unlinking all assets in the group without deleting them.
///
/// Note: This method returns the HTTP [Response].
///
@@ -51,9 +51,9 @@ class DuplicatesApi {
);
}
/// Delete a duplicate
/// Dismiss a duplicate group
///
/// Delete a single duplicate asset specified by its ID.
/// Dismiss a duplicate group by its ID, unlinking all assets in the group without deleting them.
///
/// Parameters:
///
+2 -2
View File
@@ -5172,7 +5172,7 @@
},
"/duplicates/{id}": {
"delete": {
"description": "Delete a single duplicate asset specified by its ID.",
"description": "Dismiss a duplicate group by its ID, unlinking all assets in the group without deleting them.",
"operationId": "deleteDuplicate",
"parameters": [
{
@@ -5202,7 +5202,7 @@
"api_key": []
}
],
"summary": "Delete a duplicate",
"summary": "Dismiss a duplicate group",
"tags": [
"Duplicates"
],
+1 -1
View File
@@ -4480,7 +4480,7 @@ export function resolveDuplicates({ duplicateResolveDto }: {
})));
}
/**
* Delete a duplicate
* Dismiss a duplicate group
*/
export function deleteDuplicate({ id }: {
id: string;
@@ -41,8 +41,8 @@ export class DuplicateController {
@Authenticated({ permission: Permission.DuplicateDelete })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Delete a duplicate',
description: 'Delete a single duplicate asset specified by its ID.',
summary: 'Dismiss a duplicate group',
description: 'Dismiss a duplicate group by its ID, unlinking all assets in the group without deleting them.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
deleteDuplicate(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
@@ -1,3 +1,4 @@
import { BadRequestException } from '@nestjs/common';
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { AssetType, AssetVisibility, JobName, JobStatus } from 'src/enum';
@@ -149,6 +150,36 @@ describe(DuplicateService.name, () => {
});
});
describe('delete', () => {
it('should throw for an unknown or unauthorized group id', async () => {
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set());
await expect(sut.delete(authStub.admin, 'group-1')).rejects.toThrow(BadRequestException);
expect(mocks.duplicateRepository.delete).not.toHaveBeenCalled();
});
it('should dismiss the duplicate group', async () => {
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1']));
mocks.duplicateRepository.delete.mockResolvedValue();
await expect(sut.delete(authStub.admin, 'group-1')).resolves.toBeUndefined();
expect(mocks.duplicateRepository.delete).toHaveBeenCalledWith(authStub.admin.user.id, 'group-1');
});
});
describe('deleteAll', () => {
it('should throw if any group id is unknown or unauthorized', async () => {
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1']));
await expect(sut.deleteAll(authStub.admin, { ids: ['group-1', 'group-2'] })).rejects.toThrow(BadRequestException);
expect(mocks.duplicateRepository.deleteAll).not.toHaveBeenCalled();
});
it('should dismiss all duplicate groups', async () => {
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1', 'group-2']));
mocks.duplicateRepository.deleteAll.mockResolvedValue();
await expect(sut.deleteAll(authStub.admin, { ids: ['group-1', 'group-2'] })).resolves.toBeUndefined();
expect(mocks.duplicateRepository.deleteAll).toHaveBeenCalledWith(authStub.admin.user.id, ['group-1', 'group-2']);
});
});
describe('resolve', () => {
it('should handle mixed success and failure', async () => {
const asset = AssetFactory.create();
+2
View File
@@ -82,10 +82,12 @@ export class DuplicateService extends BaseService {
}
async delete(auth: AuthDto, id: string): Promise<void> {
await this.requireAccess({ auth, permission: Permission.DuplicateDelete, ids: [id] });
await this.duplicateRepository.delete(auth.user.id, id);
}
async deleteAll(auth: AuthDto, dto: BulkIdsDto) {
await this.requireAccess({ auth, permission: Permission.DuplicateDelete, ids: dto.ids });
await this.duplicateRepository.deleteAll(auth.user.id, dto.ids);
}
@@ -9,6 +9,7 @@
import OnEvents from '$lib/components/OnEvents.svelte';
import { AssetAction, ProjectionType } from '$lib/constants';
import { activityManager } from '$lib/managers/activity-manager.svelte';
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte';
@@ -99,9 +100,10 @@
const stackSelectedThumbnailSize = 65;
let previewStackedAsset: AssetResponseDto | undefined = $state();
let stack: StackResponseDto | null = $state(null);
let stack: StackResponseDto | undefined = $state();
let selectedStackAsset: AssetResponseDto | undefined = $state();
const asset = $derived(previewStackedAsset ?? cursor.current);
const asset = $derived(previewStackedAsset ?? selectedStackAsset ?? cursor.current);
const nextAsset = $derived(cursor.nextAsset);
const previousAsset = $derived(cursor.previousAsset);
let sharedLink = getSharedLink();
@@ -114,17 +116,29 @@
playOriginalVideo = value;
};
const selectStackedAsset = async (id: string) => {
ocrManager.clear();
selectedStackAsset = await assetCacheManager.getAsset({ id });
if (!sharedLink) {
await ocrManager.getAssetOcr(id);
}
};
const refreshStack = async () => {
if (authManager.isSharedLink || !withStacked) {
return;
}
if (asset.stack) {
stack = await getStack({ id: asset.stack.id });
if (!cursor.current.stack) {
stack = undefined;
selectedStackAsset = undefined;
return;
}
if (!stack?.assets.some(({ id }) => id === asset.id)) {
stack = null;
stack = await getStack({ id: cursor.current.stack.id });
const primaryAsset = stack?.assets.find(({ id }) => id === stack?.primaryAssetId);
if (primaryAsset) {
await selectStackedAsset(primaryAsset.id);
}
};
@@ -182,11 +196,21 @@
onClose?.(asset.id);
};
const refreshPreservingSelection = async () => {
const id = asset.id;
assetCacheManager.invalidateAsset(id);
if (selectedStackAsset) {
await selectStackedAsset(id);
} else {
const refreshedAsset = await assetCacheManager.getAsset({ id });
assetViewerManager.setAsset(refreshedAsset);
}
onAssetChange?.(asset);
};
const closeEditor = async () => {
if (editManager.hasAppliedEdits) {
const refreshedAsset = await getAssetInfo({ id: asset.id });
onAssetChange?.(refreshedAsset);
assetViewerManager.setAsset(refreshedAsset);
await refreshPreservingSelection();
}
assetViewerManager.closeEditor();
};
@@ -285,10 +309,6 @@
}
};
const handleStackedAssetMouseEvent = (isMouseOver: boolean, stackedAsset: AssetResponseDto) => {
previewStackedAsset = isMouseOver ? stackedAsset : undefined;
};
const handlePreAction = (action: Action) => {
preAction?.(action);
};
@@ -301,7 +321,7 @@
break;
}
case AssetAction.REMOVE_ASSET_FROM_STACK: {
stack = action.stack;
stack = action.stack ?? undefined;
if (stack) {
cursor.current = stack.assets[0];
}
@@ -309,7 +329,7 @@
}
case AssetAction.STACK:
case AssetAction.SET_STACK_PRIMARY_ASSET: {
stack = action.stack;
stack = action.stack ?? undefined;
break;
}
case AssetAction.SET_PERSON_FEATURED_PHOTO: {
@@ -368,7 +388,7 @@
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
asset;
cursor.current;
untrack(() => handlePromiseError(refresh()));
});
@@ -533,7 +553,12 @@
{:else if viewerKind === 'CropArea'}
<CropArea {asset} />
{:else if viewerKind === 'PhotoViewer'}
<PhotoViewer cursor={{ ...cursor, current: asset }} {sharedLink} {onSwipe} />
<PhotoViewer
cursor={{ ...cursor, current: asset }}
{sharedLink}
{onSwipe}
onTagFace={refreshPreservingSelection}
/>
{:else if viewerKind === 'VideoViewer'}
<VideoViewer
{asset}
@@ -586,7 +611,7 @@
translate="yes"
>
{#if showDetailPanel}
<DetailPanel {asset} currentAlbum={album} />
<DetailPanel {asset} currentAlbum={album} onRefreshPeople={refreshPreservingSelection} />
{:else if assetViewerManager.isShowEditor}
<EditorPanel {asset} onClose={closeEditor} />
{/if}
@@ -598,27 +623,24 @@
<div id="stack-slideshow" class="pointer-events-none absolute bottom-0 col-span-4 col-start-1 w-full">
<div class="no-wrap horizontal-scrollbar relative flex flex-row overflow-x-auto overflow-y-hidden">
{#each stackedAssets as stackedAsset (stackedAsset.id)}
{@const isSelected = stackedAsset.id === (selectedStackAsset?.id ?? cursor.current.id)}
<div
class={['pointer-events-auto relative inline-block px-1 pb-2 transition-all']}
style:bottom={stackedAsset.id === asset.id ? '0' : '-10px'}
style:bottom={isSelected ? '0' : '-10px'}
>
<Thumbnail
imageClass={{ 'border-2 border-white': stackedAsset.id === asset.id }}
imageClass={{ 'border-2 border-white': isSelected }}
brokenAssetClass="text-xs"
dimmed={stackedAsset.id !== asset.id}
dimmed={!isSelected}
asset={toTimelineAsset(stackedAsset)}
onClick={() => {
cursor.current = stackedAsset;
previewStackedAsset = undefined;
}}
onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}
onClick={() => selectStackedAsset(stackedAsset.id)}
readonly
thumbnailSize={stackedAsset.id === asset.id ? stackSelectedThumbnailSize : stackThumbnailSize}
thumbnailSize={isSelected ? stackSelectedThumbnailSize : stackThumbnailSize}
showStackedIcon={false}
disableLinkMouseOver
/>
{#if stackedAsset.id === asset.id}
{#if isSelected}
<div class="flex w-full place-content-center place-items-center">
<div class="mt-0.5 flex size-2 rounded-full bg-white"></div>
</div>
@@ -16,13 +16,7 @@
import { getByteUnitString } from '$lib/utils/byte-units';
import { handleError } from '$lib/utils/handle-error';
import { getParentPath } from '$lib/utils/tree-utils';
import {
AssetMediaSize,
getAllAlbums,
getAssetInfo,
type AlbumResponseDto,
type AssetResponseDto,
} from '@immich/sdk';
import { AssetMediaSize, getAllAlbums, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
import { Icon, IconButton, LoadingSpinner, Text } from '@immich/ui';
import { mdiCamera, mdiCameraIris, mdiClose, mdiImageOutline, mdiInformationOutline } from '@mdi/js';
import { onDestroy } from 'svelte';
@@ -37,9 +31,10 @@
interface Props {
asset: AssetResponseDto;
currentAlbum?: AlbumResponseDto | null;
onRefreshPeople?: () => Promise<void>;
}
let { asset, currentAlbum = null }: Props = $props();
let { asset, currentAlbum = null, onRefreshPeople }: Props = $props();
let isOwner = $derived(authManager.authenticated && authManager.user.id === asset.ownerId);
let latlng = $derived(
@@ -94,11 +89,6 @@
return undefined;
};
const handleRefreshPeople = async () => {
asset = await getAssetInfo({ id: asset.id });
assetViewerManager.closeEditFacesPanel();
};
const getAssetFolderHref = (asset: AssetResponseDto) => {
// Remove the last part of the path to get the parent path
return Route.folders({ path: getParentPath(asset.originalPath) });
@@ -385,6 +375,6 @@
assetId={asset.id}
assetType={asset.type}
onClose={() => assetViewerManager.closeEditFacesPanel()}
onRefresh={handleRefreshPeople}
onRefresh={() => void onRefreshPeople?.()}
/>
{/if}
@@ -30,9 +30,10 @@
onReady?: () => void;
onError?: () => void;
onSwipe?: (event: SwipeCustomEvent) => void;
onTagFace?: () => Promise<void>;
};
let { cursor, element = $bindable(), sharedLink, onReady, onError, onSwipe }: Props = $props();
let { cursor, element = $bindable(), sharedLink, onReady, onError, onSwipe, onTagFace }: Props = $props();
const { slideshowState, slideshowLook } = slideshowStore;
const asset = $derived(cursor.current);
@@ -285,6 +286,12 @@
</AdaptiveImage>
{#if assetViewerManager.isFaceEditMode && assetViewerManager.imgRef}
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} />
<FaceEditor
htmlElement={assetViewerManager.imgRef}
{containerWidth}
{containerHeight}
assetId={asset.id}
{onTagFace}
/>
{/if}
</div>
@@ -18,9 +18,10 @@
containerWidth: number;
containerHeight: number;
assetId: string;
onTagFace?: () => Promise<void>;
};
let { htmlElement, containerWidth, containerHeight, assetId }: Props = $props();
let { htmlElement, containerWidth, containerHeight, assetId, onTagFace }: Props = $props();
let canvasEl: HTMLCanvasElement | undefined = $state();
let canvas: Canvas | undefined = $state();
@@ -325,7 +326,7 @@
},
});
await assetViewerManager.setAssetId(assetId);
await onTagFace?.();
} catch (error) {
handleError(error, 'Error tagging face');
} finally {
@@ -178,7 +178,10 @@
peopleWithFaces = peopleWithFaces.filter((f) => f.id !== face.id);
await assetViewerManager.setAssetId(assetId);
onRefresh();
if (peopleWithFaces.length === 0) {
onClose();
}
} catch (error) {
handleError(error, $t('error_delete_face'));
}
+10
View File
@@ -7,6 +7,9 @@ describe('formatGroupTitle', () => {
beforeAll(() => {
vi.useFakeTimers();
process.env.TZ = 'UTC';
});
beforeEach(() => {
vi.setSystemTime(new Date('2024-07-27T12:00:00Z'));
});
@@ -31,6 +34,13 @@ describe('formatGroupTitle', () => {
expect(formatGroupTitle(date)).toBe('hier');
});
it('formats yesterday across month boundaries', () => {
vi.setSystemTime(new Date('2024-05-01T12:00:00Z'));
const date = parseUtcDate('2024-04-30T23:59:59Z');
locale.set('en');
expect(formatGroupTitle(date)).toBe('yesterday');
});
it('formats last week', () => {
const date = parseUtcDate('2024-07-21T00:00:00Z');
locale.set('en');
+1 -1
View File
@@ -128,7 +128,7 @@ export function formatGroupTitle(_date: DateTime): string {
// Yesterday
if (today.minus({ days: 1 }).hasSame(date, 'day')) {
return date.toRelativeCalendar({ locale: get(locale) });
return date.toRelativeCalendar({ locale: get(locale), unit: 'days' });
}
// Last week