Files
iptvnator/libs/shared/interfaces/src/lib/search-text-fold.util.ts
T
b30c783e85 fix(search): locale-invariant Turkish case folding in every search path (#1640)
Turkish upper and lower case queries now return the same results everywhere a
title can be searched. Lower-casing the dotted capital "İ" (U+0130) leaves a
combining dot behind, so "İnş" and "inş" reached different search arms and
different results.

- Case folding is locale-invariant: `toLowerCase()`, never `toLocaleLowerCase()`,
  which under a Turkish or Azeri OS locale maps ASCII "I" to the dotless "ı".
- The Electron content search composes to NFC and drops the leftover combining
  marks before tokenizing, and its LIKE/GLOB pattern builders additionally spell
  the `'tr'`-locale İ forms, since SQLite LIKE folds only ASCII.
- A shared `foldSearchText` covers every in-memory filter: channel lists, the
  Xtream and Stalker catalogs, category filters, collections, the EPG guide, the
  command palette, sources, the playlist switcher and the download lists.
- Composing before the strip keeps canonically equivalent spellings equal while
  the fold stays accent-sensitive; the Turkish I/ı pair is deliberately left
  alone, as the FTS index does not fold it either.

Covered by a SQLite-backed spec over the real trigram index plus regression
cases in the affected renderer specs.

Closes #609.

Co-Authored-By: Justin Willhite <5132924+thejdubb02@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 21:14:02 +02:00

36 lines
1.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Locale-invariant case fold for in-memory search filters (channel lists,
* catalog and category filters, source and download lists). Pure function,
* no Angular/Node dependencies.
*
* `toLowerCase()` alone is not enough for one visible input: the Turkish
* dotted capital "İ" (U+0130) lower-cases to "i" plus a combining dot above
* (U+0307), so `"İnşaat".toLowerCase().includes("inş")` is `false` even
* though the two spellings are the same word (issue #609).
*
* The fold therefore runs `toLowerCase()`, re-composes the result to NFC and
* drops whatever combining marks are left. Composing first is what makes the
* fold agree on canonically equivalent text: a provider title stored
* decomposed ("e" + U+0301) and a query typed precomposed ("é") reach the
* same string, and a Greek "Ά" lower-cases to a form NFC maps onto the same
* "ά" the user types. Only marks that cannot compose survive to be stripped —
* the dotted I's leftover dot among them — so precomposed letters (ç, ş, ü,
* é, ё, й) are preserved and the fold stays accent-sensitive, exactly as the
* filters were before. A lower-cased string of printable ASCII is already NFC
* and carries no marks, which is the fast path taken by most titles.
*
* `toLocaleLowerCase()` is deliberately not used: under a Turkish or Azeri
* OS locale it maps ASCII "I" to the dotless "ı", so the same list would
* filter differently per machine.
*/
const COMBINING_MARKS_REGEXP = /[̀-ͯ]/g;
const NON_PRINTABLE_ASCII_REGEXP = /[^ -~]/;
export function foldSearchText(value: string): string {
const lowered = value.toLowerCase();
return NON_PRINTABLE_ASCII_REGEXP.test(lowered)
? lowered.normalize('NFC').replace(COMBINING_MARKS_REGEXP, '')
: lowered;
}