diff --git a/i18n/en.json b/i18n/en.json index efbbc453e7..97f4575567 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -897,6 +897,7 @@ "date_of_birth": "Date of birth", "date_of_birth_saved": "Date of birth saved successfully", "date_range": "Date range", + "date_time_original": "Date/Time Original", "day": "Day", "days": "Days", "deduplicate_all": "Deduplicate All", @@ -1197,11 +1198,13 @@ "export_as_json": "Export as JSON", "export_database": "Export Database", "export_database_description": "Export the SQLite database", + "exposure_time": "Exposure Time", "extension": "Extension", "external": "External", "external_libraries": "External Libraries", "external_network": "External network", "external_network_sheet_info": "When not on the preferred Wi-Fi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", + "f_number": "F-Number", "face_unassigned": "Unassigned", "failed": "Failed", "failed_count": "Failed: {count}", @@ -1219,7 +1222,6 @@ "features_setting_description": "Manage the app features", "file_name_or_extension": "File name or extension", "file_name_text": "File name", - "file_name_with_value": "File name: {file_name}", "file_size": "File size", "filename": "Filename", "filetype": "Filetype", @@ -1232,6 +1234,7 @@ "find_them_fast": "Find them fast by name with search", "first": "First", "fix_incorrect_match": "Fix incorrect match", + "focal_length": "Focal Length", "folder": "Folder", "folder_not_found": "Folder not found", "folders": "Folders", @@ -1352,6 +1355,7 @@ "ios_debug_info_no_sync_yet": "No background sync job has run yet", "ios_debug_info_processes_queued": "{count, plural, one {{count} background process queued} other {{count} background processes queued}}", "ios_debug_info_processing_ran_at": "Processing ran {dateTime}", + "iso": "ISO", "items_count": "{count, plural, one {# item} other {# items}}", "jobs": "Jobs", "json_editor": "JSON editor", @@ -1584,6 +1588,7 @@ "mobile_app": "Mobile App", "mobile_app_download_onboarding_note": "Download the companion mobile app using the following options", "model": "Model", + "modify_date": "Modify Date", "month": "Month", "more": "More", "motion": "Motion", @@ -1706,6 +1711,7 @@ "organize_into_albums": "Organize into albums", "organize_into_albums_description": "Put existing photos into albums using current sync settings", "organize_your_library": "Organize your library", + "orientation": "Orientation", "original": "original", "other": "Other", "other_devices": "Other devices", @@ -1820,6 +1826,7 @@ "profile_drawer_readonly_mode": "Read-only mode enabled. Long-press the user avatar icon to exit.", "profile_image_of_user": "Profile image of {user}", "profile_picture_set": "Profile picture set.", + "projection_type": "Projection Type", "public_album": "Public album", "public_share": "Public Share", "purchase_account_info": "Supporter", @@ -2189,7 +2196,9 @@ "show_in_timeline": "Show in timeline", "show_in_timeline_setting_description": "Show photos and videos from this user in your timeline", "show_keyboard_shortcuts": "Show keyboard shortcuts", + "show_less": "Show less", "show_metadata": "Show metadata", + "show_more_fields": "{count, plural, one {Show # more field} other {Show # more fields}}", "show_or_hide_info": "Show or hide info", "show_password": "Show password", "show_person_options": "Show person options", diff --git a/web/src/lib/utils/duplicate-utils.ts b/web/src/lib/utils/duplicate-utils.ts new file mode 100644 index 0000000000..dac4e9dc54 --- /dev/null +++ b/web/src/lib/utils/duplicate-utils.ts @@ -0,0 +1,301 @@ +import type { AssetResponseDto } from '@immich/sdk'; +import { + mdiBrightness6, + mdiCalendar, + mdiCamera, + mdiCameraIris, + mdiCameraOutline, + mdiClockEditOutline, + mdiCrosshairsGps, + mdiEarth, + mdiFileClockOutline, + mdiFileEditOutline, + mdiFileImageOutline, + mdiFitToScreen, + mdiFolderOutline, + mdiMapMarkerOutline, + mdiPanorama, + mdiPhoneRotateLandscape, + mdiRayStartArrow, + mdiStarOutline, + mdiTextBox, + mdiTimerOutline, + mdiWeightKilogram, +} from '@mdi/js'; +import { DateTime } from 'luxon'; +import type { MessageFormatter } from 'svelte-i18n'; +import { getAssetResolution, getFileSize } from '$lib/utils/asset-utils'; +import { fromISODateTime, fromISODateTimeUTC } from '$lib/utils/timeline-util'; + +const truncateMiddle = (path: string, maxLength = 50): string => { + if (path.length <= maxLength) { + return path; + } + + const lastSlash = path.lastIndexOf('/'); + const tail = lastSlash === -1 ? path : path.slice(lastSlash); + + if (tail.length >= maxLength - 3) { + const half = Math.floor((maxLength - 3) / 2); + return path.slice(0, half) + '...' + path.slice(-half); + } + + const headLength = maxLength - 3 - tail.length; + return path.slice(0, headLength) + '...' + tail; +}; + +const formatISODateToLocale = (iso: string, locale: string | undefined): string => + fromISODateTimeUTC(iso).toLocaleString({ month: 'short', day: 'numeric', year: 'numeric' }, { locale }); + +const getDateTime = (asset: AssetResponseDto) => { + const timeZone = asset.exifInfo?.timeZone; + return timeZone && asset.exifInfo?.dateTimeOriginal + ? fromISODateTime(asset.exifInfo.dateTimeOriginal, timeZone) + : fromISODateTimeUTC(asset.localDateTime); +}; + +type MetadataFieldDefinition = { + icon: string; + titleKey: string; + keys: readonly string[]; + render: (asset: AssetResponseDto, $t: MessageFormatter, locale: string | undefined) => string; +}; + +const metadataFields = [ + { + icon: mdiFileImageOutline, + titleKey: 'file_name_text', + keys: ['originalFileName'], + render: (asset, $t) => asset.originalFileName || $t('unknown'), + }, + { + icon: mdiFolderOutline, + titleKey: 'path', + keys: ['originalPath'], + render: (asset, $t) => truncateMiddle(asset.originalPath) || $t('unknown'), + }, + { + icon: mdiWeightKilogram, + titleKey: 'file_size', + keys: ['fileSize'], + render: (asset) => getFileSize(asset), + }, + { + icon: mdiFitToScreen, + titleKey: 'resolution', + keys: ['resolution'], + render: (asset, $t) => getAssetResolution(asset) || $t('unknown'), + }, + { + icon: mdiFileClockOutline, + titleKey: 'created_at', + keys: ['fileCreatedAt'], + render: (asset, _t, locale) => formatISODateToLocale(asset.fileCreatedAt, locale), + }, + { + icon: mdiFileEditOutline, + titleKey: 'updated_at', + keys: ['fileModifiedAt'], + render: (asset, _t, locale) => formatISODateToLocale(asset.fileModifiedAt, locale), + }, + { + icon: mdiCalendar, + titleKey: 'date_time_original', + keys: ['dateTimeOriginal'], + render: (asset, $t, locale) => { + const dateTime = getDateTime(asset); + return dateTime + ? dateTime.toLocaleString( + { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + second: '2-digit', + timeZoneName: 'shortOffset', + }, + { locale }, + ) + : $t('unknown'); + }, + }, + { + icon: mdiEarth, + titleKey: 'timezone', + keys: ['timeZone'], + render: (asset, $t) => getDateTime(asset)?.offsetNameShort ?? $t('unknown'), + }, + { + icon: mdiClockEditOutline, + titleKey: 'modify_date', + keys: ['modifyDate'], + render: (asset, $t, locale) => + asset.exifInfo?.modifyDate ? formatISODateToLocale(asset.exifInfo.modifyDate, locale) : $t('unknown'), + }, + { + icon: mdiMapMarkerOutline, + titleKey: 'location', + keys: ['city', 'state', 'country'], + render: (asset, $t) => { + const parts = [asset.exifInfo?.city, asset.exifInfo?.state, asset.exifInfo?.country].filter(Boolean); + return parts.length > 0 ? parts.join(', ') : $t('unknown'); + }, + }, + { + icon: mdiCrosshairsGps, + titleKey: 'gps', + keys: ['latitude', 'longitude'], + render: (asset, $t) => + asset.exifInfo?.latitude != null && asset.exifInfo?.longitude != null + ? `${asset.exifInfo.latitude.toFixed(4)}, ${asset.exifInfo.longitude.toFixed(4)}` + : $t('unknown'), + }, + { + icon: mdiCameraOutline, + titleKey: 'make', + keys: ['make'], + render: (asset, $t) => asset.exifInfo?.make || $t('unknown'), + }, + { + icon: mdiCamera, + titleKey: 'model', + keys: ['model'], + render: (asset, $t) => asset.exifInfo?.model || $t('unknown'), + }, + { + icon: mdiCameraIris, + titleKey: 'lens_model', + keys: ['lensModel'], + render: (asset, $t) => asset.exifInfo?.lensModel || $t('unknown'), + }, + { + icon: mdiCameraIris, + titleKey: 'f_number', + keys: ['fNumber'], + render: (asset, $t) => (asset.exifInfo?.fNumber == null ? $t('unknown') : `f/${asset.exifInfo.fNumber.toFixed(1)}`), + }, + { + icon: mdiRayStartArrow, + titleKey: 'focal_length', + keys: ['focalLength'], + render: (asset, $t) => (asset.exifInfo?.focalLength == null ? $t('unknown') : `${asset.exifInfo.focalLength} mm`), + }, + { + icon: mdiBrightness6, + titleKey: 'iso', + keys: ['iso'], + render: (asset, $t) => (asset.exifInfo?.iso == null ? $t('unknown') : `ISO ${asset.exifInfo.iso}`), + }, + { + icon: mdiTimerOutline, + titleKey: 'exposure_time', + keys: ['exposureTime'], + render: (asset, $t) => asset.exifInfo?.exposureTime || $t('unknown'), + }, + { + icon: mdiTextBox, + titleKey: 'description', + keys: ['description'], + render: (asset, $t) => asset.exifInfo?.description || $t('unknown'), + }, + { + icon: mdiStarOutline, + titleKey: 'rating', + keys: ['rating'], + render: (asset, $t) => (asset.exifInfo?.rating == null ? $t('unknown') : `${asset.exifInfo.rating} stars`), + }, + { + icon: mdiPhoneRotateLandscape, + titleKey: 'orientation', + keys: ['orientation'], + render: (asset, $t) => String(asset.exifInfo?.orientation || $t('unknown')), + }, + { + icon: mdiPanorama, + titleKey: 'projection_type', + keys: ['projectionType'], + render: (asset, $t) => asset.exifInfo?.projectionType || $t('unknown'), + }, +] as const satisfies readonly MetadataFieldDefinition[]; + +export type MetadataFieldKey = (typeof metadataFields)[number]['keys'][number]; +export type DifferingMetadataFields = Partial>; + +export const metadataKeys: readonly MetadataFieldKey[] = metadataFields.flatMap(({ keys }) => keys); + +export const countDifferingMetadataItems = (differing: DifferingMetadataFields): number => + metadataFields.filter(({ keys }) => keys.some((k) => differing[k as MetadataFieldKey])).length; + +export const getAllMetadataItems = (asset: AssetResponseDto, $t: MessageFormatter, locale: string | undefined) => + metadataFields.map(({ icon, titleKey, keys, render }) => ({ + icon, + title: $t(titleKey), + render: render(asset, $t, locale), + keys, + })); + +const normalizeForComparison = (key: MetadataFieldKey, value: unknown): unknown => { + if (value === null || value === undefined) { + return value; + } + + if (key === 'fileCreatedAt' || key === 'fileModifiedAt' || key === 'dateTimeOriginal' || key === 'modifyDate') { + const dateTime = DateTime.fromISO(String(value)); + return dateTime.isValid ? dateTime.toISO() : String(value); + } + + if (key === 'fNumber' && typeof value === 'number') { + return Number(value.toFixed(1)); + } + if ((key === 'latitude' || key === 'longitude') && typeof value === 'number') { + return Number(value.toFixed(4)); + } + if (key === 'focalLength' && typeof value === 'number') { + return Number(value.toFixed(2)); + } + + return value; +}; + +const getValueForAsset = (asset: AssetResponseDto, key: MetadataFieldKey): unknown => { + switch (key) { + case 'fileCreatedAt': + case 'fileModifiedAt': + case 'originalFileName': + case 'originalPath': { + return asset[key]; + } + case 'fileSize': { + return getFileSize(asset); + } + case 'resolution': { + return getAssetResolution(asset); + } + default: { + if (asset.exifInfo && key in asset.exifInfo) { + return asset.exifInfo[key as keyof typeof asset.exifInfo]; + } + return undefined; + } + } +}; + +export const computeDifferingMetadataFields = (assets: AssetResponseDto[]): DifferingMetadataFields => { + const diffs: DifferingMetadataFields = {}; + + for (const key of metadataKeys) { + const uniqueValues = new Set(); + + for (const asset of assets) { + const value = getValueForAsset(asset, key); + if (value !== undefined && value !== null) { + uniqueValues.add(normalizeForComparison(key, value)); + } + } + + diffs[key] = uniqueValues.size > 1; + } + + return diffs; +}; diff --git a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/DuplicateAsset.svelte b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/DuplicateAsset.svelte index e3662fd369..70d549cc48 100644 --- a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/DuplicateAsset.svelte +++ b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/DuplicateAsset.svelte @@ -1,103 +1,42 @@
@@ -166,69 +105,13 @@ ? 'bg-success/15 dark:bg-[#001a06]' : 'bg-transparent'}" > - - {asset.originalFileName} - - - - {truncateMiddle(getBasePath(asset.originalPath, asset.originalFileName)) || $t('unknown')} - - - - {getFileSize(asset)} - - - - {getAssetResolution(asset)} - - - - {#if dateTime} - {dateTime.toLocaleString( - { - month: 'short', - day: 'numeric', - year: 'numeric', - }, - { locale: $locale }, - )} - {:else} - {$t('unknown')} - {/if} - - - - {#if dateTime} - {dateTime.toLocaleString( - { - hour: 'numeric', - minute: '2-digit', - second: '2-digit', - timeZoneName: timeZone ? 'shortOffset' : undefined, - }, - { locale: $locale }, - )} - {:else} - {$t('unknown')} - {/if} - - - - {#if locationParts.length > 0} - {locationParts.join(', ')} - {:else} - {$t('unknown')} - {/if} - + {#each visibleMetadataItems as { icon, title, render, keys } (keys[0])} + + {render} + + {/each} + {#await getAllAlbums({ assetId: asset.id })} {$t('scanning_for_album')} diff --git a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/DuplicatesCompareControl.svelte b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/DuplicatesCompareControl.svelte index d62d97dc8e..7a3505c575 100644 --- a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/DuplicatesCompareControl.svelte +++ b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/DuplicatesCompareControl.svelte @@ -6,10 +6,15 @@ import { authManager } from '$lib/managers/auth-manager.svelte'; import { handlePromiseError } from '$lib/utils'; import { getNextAsset, getPreviousAsset } from '$lib/utils/asset-utils'; + import { + computeDifferingMetadataFields, + countDifferingMetadataItems, + type DifferingMetadataFields, + } from '$lib/utils/duplicate-utils'; import { navigate } from '$lib/utils/navigation'; import { getAssetInfo, type AssetResponseDto } from '@immich/sdk'; - import { Button } from '@immich/ui'; - import { mdiCheck, mdiImageMultipleOutline, mdiTrashCanOutline } from '@mdi/js'; + import { Button, Icon } from '@immich/ui'; + import { mdiCheck, mdiChevronDown, mdiChevronUp, mdiImageMultipleOutline, mdiTrashCanOutline } from '@mdi/js'; import { onDestroy, onMount } from 'svelte'; import { t } from 'svelte-i18n'; import { SvelteSet } from 'svelte/reactivity'; @@ -26,6 +31,13 @@ let selectedAssetIds = $state(new SvelteSet()); let trashCount = $derived(assets.length - selectedAssetIds.size); + const InitialVisibleCount = 5; + + const differingMetadataFields: DifferingMetadataFields = $derived(computeDifferingMetadataFields(assets)); + const differingCount = $derived(countDifferingMetadataItems(differingMetadataFields)); + const hasMore = $derived(differingCount > InitialVisibleCount); + let showMore = $state(false); + onMount(() => { if (suggestedKeepAssetIds.length > 0) { for (const id of suggestedKeepAssetIds) { @@ -160,10 +172,29 @@
{#each assets as asset (asset.id)} - + {/each}
+ + {#if hasMore} +
+ +
+ {/if}
{#if assetViewerManager.isViewing} diff --git a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/InfoRow.svelte b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/InfoRow.svelte index f7800ef40e..fbc2db099f 100644 --- a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/InfoRow.svelte +++ b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/InfoRow.svelte @@ -6,21 +6,23 @@ icon: string; children?: Snippet; borderBottom?: boolean; - highlight?: boolean; title?: string; } - let { icon, children, borderBottom = true, highlight = false, title }: Props = $props(); + let { icon, children, borderBottom = true, title }: Props = $props(); -
- -
- +
+ + + {#if title} + + {title} + + {/if} + +
+ {@render children?.()}