diff --git a/mobile/lib/extensions/string_extensions.dart b/mobile/lib/extensions/string_extensions.dart index 792b932df2..a4308c4ec4 100644 --- a/mobile/lib/extensions/string_extensions.dart +++ b/mobile/lib/extensions/string_extensions.dart @@ -1,11 +1,15 @@ import 'dart:convert'; +import 'package:diacritic/diacritic.dart' as diacritic; + extension StringExtension on String { String capitalize() { return split(" ").map((str) => str.isEmpty ? str : str[0].toUpperCase() + str.substring(1)).join(" "); } String? get nullIfEmpty => isEmpty ? null : this; + + String removeDiacritics() => diacritic.removeDiacritics(this); } extension DurationExtension on String { diff --git a/mobile/lib/presentation/pages/drift_people_collection.page.dart b/mobile/lib/presentation/pages/drift_people_collection.page.dart index 32bbd7e60b..26aa2e62ab 100644 --- a/mobile/lib/presentation/pages/drift_people_collection.page.dart +++ b/mobile/lib/presentation/pages/drift_people_collection.page.dart @@ -3,6 +3,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/string_extensions.dart'; import 'package:immich_mobile/providers/infrastructure/people.provider.dart'; import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -64,7 +65,9 @@ class _DriftPeopleCollectionPageState extends ConsumerState person.name.toLowerCase().removeDiacritics().contains( + searchQuery.value.toLowerCase().removeDiacritics(), + ), + ) + .toList(); return ListView.builder( shrinkWrap: true, - itemCount: people - .where((person) => person.name.toLowerCase().contains(searchQuery.value.toLowerCase())) - .length, + itemCount: filtered.length, padding: const EdgeInsets.all(8), itemBuilder: (context, index) { - final person = people - .where((person) => person.name.toLowerCase().contains(searchQuery.value.toLowerCase())) - .toList()[index]; + final person = filtered[index]; final isSelected = selectedPeople.value.contains(person); return Padding( diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 63341a6c2a..2a792bd29c 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -354,6 +354,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.3" + diacritic: + dependency: "direct main" + description: + name: diacritic + sha256: "12981945ec38931748836cd76f2b38773118d0baef3c68404bdfde9566147876" + url: "https://pub.dev" + source: hosted + version: "0.1.6" drift: dependency: "direct main" description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index b78112d533..7301bba481 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -18,6 +18,7 @@ dependencies: crop_image: ^1.0.17 crypto: ^3.0.7 device_info_plus: ^12.4.0 + diacritic: ^0.1.6 drift: ^2.32.1 drift_sqlite_async: 0.3.1 dynamic_color: ^1.8.1 diff --git a/mobile/test/modules/extensions/builtin_extensions_test.dart b/mobile/test/modules/extensions/builtin_extensions_test.dart index f506070722..42538c17bc 100644 --- a/mobile/test/modules/extensions/builtin_extensions_test.dart +++ b/mobile/test/modules/extensions/builtin_extensions_test.dart @@ -18,6 +18,56 @@ void main() { expect("a:b:c".toDuration(), isNull); }); }); + group('Test removeDiacritics', () { + test('removes acute accents', () { + expect('Amélie'.removeDiacritics(), 'Amelie'); + }); + + test('removes grave accents', () { + expect('À la carte'.removeDiacritics(), 'A la carte'); + }); + + test('removes circumflex', () { + expect('hôpital'.removeDiacritics(), 'hopital'); + }); + + test('removes tilde', () { + expect('São João'.removeDiacritics(), 'Sao Joao'); + }); + + test('removes diaeresis', () => expect('naïve'.removeDiacritics(), 'naive')); + + test('removes cedilla', () => expect('ça va'.removeDiacritics(), 'ca va')); + + test('handles Hungarian exteded characters (ű/ő)', () { + expect('árvíztűrő tükörfúrógép'.removeDiacritics(), 'arvizturo tukorfurogep'); + }); + + test('handles Polish characters', () { + expect('Jędrzej Łącki'.removeDiacritics(), 'Jedrzej Lacki'); + }); + + test('handles German umlauts', () => expect('Müller'.removeDiacritics(), 'Muller')); + + test('handles Nordic characters', () => expect('Göteborg'.removeDiacritics(), 'Goteborg')); + + test('handles empty string', () => expect(''.removeDiacritics(), '')); + + test('handles string with no diacritics', () { + expect('hello world'.removeDiacritics(), 'hello world'); + }); + + test('handles Ñ/ñ', () => expect('Niño'.removeDiacritics(), 'Nino')); + + test('diacritic removal is order-independent', () { + const raw = 'Árvíztűrő'; + expect( + raw.toLowerCase().removeDiacritics(), + raw.removeDiacritics().toLowerCase(), + ); + }); + }); + group('Test uniqueConsecutive', () { test('empty', () { final a = []; diff --git a/web/src/lib/components/asset-viewer/face-editor/FaceEditor.svelte b/web/src/lib/components/asset-viewer/face-editor/FaceEditor.svelte index 09a908fe78..fbc6e33197 100644 --- a/web/src/lib/components/asset-viewer/face-editor/FaceEditor.svelte +++ b/web/src/lib/components/asset-viewer/face-editor/FaceEditor.svelte @@ -7,6 +7,7 @@ import { getPeopleThumbnailUrl } from '$lib/utils'; import { getNaturalSize, scaleToFit } from '$lib/utils/container-utils'; import { handleError } from '$lib/utils/handle-error'; + import { normalizeSearchString } from '$lib/utils/string-utils'; import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk'; import { Button, Input, modalManager, toastManager } from '@immich/ui'; import { Canvas, InteractiveFabricObject, Rect } from 'fabric'; @@ -37,7 +38,7 @@ let filteredCandidates = $derived( searchTerm - ? candidates.filter((person) => person.name.toLowerCase().includes(searchTerm.toLowerCase())) + ? candidates.filter((person) => normalizeSearchString(person.name).includes(normalizeSearchString(searchTerm))) : candidates, ); diff --git a/web/src/lib/modals/PeoplePickerModal.svelte b/web/src/lib/modals/PeoplePickerModal.svelte index 10d7ee5033..a059a4aafb 100644 --- a/web/src/lib/modals/PeoplePickerModal.svelte +++ b/web/src/lib/modals/PeoplePickerModal.svelte @@ -3,6 +3,7 @@ import SearchBar from '$lib/elements/SearchBar.svelte'; import { getPeopleThumbnailUrl } from '$lib/utils'; import { handleError } from '$lib/utils/handle-error'; + import { normalizeSearchString } from '$lib/utils/string-utils'; import { getAllPeople, type PersonResponseDto } from '@immich/sdk'; import { Button, HStack, LoadingSpinner, Modal, ModalBody, ModalFooter } from '@immich/ui'; import { onMount } from 'svelte'; @@ -24,7 +25,9 @@ const filteredPeople = $derived( people .filter((person) => !excludedIds.includes(person.id)) - .filter((person) => !searchName || person.name.toLowerCase().includes(searchName.toLowerCase())), + .filter( + (person) => !searchName || normalizeSearchString(person.name).includes(normalizeSearchString(searchName)), + ), ); onMount(async () => { diff --git a/web/src/lib/utils/person.spec.ts b/web/src/lib/utils/person.spec.ts new file mode 100644 index 0000000000..3e4d4f36b7 --- /dev/null +++ b/web/src/lib/utils/person.spec.ts @@ -0,0 +1,118 @@ +import type { PersonResponseDto } from '@immich/sdk'; +import { searchNameLocal } from './person'; + +const makePerson = (overrides: Partial = {}): PersonResponseDto => ({ + id: 'person-1', + name: 'Amélie', + thumbnailPath: '', + isHidden: false, + birthDate: null, + ...overrides, +}); + +describe('searchNameLocal with single-word names', () => { + it('should find a person by exact name match', () => { + const people = [makePerson({ id: '1', name: 'Amélie' })]; + expect(searchNameLocal('Amélie', people, 10)).toEqual([people[0]]); + }); + + it('should find a person with accent-insensitive search', () => { + const people = [makePerson({ id: '1', name: 'Amélie' })]; + expect(searchNameLocal('amelie', people, 10)).toEqual([people[0]]); + }); + + it('should find a person by prefix match', () => { + const people = [makePerson({ id: '1', name: 'Amélie' })]; + expect(searchNameLocal('ame', people, 10)).toEqual([people[0]]); + }); + + it('should not match partial name where prefix does not match', () => { + const people = [makePerson({ id: '1', name: 'Amélie' })]; + expect(searchNameLocal('lie', people, 10)).toEqual([]); + }); + + it('should be case insensitive', () => { + const people = [makePerson({ id: '1', name: 'AMÉLIE' })]; + expect(searchNameLocal('amelie', people, 10)).toEqual([people[0]]); + }); + + it('should handle Hungarian accented characters', () => { + const people = [makePerson({ id: '1', name: 'Árvíztűrő' })]; + expect(searchNameLocal('arvizturo', people, 10)).toEqual([people[0]]); + }); + + it('should handle Polish accented characters', () => { + const people = [makePerson({ id: '1', name: 'Jędrzej' })]; + expect(searchNameLocal('jedrzej', people, 10)).toEqual([people[0]]); + }); + + it('should handle no matches', () => { + const people = [makePerson({ id: '1', name: 'Amélie' })]; + expect(searchNameLocal('xyz', people, 10)).toEqual([]); + }); + + it('should respect the slice parameter', () => { + const people = [ + makePerson({ id: '1', name: 'Amélie' }), + makePerson({ id: '2', name: 'Amadeus' }), + makePerson({ id: '3', name: 'Aminta' }), + ]; + expect(searchNameLocal('am', people, 2)).toHaveLength(2); + }); +}); + +describe('searchNameLocal with multi-word names', () => { + it('should find a person matching the first name', () => { + const people = [makePerson({ id: '1', name: 'Jean Amélie' })]; + expect(searchNameLocal('jean', people, 10)).toEqual([people[0]]); + }); + + it('should find a person matching the last name with accent insensitivity', () => { + const people = [makePerson({ id: '1', name: 'Amélie Dupont' })]; + expect(searchNameLocal('dupont', people, 10)).toEqual([people[0]]); + }); + + it('should find a person matching any space-separated word', () => { + const people = [makePerson({ id: '1', name: 'Jean Amélie Dupont' })]; + expect(searchNameLocal('dupont', people, 10)).toEqual([people[0]]); + expect(searchNameLocal('jean', people, 10)).toEqual([people[0]]); + }); + + it('should match prefix of any word in a multi-word name', () => { + const people = [makePerson({ id: '1', name: 'Maria João Silva' })]; + expect(searchNameLocal('joão', people, 10)).toEqual([people[0]]); + expect(searchNameLocal('joao', people, 10)).toEqual([people[0]]); + expect(searchNameLocal('sil', people, 10)).toEqual([people[0]]); + }); + + it('should match when search term is a multi-word prefix of the full name', () => { + const people = [makePerson({ id: '1', name: 'Jean Amélie Dupont' })]; + expect(searchNameLocal('jean amélie', people, 10)).toEqual([people[0]]); + }); + + it('should not match when search term does not prefix the full name', () => { + const people = [makePerson({ id: '1', name: 'Jean Amélie' })]; + expect(searchNameLocal('jean x', people, 10)).toEqual([]); + }); +}); + +describe('searchNameLocal with personId exclusion', () => { + it('should exclude the person with the given id', () => { + const people = [makePerson({ id: '1', name: 'Amélie' }), makePerson({ id: '2', name: 'Amélie' })]; + const result = searchNameLocal('amélie', people, 10, '1'); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('2'); + }); + + it('should return empty when only the excluded person matches', () => { + const people = [makePerson({ id: '1', name: 'Amélie' })]; + expect(searchNameLocal('amélie', people, 10, '1')).toEqual([]); + }); + + it('should still exclude when search is accent-insensitive', () => { + const people = [makePerson({ id: '1', name: 'Amélie' }), makePerson({ id: '2', name: 'Amélie' })]; + const result = searchNameLocal('amelie', people, 10, '1'); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('2'); + }); +}); diff --git a/web/src/lib/utils/person.ts b/web/src/lib/utils/person.ts index 0b30556516..75f8c9c267 100644 --- a/web/src/lib/utils/person.ts +++ b/web/src/lib/utils/person.ts @@ -1,6 +1,7 @@ import type { PersonResponseDto } from '@immich/sdk'; import { t } from 'svelte-i18n'; import { derived } from 'svelte/store'; +import { normalizeSearchString } from './string-utils'; export const searchNameLocal = ( name: string, @@ -8,21 +9,22 @@ export const searchNameLocal = ( slice: number, personId?: string, ): PersonResponseDto[] => { + const normalizedName = normalizeSearchString(name); return name.includes(' ') ? people .filter((person: PersonResponseDto) => { return personId - ? person.name.toLowerCase().startsWith(name.toLowerCase()) && person.id !== personId - : person.name.toLowerCase().startsWith(name.toLowerCase()); + ? normalizeSearchString(person.name).startsWith(normalizedName) && person.id !== personId + : normalizeSearchString(person.name).startsWith(normalizedName); }) .slice(0, slice) : people .filter((person: PersonResponseDto) => { const nameParts = person.name.split(' '); return personId - ? nameParts.some((splitName) => splitName.toLowerCase().startsWith(name.toLowerCase())) && + ? nameParts.some((splitName) => normalizeSearchString(splitName).startsWith(normalizedName)) && person.id !== personId - : nameParts.some((splitName) => splitName.toLowerCase().startsWith(name.toLowerCase())); + : nameParts.some((splitName) => normalizeSearchString(splitName).startsWith(normalizedName)); }) .slice(0, slice); }; diff --git a/web/src/routes/(user)/people/+page.svelte b/web/src/routes/(user)/people/+page.svelte index ccbcbc2a68..9a672e6a27 100644 --- a/web/src/routes/(user)/people/+page.svelte +++ b/web/src/routes/(user)/people/+page.svelte @@ -13,6 +13,7 @@ import { Route } from '$lib/route'; import { locale } from '$lib/stores/preferences.store'; import { websocketEvents } from '$lib/stores/websocket'; + import { normalizeSearchString } from '$lib/utils/string-utils'; import { handlePromiseError } from '$lib/utils'; import { handleError } from '$lib/utils/handle-error'; import { clearQueryParam } from '$lib/utils/navigation'; @@ -237,8 +238,8 @@ potentialMergePeople = people .filter( (person: PersonResponseDto) => - personMerge2?.name.toLowerCase() === person.name.toLowerCase() && - person.id !== personMerge2.id && + normalizeSearchString(personMerge2?.name ?? '') === normalizeSearchString(person.name) && + person.id !== personMerge2?.id && person.id !== personMerge1?.id && !person.isHidden, ) @@ -269,8 +270,9 @@ const findPeopleWithSimilarName = async (name: string, personId: string) => { const searchResult = await searchPerson({ name, withHidden: true }); + const normalizedName = normalizeSearchString(name); return searchResult.find( - (person) => person.name.toLowerCase() === name.toLowerCase() && person.id !== personId && person.name, + (person) => normalizeSearchString(person.name) === normalizedName && person.id !== personId && person.name, ); }; diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 803c078700..88716f9429 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -36,6 +36,7 @@ import { getPeopleThumbnailUrl } from '$lib/utils'; import { handleError } from '$lib/utils/handle-error'; import { isExternalUrl } from '$lib/utils/navigation'; + import { normalizeSearchString } from '$lib/utils/string-utils'; import { AssetVisibility, searchPerson, updatePerson, type PersonResponseDto } from '@immich/sdk'; import { ActionButton, @@ -236,8 +237,10 @@ const result = await searchPerson({ name: personName, withHidden: true }); + const normalizedPersonName = normalizeSearchString(personName); const existingPerson = result.find( - ({ name, id }: PersonResponseDto) => name.toLowerCase() === personName.toLowerCase() && id !== person.id && name, + ({ name, id }: PersonResponseDto) => + normalizeSearchString(name) === normalizedPersonName && id !== person.id && name, ); if (existingPerson) { personMerge2 = existingPerson; @@ -245,8 +248,8 @@ potentialMergePeople = result .filter( (person: PersonResponseDto) => - personMerge2?.name.toLowerCase() === person.name.toLowerCase() && - person.id !== personMerge2.id && + normalizeSearchString(personMerge2?.name ?? '') === normalizeSearchString(person.name) && + person.id !== personMerge2?.id && person.id !== personMerge1?.id && !person.isHidden, )