mirror of
https://github.com/immich-app/immich.git
synced 2026-06-12 19:11:52 -07:00
fix: normalize diacritics in person name search in Web & Mobile (#28887)
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+6
-3
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user