diff --git a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte index b9405c617f..4e5aac8d18 100644 --- a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte +++ b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte @@ -4,9 +4,9 @@ import Portal from '$lib/components/shared-components/portal/portal.svelte'; import DuplicateAsset from '$lib/components/utilities-page/duplicates/duplicate-asset.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; + import { suggestDuplicateByFileSize } from '$lib/utils'; import { type AssetResponseDto } from '@immich/sdk'; import { mdiCheck, mdiTrashCanOutline } from '@mdi/js'; - import { sortBy } from 'lodash-es'; import { onDestroy, onMount } from 'svelte'; import { t } from 'svelte-i18n'; @@ -20,7 +20,7 @@ $: trashCount = assets.length - selectedAssetIds.size; onMount(() => { - const suggestedAsset = sortBy(assets, (asset) => asset.exifInfo?.fileSizeInByte).pop(); + const suggestedAsset = suggestDuplicateByFileSize(assets); if (!suggestedAsset) { selectedAssetIds = new Set(assets[0].id); diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index 26c82d9852..058e790d89 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -323,6 +323,9 @@ "back": "Back", "backward": "Backward", "blurred_background": "Blurred background", + "bulk_delete_duplicates_confirmation": "Are you sure you want to bulk delete {count} duplicate assets? This will keep the largest asset of each group and permanently delete all other duplicates. You cannot undo this action!", + "bulk_keep_duplicates_confirmation": "Are you sure you want to keep {count} duplicate assets? This will resolve all duplicate groups without deleting anything.", + "bulk_trash_duplicates_confirmation": "Are you sure you want to bulk trash {count} duplicate assets? This will keep the largest asset of each group and trash all other duplicates.", "camera": "Camera", "camera_brand": "Camera brand", "camera_model": "Camera model", @@ -392,6 +395,7 @@ "date_before": "Date before", "date_range": "Date range", "day": "Day", + "deduplicate_all": "Deduplicate All", "default_locale": "Default Locale", "default_locale_description": "Format dates and numbers based on your browser locale", "delete": "Delete", @@ -699,6 +703,7 @@ "permanent_deletion_warning_setting_description": "Show a warning when permanently deleting assets", "permanently_delete": "Permanently delete", "permanently_deleted_asset": "Permanently deleted asset", + "permanently_deleted_assets": "Permanently deleted {count, plural, one {# asset} other {# assets}}", "photos": "Photos", "photos_count": "{count, plural, one {{count, number} Photo} other {{count, number} Photos}}", "photos_from_previous_years": "Photos from previous years", @@ -740,6 +745,7 @@ "reset": "Reset", "reset_password": "Reset password", "reset_people_visibility": "Reset people visibility", + "resolved_all_duplicates": "Resolved all duplicates", "restore": "Restore", "restore_all": "Restore all", "restore_user": "Restore user", diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index a99f3ba1b0..0cb93bf6cc 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -15,9 +15,11 @@ import { linkOAuthAccount, startOAuth, unlinkOAuthAccount, + type AssetResponseDto, type SharedLinkResponseDto, } from '@immich/sdk'; import { mdiCogRefreshOutline, mdiDatabaseRefreshOutline, mdiImageRefreshOutline } from '@mdi/js'; +import { sortBy } from 'lodash-es'; import { t } from 'svelte-i18n'; import { derived, get } from 'svelte/store'; @@ -310,3 +312,7 @@ export const withError = async (fn: () => Promise): Promise<[undefined, T] return [error, undefined]; } }; + +export const suggestDuplicateByFileSize = (assets: AssetResponseDto[]): AssetResponseDto | undefined => { + return sortBy(assets, (asset) => asset.exifInfo?.fileSizeInByte).pop(); +}; diff --git a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte index 7c048e8ed0..3e0ab8bb60 100644 --- a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -11,42 +11,129 @@ import { deleteAssets, updateAssets } from '@immich/sdk'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; + import { suggestDuplicateByFileSize } from '$lib/utils'; + import LinkButton from '$lib/components/elements/buttons/link-button.svelte'; + import { mdiCheckOutline, mdiTrashCanOutline } from '@mdi/js'; + import Icon from '$lib/components/elements/icon.svelte'; export let data: PageData; - const handleResolve = async (duplicateId: string, duplicateAssetIds: string[], trashIds: string[]) => { - try { - if (!$featureFlags.trash && trashIds.length > 0) { - const isConfirmed = await dialogController.show({ - prompt: $t('delete_duplicates_confirmation'), - confirmText: $t('permanently_delete'), - }); + $: hasDuplicates = data.duplicates.length > 0; - if (!isConfirmed) { - return; - } - } - - await updateAssets({ assetBulkUpdateDto: { ids: duplicateAssetIds, duplicateId: null } }); - await deleteAssets({ assetBulkDeleteDto: { ids: trashIds, force: !$featureFlags.trash } }); - - data.duplicates = data.duplicates.filter((duplicate) => duplicate.duplicateId !== duplicateId); - - if (trashIds.length === 0) { + const withConfirmation = async (callback: () => Promise, prompt?: string, confirmText?: string) => { + if (prompt && confirmText) { + const isConfirmed = await dialogController.show({ prompt, confirmText }); + if (!isConfirmed) { return; } + } - notificationController.show({ - message: $t('assets_moved_to_trash', { values: { count: trashIds.length } }), - type: NotificationType.Info, - }); + try { + return await callback(); } catch (error) { handleError(error, $t('errors.unable_to_resolve_duplicate')); } }; + + const deletedNotification = (trashedCount: number) => { + if (!trashedCount) { + return; + } + + notificationController.show({ + message: $featureFlags.trash + ? $t('assets_moved_to_trash', { values: { count: trashedCount } }) + : $t('permanently_deleted_assets', { values: { count: trashedCount } }), + type: NotificationType.Info, + }); + }; + + const handleResolve = async (duplicateId: string, duplicateAssetIds: string[], trashIds: string[]) => { + return withConfirmation( + async () => { + await deleteAssets({ assetBulkDeleteDto: { ids: trashIds, force: !$featureFlags.trash } }); + await updateAssets({ assetBulkUpdateDto: { ids: duplicateAssetIds, duplicateId: null } }); + + data.duplicates = data.duplicates.filter((duplicate) => duplicate.duplicateId !== duplicateId); + + deletedNotification(trashIds.length); + }, + trashIds.length > 0 && !$featureFlags.trash ? $t('delete_duplicates_confirmation') : undefined, + trashIds.length > 0 && !$featureFlags.trash ? $t('permanently_delete') : undefined, + ); + }; + + const handleDeduplicateAll = async () => { + const idsToKeep = data.duplicates + .map((group) => suggestDuplicateByFileSize(group.assets)) + .map((asset) => asset?.id); + const idsToDelete = data.duplicates.flatMap((group, i) => + group.assets.map((asset) => asset.id).filter((asset) => asset !== idsToKeep[i]), + ); + + let prompt, confirmText; + if ($featureFlags.trash) { + prompt = $t('bulk_trash_duplicates_confirmation', { values: { count: idsToDelete.length } }); + confirmText = $t('confirm'); + } else { + prompt = $t('bulk_delete_duplicates_confirmation', { values: { count: idsToDelete.length } }); + confirmText = $t('permanently_delete'); + } + + return withConfirmation( + async () => { + await deleteAssets({ assetBulkDeleteDto: { ids: idsToDelete, force: !$featureFlags.trash } }); + await updateAssets({ + assetBulkUpdateDto: { + ids: [...idsToDelete, ...idsToKeep.filter((id): id is string => !!id)], + duplicateId: null, + }, + }); + + data.duplicates = []; + + deletedNotification(idsToDelete.length); + }, + prompt, + confirmText, + ); + }; + + const handleKeepAll = async () => { + const ids = data.duplicates.flatMap((group) => group.assets.map((asset) => asset.id)); + return withConfirmation( + async () => { + await updateAssets({ assetBulkUpdateDto: { ids, duplicateId: null } }); + + data.duplicates = []; + + notificationController.show({ + message: $t('resolved_all_duplicates'), + type: NotificationType.Info, + }); + }, + $t('bulk_keep_duplicates_confirmation', { values: { count: ids.length } }), + $t('confirm'), + ); + }; +
+ handleDeduplicateAll()} disabled={!hasDuplicates}> +
+ + {$t('deduplicate_all')} +
+
+ handleKeepAll()} disabled={!hasDuplicates}> +
+ + {$t('keep_all')} +
+
+
+
{#if data.duplicates && data.duplicates.length > 0}