mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d9308eb40 | |||
| 2015f95ff5 | |||
| d4f29ab6ac |
Generated
+1
-1
@@ -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
@@ -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:
|
||||
///
|
||||
|
||||
@@ -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"
|
||||
],
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user