mirror of
https://github.com/immich-app/immich.git
synced 2024-12-29 15:11:58 +00:00
feat(web): bulk deduplicate (#10448)
* bulk deduplicate * notification for keeping all duplicates * fix notification * remove unused text * pr feedback * wording * formatting
This commit is contained in:
parent
86cbc6e125
commit
b3f9641edf
4 changed files with 123 additions and 24 deletions
|
@ -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);
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 <T>(fn: () => Promise<T>): Promise<[undefined, T]
|
|||
return [error, undefined];
|
||||
}
|
||||
};
|
||||
|
||||
export const suggestDuplicateByFileSize = (assets: AssetResponseDto[]): AssetResponseDto | undefined => {
|
||||
return sortBy(assets, (asset) => asset.exifInfo?.fileSizeInByte).pop();
|
||||
};
|
||||
|
|
|
@ -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<void>, 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'),
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<UserPageLayout title={data.meta.title + ` (${data.duplicates.length})`} scrollbar={true}>
|
||||
<div class="flex place-items-center gap-2" slot="buttons">
|
||||
<LinkButton on:click={() => handleDeduplicateAll()} disabled={!hasDuplicates}>
|
||||
<div class="flex place-items-center gap-2 text-sm">
|
||||
<Icon path={mdiTrashCanOutline} size="18" />
|
||||
{$t('deduplicate_all')}
|
||||
</div>
|
||||
</LinkButton>
|
||||
<LinkButton on:click={() => handleKeepAll()} disabled={!hasDuplicates}>
|
||||
<div class="flex place-items-center gap-2 text-sm">
|
||||
<Icon path={mdiCheckOutline} size="18" />
|
||||
{$t('keep_all')}
|
||||
</div>
|
||||
</LinkButton>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
{#if data.duplicates && data.duplicates.length > 0}
|
||||
<div class="mb-4 text-sm dark:text-white">
|
||||
|
|
Loading…
Reference in a new issue