From 0855bafca7dd307af29fbc6fffaae343132bc6d6 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 12 Nov 2026 23:06:36 +0100 Subject: [PATCH] fix(search): place searches find a name typed without its accents (#3075) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Obidos" did not find "Óbidos", nor "Sao Bras" "São Brás". The search of the places list, a collection, the saved-place picker, the import from a trip and the location picker now compare without case and Latin accents, with letters such as ß, ø or ł folded to their plain spelling. Marks outside Latin script stay, since there they change the word. --- .../Collections/CollectionPicker.tsx | 5 ++- .../Collections/ImportFromTripModal.tsx | 4 +-- .../src/components/Planner/LocationSelect.tsx | 3 +- .../collections/collectionsModel.test.ts | 11 ++++++ .../src/pages/collections/collectionsModel.ts | 9 ++--- client/src/utils/placeSearch.test.ts | 24 ++++++++++++- client/src/utils/placeSearch.ts | 34 ++++++++++++++++--- 7 files changed, 71 insertions(+), 19 deletions(-) diff --git a/client/src/components/Collections/CollectionPicker.tsx b/client/src/components/Collections/CollectionPicker.tsx index 4e84c99eb..db5ffba18 100644 --- a/client/src/components/Collections/CollectionPicker.tsx +++ b/client/src/components/Collections/CollectionPicker.tsx @@ -6,6 +6,7 @@ import { loadUnionPlaces } from '../../store/collectionUnion' import { STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel' import type { CollectionPlace, CollectionStatus } from '@trek/shared' import type { TranslationFn } from '../../types' +import { textMatchesSearch } from '../../utils/placeSearch' import { fs } from '../shared/DialogShell' import { EditorField, INPUT, LABEL } from '../shared/dialogParts' import { ColumnHead, HintCard, SIDE_COLUMN, WHITE_BUTTON } from '../Planner/placeDialogParts' @@ -143,12 +144,10 @@ export default function CollectionPicker({ bias, onSelect, t }: CollectionPicker const [shown, setShown] = useState(PAGE) const visible = useMemo(() => { - const q = search.trim().toLowerCase() const list = places.filter(p => { if (listFilter !== 'all' && p.collection_id !== listFilter) return false if (statusFilter !== 'all' && p.status !== statusFilter) return false - if (!q) return true - return p.name.toLowerCase().includes(q) || (p.address ?? '').toLowerCase().includes(q) + return textMatchesSearch([p.name, p.address], search) }) if (center) list.sort((a, b) => distanceTo(a, center) - distanceTo(b, center)) else list.sort((a, b) => a.name.localeCompare(b.name)) diff --git a/client/src/components/Collections/ImportFromTripModal.tsx b/client/src/components/Collections/ImportFromTripModal.tsx index df2663f13..8bfe2d7b4 100644 --- a/client/src/components/Collections/ImportFromTripModal.tsx +++ b/client/src/components/Collections/ImportFromTripModal.tsx @@ -9,6 +9,7 @@ import { getCategoryIcon } from '../shared/categoryIcons' import { useToast } from '../shared/Toast' import { useTranslation } from '../../i18n' import { formatDate } from '../../utils/formatters' +import { textMatchesSearch } from '../../utils/placeSearch' import { getApiErrorMessage } from '../../types' import type { Category, TranslationFn } from '../../types' import type { CollectionImportablePlace, Trip } from '@trek/shared' @@ -128,10 +129,9 @@ export default function ImportFromTripModal({ isOpen, collectionId, collectionNa const savedCount = items.length - newCount const visible = useMemo(() => { - const q = query.trim().toLowerCase() return items .filter(p => (onlyNew ? !p.already_in_list : true)) - .filter(p => !q || p.name.toLowerCase().includes(q) || (p.address ?? '').toLowerCase().includes(q)) + .filter(p => textMatchesSearch([p.name, p.address], query)) .sort((a, b) => importOrder(a) - importOrder(b) || a.name.localeCompare(b.name)) }, [items, onlyNew, query]) diff --git a/client/src/components/Planner/LocationSelect.tsx b/client/src/components/Planner/LocationSelect.tsx index 9772fafd3..aea9e5da1 100644 --- a/client/src/components/Planner/LocationSelect.tsx +++ b/client/src/components/Planner/LocationSelect.tsx @@ -1,6 +1,7 @@ import { useEffect, useId, useRef, useState } from 'react' import { MapPin, Search } from 'lucide-react' import { mapsApi } from '../../api/client' +import { textMatchesSearch } from '../../utils/placeSearch' import { useTranslation } from '../../i18n' import { useLocationBias } from '../../hooks/useLocationBias' import { usePlaceSuggestions } from '../../hooks/usePlaceSuggestions' @@ -196,7 +197,7 @@ function SourceMark({ label }: { label: string | null }) { function pickRows(value: LocationPoint | null, typed: string, places: LocationPoint[] | undefined, results: T[]) { const showPicks = !value && typed.length < 3 && !!places?.length const rows: (T | LocationPoint)[] = showPicks - ? (places ?? []).filter(p => !typed || p.name.toLowerCase().includes(typed.toLowerCase())) + ? (places ?? []).filter(p => textMatchesSearch([p.name], typed)) : results return { showPicks, rows } } diff --git a/client/src/pages/collections/collectionsModel.test.ts b/client/src/pages/collections/collectionsModel.test.ts index 3d90a6e7a..64d98c52b 100644 --- a/client/src/pages/collections/collectionsModel.test.ts +++ b/client/src/pages/collections/collectionsModel.test.ts @@ -53,6 +53,17 @@ const HOLE = undefined as unknown as CollectionPlace; describe('collectionsModel', () => { // ── filterPlaces ──────────────────────────────────────────────────────────── describe('filterPlaces', () => { + it('FE-COLLECTIONS-MODEL-045: the search finds Óbidos and São Brás typed without accents (#3075)', () => { + const places = [ + cp({ id: 1, name: 'Óbidos', status: 'idea' }), + cp({ id: 2, name: 'São Brás de Alportel', status: 'idea' }), + cp({ id: 3, name: 'Lagos', status: 'idea', address: 'Rua de Évora' }), + ]; + expect(filterPlaces(places, 'all', 'obidos').map(p => p.id)).toEqual([1]); + expect(filterPlaces(places, 'all', 'Sao Bras').map(p => p.id)).toEqual([2]); + expect(filterPlaces(places, 'all', 'evora').map(p => p.id)).toEqual([3]); + }); + it('FE-COLLECTIONS-MODEL-001: status filter keeps only the matching status', () => { const places = [ cp({ id: 1, name: 'A', status: 'idea' }), diff --git a/client/src/pages/collections/collectionsModel.ts b/client/src/pages/collections/collectionsModel.ts index f306b46dc..0122862de 100644 --- a/client/src/pages/collections/collectionsModel.ts +++ b/client/src/pages/collections/collectionsModel.ts @@ -1,5 +1,6 @@ import type { CollectionPlace, CollectionLabel } from '@trek/shared' import type { StatusFilter, CollectionSortMode } from '../../store/collectionStore' +import { textMatchesSearch } from '../../utils/placeSearch' /** * Pure data shaping + presentation metadata for the Collections page. No React @@ -36,7 +37,6 @@ export function filterPlaces( labelFilter: number[] = [], ratingFilter: number | 'all' = 'all', ): CollectionPlace[] { - const q = search.trim().toLowerCase() return places.filter(p => { if (!p) return false if (statusFilter !== 'all' && p.status !== statusFilter) return false @@ -44,12 +44,7 @@ export function filterPlaces( // Minimum-average-stars filter (#1435): unrated places drop out once a floor is set. if (ratingFilter !== 'all' && (p.rating_avg == null || p.rating_avg < ratingFilter)) return false if (labelFilter.length && !labelFilter.some(id => (p.label_ids ?? []).includes(id))) return false - if (!q) return true - return ( - p.name.toLowerCase().includes(q) || - (p.address ?? '').toLowerCase().includes(q) || - (p.notes ?? '').toLowerCase().includes(q) - ) + return textMatchesSearch([p.name, p.address, p.notes], search) }) } diff --git a/client/src/utils/placeSearch.test.ts b/client/src/utils/placeSearch.test.ts index 01677361f..fab74f70f 100644 --- a/client/src/utils/placeSearch.test.ts +++ b/client/src/utils/placeSearch.test.ts @@ -1,6 +1,6 @@ // FE-UTIL-PLACESEARCH-001 to FE-UTIL-PLACESEARCH-004 import { describe, it, expect } from 'vitest' -import { placeMatchesSearch } from './placeSearch' +import { foldForSearch, placeMatchesSearch, textMatchesSearch } from './placeSearch' const place = { name: 'Café Central', @@ -32,3 +32,25 @@ describe('placeMatchesSearch', () => { expect(placeMatchesSearch(place, 'pizza')).toBe(false) }) }) + +describe('search without accents (#3075)', () => { + it('FE-UTIL-PLACESEARCH-005: a query without accents finds the accented name, and the other way round', () => { + for (const [query, name] of [['Obidos', 'Óbidos'], ['Evora', 'Évora'], ['Sao Bras', 'São Brás'], ['Loule', 'Loulé']]) { + expect(placeMatchesSearch({ name, address: null, description: null, notes: null }, query)).toBe(true) + expect(placeMatchesSearch({ name: query, address: null, description: null, notes: null }, name)).toBe(true) + } + }) + + it('FE-UTIL-PLACESEARCH-006: letters with the mark built in fold to their plain spelling', () => { + expect(foldForSearch('Straße')).toBe('strasse') + expect(foldForSearch('Łódź')).toBe('lodz') + expect(foldForSearch('Ærø')).toBe('aero') + expect(textMatchesSearch(['Kraków'], 'krakow')).toBe(true) + }) + + it('FE-UTIL-PLACESEARCH-007: marks that change a word outside Latin script stay, so が is not か', () => { + expect(foldForSearch('が')).toBe('が') + expect(textMatchesSearch(['かき'], 'がき')).toBe(false) + expect(foldForSearch('한국')).toBe('한국') + }) +}) diff --git a/client/src/utils/placeSearch.ts b/client/src/utils/placeSearch.ts index caecc18ea..885e8c9da 100644 --- a/client/src/utils/placeSearch.ts +++ b/client/src/utils/placeSearch.ts @@ -10,9 +10,33 @@ type Searchable = Pick * can never disagree about what a search finds. */ export function placeMatchesSearch(place: Searchable, query: string): boolean { - const q = query.trim().toLowerCase() - if (!q) return true - return [place.name, place.address, place.description, place.notes].some( - field => !!field && field.toLowerCase().includes(q), - ) + return textMatchesSearch([place.name, place.address, place.description, place.notes], query) +} + +/** The combining accents of Latin script (U+0300 to U+036F), the marks NFD splits off a letter. */ +const LATIN_MARKS = new RegExp(`[${String.fromCharCode(0x300)}-${String.fromCharCode(0x36f)}]`, 'g') + +/** Letters that carry their mark in the letter itself, so taking the marks off leaves them as they are. */ +const FOLDED_LETTERS: Record = { ß: 'ss', ø: 'o', æ: 'ae', œ: 'oe', ł: 'l', đ: 'd', ð: 'd', þ: 'th', ı: 'i' } + +/** + * Text as a search compares it: lower case and without accents, so "Obidos" finds + * "Óbidos", "Sao Bras" finds "São Brás" and the other way round (#3075). Only + * Latin marks go: a kana's voicing mark or a Hangul syllable stays whole, since + * there it changes the word. + */ +export function foldForSearch(text: string): string { + return text + .toLocaleLowerCase() + .normalize('NFD') + .replace(LATIN_MARKS, '') + .replace(/[ßøæœłđðþı]/g, ch => FOLDED_LETTERS[ch] ?? ch) + .normalize('NFC') +} + +/** Whether any of the fields holds the query, accents and case aside. An empty query matches everything. */ +export function textMatchesSearch(fields: readonly (string | null | undefined)[], query: string): boolean { + const q = foldForSearch(query.trim()) + if (!q) return true + return fields.some(field => !!field && foldForSearch(field).includes(q)) }