Skip to content

Commit

Permalink
feat(web): Synchronize albums from deduplicated images
Browse files Browse the repository at this point in the history
* Added visual indication next to the favorite icon to the images if they are archived.
* Added new settings menu to the the deduplication tab.
* The toggable options in the settings are synchronization of: albums, favorites, archives.
* When synchronizing the albums, all albums will be added to all albums of the duplicates. This way the final deduplicated image is in all albums and also the removed images are in these albums if they may be restored.
* When synchronizing the favorite/archive status, all images will be marked as favorite/archived, if only a single image is in the favorite/archived.
* The selected image to keep will be marked with a red favorite/archive symbol if that status will be applied after the deduplication.
  • Loading branch information
EinToni committed Jan 29, 2025
1 parent cc0cbd7 commit bcd98d7
Show file tree
Hide file tree
Showing 5 changed files with 189 additions and 19 deletions.
7 changes: 7 additions & 0 deletions i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1192,6 +1192,7 @@
"show_slideshow_transition": "Show slideshow transition",
"show_supporter_badge": "Supporter badge",
"show_supporter_badge_description": "Show a supporter badge",
"show_synchronise_albums": "Synchronize the albums of all images.",
"shuffle": "Shuffle",
"sidebar": "Sidebar",
"sidebar_display_description": "Display a link to the view in the sidebar",
Expand Down Expand Up @@ -1237,6 +1238,12 @@
"support_third_party_description": "Your Immich installation was packaged by a third-party. Issues you experience may be caused by that package, so please raise issues with them in the first instance using the links below.",
"swap_merge_direction": "Swap merge direction",
"sync": "Sync",
"synchronize_albums" : "Synchronize Albums",
"synchronize_albums_description" : "All deduplicates get put in all albums of all duplicates.",
"synchronize_archives" : "Synchronize Archives",
"synchronize_archives_description" : "If one of the duplicates is a favorite, mark all deduplicated as favorites.",
"synchronize_favorites" : "Synchronize Favorites",
"synchronize_favorites_description" : "If one of the duplicates is already archived, also archive the deduplicated result.",
"tag": "Tag",
"tag_assets": "Tag assets",
"tag_created": "Created tag: {tag}",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,47 @@
import { getAssetResolution, getFileSize } from '$lib/utils/asset-utils';
import { getAltText } from '$lib/utils/thumbnail-util';
import { type AssetResponseDto, getAllAlbums } from '@immich/sdk';
import { mdiHeart, mdiImageMultipleOutline, mdiMagnifyPlus } from '@mdi/js';
import { mdiHeart, mdiImageMultipleOutline, mdiMagnifyPlus, mdiArchiveArrowDownOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { SelectedSyncData } from '$lib/components/utilities-page/duplicates/duplicates-compare-control.svelte';
interface Props {
asset: AssetResponseDto;
isSelected: boolean;
isSynchronizeAlbumsActive: boolean;
isSynchronizeFavoritesActive: boolean;
isSynchronizeArchivesActive: boolean;
selectedSyncData: SelectedSyncData;
onSelectAsset: (asset: AssetResponseDto) => void;
onViewAsset: (asset: AssetResponseDto) => void;
}
let { asset, isSelected, onSelectAsset, onViewAsset }: Props = $props();
let {
asset,
isSelected,
isSynchronizeAlbumsActive,
isSynchronizeFavoritesActive,
isSynchronizeArchivesActive,
selectedSyncData = $bindable(),
onSelectAsset,
onViewAsset,
}: Props = $props();
let isFromExternalLibrary = $derived(!!asset.libraryId);
let assetData = $derived(JSON.stringify(asset, null, 2));
selectedSyncData.isFavorite = selectedSyncData.isFavorite || asset.isFavorite;
selectedSyncData.isArchived = selectedSyncData.isArchived || asset.isArchived;
let displayedFavorite = $derived(
isSelected && isSynchronizeFavoritesActive && selectedSyncData?.isFavorite
? selectedSyncData.isFavorite
: asset.isFavorite,
);
let displayedArchived = $derived(
isSelected && isSynchronizeArchivesActive && selectedSyncData?.isArchived
? selectedSyncData.isArchived
: asset.isArchived,
);
</script>

<div
Expand All @@ -43,11 +70,22 @@
/>

<!-- FAVORITE ICON -->
{#if asset.isFavorite}
<div class="absolute bottom-2 left-2">
<Icon path={mdiHeart} size="24" class="text-white" />
</div>
{/if}
<div class="absolute bottom-2 left-2">
{#if displayedFavorite}
{#if asset.isFavorite}
<Icon path={mdiHeart} size="24" class="text-white inline-block" />
{:else}
<Icon path={mdiHeart} size="24" color="red" class="text-white inline-block" />
{/if}
{/if}
{#if displayedArchived}
{#if asset.isArchived}
<Icon path={mdiArchiveArrowDownOutline} size="24" class="text-white inline-block" />
{:else}
<Icon path={mdiArchiveArrowDownOutline} size="24" color="red" class="text-white inline-block" />
{/if}
{/if}
</div>

<!-- OVERLAY CHIP -->
<div
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<script lang="ts">
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { t } from 'svelte-i18n';
interface Props {
synchronizeAlbums: boolean;
synchronizeArchives: boolean;
synchronizeFavorites: boolean;
onClose: () => void;
onToggleSyncAlbum: () => void;
onToggleSyncArchives: () => void;
onToggleSyncFavorites: () => void;
}
let {
synchronizeAlbums,
synchronizeArchives,
synchronizeFavorites,
onClose,
onToggleSyncAlbum,
onToggleSyncArchives,
onToggleSyncFavorites,
}: Props = $props();
</script>

<FullScreenModal title={$t('options')} width="auto" {onClose}>
<div class="items-center justify-center">
<div class="grid p-2 gap-y-2">
<SettingSwitch
title={$t('synchronize_albums')}
subtitle={$t('synchronize_albums_description')}
checked={synchronizeAlbums}
onToggle={onToggleSyncAlbum}
/>
<SettingSwitch
title={$t('synchronize_favorites')}
subtitle={$t('synchronize_favorites_description')}
checked={synchronizeFavorites}
onToggle={onToggleSyncFavorites}
/>
<SettingSwitch
title={$t('synchronize_archives')}
subtitle={$t('synchronize_archives_description')}
checked={synchronizeArchives}
onToggle={onToggleSyncArchives}
/>
</div>
</div>
</FullScreenModal>
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,35 @@
interface Props {
assets: AssetResponseDto[];
onResolve: (duplicateAssetIds: string[], trashIds: string[]) => void;
isSynchronizeAlbumsActive: boolean;
isSynchronizeArchivesActive: boolean;
isSynchronizeFavoritesActive: boolean;
onResolve: (duplicateAssetIds: string[], trashIds: string[], selectedDataToSync: SelectedSyncData) => void;
onStack: (assets: AssetResponseDto[]) => void;
}
let { assets, onResolve, onStack }: Props = $props();
let {
assets,
isSynchronizeAlbumsActive,
isSynchronizeArchivesActive,
isSynchronizeFavoritesActive,
onResolve,
onStack,
}: Props = $props();
const { isViewing: showAssetViewer, asset: viewingAsset, setAsset } = assetViewingStore;
const getAssetIndex = (id: string) => assets.findIndex((asset) => asset.id === id);
let selectedAssetIds = $state(new SvelteSet<string>());
let trashCount = $derived(assets.length - selectedAssetIds.size);
export interface SelectedSyncData {
isArchived: boolean | null;
isFavorite: boolean | null;
}
let selectedSyncData: SelectedSyncData = $state({
isArchived: null,
isFavorite: null,
});
onMount(() => {
const suggestedAsset = suggestDuplicate(assets);
Expand Down Expand Up @@ -89,7 +108,7 @@
const handleResolve = () => {
const trashIds = assets.map((asset) => asset.id).filter((id) => !selectedAssetIds.has(id));
const duplicateAssetIds = assets.map((asset) => asset.id);
onResolve(duplicateAssetIds, trashIds);
onResolve(duplicateAssetIds, trashIds, selectedSyncData);
};
const handleStack = () => {
Expand Down Expand Up @@ -118,8 +137,12 @@
<DuplicateAsset
{asset}
{onSelectAsset}
bind:selectedSyncData
isSelected={selectedAssetIds.has(asset.id)}
onViewAsset={(asset) => setAsset(asset)}
{isSynchronizeAlbumsActive}
{isSynchronizeFavoritesActive}
{isSynchronizeArchivesActive}
/>
{/each}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,30 +9,38 @@
} from '$lib/components/shared-components/notification/notification';
import ShowShortcuts from '$lib/components/shared-components/show-shortcuts.svelte';
import DuplicatesCompareControl from '$lib/components/utilities-page/duplicates/duplicates-compare-control.svelte';
import { locale } from '$lib/stores/preferences.store';
import type { AssetResponseDto, AlbumResponseDto, AssetBulkUpdateDto } from '@immich/sdk';
import { deleteAssets, updateAssets, getAllAlbums } from '@immich/sdk';
import { featureFlags } from '$lib/stores/server-config.store';
import { stackAssets } from '$lib/utils/asset-utils';
import { stackAssets, addAssetsToAlbum } from '$lib/utils/asset-utils';
import { suggestDuplicate } from '$lib/utils/duplicate-utils';
import { handleError } from '$lib/utils/handle-error';
import type { AssetResponseDto } from '@immich/sdk';
import { deleteAssets, updateAssets } from '@immich/sdk';
import { Button, HStack, IconButton, Text } from '@immich/ui';
import { mdiCheckOutline, mdiInformationOutline, mdiKeyboard, mdiTrashCanOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
import { mdiCheckOutline, mdiInformationOutline, mdiKeyboard, mdiTrashCanOutline, mdiCogOutline } from '@mdi/js';
import DuplicateOptions from '$lib/components/utilities-page/duplicates/duplicate-options.svelte';
import { locale } from '$lib/stores/preferences.store';
import type { SelectedSyncData } from '$lib/components/utilities-page/duplicates/duplicates-compare-control.svelte';
interface Props {
data: PageData;
isShowKeyboardShortcut?: boolean;
isShowDuplicateInfo?: boolean;
isShowOptions?: boolean;
}
let {
data = $bindable(),
isShowKeyboardShortcut = $bindable(false),
isShowDuplicateInfo = $bindable(false),
isShowOptions = $bindable(false),
}: Props = $props();
let isSynchronizeAlbumsActive = $state(true);
let isSynchronizeFavoritesActive = $state(true);
let isSynchronizeArchivesActive = $state(true);
interface Shortcuts {
general: ExplainedShortcut[];
actions: ExplainedShortcut[];
Expand Down Expand Up @@ -84,11 +92,29 @@
});
};
const handleResolve = async (duplicateId: string, duplicateAssetIds: string[], trashIds: string[]) => {
const handleResolve = async (
duplicateId: string,
duplicateAssetIds: string[],
trashIds: string[],
selectedDataToSync: SelectedSyncData,
) => {
return withConfirmation(
async () => {
let assetBulkUpdate: AssetBulkUpdateDto = {
ids: duplicateAssetIds,
duplicateId: null,
};
if (isSynchronizeAlbumsActive) {
await synchronizeAlbums(duplicateAssetIds);
}
if (isSynchronizeArchivesActive) {
assetBulkUpdate.isArchived = selectedDataToSync.isArchived;
}
if (isSynchronizeFavoritesActive) {
assetBulkUpdate.isFavorite = selectedDataToSync.isFavorite;
}
await deleteAssets({ assetBulkDeleteDto: { ids: trashIds, force: !$featureFlags.trash } });
await updateAssets({ assetBulkUpdateDto: { ids: duplicateAssetIds, duplicateId: null } });
await updateAssets({ assetBulkUpdateDto: assetBulkUpdate });
duplicates = duplicates.filter((duplicate) => duplicate.duplicateId !== duplicateId);
Expand All @@ -99,6 +125,17 @@
);
};
const synchronizeAlbums = async (assetIds: string[]) => {
const allAlbums: AlbumResponseDto[] = await Promise.all(
assetIds.map((assetId) => getAllAlbums({ assetId: assetId })),
);
const albumIds = [...new Set(allAlbums.flat().map((album) => album.id))];
albumIds.forEach((albumId) => {
addAssetsToAlbum(albumId, assetIds, false);
});
};
const handleStack = async (duplicateId: string, assets: AssetResponseDto[]) => {
await stackAssets(assets, false);
const duplicateAssetIds = assets.map((asset) => asset.id);
Expand Down Expand Up @@ -191,6 +228,7 @@
title={$t('show_keyboard_shortcuts')}
onclick={() => (isShowKeyboardShortcut = !isShowKeyboardShortcut)}
/>
<CircleIconButton icon={mdiCogOutline} title={$t('options')} onclick={() => (isShowOptions = !isShowOptions)} />
</HStack>
{/snippet}

Expand All @@ -212,9 +250,12 @@
{#key duplicates[0].duplicateId}
<DuplicatesCompareControl
assets={duplicates[0].assets}
onResolve={(duplicateAssetIds, trashIds) =>
handleResolve(duplicates[0].duplicateId, duplicateAssetIds, trashIds)}
onResolve={(duplicateAssetIds, trashIds, selectedDataToSync) =>
handleResolve(duplicates[0].duplicateId, duplicateAssetIds, trashIds, selectedDataToSync)}
onStack={(assets) => handleStack(duplicates[0].duplicateId, assets)}
{isSynchronizeAlbumsActive}
{isSynchronizeFavoritesActive}
{isSynchronizeArchivesActive}
/>
{/key}
{:else}
Expand All @@ -225,6 +266,18 @@
</div>
</UserPageLayout>

{#if isShowOptions}
<DuplicateOptions
synchronizeAlbums={isSynchronizeAlbumsActive}
synchronizeFavorites={isSynchronizeFavoritesActive}
synchronizeArchives={isSynchronizeArchivesActive}
onClose={() => (isShowOptions = false)}
onToggleSyncAlbum={() => (isSynchronizeAlbumsActive = !isSynchronizeAlbumsActive)}
onToggleSyncFavorites={() => (isSynchronizeFavoritesActive = !isSynchronizeFavoritesActive)}
onToggleSyncArchives={() => (isSynchronizeArchivesActive = !isSynchronizeArchivesActive)}
/>
{/if}

{#if isShowKeyboardShortcut}
<ShowShortcuts shortcuts={duplicateShortcuts} onClose={() => (isShowKeyboardShortcut = false)} />
{/if}
Expand Down

0 comments on commit bcd98d7

Please sign in to comment.