fix: normalize diacritics in person name search in Web & Mobile (#28887)

This commit is contained in:
Pedro Vieira
2026-06-10 11:05:07 +01:00
committed by GitHub
parent 5c38373808
commit a7b5f81701
12 changed files with 218 additions and 19 deletions
@@ -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 {
@@ -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<DriftPeopleCollectio
data: (people) {
if (_search != null) {
people = people.where((person) {
return person.name.toLowerCase().contains(_search!.toLowerCase());
return person.name.toLowerCase().removeDiacritics().contains(
_search!.toLowerCase().removeDiacritics(),
);
}).toList();
}
return GridView.builder(
@@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/providers/search/people.provider.dart';
@@ -44,16 +45,19 @@ class PeoplePicker extends HookConsumerWidget {
Expanded(
child: people.widgetWhen(
onData: (people) {
final filtered = people
.where(
(person) => 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(
+8
View File
@@ -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:
+1
View File
@@ -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
@@ -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 = [];
@@ -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,
);
+4 -1
View File
@@ -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 () => {
+118
View File
@@ -0,0 +1,118 @@
import type { PersonResponseDto } from '@immich/sdk';
import { searchNameLocal } from './person';
const makePerson = (overrides: Partial<PersonResponseDto> = {}): 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');
});
});
+6 -4
View File
@@ -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);
};
+5 -3
View File
@@ -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,
);
};
@@ -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,
)