Merge d88941be7d into 5ade152bc5
commit
8f804e03ba
12
i18n/en.json
12
i18n/en.json
|
|
@ -2047,6 +2047,18 @@
|
||||||
"sync_status": "Sync Status",
|
"sync_status": "Sync Status",
|
||||||
"sync_status_subtitle": "View and manage the sync system",
|
"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",
|
"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": "Tag",
|
||||||
"tag_assets": "Tag assets",
|
"tag_assets": "Tag assets",
|
||||||
"tag_created": "Created tag: {tag}",
|
"tag_created": "Created tag: {tag}",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { DuplicateSettings } from '$lib/stores/preferences.store';
|
||||||
|
import { Button, Field, HStack, Modal, ModalBody, ModalFooter, Stack, Switch } from '@immich/ui';
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
settings: DuplicateSettings;
|
||||||
|
onClose: (settings?: DuplicateSettings) => void;
|
||||||
|
}
|
||||||
|
let { settings: initialValues, onClose }: Props = $props();
|
||||||
|
let settings = $state(initialValues);
|
||||||
|
|
||||||
|
const onsubmit = (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onClose(settings);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal title={$t('options')} {onClose} size="medium">
|
||||||
|
<ModalBody>
|
||||||
|
<form {onsubmit} id="duplicate-settings-form">
|
||||||
|
<Stack gap={4}>
|
||||||
|
<Field label={$t('synchronize_albums')} description={$t('synchronize_albums_description')}>
|
||||||
|
<Switch bind:checked={settings.synchronizeAlbums} />
|
||||||
|
</Field>
|
||||||
|
<Field label={$t('synchronize_favorites')} description={$t('synchronize_favorites_description')}>
|
||||||
|
<Switch bind:checked={settings.synchronizeFavorites} />
|
||||||
|
</Field>
|
||||||
|
<Field label={$t('synchronize_rating')} description={$t('synchronize_rating_description')}>
|
||||||
|
<Switch bind:checked={settings.synchronizeRating} />
|
||||||
|
</Field>
|
||||||
|
<Field label={$t('synchronize_description')} description={$t('synchronize_description_description')}>
|
||||||
|
<Switch bind:checked={settings.synchronizeDescpription} />
|
||||||
|
</Field>
|
||||||
|
<Field label={$t('synchronize_visibility')} description={$t('synchronize_visibility_description')}>
|
||||||
|
<Switch bind:checked={settings.synchronizeVisibility} />
|
||||||
|
</Field>
|
||||||
|
<Field label={$t('synchronize_location')} description={$t('synchronize_location_description')}>
|
||||||
|
<Switch bind:checked={settings.synchronizeLocation} />
|
||||||
|
</Field>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
</ModalBody>
|
||||||
|
|
||||||
|
<ModalFooter>
|
||||||
|
<HStack fullWidth>
|
||||||
|
<Button color="secondary" shape="round" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
|
||||||
|
<Button type="submit" shape="round" fullWidth form="duplicate-settings-form">{$t('save')}</Button>
|
||||||
|
</HStack>
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
||||||
|
|
@ -151,3 +151,21 @@ export const autoPlayVideo = persisted<boolean>('auto-play-video', true, {});
|
||||||
export const alwaysLoadOriginalVideo = persisted<boolean>('always-load-original-video', false, {});
|
export const alwaysLoadOriginalVideo = persisted<boolean>('always-load-original-video', false, {});
|
||||||
|
|
||||||
export const recentAlbumsDropdown = persisted<boolean>('recent-albums-open', true, {});
|
export const recentAlbumsDropdown = persisted<boolean>('recent-albums-open', true, {});
|
||||||
|
|
||||||
|
export interface DuplicateSettings {
|
||||||
|
synchronizeAlbums: boolean;
|
||||||
|
synchronizeVisibility: boolean;
|
||||||
|
synchronizeFavorites: boolean;
|
||||||
|
synchronizeRating: boolean;
|
||||||
|
synchronizeDescpription: boolean;
|
||||||
|
synchronizeLocation: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const duplicateSettings = persistedObject<DuplicateSettings>('duplicate-settings', {
|
||||||
|
synchronizeAlbums: false,
|
||||||
|
synchronizeVisibility: false,
|
||||||
|
synchronizeFavorites: false,
|
||||||
|
synchronizeRating: false,
|
||||||
|
synchronizeDescpription: false,
|
||||||
|
synchronizeLocation: false,
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,23 +3,26 @@
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { shortcuts } from '$lib/actions/shortcut';
|
import { shortcuts } from '$lib/actions/shortcut';
|
||||||
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
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 DuplicatesCompareControl from '$lib/components/utilities-page/duplicates/duplicates-compare-control.svelte';
|
||||||
import { AppRoute } from '$lib/constants';
|
import { AppRoute } from '$lib/constants';
|
||||||
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||||
import DuplicatesInformationModal from '$lib/modals/DuplicatesInformationModal.svelte';
|
import DuplicatesInformationModal from '$lib/modals/DuplicatesInformationModal.svelte';
|
||||||
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
|
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
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 { stackAssets } from '$lib/utils/asset-utils';
|
||||||
import { suggestDuplicate } from '$lib/utils/duplicate-utils';
|
import { suggestDuplicate } from '$lib/utils/duplicate-utils';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import type { AssetResponseDto } from '@immich/sdk';
|
import type { AssetBulkUpdateDto, AssetResponseDto } from '@immich/sdk';
|
||||||
import { deleteAssets, deleteDuplicates, updateAssets } from '@immich/sdk';
|
import { AssetVisibility, copyAsset, deleteAssets, deleteDuplicates, getAssetInfo, updateAssets } from '@immich/sdk';
|
||||||
import { Button, HStack, IconButton, modalManager, Text, toastManager } from '@immich/ui';
|
import { Button, HStack, IconButton, modalManager, Text, toastManager } from '@immich/ui';
|
||||||
import {
|
import {
|
||||||
mdiCheckOutline,
|
mdiCheckOutline,
|
||||||
mdiChevronLeft,
|
mdiChevronLeft,
|
||||||
mdiChevronRight,
|
mdiChevronRight,
|
||||||
|
mdiCogOutline,
|
||||||
mdiInformationOutline,
|
mdiInformationOutline,
|
||||||
mdiKeyboard,
|
mdiKeyboard,
|
||||||
mdiPageFirst,
|
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);
|
let duplicates = $state(data.duplicates);
|
||||||
const { isViewing: showAssetViewer } = assetViewingStore;
|
const { isViewing: showAssetViewer } = assetViewingStore;
|
||||||
|
|
||||||
|
|
@ -98,11 +108,68 @@
|
||||||
toastManager.success(message);
|
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[]) => {
|
const handleResolve = async (duplicateId: string, duplicateAssetIds: string[], trashIds: string[]) => {
|
||||||
return withConfirmation(
|
return withConfirmation(
|
||||||
async () => {
|
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 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);
|
duplicates = duplicates.filter((duplicate) => duplicate.duplicateId !== duplicateId);
|
||||||
|
|
||||||
|
|
@ -245,6 +312,15 @@
|
||||||
onclick={() => modalManager.show(ShortcutsModal, { shortcuts: duplicateShortcuts })}
|
onclick={() => modalManager.show(ShortcutsModal, { shortcuts: duplicateShortcuts })}
|
||||||
aria-label={$t('show_keyboard_shortcuts')}
|
aria-label={$t('show_keyboard_shortcuts')}
|
||||||
/>
|
/>
|
||||||
|
<IconButton
|
||||||
|
shape="round"
|
||||||
|
variant="ghost"
|
||||||
|
color="secondary"
|
||||||
|
icon={mdiCogOutline}
|
||||||
|
title={$t('settings')}
|
||||||
|
onclick={onShowSettings}
|
||||||
|
aria-label={$t('settings')}
|
||||||
|
/>
|
||||||
</HStack>
|
</HStack>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue