mirror of
https://github.com/immich-app/immich.git
synced 2026-05-21 07:06:31 -04:00
302 lines
8.8 KiB
TypeScript
302 lines
8.8 KiB
TypeScript
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<Record<MetadataFieldKey, boolean>>;
|
|
|
|
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<unknown>();
|
|
|
|
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;
|
|
};
|