diff --git a/i18n/en.json b/i18n/en.json index 5903d7850e..371ab67ef7 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -2047,6 +2047,18 @@ "sync_status": "Sync Status", "sync_status_subtitle": "View and manage the sync system", "sync_upload_album_setting_subtitle": "Create and upload your photos and videos to the selected albums on Immich", + "synchronize_albums": "Synchronize Albums", + "synchronize_albums_description": "Add the resolved asset to every album that any of the duplicates belong to.", + "synchronize_description": "Synchronize Description", + "synchronize_description_description": "Merge the descriptions from all duplicates into a single description for the resolved asset.", + "synchronize_favorites": "Synchronize Favorites", + "synchronize_favorites_description": "If any duplicate is marked as a favorite, mark the resolved asset as favorite.", + "synchronize_location": "Synchronize Location", + "synchronize_location_description": "If exactly one unique latitude/longitude pair exists across the duplicates, apply that location to the resolved asset.", + "synchronize_rating": "Synchronize Rating", + "synchronize_rating_description": "Use the highest rating found in the duplicates' EXIF data for the resolved asset.", + "synchronize_visibility": "Synchronize Visibility setting", + "synchronize_visibility_description": "Apply the most restrictive visibility present among the duplicates (Locked → Hidden → Archive → Timeline).", "tag": "Tag", "tag_assets": "Tag assets", "tag_created": "Created tag: {tag}", diff --git a/web/src/lib/components/utilities-page/duplicates/duplicate-settings-modal.svelte b/web/src/lib/components/utilities-page/duplicates/duplicate-settings-modal.svelte new file mode 100644 index 0000000000..b982e463ba --- /dev/null +++ b/web/src/lib/components/utilities-page/duplicates/duplicate-settings-modal.svelte @@ -0,0 +1,51 @@ + + + + +
+ + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + +
diff --git a/web/src/lib/stores/preferences.store.ts b/web/src/lib/stores/preferences.store.ts index e6d5941a18..0669e4f9d9 100644 --- a/web/src/lib/stores/preferences.store.ts +++ b/web/src/lib/stores/preferences.store.ts @@ -151,3 +151,21 @@ export const autoPlayVideo = persisted('auto-play-video', true, {}); export const alwaysLoadOriginalVideo = persisted('always-load-original-video', false, {}); export const recentAlbumsDropdown = persisted('recent-albums-open', true, {}); + +export interface DuplicateSettings { + synchronizeAlbums: boolean; + synchronizeVisibility: boolean; + synchronizeFavorites: boolean; + synchronizeRating: boolean; + synchronizeDescpription: boolean; + synchronizeLocation: boolean; +} + +export const duplicateSettings = persistedObject('duplicate-settings', { + synchronizeAlbums: false, + synchronizeVisibility: false, + synchronizeFavorites: false, + synchronizeRating: false, + synchronizeDescpription: false, + synchronizeLocation: false, +}); 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 c6943c6491..3bc1853056 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 @@ -3,23 +3,26 @@ import { page } from '$app/state'; import { shortcuts } from '$lib/actions/shortcut'; import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; + import DuplicateSettingsModal from '$lib/components/utilities-page/duplicates/duplicate-settings-modal.svelte'; import DuplicatesCompareControl from '$lib/components/utilities-page/duplicates/duplicates-compare-control.svelte'; import { AppRoute } from '$lib/constants'; + import { authManager } from '$lib/managers/auth-manager.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import DuplicatesInformationModal from '$lib/modals/DuplicatesInformationModal.svelte'; import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; - import { locale } from '$lib/stores/preferences.store'; + import { duplicateSettings, locale } from '$lib/stores/preferences.store'; import { stackAssets } 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, deleteDuplicates, updateAssets } from '@immich/sdk'; + import type { AssetBulkUpdateDto, AssetResponseDto } from '@immich/sdk'; + import { AssetVisibility, copyAsset, deleteAssets, deleteDuplicates, getAssetInfo, updateAssets } from '@immich/sdk'; import { Button, HStack, IconButton, modalManager, Text, toastManager } from '@immich/ui'; import { mdiCheckOutline, mdiChevronLeft, mdiChevronRight, + mdiCogOutline, mdiInformationOutline, mdiKeyboard, mdiPageFirst, @@ -56,6 +59,13 @@ ], }; + const onShowSettings = async () => { + const settings = await modalManager.show(DuplicateSettingsModal, { settings: { ...$duplicateSettings } }); + if (settings) { + $duplicateSettings = settings; + } + }; + let duplicates = $state(data.duplicates); const { isViewing: showAssetViewer } = assetViewingStore; @@ -98,11 +108,68 @@ toastManager.success(message); }; + const getSyncedInfo = async (assetIds: string[]) => { + const allAssetsInfo = await Promise.all( + assetIds.map((assetId) => getAssetInfo({ ...authManager.params, id: assetId })), + ); + // If any of the assets is favorite, we consider the synced info as favorite + const isFavorite = allAssetsInfo.some((asset) => asset.isFavorite); + // Choose the most restrictive visibility level among the assets + const visibility = [ + AssetVisibility.Locked, + AssetVisibility.Hidden, + AssetVisibility.Archive, + AssetVisibility.Timeline, + ].find((level) => allAssetsInfo.some((asset) => asset.visibility === level)); + // Choose the highest rating from the exif data of the assets + const rating = Math.max(...allAssetsInfo.map((asset) => asset.exifInfo?.rating ?? 0)); + // Concatenate the single descriptions of the assets + const description = allAssetsInfo.map((asset) => asset.exifInfo?.description).join('\n'); + // Check that only one pair of latitude/longitude exists among the assets + const latitudes = new Set(allAssetsInfo.map((asset) => asset.exifInfo?.latitude).filter((lat) => lat !== null)); + const longitudes = new Set(allAssetsInfo.map((asset) => asset.exifInfo?.longitude).filter((lon) => lon !== null)); + const latitude = latitudes.size === 1 ? Array.from(latitudes)[0] : null; + const longitude = longitudes.size === 1 ? Array.from(longitudes)[0] : null; + + return { isFavorite, visibility, rating, description, latitude, longitude }; + }; + const handleResolve = async (duplicateId: string, duplicateAssetIds: string[], trashIds: string[]) => { return withConfirmation( async () => { + const { isFavorite, visibility, rating, description, latitude, longitude } = + await getSyncedInfo(duplicateAssetIds); + let assetBulkUpdate: AssetBulkUpdateDto = { + ids: duplicateAssetIds, + duplicateId: null, + }; + if ($duplicateSettings.synchronizeFavorites) { + assetBulkUpdate.isFavorite = isFavorite; + } + if ($duplicateSettings.synchronizeVisibility) { + assetBulkUpdate.visibility = visibility; + } + if ($duplicateSettings.synchronizeRating) { + assetBulkUpdate.rating = rating; + } + if ($duplicateSettings.synchronizeDescpription) { + assetBulkUpdate.description = description; + } + if ($duplicateSettings.synchronizeLocation && latitude !== null && longitude !== null) { + assetBulkUpdate.latitude = latitude; + assetBulkUpdate.longitude = longitude; + } + if ($duplicateSettings.synchronizeAlbums) { + const idsToKeep = duplicateAssetIds.filter((id) => !trashIds.includes(id)); + for (const sourceId of trashIds) { + for (const targetId of idsToKeep) { + await copyAsset({ assetCopyDto: { sourceId, targetId, albums: true } }); + } + } + } + await deleteAssets({ assetBulkDeleteDto: { ids: trashIds, force: !featureFlagsManager.value.trash } }); - await updateAssets({ assetBulkUpdateDto: { ids: duplicateAssetIds, duplicateId: null } }); + await updateAssets({ assetBulkUpdateDto: assetBulkUpdate }); duplicates = duplicates.filter((duplicate) => duplicate.duplicateId !== duplicateId); @@ -245,6 +312,15 @@ onclick={() => modalManager.show(ShortcutsModal, { shortcuts: duplicateShortcuts })} aria-label={$t('show_keyboard_shortcuts')} /> + {/snippet}