mirror of
https://github.com/immich-app/immich.git
synced 2025-01-27 22:22:45 +01:00
feat(web): Add stacking option to deduplication utilities (#11114)
* feat(web): Add stacking option to deduplication utilities * Update web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte Co-authored-by: Alex <alex.tran1502@gmail.com> * Fix prettier * Draft for server side modifications. Endpoint for stacks (PUT,DELETE) * Fix error * Disable stakc button if less or more than one asset selected * Remove unnecesarry log * Revert to first commit * Further Revert * Actually Revert to Origin * Only one stack button * Update +page.svelte * Fix optional arguments * Fix Prettier * Fix Linting * Add stack information to asset view * clean up --------- Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
parent
9f4fad2a0f
commit
65f5118bdd
5 changed files with 82 additions and 30 deletions
web/src
lib
components/utilities-page/duplicates
i18n
utils
routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]
|
@ -4,7 +4,7 @@
|
||||||
import { getAssetResolution, getFileSize } from '$lib/utils/asset-utils';
|
import { getAssetResolution, getFileSize } from '$lib/utils/asset-utils';
|
||||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||||
import { getAllAlbums, type AssetResponseDto } from '@immich/sdk';
|
import { getAllAlbums, type AssetResponseDto } from '@immich/sdk';
|
||||||
import { mdiHeart, mdiMagnifyPlus } from '@mdi/js';
|
import { mdiHeart, mdiMagnifyPlus, mdiImageMultipleOutline } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
export let asset: AssetResponseDto;
|
export let asset: AssetResponseDto;
|
||||||
|
@ -14,6 +14,7 @@
|
||||||
|
|
||||||
$: isFromExternalLibrary = !!asset.libraryId;
|
$: isFromExternalLibrary = !!asset.libraryId;
|
||||||
$: assetData = JSON.stringify(asset, null, 2);
|
$: assetData = JSON.stringify(asset, null, 2);
|
||||||
|
$: stackCount = asset.stackCount;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
@ -54,12 +55,22 @@
|
||||||
{isSelected ? $t('keep') : $t('to_trash')}
|
{isSelected ? $t('keep') : $t('to_trash')}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- EXTERNAL LIBRARY CHIP-->
|
<!-- EXTERNAL LIBRARY / STACK COUNT CHIP-->
|
||||||
{#if isFromExternalLibrary}
|
<div class="absolute top-2 right-3">
|
||||||
<div class="absolute top-2 right-3 bg-immich-primary/90 px-4 py-1 rounded-xl text-xs text-white">
|
{#if isFromExternalLibrary}
|
||||||
{$t('external')}
|
<div class="bg-immich-primary/90 px-2 py-1 rounded-xl text-xs text-white">
|
||||||
</div>
|
{$t('external')}
|
||||||
{/if}
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if stackCount != null && stackCount != 0}
|
||||||
|
<div class="bg-immich-primary/90 px-2 py-1 my-0.5 rounded-xl text-xs text-white">
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
<div class="mr-1">{stackCount}</div>
|
||||||
|
<Icon path={mdiImageMultipleOutline} size="18" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
|
@ -7,13 +7,13 @@
|
||||||
import { suggestDuplicateByFileSize } from '$lib/utils';
|
import { suggestDuplicateByFileSize } from '$lib/utils';
|
||||||
import { shortcuts } from '$lib/actions/shortcut';
|
import { shortcuts } from '$lib/actions/shortcut';
|
||||||
import { type AssetResponseDto } from '@immich/sdk';
|
import { type AssetResponseDto } from '@immich/sdk';
|
||||||
import { mdiCheck, mdiTrashCanOutline } from '@mdi/js';
|
import { mdiCheck, mdiTrashCanOutline, mdiImageMultipleOutline } from '@mdi/js';
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
export let assets: AssetResponseDto[];
|
export let assets: AssetResponseDto[];
|
||||||
export let onResolve: (duplicateAssetIds: string[], trashIds: string[]) => void;
|
export let onResolve: (duplicateAssetIds: string[], trashIds: string[]) => void;
|
||||||
|
export let onStack: (assets: AssetResponseDto[]) => void;
|
||||||
const { isViewing: showAssetViewer, asset: viewingAsset, setAsset } = assetViewingStore;
|
const { isViewing: showAssetViewer, asset: viewingAsset, setAsset } = assetViewingStore;
|
||||||
const getAssetIndex = (id: string) => assets.findIndex((asset) => asset.id === id);
|
const getAssetIndex = (id: string) => assets.findIndex((asset) => asset.id === id);
|
||||||
|
|
||||||
|
@ -60,6 +60,10 @@
|
||||||
const duplicateAssetIds = assets.map((asset) => asset.id);
|
const duplicateAssetIds = assets.map((asset) => asset.id);
|
||||||
onResolve(duplicateAssetIds, trashIds);
|
onResolve(duplicateAssetIds, trashIds);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleStack = () => {
|
||||||
|
onStack(assets);
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window
|
<svelte:window
|
||||||
|
@ -73,6 +77,7 @@
|
||||||
},
|
},
|
||||||
{ shortcut: { key: 'd' }, onShortcut: onSelectNone },
|
{ shortcut: { key: 'd' }, onShortcut: onSelectNone },
|
||||||
{ shortcut: { key: 'c', shift: true }, onShortcut: handleResolve },
|
{ shortcut: { key: 'c', shift: true }, onShortcut: handleResolve },
|
||||||
|
{ shortcut: { key: 's', shift: true }, onShortcut: handleStack },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
@ -104,17 +109,38 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- CONFIRM BUTTONS -->
|
<!-- CONFIRM BUTTONS -->
|
||||||
{#if trashCount === 0}
|
<div class="flex text-xs text-black">
|
||||||
<Button size="sm" color="primary" class="flex place-items-center gap-2" on:click={handleResolve}>
|
{#if trashCount === 0}
|
||||||
<Icon path={mdiCheck} size="20" />{$t('keep_all')}
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="primary"
|
||||||
|
class="flex place-items-center rounded-tl-full rounded-bl-full gap-2"
|
||||||
|
on:click={handleResolve}
|
||||||
|
>
|
||||||
|
<Icon path={mdiCheck} size="20" />{$t('keep_all')}
|
||||||
|
</Button>
|
||||||
|
{:else}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="red"
|
||||||
|
class="flex place-items-center rounded-tl-full rounded-bl-full gap-2 py-3"
|
||||||
|
on:click={handleResolve}
|
||||||
|
>
|
||||||
|
<Icon path={mdiTrashCanOutline} size="20" />{trashCount === assets.length
|
||||||
|
? $t('trash_all')
|
||||||
|
: $t('trash_count', { values: { count: trashCount } })}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="primary"
|
||||||
|
class="flex place-items-center rounded-tr-full rounded-br-full gap-2"
|
||||||
|
on:click={handleStack}
|
||||||
|
disabled={selectedAssetIds.size !== 1}
|
||||||
|
>
|
||||||
|
<Icon path={mdiImageMultipleOutline} size="20" />{$t('stack')}
|
||||||
</Button>
|
</Button>
|
||||||
{:else}
|
</div>
|
||||||
<Button size="sm" color="red" class="flex place-items-center gap-2 py-3" on:click={handleResolve}>
|
|
||||||
<Icon path={mdiTrashCanOutline} size="20" />{trashCount === assets.length
|
|
||||||
? $t('trash_all')
|
|
||||||
: $t('trash_count', { values: { count: trashCount } })}
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -1116,6 +1116,8 @@
|
||||||
"sort_title": "Title",
|
"sort_title": "Title",
|
||||||
"source": "Source",
|
"source": "Source",
|
||||||
"stack": "Stack",
|
"stack": "Stack",
|
||||||
|
"stack_duplicates": "Stack duplicates",
|
||||||
|
"stack_select_one_photo": "Select one main photo for the stack",
|
||||||
"stack_selected_photos": "Stack selected photos",
|
"stack_selected_photos": "Stack selected photos",
|
||||||
"stacked_assets_count": "Stacked {count, plural, one {# asset} other {# assets}}",
|
"stacked_assets_count": "Stacked {count, plural, one {# asset} other {# assets}}",
|
||||||
"stacktrace": "Stacktrace",
|
"stacktrace": "Stacktrace",
|
||||||
|
|
|
@ -324,7 +324,7 @@ export const getSelectedAssets = (assets: Set<AssetResponseDto>, user: UserRespo
|
||||||
return ids;
|
return ids;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const stackAssets = async (assets: AssetResponseDto[]) => {
|
export const stackAssets = async (assets: AssetResponseDto[], showNotification = true) => {
|
||||||
if (assets.length < 2) {
|
if (assets.length < 2) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
@ -362,16 +362,18 @@ export const stackAssets = async (assets: AssetResponseDto[]) => {
|
||||||
parent.stack = parent.stack.concat(children, grandChildren);
|
parent.stack = parent.stack.concat(children, grandChildren);
|
||||||
parent.stackCount = parent.stack.length + 1;
|
parent.stackCount = parent.stack.length + 1;
|
||||||
|
|
||||||
notificationController.show({
|
if (showNotification) {
|
||||||
message: $t('stacked_assets_count', { values: { count: parent.stackCount } }),
|
notificationController.show({
|
||||||
type: NotificationType.Info,
|
message: $t('stacked_assets_count', { values: { count: parent.stackCount } }),
|
||||||
button: {
|
type: NotificationType.Info,
|
||||||
text: $t('view_stack'),
|
button: {
|
||||||
onClick() {
|
text: $t('view_stack'),
|
||||||
return assetViewingStore.setAssetId(parent.id);
|
onClick() {
|
||||||
|
return assetViewingStore.setAssetId(parent.id);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
return ids;
|
return ids;
|
||||||
};
|
};
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
notificationController,
|
notificationController,
|
||||||
} from '$lib/components/shared-components/notification/notification';
|
} from '$lib/components/shared-components/notification/notification';
|
||||||
import DuplicatesCompareControl from '$lib/components/utilities-page/duplicates/duplicates-compare-control.svelte';
|
import DuplicatesCompareControl from '$lib/components/utilities-page/duplicates/duplicates-compare-control.svelte';
|
||||||
|
import type { AssetResponseDto } from '@immich/sdk';
|
||||||
import { featureFlags } from '$lib/stores/server-config.store';
|
import { featureFlags } from '$lib/stores/server-config.store';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { deleteAssets, updateAssets } from '@immich/sdk';
|
import { deleteAssets, updateAssets } from '@immich/sdk';
|
||||||
|
@ -13,10 +14,11 @@
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import { suggestDuplicateByFileSize } from '$lib/utils';
|
import { suggestDuplicateByFileSize } from '$lib/utils';
|
||||||
import LinkButton from '$lib/components/elements/buttons/link-button.svelte';
|
import LinkButton from '$lib/components/elements/buttons/link-button.svelte';
|
||||||
|
import { mdiCheckOutline, mdiTrashCanOutline } from '@mdi/js';
|
||||||
|
import { stackAssets } from '$lib/utils/asset-utils';
|
||||||
import ShowShortcuts from '$lib/components/shared-components/show-shortcuts.svelte';
|
import ShowShortcuts from '$lib/components/shared-components/show-shortcuts.svelte';
|
||||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||||
import { mdiKeyboard } from '@mdi/js';
|
import { mdiKeyboard } from '@mdi/js';
|
||||||
import { mdiCheckOutline, mdiTrashCanOutline } from '@mdi/js';
|
|
||||||
import Icon from '$lib/components/elements/icon.svelte';
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
import { locale } from '$lib/stores/preferences.store';
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
|
|
||||||
|
@ -40,6 +42,7 @@
|
||||||
{ key: ['s'], action: $t('view') },
|
{ key: ['s'], action: $t('view') },
|
||||||
{ key: ['d'], action: $t('unselect_all_duplicates') },
|
{ key: ['d'], action: $t('unselect_all_duplicates') },
|
||||||
{ key: ['⇧', 'c'], action: $t('resolve_duplicates') },
|
{ key: ['⇧', 'c'], action: $t('resolve_duplicates') },
|
||||||
|
{ key: ['⇧', 'c'], action: $t('stack_duplicates') },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -88,6 +91,13 @@
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleStack = async (duplicateId: string, assets: AssetResponseDto[]) => {
|
||||||
|
await stackAssets(assets, false);
|
||||||
|
const duplicateAssetIds = assets.map((asset) => asset.id);
|
||||||
|
await updateAssets({ assetBulkUpdateDto: { ids: duplicateAssetIds, duplicateId: null } });
|
||||||
|
data.duplicates = data.duplicates.filter((duplicate) => duplicate.duplicateId !== duplicateId);
|
||||||
|
};
|
||||||
|
|
||||||
const handleDeduplicateAll = async () => {
|
const handleDeduplicateAll = async () => {
|
||||||
const idsToKeep = data.duplicates
|
const idsToKeep = data.duplicates
|
||||||
.map((group) => suggestDuplicateByFileSize(group.assets))
|
.map((group) => suggestDuplicateByFileSize(group.assets))
|
||||||
|
@ -174,6 +184,7 @@
|
||||||
assets={data.duplicates[0].assets}
|
assets={data.duplicates[0].assets}
|
||||||
onResolve={(duplicateAssetIds, trashIds) =>
|
onResolve={(duplicateAssetIds, trashIds) =>
|
||||||
handleResolve(data.duplicates[0].duplicateId, duplicateAssetIds, trashIds)}
|
handleResolve(data.duplicates[0].duplicateId, duplicateAssetIds, trashIds)}
|
||||||
|
onStack={(assets) => handleStack(data.duplicates[0].duplicateId, assets)}
|
||||||
/>
|
/>
|
||||||
{/key}
|
{/key}
|
||||||
{:else}
|
{:else}
|
||||||
|
|
Loading…
Reference in a new issue