diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart index 3f198fad6..ec2daf416 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart @@ -8,6 +8,7 @@ import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/utils/bytes_units.dart'; +import 'package:immich_mobile/utils/camera.utils.dart'; const _kSeparator = ' • '; @@ -114,12 +115,7 @@ class TechnicalDetails extends ConsumerWidget { if (exifInfo == null) { return null; } - return switch ((exifInfo.make, exifInfo.model)) { - (null, null) => null, - (final String make, null) => make, - (null, final String model) => model, - (final String make, final String model) => '$make $model', - }; + return getCameraName(exifInfo.make, exifInfo.model); } static String? _getCameraInfoSubtitle(ExifInfo? exifInfo) { diff --git a/mobile/lib/utils/camera.utils.dart b/mobile/lib/utils/camera.utils.dart new file mode 100644 index 000000000..4bfeda599 --- /dev/null +++ b/mobile/lib/utils/camera.utils.dart @@ -0,0 +1,38 @@ +final _whitespace = RegExp(r'\s+'); +final _wordChar = RegExp(r'\w'); + +/// Builds the display name of a camera from its EXIF make and model. +/// +/// Many cameras (e.g. Canon) already include the manufacturer in the model +/// (`Canon EOS R6m2`), so prefixing the make again would produce +/// "Canon Canon EOS R6m2". The make is only prepended when the model does not +/// already start with it. Makes made up of several words are matched word by +/// word, since the model usually repeats only part of them: +/// `NIKON CORPORATION` / `NIKON D850` and +/// `EASTMAN KODAK COMPANY` / `KODAK EASYSHARE C875`. +/// +/// Returns `null` when neither a make nor a model is available. +String? getCameraName(String? make, String? model) { + final trimmedMake = make?.trim() ?? ''; + final trimmedModel = model?.trim() ?? ''; + + if (trimmedMake.isEmpty && trimmedModel.isEmpty) { + return null; + } + + if (trimmedMake.isEmpty) { + return trimmedModel; + } + + if (trimmedModel.isEmpty) { + return trimmedMake; + } + + final lowerModel = trimmedModel.toLowerCase(); + final modelIncludesMake = trimmedMake.toLowerCase().split(_whitespace).any((word) { + final charAfterWord = lowerModel.length > word.length ? lowerModel[word.length] : ''; + return lowerModel.startsWith(word) && !_wordChar.hasMatch(charAfterWord); + }); + + return modelIncludesMake ? trimmedModel : '$trimmedMake $trimmedModel'; +} diff --git a/mobile/test/utils/camera_utils_test.dart b/mobile/test/utils/camera_utils_test.dart new file mode 100644 index 000000000..5248a2e1c --- /dev/null +++ b/mobile/test/utils/camera_utils_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/utils/camera.utils.dart'; + +void main() { + group('getCameraName', () { + test('does not repeat the make when the model already starts with it', () { + expect(getCameraName('Canon', 'Canon EOS R6m2'), 'Canon EOS R6m2'); + expect(getCameraName('Canon', 'Canon EOS 5D Mark IV'), 'Canon EOS 5D Mark IV'); + }); + + test('compares the make and model case-insensitively', () { + expect(getCameraName('NIKON', 'Nikon Z 6'), 'Nikon Z 6'); + expect(getCameraName('canon', 'CANON EOS R5'), 'CANON EOS R5'); + }); + + test('compares against the first word of a multi-word make', () { + expect(getCameraName('NIKON CORPORATION', 'NIKON D850'), 'NIKON D850'); + expect(getCameraName('OLYMPUS IMAGING CORP.', 'OLYMPUS E-M1'), 'OLYMPUS E-M1'); + }); + + test('matches any word of a multi-word make', () { + expect(getCameraName('EASTMAN KODAK COMPANY', 'KODAK EASYSHARE C875'), 'KODAK EASYSHARE C875'); + expect(getCameraName('EASTMAN KODAK COMPANY', 'EASTMAN KODAK 1'), 'EASTMAN KODAK 1'); + }); + + test('prepends the make when the model does not include it', () { + expect(getCameraName('SONY', 'ILCE-7M4'), 'SONY ILCE-7M4'); + expect(getCameraName('FUJIFILM', 'X-T5'), 'FUJIFILM X-T5'); + expect(getCameraName('Apple', 'iPhone 15 Pro'), 'Apple iPhone 15 Pro'); + }); + + test('only treats the make as a prefix on a word boundary', () { + expect(getCameraName('Canon', 'Canonical X1'), 'Canon Canonical X1'); + }); + + test('handles a missing make or model', () { + expect(getCameraName('Canon', null), 'Canon'); + expect(getCameraName(null, 'EOS R6m2'), 'EOS R6m2'); + expect(getCameraName(null, null), isNull); + expect(getCameraName('', ''), isNull); + }); + + test('ignores surrounding whitespace', () { + expect(getCameraName(' Canon ', ' Canon EOS R6m2 '), 'Canon EOS R6m2'); + }); + }); +} diff --git a/web/src/lib/components/asset-viewer/DetailPanel.svelte b/web/src/lib/components/asset-viewer/DetailPanel.svelte index e1e8981ad..903ddb833 100644 --- a/web/src/lib/components/asset-viewer/DetailPanel.svelte +++ b/web/src/lib/components/asset-viewer/DetailPanel.svelte @@ -29,6 +29,7 @@ import { getAlbumsByAssetQuery } from '#lib/queries/album.query.ts'; import { createPreloadedQuery } from '#lib/queries/client.ts'; import { getPublicConfigQuery } from '#lib/queries/config-public.query.ts'; + import { getCameraName } from '#lib/utils/camera-utils.ts'; const query = createPreloadedQuery(getPublicConfigQuery); const publicConfig = $derived(query.data); @@ -203,17 +204,17 @@
- {asset.exifInfo.make || ''} - {asset.exifInfo.model || ''} + {cameraName}
{/if} diff --git a/web/src/lib/utils/camera-utils.spec.ts b/web/src/lib/utils/camera-utils.spec.ts new file mode 100644 index 000000000..c760300aa --- /dev/null +++ b/web/src/lib/utils/camera-utils.spec.ts @@ -0,0 +1,44 @@ +import { getCameraName } from '#lib/utils/camera-utils.ts'; + +describe('getCameraName', () => { + it('should not repeat the make when the model already starts with it', () => { + expect(getCameraName('Canon', 'Canon EOS R6m2')).toBe('Canon EOS R6m2'); + expect(getCameraName('Canon', 'Canon EOS 5D Mark IV')).toBe('Canon EOS 5D Mark IV'); + }); + + it('should compare the make and model case-insensitively', () => { + expect(getCameraName('NIKON', 'Nikon Z 6')).toBe('Nikon Z 6'); + expect(getCameraName('canon', 'CANON EOS R5')).toBe('CANON EOS R5'); + }); + + it('should compare against the first word of a multi-word make', () => { + expect(getCameraName('NIKON CORPORATION', 'NIKON D850')).toBe('NIKON D850'); + expect(getCameraName('OLYMPUS IMAGING CORP.', 'OLYMPUS E-M1')).toBe('OLYMPUS E-M1'); + }); + + it('should match any word of a multi-word make', () => { + expect(getCameraName('EASTMAN KODAK COMPANY', 'KODAK EASYSHARE C875')).toBe('KODAK EASYSHARE C875'); + expect(getCameraName('EASTMAN KODAK COMPANY', 'EASTMAN KODAK 1')).toBe('EASTMAN KODAK 1'); + }); + + it('should prepend the make when the model does not include it', () => { + expect(getCameraName('SONY', 'ILCE-7M4')).toBe('SONY ILCE-7M4'); + expect(getCameraName('FUJIFILM', 'X-T5')).toBe('FUJIFILM X-T5'); + expect(getCameraName('Apple', 'iPhone 15 Pro')).toBe('Apple iPhone 15 Pro'); + }); + + it('should only treat the make as a prefix on a word boundary', () => { + expect(getCameraName('Canon', 'Canonical X1')).toBe('Canon Canonical X1'); + }); + + it('should handle a missing make or model', () => { + expect(getCameraName('Canon', null)).toBe('Canon'); + expect(getCameraName(undefined, 'EOS R6m2')).toBe('EOS R6m2'); + expect(getCameraName(null, null)).toBe(''); + expect(getCameraName('', '')).toBe(''); + }); + + it('should ignore surrounding whitespace', () => { + expect(getCameraName(' Canon ', ' Canon EOS R6m2 ')).toBe('Canon EOS R6m2'); + }); +}); diff --git a/web/src/lib/utils/camera-utils.ts b/web/src/lib/utils/camera-utils.ts new file mode 100644 index 000000000..be11cdeb4 --- /dev/null +++ b/web/src/lib/utils/camera-utils.ts @@ -0,0 +1,34 @@ +const whitespace = /\s+/; + +const isWordChar = (char: string | undefined) => char !== undefined && /\w/.test(char); + +/** +Builds the display name of a camera from its EXIF make and model. + +Many cameras (e.g. Canon) already include the manufacturer in the model +(`Canon EOS R6m2`), so prefixing the make again would produce "Canon Canon EOS R6m2". +The make is only prepended when the model does not already start with it. +Makes made up of several words are matched word by word, since the model usually +repeats only part of them: `NIKON CORPORATION` / `NIKON D850` and +`EASTMAN KODAK COMPANY` / `KODAK EASYSHARE C875`. +*/ +export const getCameraName = (make?: string | null, model?: string | null): string => { + const trimmedMake = make?.trim() ?? ''; + const trimmedModel = model?.trim() ?? ''; + + if (!trimmedMake) { + return trimmedModel; + } + + if (!trimmedModel) { + return trimmedMake; + } + + const lowerModel = trimmedModel.toLowerCase(); + const modelIncludesMake = trimmedMake + .toLowerCase() + .split(whitespace) + .some((word) => lowerModel.startsWith(word) && !isWordChar(lowerModel[word.length])); + + return modelIncludesMake ? trimmedModel : `${trimmedMake} ${trimmedModel}`; +};