From b30c783e85b992ba58d7887fc510b96e7cee832e Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 20 Sep 2026 21:14:02 +0200 Subject: [PATCH] fix(search): locale-invariant Turkish case folding in every search path (#1640) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .changes/search-turkish-case-folding.md | 7 + CLAUDE.md | 2 +- .../operations/content-search.util.spec.ts | 84 ++++++- .../content-search.util.sqlite.spec.ts | 221 ++++++++++++++++++ .../operations/content-search.util.ts | 95 +++++--- .../playlist-switcher.component.ts | 8 +- .../recent-playlists.component.ts | 7 +- .../src/lib/download-manager.viewmodel.ts | 5 +- .../src/lib/recording-manager.viewmodel.ts | 5 +- .../unified-grid-tab.component.ts | 5 +- .../util/src/lib/favorites-channel-sort.ts | 6 +- .../src/lib/portal-collection-items.spec.ts | 21 ++ .../util/src/lib/portal-collection-items.ts | 6 +- .../stalker-itv-all-items.component.ts | 9 +- .../stalker-live-stream-layout.component.ts | 13 +- .../data-sources/pwa-xtream-data-source.ts | 5 +- .../features/with-selection.feature.spec.ts | 21 ++ .../stores/features/with-selection.feature.ts | 5 +- .../category-management-dialog.component.ts | 5 +- ...am-live-channel-navigation.service.spec.ts | 23 +- .../xtream-live-channel-navigation.service.ts | 9 +- .../portal-channels-list.component.ts | 9 +- libs/shared/interfaces/src/index.ts | 1 + .../src/lib/search-text-fold.util.spec.ts | 58 +++++ .../src/lib/search-text-fold.util.ts | 35 +++ .../all-channels-view.component.spec.ts | 24 ++ .../all-channels-view.component.ts | 7 +- .../channel-list-container.component.ts | 5 +- .../favorites-view.component.ts | 9 +- .../group-management-dialog.component.ts | 5 +- .../groups-view/groups-view.component.ts | 13 +- .../recent-view/recent-view.component.ts | 9 +- .../vod-sources/vod-sources-menu.component.ts | 9 +- .../src/lib/epg-guide/epg-guide-view-state.ts | 6 +- ...orkspace-command-palette.component.spec.ts | 72 ++++++ .../workspace-command-palette.component.ts | 9 +- .../workspace-context-panel.component.ts | 9 +- .../workspace-sources.component.ts | 5 +- 38 files changed, 736 insertions(+), 111 deletions(-) create mode 100644 .changes/search-turkish-case-folding.md create mode 100644 apps/electron-backend/src/app/database/operations/content-search.util.sqlite.spec.ts create mode 100644 libs/shared/interfaces/src/lib/search-text-fold.util.spec.ts create mode 100644 libs/shared/interfaces/src/lib/search-text-fold.util.ts diff --git a/.changes/search-turkish-case-folding.md b/.changes/search-turkish-case-folding.md new file mode 100644 index 000000000..05a94724a --- /dev/null +++ b/.changes/search-turkish-case-folding.md @@ -0,0 +1,7 @@ +--- +type: fix +area: search +issues: [609] +--- + +Search now returns the same results whether you type upper or lower case Turkish characters. Titles with the dotted capital I (for example "İnşaat") are found by typing a plain lower case i, so "inş" and "İnş" match the same channels and movies — in global search, in each portal's search, in the command palette, and in the channel, category, favorites and download list filters. diff --git a/CLAUDE.md b/CLAUDE.md index 44b439f06..908f3a906 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -394,7 +394,7 @@ This is an Nx monorepo with the following structure: - **portal/downloads/feature** - Download manager UI - **portal/shared/{data-access,ui,util}** - Cross-portal shared code: stateful collection services and VOD multi-source discovery/resolve/ranking live in `data-access`; reusable views live in `ui`; `util` is for pure contracts/helpers - **services** - Abstract DataService contract and shared app services (incl. the TMDB metadata enrichment module in `lib/tmdb/`) - - **shared/interfaces** - TypeScript interfaces and types (incl. `ElectronBridgeApi`) + - **shared/interfaces** - TypeScript interfaces and types (incl. `ElectronBridgeApi`). `foldSearchText` (`search-text-fold.util.ts`) is the one case fold every in-memory search filter uses (channel lists, catalog/category filters, the command palette, sources, downloads): `toLowerCase()`, then NFC, then a combining-mark strip, never `toLocaleLowerCase()`. Composing before the strip is what makes canonically equivalent spellings fold to one string (a decomposed `e`+U+0301 title and a precomposed `é` query, a Greek `Ά` and `ά`); only marks that cannot compose are dropped, the Turkish dotted İ's leftover dot among them, so the fold matches a plain `i` while staying accent-sensitive and locale-independent (issue #609). A filter must call it on BOTH sides, and a list's row filter and its count/queue source must use the same fold or the two disagree. The Electron content search (`content-search.util.ts`) folds the same way and additionally spells explicit `'tr'`-locale İ forms into its LIKE/GLOB variants, because SQLite LIKE folds only ASCII - **shared/logging** - Dependency-free structured redaction for diagnostic logs - **shared/host-health** - Per-host circuit breaker for portal requests (`HostConnectivityGuard`), shared by the Electron main process and the web backend; transport-free, the owning app supplies the clock and owns the instance. Also home to the two Node networking helpers both backends share: the happy-eyeballs attempt-timeout default (`network-family-autoselection.ts`) and the socket-connect observer that feeds the guard's `connected` flag (`socket-connect-observer.ts`). Monotonic admission ids with per-endpoint failure boundaries distinguish parallel failures from later attempts even within one clock tick (#1438) - **shared/database** - Canonical Drizzle schema and DB connection (used by the Electron backend) diff --git a/apps/electron-backend/src/app/database/operations/content-search.util.spec.ts b/apps/electron-backend/src/app/database/operations/content-search.util.spec.ts index c668afcf6..db2e6b8a2 100644 --- a/apps/electron-backend/src/app/database/operations/content-search.util.spec.ts +++ b/apps/electron-backend/src/app/database/operations/content-search.util.spec.ts @@ -1,10 +1,14 @@ import { + buildContentTitleFtsMatchQuery, buildCompoundFtsMatchQuery, buildCompoundLikePatterns, + buildGlobPrefixPatterns, + buildLikePatterns, buildM3uPayloadCompoundPatterns, getCompoundResidualTokenGroups, - getCompoundSearchWords, getSearchWordPlans, + getSqlSearchTokenGroups, + getCompoundSearchWords, scoreSearchTextMatch, shouldUseContentTitlePrefixIndex, } from './content-search.util'; @@ -122,6 +126,84 @@ describe('content-search.util', () => { }); }); + describe('Turkish case folding (issue #609)', () => { + // Upper and lower case forms of the same query must build the same + // search and match the same titles. The folding is locale-invariant, + // so these hold under a Turkish OS locale too, where toLocaleLowerCase + // would otherwise send "İ" and "I" to different letters. + const pairs: ReadonlyArray = [ + ['İnş', 'inş'], + ['Ünlü', 'ünlü'], + ['Çan', 'çan'], + ['Şan', 'şan'], + ]; + + it.each(pairs)( + 'builds the same search for %s and %s', + (upper, lower) => { + expect(getSqlSearchTokenGroups(upper)).toEqual( + getSqlSearchTokenGroups(lower) + ); + expect(buildContentTitleFtsMatchQuery(upper)).toBe( + buildContentTitleFtsMatchQuery(lower) + ); + expect(shouldUseContentTitlePrefixIndex(upper)).toBe( + shouldUseContentTitlePrefixIndex(lower) + ); + } + ); + + it('keeps the dotted capital I as one word, not "i" + "nş"', () => { + // The combining dot from lower-casing "İ" must not split the word, + // which used to flip it onto the short-token prefix index. + expect(getSqlSearchTokenGroups('İnş')).toEqual([['inş', 'ins']]); + expect(shouldUseContentTitlePrefixIndex('İnş')).toBe(false); + }); + + it('scores the same title for the upper and lower case query', () => { + const upper = scoreSearchTextMatch('İnşaat', 'İnş'); + const lower = scoreSearchTextMatch('İnşaat', 'inş'); + + expect(lower).not.toBeNull(); + expect(lower).toBe(upper); + }); + + it('spells the dotted capital İ forms in LIKE and GLOB patterns', () => { + // SQLite LIKE folds only ASCII and GLOB folds nothing, and the + // locale-invariant toUpperCase() turns "inş" into "INŞ", which + // never matches a stored "İnşaat". The Turkish forms are added + // with an explicit 'tr' locale so they do not depend on the OS. + expect(buildLikePatterns('inş')).toEqual( + expect.arrayContaining(['%İNŞ%', '%İnş%', '%INŞ%', '%inş%']) + ); + expect(buildLikePatterns('İnş', 'prefix')).toEqual( + expect.arrayContaining(['İnş%', 'İNŞ%', 'inş%']) + ); + expect(buildGlobPrefixPatterns('iş')).toEqual( + expect.arrayContaining(['İş*', 'İŞ*', 'iş*', 'IŞ*']) + ); + expect(buildCompoundLikePatterns('iş-tv')).toEqual( + expect.arrayContaining(['%İş-tv%', '%İŞ-TV%']) + ); + }); + + it('adds no Turkish forms to values without an i', () => { + expect(buildLikePatterns('çan')).toEqual([ + '%çan%', + '%ÇAN%', + '%Çan%', + '%can%', + '%CAN%', + '%Can%', + ]); + expect(buildGlobPrefixPatterns('ма')).toEqual([ + 'ма*', + 'МА*', + 'Ма*', + ]); + }); + }); + describe('scoreSearchTextMatch', () => { it('matches compound words anywhere in the title (issue #1161)', () => { expect(scoreSearchTextMatch('US: A&E', 'A&E')).toBe(40); diff --git a/apps/electron-backend/src/app/database/operations/content-search.util.sqlite.spec.ts b/apps/electron-backend/src/app/database/operations/content-search.util.sqlite.spec.ts new file mode 100644 index 000000000..24115f65c --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/content-search.util.sqlite.spec.ts @@ -0,0 +1,221 @@ +/** + * Runs the search helpers' output through a real SQLite instance with the + * same trigram FTS tokenizer the content index uses, so the case-folding + * contract is checked against SQLite's own LIKE/GLOB/MATCH semantics rather + * than against a string assertion (issue #609). Each SQL arm of the global + * and per-playlist search is reproduced from the same pattern builders the + * operations consume; the AND/OR composition mirrors `content.operations.ts`. + */ +import Database from 'better-sqlite3'; +import { + buildCompoundLikePatterns, + buildContentTitleFtsMatchQuery, + buildGlobPrefixPatterns, + buildLikePatterns, + buildM3uPayloadTextFieldPatterns, + getCompoundSearchWords, + getSqlSearchTokenGroups, + isShortSearchTokenGroup, + scoreSearchTextMatch, + shouldUseContentTitleFts, + shouldUseContentTitlePrefixIndex, +} from './content-search.util'; + +const TITLES = [ + 'İnşaat Kanalı', + 'inşaat dünyası', + 'İşte Benim Stilim', + 'Ünlü Şef', + 'Çanakkale', + 'Şan Ve Şeref', + 'Первый канал HD', + 'ПЕРВЫЙ КАНАЛ', + 'Ёлки 1914', + 'Йога для всех', + 'Матч ТВ', + 'Ελλάδα Σήμερα', + 'Amélie', + 'US: iş-tv', +]; + +type Arm = 'fts' | 'glob' | 'like' | 'm3u' | 'compound' | 'score'; + +function likeClauses( + column: string, + groups: string[][], + build: (token: string, mode: 'contains' | 'prefix') => string[] +): { where: string; params: string[] } { + const clauses: string[] = []; + const params: string[] = []; + groups.forEach((tokens, index) => { + const mode = + index === 0 && isShortSearchTokenGroup(tokens) + ? 'prefix' + : 'contains'; + const patterns = tokens.flatMap((token) => build(token, mode)); + clauses.push( + `(${patterns.map(() => `${column} LIKE ? ESCAPE '\\'`).join(' OR ')})` + ); + params.push(...patterns); + }); + return { where: clauses.join(' AND '), params }; +} + +describe('content-search.util against SQLite', () => { + let db: Database.Database; + + beforeAll(() => { + db = new Database(':memory:'); + db.exec(` + CREATE TABLE c(title TEXT); + CREATE VIRTUAL TABLE f USING fts5( + title, content='c', content_rowid='rowid', + tokenize='trigram remove_diacritics 1' + ); + CREATE TABLE p(title TEXT, payload TEXT); + `); + const insert = db.prepare('INSERT INTO c(title) VALUES (?)'); + const insertPayload = db.prepare( + 'INSERT INTO p(title, payload) VALUES (?, ?)' + ); + for (const title of TITLES) { + insert.run(title); + insertPayload.run( + title, + JSON.stringify({ items: [{ name: title, title }] }) + ); + } + db.exec(`INSERT INTO f(f) VALUES ('rebuild')`); + }); + + afterAll(() => { + db.close(); + }); + + function titles(sql: string, params: string[]): string[] { + return db + .prepare(sql) + .all(...params) + .map((row) => (row as { title: string }).title) + .sort(); + } + + function run(query: string): Partial> { + const out: Partial> = {}; + const groups = getSqlSearchTokenGroups(query); + + if (shouldUseContentTitlePrefixIndex(query)) { + const patterns = groups[0].flatMap((token) => + buildGlobPrefixPatterns(token) + ); + out.glob = titles( + `SELECT title FROM c WHERE ${patterns + .map(() => 'title GLOB ?') + .join(' OR ')}`, + patterns + ); + } else if (shouldUseContentTitleFts(query)) { + out.fts = titles( + `SELECT c.title FROM f INNER JOIN c ON c.rowid = f.rowid WHERE f MATCH ?`, + [buildContentTitleFtsMatchQuery(query)] + ); + } + + const like = likeClauses('title', groups, buildLikePatterns); + out.like = titles( + `SELECT title FROM c WHERE ${like.where}`, + like.params + ); + + const m3u = likeClauses( + 'payload', + groups, + buildM3uPayloadTextFieldPatterns + ); + out.m3u = titles(`SELECT title FROM p WHERE ${m3u.where}`, m3u.params); + + // The compound arm of the prefix path: a punctuation-joined word is + // looked up as an intact substring, because the prefix index only + // sees titles starting with the short first token (issue #1161). + const compoundWords = getCompoundSearchWords(query); + if (compoundWords.length > 0) { + const patterns = compoundWords.flatMap((word) => + buildCompoundLikePatterns(word) + ); + out.compound = titles( + `SELECT title FROM c WHERE ${patterns + .map(() => `title LIKE ? ESCAPE '\\'`) + .join(' OR ')}`, + patterns + ); + } + + out.score = TITLES.filter( + (title) => scoreSearchTextMatch(title, query) !== null + ).sort(); + return out; + } + + it.each([ + ['İnş', 'inş', ['İnşaat Kanalı', 'inşaat dünyası']], + ['İşt', 'işt', ['İşte Benim Stilim']], + ['İş', 'iş', ['İşte Benim Stilim']], + ['Ünl', 'ünl', ['Ünlü Şef']], + ['Çan', 'çan', ['Çanakkale']], + ['Şan', 'şan', ['Şan Ve Şeref']], + ])( + 'finds the same Turkish titles for %s and %s in every SQL arm', + (upper, lower, expected) => { + const upperResult = run(upper); + const lowerResult = run(lower); + + expect(lowerResult).toEqual(upperResult); + for (const arm of Object.keys(lowerResult) as Arm[]) { + expect(lowerResult[arm]).toEqual([...expected].sort()); + } + } + ); + + it.each([ + ['Первый', 'первый', ['Первый канал HD', 'ПЕРВЫЙ КАНАЛ']], + ['ПЕРВ', 'перв', ['Первый канал HD', 'ПЕРВЫЙ КАНАЛ']], + ['Ёлки', 'ёлки', ['Ёлки 1914']], + ['Йога', 'йога', ['Йога для всех']], + ['Ма', 'ма', ['Матч ТВ']], + ['ΕΛΛ', 'ελλ', ['Ελλάδα Σήμερα']], + ])( + 'keeps Cyrillic and Greek case pairs %s / %s equal and complete', + (upper, lower, expected) => { + const upperResult = run(upper); + const lowerResult = run(lower); + + expect(lowerResult).toEqual(upperResult); + for (const arm of Object.keys(lowerResult) as Arm[]) { + expect(lowerResult[arm]).toEqual([...expected].sort()); + } + } + ); + + it('reaches a lower-case compound title from the dotted capital İ spelling', () => { + // "İş-TV" lower-cases to "i" + U+0307, which is not how the stored + // "US: iş-tv" is spelled, and its short first token keeps the token + // arm prefix-anchored — so the compound arm has to carry the + // mark-free lower-case form. + expect(run('İş-TV').compound).toEqual(['US: iş-tv']); + expect(run('iş-tv').compound).toEqual(['US: iş-tv']); + expect(run('İş-TV')).toEqual(run('iş-tv')); + }); + + it('matches a decomposed query against the precomposed stored title', () => { + // The query normalizer composes first, so "Ame" + U+0301 + "lie" + // produces the same tokens as the precomposed spelling. + expect(run('Ame\u0301lie').like).toEqual(run('Amélie').like); + expect(run('Ame\u0301lie').score).toEqual(['Amélie']); + }); + + it('keeps the diacritic-stripped fallback for Latin accents', () => { + expect(run('Amélie').fts).toEqual(['Amélie']); + expect(run('amelie').fts).toEqual(['Amélie']); + expect(run('amelie').score).toEqual(['Amélie']); + }); +}); diff --git a/apps/electron-backend/src/app/database/operations/content-search.util.ts b/apps/electron-backend/src/app/database/operations/content-search.util.ts index dc0d9488b..ba48c662a 100644 --- a/apps/electron-backend/src/app/database/operations/content-search.util.ts +++ b/apps/electron-backend/src/app/database/operations/content-search.util.ts @@ -16,12 +16,55 @@ export function escapeLikePattern(term: string): string { return term.replace(/[%_\\]/g, '\\$&'); } +/** + * Case spellings a stored title may use for `value`, for LIKE/GLOB patterns: + * SQLite LIKE folds only ASCII and GLOB folds nothing, so every form has to + * be spelled out. Besides lower/upper/title case, a value containing "i" + * also gets the Turkish forms with the dotted capital İ (U+0130) — "inş" + * becomes "İNŞ" / "İnş" — because the locale-invariant `toUpperCase()` + * yields "INŞ", which never matches a title such as "İnşaat" (issue #609), + * and conversely the mark-free lower-case form, which `toLowerCase()` alone + * does not produce for a value already spelled with "İ". + * The explicit `'tr'` locale is the point: it is fixed, not the OS locale. + */ +function getCaseVariants(value: string): string[] { + const lower = value.toLowerCase(); + // Lower-casing "İ" leaves "i" + U+0307, so `lower` is not the spelling a + // stored lower-case title uses; `folded` is. Without it a query typed as + // "İş-TV" carries no pattern that matches a stored "US: iş-tv" — the + // token arm is prefix-anchored on the short first token, and the + // compound arm would only offer the diacritic-stripped "is-tv". + const folded = lower.replace(/[\u0300-\u036f]/g, ''); + const variants = new Set([ + value, + lower, + folded, + value.toUpperCase(), + value.charAt(0).toUpperCase() + value.slice(1).toLowerCase(), + ]); + + if (folded.includes('i')) { + variants.add(folded.toLocaleUpperCase('tr')); + variants.add( + folded.charAt(0).toLocaleUpperCase('tr') + folded.slice(1) + ); + } + + return [...variants]; +} + +// Search case folding is deliberately locale-invariant (toLowerCase, not +// toLocaleLowerCase). Under a Turkish or Azeri locale toLocaleLowerCase maps +// ASCII "I" to the dotless "ı" and folds the dotted "İ" its own way, so the +// same title would be found or missed depending on the user's OS locale, and +// the query would not line up with the SQLite FTS index, which folds +// locale-invariantly. Issue #609: "İnş" and "inş" returned different results. export function normalizeSearchMatchText(value: unknown): string { return typeof value === 'string' ? value .normalize('NFKD') .replace(/[\u0300-\u036f]/g, '') - .toLocaleLowerCase() + .toLowerCase() .replace(/[^\p{L}\p{N}]+/gu, ' ') .trim() .replace(/\s+/g, ' ') @@ -31,7 +74,20 @@ export function normalizeSearchMatchText(value: unknown): string { function normalizeSqlSearchText(value: unknown): string { return typeof value === 'string' ? value - .toLocaleLowerCase() + .toLowerCase() + // Re-compose first so canonically equivalent spellings of the + // query produce one token: a decomposed "e" + U+0301 would + // otherwise lose its accent below and stop matching a stored + // precomposed "é". Same rule as `foldSearchText`. + .normalize('NFC') + // Drop the combining marks case folding leaves behind, before the + // split below can read them as word separators: "İ" (U+0130) + // lower-cases to "i" plus a combining dot above (U+0307), which + // otherwise split "İnş" into "i" and "nş" while "inş" stayed one + // token, routing the two differently (issue #609). Same + // U+0300-U+036F range normalizeSearchMatchText strips; precomposed + // letters (ç, ş, ü, é) are outside it and left untouched. + .replace(/[\u0300-\u036f]/g, '') .replace(/[^\p{L}\p{N}]+/gu, ' ') .trim() .replace(/\s+/g, ' ') @@ -75,16 +131,9 @@ export function buildLikePatterns( continue; } - const titleCase = - trimmedValue.length > 0 - ? trimmedValue.charAt(0).toLocaleUpperCase() + - trimmedValue.slice(1).toLocaleLowerCase() - : trimmedValue; - - variants.add(trimmedValue); - variants.add(trimmedValue.toLocaleLowerCase()); - variants.add(trimmedValue.toLocaleUpperCase()); - variants.add(titleCase); + for (const variant of getCaseVariants(trimmedValue)) { + variants.add(variant); + } } return [...variants].map((value) => { @@ -97,13 +146,9 @@ export function buildGlobPrefixPatterns(token: string): string[] { const variants = new Set(); for (const value of [token, ...getSqlSearchTokenVariants(token)]) { - variants.add(value); - variants.add(value.toLocaleLowerCase()); - variants.add(value.toLocaleUpperCase()); - variants.add( - value.charAt(0).toLocaleUpperCase() + - value.slice(1).toLocaleLowerCase() - ); + for (const variant of getCaseVariants(value)) { + variants.add(variant); + } } return [...variants].map((value) => `${value}*`); @@ -236,13 +281,9 @@ export function buildCompoundLikePatterns(word: string): string[] { const variants = new Set(); for (const value of getCompoundWordVariants(word)) { - variants.add(value); - variants.add(value.toLocaleLowerCase()); - variants.add(value.toLocaleUpperCase()); - variants.add( - value.charAt(0).toLocaleUpperCase() + - value.slice(1).toLocaleLowerCase() - ); + for (const variant of getCaseVariants(value)) { + variants.add(variant); + } } return [...variants].map((value) => `%${escapeLikePattern(value)}%`); @@ -261,7 +302,7 @@ export function buildCompoundFtsMatchQuery(searchTerm: string): string { const quotedVariants = [ ...new Set( getCompoundWordVariants(word).map((variant) => - variant.toLocaleLowerCase() + variant.toLowerCase() ) ), ].map((variant) => `"${variant.replace(/"/g, '""')}"`); diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts index 6563f04a7..d44c5899a 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts @@ -1,7 +1,7 @@ import { Injector } from '@angular/core'; import { RuntimeCapabilitiesService } from '@iptvnator/services'; import { SourceHealthService } from '@iptvnator/portal/shared/data-access'; -import { sourceHealthType } from '@iptvnator/shared/interfaces'; +import { foldSearchText, sourceHealthType } from '@iptvnator/shared/interfaces'; import { SourceHealthIndicatorComponent } from '../source-health/source-health-indicator.component'; import { DatePipe, DOCUMENT } from '@angular/common'; import { @@ -138,7 +138,7 @@ export class PlaylistSwitcherComponent { readonly activePlaylistId = this.playlistContext.resolvedPlaylistId; readonly activePlaylist = this.playlistContext.activePlaylist; readonly filteredPlaylists = computed(() => { - const query = this.searchQuery().toLowerCase().trim(); + const query = foldSearchText(this.searchQuery()).trim(); const filters = this.playlistTypeFilters(); const allPlaylists = this.playlists(); const filteredByType = allPlaylists.filter((playlist) => { @@ -148,8 +148,8 @@ export class PlaylistSwitcherComponent { const filtered = query ? filteredByType.filter( (playlist) => - playlist.title?.toLowerCase().includes(query) || - playlist.filename?.toLowerCase().includes(query) + foldSearchText(playlist.title ?? '').includes(query) || + foldSearchText(playlist.filename ?? '').includes(query) ) : filteredByType; diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts index 570af73f2..05153931c 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts @@ -40,6 +40,7 @@ import { SortService, } from '@iptvnator/services'; import { + foldSearchText, PLAYLIST_UPDATE, PlaylistMeta, PlaylistRefreshEvent, @@ -169,9 +170,9 @@ export class RecentPlaylistsComponent { ); }) .filter((item) => - (item.title || '') - .toLowerCase() - .includes(searchQuery.toLowerCase()) + foldSearchText(item.title || '').includes( + foldSearchText(searchQuery) + ) ); // Apply sorting using the SortService diff --git a/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.ts b/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.ts index 5682b30ba..4efc557c1 100644 --- a/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.ts +++ b/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.ts @@ -9,6 +9,7 @@ import { type DownloadLibraryEntity, type DownloadLibraryRow, } from './download-library.viewmodel'; +import { foldSearchText } from '@iptvnator/shared/interfaces'; export type { DownloadCatchupCardViewModel, @@ -124,7 +125,7 @@ function matchesSearch( row: DownloadListItemViewModel, searchTerm: string ): boolean { - const query = searchTerm.trim().toLowerCase(); + const query = foldSearchText(searchTerm.trim()); return ( query.length === 0 || [ @@ -133,7 +134,7 @@ function matchesSearch( row.sourceName, row.episodeLabel, row.item.errorMessage ?? '', - ].some((value) => value.toLowerCase().includes(query)) + ].some((value) => foldSearchText(value).includes(query)) ); } diff --git a/libs/portal/downloads/feature/src/lib/recording-manager.viewmodel.ts b/libs/portal/downloads/feature/src/lib/recording-manager.viewmodel.ts index 4304f24d9..d7571ceb7 100644 --- a/libs/portal/downloads/feature/src/lib/recording-manager.viewmodel.ts +++ b/libs/portal/downloads/feature/src/lib/recording-manager.viewmodel.ts @@ -1,5 +1,6 @@ import type { RecordingItem } from '@iptvnator/services'; import { normalizeDownloadFilter } from './download-manager.viewmodel'; +import { foldSearchText } from '@iptvnator/shared/interfaces'; export type RecordingAttentionReason = 'file-missing' | 'failed'; @@ -96,7 +97,7 @@ function toRow(item: RecordingItem): RecordingRowViewModel { } function matchesSearch(row: RecordingRowViewModel, searchTerm: string): boolean { - const query = searchTerm.trim().toLowerCase(); + const query = foldSearchText(searchTerm.trim()); return ( query.length === 0 || [ @@ -104,7 +105,7 @@ function matchesSearch(row: RecordingRowViewModel, searchTerm: string): boolean row.channelName, row.item.playlistName ?? '', row.item.errorMessage ?? '', - ].some((value) => value.toLowerCase().includes(query)) + ].some((value) => foldSearchText(value).includes(query)) ); } diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-grid-tab.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-grid-tab.component.ts index ff88648f5..075978c42 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-grid-tab.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-grid-tab.component.ts @@ -12,6 +12,7 @@ import { UnifiedCollectionItem, } from '@iptvnator/portal/shared/util'; import { ContentCardComponent } from '../content-card/content-card.component'; +import { foldSearchText } from '@iptvnator/shared/interfaces'; @Component({ selector: 'app-unified-grid-tab', @@ -30,7 +31,7 @@ export class UnifiedGridTabComponent { readonly itemSelected = output(); private readonly normalizedSearchTerm = computed(() => - this.searchTerm().trim().toLowerCase() + foldSearchText(this.searchTerm().trim()) ); /** * Search results are identified by the name the user typed, so a @@ -44,7 +45,7 @@ export class UnifiedGridTabComponent { const term = this.normalizedSearchTerm(); const all = this.items(); return term - ? all.filter((i) => i.name.toLowerCase().includes(term)) + ? all.filter((i) => foldSearchText(i.name).includes(term)) : all; }); diff --git a/libs/portal/shared/util/src/lib/favorites-channel-sort.ts b/libs/portal/shared/util/src/lib/favorites-channel-sort.ts index 39978c1da..e4b994c95 100644 --- a/libs/portal/shared/util/src/lib/favorites-channel-sort.ts +++ b/libs/portal/shared/util/src/lib/favorites-channel-sort.ts @@ -1,3 +1,5 @@ +import { foldSearchText } from '@iptvnator/shared/interfaces'; + const FAVORITES_SORT_COLLATOR = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base', @@ -72,10 +74,10 @@ export function deriveVisibleFavoriteChannels( getAddedAt?: (item: T) => string | null | undefined; } ): readonly T[] { - const term = options.searchTerm.trim().toLowerCase(); + const term = foldSearchText(options.searchTerm.trim()); const filtered = term ? channels.filter((channel) => - (options.getName(channel) ?? '').toLowerCase().includes(term) + foldSearchText(options.getName(channel) ?? '').includes(term) ) : channels; diff --git a/libs/portal/shared/util/src/lib/portal-collection-items.spec.ts b/libs/portal/shared/util/src/lib/portal-collection-items.spec.ts index 670f6b2a1..f74dfd236 100644 --- a/libs/portal/shared/util/src/lib/portal-collection-items.spec.ts +++ b/libs/portal/shared/util/src/lib/portal-collection-items.spec.ts @@ -135,4 +135,25 @@ describe('portal-collection-items', () => { expect(filtered).toEqual([items[1]]); }); + + it('folds the Turkish dotted capital İ so upper and lower case terms agree (issue #609)', () => { + const items = [ + { name: 'İnşaat Kanalı', o_name: '' }, + { name: 'Matrix', o_name: 'Neo' }, + ]; + const filter = (searchTerm: string) => + filterCollectionBucket({ + selectedCategoryId: 'all', + allItems: items, + buckets: { + movie: items, + }, + searchTerm, + textOf: (item) => `${item.name} ${item.o_name}`, + }); + + expect(filter('inş')).toEqual([items[0]]); + expect(filter('İnş')).toEqual([items[0]]); + expect(filter('İNŞ')).toEqual([items[0]]); + }); }); diff --git a/libs/portal/shared/util/src/lib/portal-collection-items.ts b/libs/portal/shared/util/src/lib/portal-collection-items.ts index 5e4fe7177..4797d1ebd 100644 --- a/libs/portal/shared/util/src/lib/portal-collection-items.ts +++ b/libs/portal/shared/util/src/lib/portal-collection-items.ts @@ -1,4 +1,4 @@ -import { XtreamCategory } from '@iptvnator/shared/interfaces'; +import { foldSearchText, XtreamCategory } from '@iptvnator/shared/interfaces'; type StandardCollectionBucket = 'all' | 'movie' | 'live' | 'series'; @@ -84,13 +84,13 @@ export function filterCollectionBucket( : selectedCategoryId === 'series' ? buckets.series ?? [] : allItems ?? []; - const normalizedTerm = (searchTerm ?? '').trim().toLowerCase(); + const normalizedTerm = foldSearchText((searchTerm ?? '').trim()); if (!normalizedTerm) { return [...baseItems]; } return baseItems.filter((item) => - String(textOf(item)).toLowerCase().includes(normalizedTerm) + foldSearchText(String(textOf(item))).includes(normalizedTerm) ); } diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-itv-all-items.component.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-itv-all-items.component.ts index 9e7303ab6..0aadc6156 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-itv-all-items.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-itv-all-items.component.ts @@ -16,6 +16,7 @@ import { StalkerItvChannel, StalkerItvLoadProgress, } from '@iptvnator/portal/stalker/data-access'; +import { foldSearchText } from '@iptvnator/shared/interfaces'; /** Initial render window and per-`loadMore` growth over the cached list. */ const RENDER_CHUNK = 50; @@ -101,16 +102,16 @@ export class StalkerItvAllItemsComponent { }); readonly filteredChannels = computed(() => { - const term = this.searchTerm().trim().toLowerCase(); + const term = foldSearchText(this.searchTerm().trim()); const channels = this.channels(); if (!term) { return channels; } return channels.filter((channel) => - `${channel.o_name ?? ''} ${channel.name ?? ''}` - .toLowerCase() - .includes(term) + foldSearchText( + `${channel.o_name ?? ''} ${channel.name ?? ''}` + ).includes(term) ); }); diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts index 12378040b..1ef6827fe 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -42,6 +42,7 @@ import { SettingsStore, } from '@iptvnator/services'; import { + foldSearchText, buildStalkerEpgMappingKey, Channel, EpgItem, @@ -132,9 +133,9 @@ function matchesStalkerChannelTerm( item: StalkerItvChannel, term: string ): boolean { - return `${item.o_name ?? ''} ${item.name ?? ''}` - .toLowerCase() - .includes(term); + return foldSearchText(`${item.o_name ?? ''} ${item.name ?? ''}`).includes( + term + ); } import { StalkerLiveNavigation } from './stalker-live-navigation'; @@ -203,7 +204,7 @@ export class StalkerLiveStreamLayoutComponent this.isRadioMode() ? this.radioChannels() : this.itvChannels() ); readonly searchTerm = computed(() => - this.stalkerStore.searchPhrase().trim().toLowerCase() + foldSearchText(this.stalkerStore.searchPhrase().trim()) ); /** Full-list mode: the complete channel list is cached, so search covers everything. */ readonly isFullListMode = computed( @@ -1099,7 +1100,7 @@ export class StalkerLiveStreamLayoutComponent * instance shows the sidebar's windowed rows. */ channelsForList(panelSearchTerm?: Signal): StalkerItvChannel[] { - const term = panelSearchTerm?.().trim().toLowerCase() ?? ''; + const term = foldSearchText(panelSearchTerm?.().trim() ?? ''); if (!term) { if (!panelSearchTerm) { return this.visibleChannels(); @@ -1776,7 +1777,7 @@ export class StalkerLiveStreamLayoutComponent matchesStalkerChannelTerm(item, this.searchTerm()) ) : this.filteredChannels(); - const query = term().trim().toLowerCase(); + const query = foldSearchText(term().trim()); return query ? this.searchableChannels().filter((item) => matchesStalkerChannelTerm(item, query) diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts index d469dd8be..d31de8e6f 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts @@ -1,5 +1,6 @@ import { inject, Injectable, Injector } from '@angular/core'; import { + foldSearchText, ContentMetadataPatch, Playlist, PlaybackPositionData, @@ -548,7 +549,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { ): Promise { void excludeHidden; const results: XtreamCachedContentItem[] = []; - const searchLower = searchTerm.toLowerCase(); + const searchLower = foldSearchText(searchTerm); for (const type of types) { const cacheKey = `${playlistId}-${type}-content`; @@ -557,7 +558,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { const filtered = content.filter((item) => { const title = item.name || item.title || item.stream_display_name || ''; - return title.toLowerCase().includes(searchLower); + return foldSearchText(title).includes(searchLower); }); results.push(...filtered); diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.spec.ts index 16e07a691..c813f680d 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.spec.ts @@ -110,6 +110,12 @@ const TestSelectionStore = signalStore( title: 'Cosmos', added: '6', }, + { + xtream_id: 7, + category_id: '20', + title: 'İnşaat Belgeseli', + added: '7', + }, ...bulkVodStreams, ], serialCategories: [ @@ -191,6 +197,21 @@ describe('withSelection', () => { expect(store.visibleCount()).toBe(coveredCount); }); + it('matches the Turkish dotted capital İ from a lower-case search term (issue #609)', () => { + store.setSelectedContentType('vod'); + store.setSelectedCategory(20); + + store.setCategorySearchTerm('inş'); + expect(store.getPaginatedContent().map((item) => item.title)).toEqual([ + 'İnşaat Belgeseli', + ]); + + store.setCategorySearchTerm('İNŞ'); + expect(store.getPaginatedContent().map((item) => item.title)).toEqual([ + 'İnşaat Belgeseli', + ]); + }); + it('keeps the render window when the category search term is unchanged', () => { store.setSelectedContentType('vod'); store.setSelectedCategory(90); diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts index 531067e9b..ec60c8da1 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts @@ -7,6 +7,7 @@ import { withState, } from '@ngrx/signals'; import { ContentType, XtreamContentLoadState } from '../../xtream-state'; +import { foldSearchText } from '@iptvnator/shared/interfaces'; /** * Module-level collator — allocating Intl.Collator is expensive; @@ -295,14 +296,14 @@ export function withSelection() { items: XtreamSelectionItem[], searchTerm: string ): XtreamSelectionItem[] => { - const normalized = searchTerm.trim().toLocaleLowerCase(); + const normalized = foldSearchText(searchTerm.trim()); if (!normalized) { return items; } return items.filter((item) => { const title = (item.title ?? item.name ?? '').toString(); - return title.toLocaleLowerCase().includes(normalized); + return foldSearchText(title).includes(normalized); }); }; diff --git a/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.ts b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.ts index 8c2fd60e4..33b16ee16 100644 --- a/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.ts +++ b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.ts @@ -19,6 +19,7 @@ import { MatSnackBar } from '@angular/material/snack-bar'; import { TranslatePipe } from '@ngx-translate/core'; import { DatabaseService, XCategoryFromDb } from '@iptvnator/services'; import { createLogger } from '@iptvnator/portal/shared/util'; +import { foldSearchText } from '@iptvnator/shared/interfaces'; export interface CategoryManagementDialogData { playlistId: string; @@ -58,10 +59,10 @@ export class CategoryManagementDialogComponent implements OnInit { readonly searchTerm = signal(''); readonly filteredCategories = computed(() => { - const term = this.searchTerm().toLowerCase(); + const term = foldSearchText(this.searchTerm()); if (!term) return this.categories(); return this.categories().filter((c) => - c.name.toLowerCase().includes(term) + foldSearchText(c.name).includes(term) ); }); diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/xtream-live-channel-navigation.service.spec.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/xtream-live-channel-navigation.service.spec.ts index 88bf0f9e0..6e500c8b4 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/xtream-live-channel-navigation.service.spec.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/xtream-live-channel-navigation.service.spec.ts @@ -15,6 +15,7 @@ import { } from '@angular/router'; import { RouterTestingHarness } from '@angular/router/testing'; import { LiveLayoutSidebarStateService } from '@iptvnator/portal/shared/util'; +import { foldSearchText } from '@iptvnator/shared/interfaces'; import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; import { BehaviorSubject, Subject } from 'rxjs'; import { XtreamLiveChannelNavigationService } from './xtream-live-channel-navigation.service'; @@ -22,6 +23,7 @@ import { XtreamLiveChannelNavigationService } from './xtream-live-channel-naviga const first = { xtream_id: 1, category_id: '10', title: 'Zulu' }; const second = { xtream_id: 2, category_id: '10', title: 'Alpha' }; const other = { xtream_id: 3, category_id: '20', title: 'Other' }; +const turkish = { xtream_id: 4, category_id: '10', title: 'İnşaat TV' }; @Component({ template: '', @@ -47,9 +49,11 @@ describe('XtreamLiveChannelNavigationService', () => { (item) => (!selectedCategoryId() || Number(item.category_id) === selectedCategoryId()) && - item.title - .toLowerCase() - .includes(categorySearchTerm().toLowerCase()) + // Mirrors the real store's withSelection filter, which + // folds the term (issue #609). + foldSearchText(item.title).includes( + foldSearchText(categorySearchTerm()) + ) ) ); const store = { @@ -118,6 +122,19 @@ describe('XtreamLiveChannelNavigationService', () => { TestBed.flushEffects(); }); + it('lists the dotted capital İ for a lower-case route query, like the sidebar (issue #609)', () => { + // The rendered sidebar and this playback-order source must agree on + // what the query matches, or capture() records a queue that does not + // contain the row the user just clicked. + liveStreams.set([first, second, other, turkish]); + + query.next(convertToParamMap({ q: 'inş' })); + expect(service.displayedChannels()).toEqual([turkish]); + + query.next(convertToParamMap({ q: 'İNŞ' })); + expect(service.displayedChannels()).toEqual([turkish]); + }); + it('captures the displayed sort and keeps it after category, search, and sort changes', () => { service.sortMode.set('name-asc'); service.capture(first); diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/xtream-live-channel-navigation.service.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/xtream-live-channel-navigation.service.ts index 2519914a6..bbbb843e7 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/xtream-live-channel-navigation.service.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/xtream-live-channel-navigation.service.ts @@ -17,6 +17,7 @@ import { sortPortalChannelItems, } from '@iptvnator/portal/shared/util'; import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; +import { foldSearchText } from '@iptvnator/shared/interfaces'; export interface XtreamLiveChannelItem { readonly added?: string; @@ -59,7 +60,7 @@ export class XtreamLiveChannelNavigationService { return playing?.owner === this.owner() ? playing.item : null; }); readonly displayedChannels = computed(() => { - const term = this.query().trim().toLowerCase(); + const term = foldSearchText(this.query().trim()); const channels = sortPortalChannelItems( this.store.selectItemsFromSelectedCategory() as XtreamLiveChannelItem[], this.sortMode(), @@ -67,9 +68,9 @@ export class XtreamLiveChannelNavigationService { ); return term ? channels.filter((item) => - `${item.title ?? ''} ${item.name ?? ''}` - .toLowerCase() - .includes(term) + foldSearchText( + `${item.title ?? ''} ${item.name ?? ''}` + ).includes(term) ) : channels; }); diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts index a18305d3b..6df4a38cf 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts @@ -28,6 +28,7 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Subscription } from 'rxjs'; import { debounceTime } from 'rxjs/operators'; import { + foldSearchText, buildXtreamEpgMappingKey, EpgItem, EpgProgram, @@ -146,7 +147,7 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { ); }); readonly filteredChannels = computed(() => { - const term = this.searchTermInput().trim().toLowerCase(); + const term = foldSearchText(this.searchTermInput().trim()); const channels = this.sortedChannels(); if (!term) { @@ -154,9 +155,9 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { } return channels.filter((item) => - `${item.title ?? ''} ${item.name ?? ''}` - .toLowerCase() - .includes(term) + foldSearchText(`${item.title ?? ''} ${item.name ?? ''}`).includes( + term + ) ); }); diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index 057ede141..8bd5c3f09 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -60,6 +60,7 @@ export * from './lib/stream-format.enum'; export * from './lib/catalog-title-match.interface'; export * from './lib/theme.enum'; export * from './lib/season-marker.util'; +export * from './lib/search-text-fold.util'; export * from './lib/stalker-account-info-dialog-data.interface'; export * from './lib/title-normalization.util'; export * from './lib/tmdb.interface'; diff --git a/libs/shared/interfaces/src/lib/search-text-fold.util.spec.ts b/libs/shared/interfaces/src/lib/search-text-fold.util.spec.ts new file mode 100644 index 000000000..5a5604000 --- /dev/null +++ b/libs/shared/interfaces/src/lib/search-text-fold.util.spec.ts @@ -0,0 +1,58 @@ +import { foldSearchText } from './search-text-fold.util'; + +describe('foldSearchText', () => { + it('folds the Turkish dotted capital I onto a plain lower-case i (issue #609)', () => { + expect(foldSearchText('İnşaat Kanalı')).toBe('inşaat kanalı'); + expect( + foldSearchText('İnşaat Kanalı').includes(foldSearchText('inş')) + ).toBe(true); + expect(foldSearchText('İnş')).toBe(foldSearchText('inş')); + }); + + it.each([ + ['Ünlü Şef', 'ünlü şef'], + ['ÇANAKKALE', 'çanakkale'], + ['Первый канал HD', 'первый канал hd'], + ['Ёлки', 'ёлки'], + ['Йога для всех', 'йога для всех'], + ['Ελλάδα Σήμερα', 'ελλάδα σήμερα'], + ['Amélie', 'amélie'], + ['Straße', 'straße'], + ])( + 'keeps precomposed letters of other scripts intact: %s', + (input, expected) => { + expect(foldSearchText(input)).toBe(expected); + } + ); + + it.each([ + ['Amélie', 'Amélie'], + ['İnşaat', 'İnşaat'], + ['Ёлки', 'Ёлки'], + ['Йога', 'Йога'], + ['Ünlü', 'Ünlü'], + ['Ά', 'Ά'], + ])( + 'folds canonically equivalent spellings of %s to one string', + (precomposed, decomposed) => { + expect(foldSearchText(decomposed)).toBe( + foldSearchText(precomposed) + ); + } + ); + + it('stays accent-sensitive, unlike the diacritic-folding SQL index', () => { + // The renderer filters were accent-sensitive before this helper and + // stay so: only marks that cannot compose are dropped. Accent-blind + // matching is the FTS index's own behaviour, not this fold's. + expect(foldSearchText('Amélie')).not.toBe(foldSearchText('Amelie')); + }); + + it('does not fold the dotless ı onto i', () => { + // The Turkish I/ı pair is a separate letter, not a case form of i; + // it is deliberately left alone (see the FTS index, which does not + // fold it either). + expect(foldSearchText('Işık')).toBe('işık'); + expect(foldSearchText('ışık')).toBe('ışık'); + }); +}); diff --git a/libs/shared/interfaces/src/lib/search-text-fold.util.ts b/libs/shared/interfaces/src/lib/search-text-fold.util.ts new file mode 100644 index 000000000..d3b37d17d --- /dev/null +++ b/libs/shared/interfaces/src/lib/search-text-fold.util.ts @@ -0,0 +1,35 @@ +/** + * 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; +} diff --git a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.spec.ts b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.spec.ts index 216855a52..d0a9124c2 100644 --- a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.spec.ts +++ b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.spec.ts @@ -91,6 +91,30 @@ describe('AllChannelsViewComponent', () => { localStorage.removeItem(ALL_CHANNELS_SORT_STORAGE_KEY); }); + it('filters the Turkish dotted capital İ case-insensitively (issue #609)', () => { + const turkishChannel = createChannel( + 'channel-2', + 'İnşaat Kanalı', + 'https://example.com/insaat.m3u8' + ); + fixture.componentRef.setInput('channels', [ + primaryChannel, + turkishChannel, + ]); + + fixture.componentRef.setInput('searchTerm', 'inş'); + fixture.detectChanges(); + expect(component.filteredChannels()).toEqual([turkishChannel]); + + fixture.componentRef.setInput('searchTerm', 'İNŞ'); + fixture.detectChanges(); + expect(component.filteredChannels()).toEqual([turkishChannel]); + + fixture.componentRef.setInput('searchTerm', 'news'); + fixture.detectChanges(); + expect(component.filteredChannels()).toEqual([primaryChannel]); + }); + it('defaults to playlist order when no saved sort mode exists', () => { expect(component.allChannelsSortMode()).toBe('server'); expect(component.allChannelsSortLabel()).toBe('Playlist Order'); diff --git a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.ts b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.ts index 76e82f7af..4a983c055 100644 --- a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.ts @@ -21,6 +21,7 @@ import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; import { SettingsStore } from '@iptvnator/services'; import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state'; import { + foldSearchText, Channel, EpgProgram, epgProviderClockMs, @@ -127,10 +128,12 @@ export class AllChannelsViewComponent { * reference when there is no search term, so large lists avoid cloning. */ readonly filteredChannels = computed(() => { - const term = this.searchTerm().trim().toLowerCase(); + const term = foldSearchText(this.searchTerm().trim()); const channels = this.channels(); const filteredChannels = term - ? channels.filter((ch) => ch.name?.toLowerCase().includes(term)) + ? channels.filter((ch) => + foldSearchText(ch.name ?? '').includes(term) + ) : channels; return sortPlaylistChannelItems( diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts index 57cf66041..ea3df8870 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts @@ -50,6 +50,7 @@ import { SettingsStore, } from '@iptvnator/services'; import { + foldSearchText, Channel, EpgProgram, isM3uRecentlyViewedItem, @@ -217,12 +218,12 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { private readonly routeSearchTerm = queryParamSignal( this.route, 'q', - (value) => (value ?? '').trim().toLowerCase() + (value) => foldSearchText((value ?? '').trim()) ); readonly workspaceSearchTerm = computed(() => { const hostTerm = this.searchTerm(); if (hostTerm !== null) { - return hostTerm.trim().toLowerCase(); + return foldSearchText(hostTerm.trim()); } return this.isWorkspaceLayout ? this.routeSearchTerm() : ''; }); diff --git a/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.ts b/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.ts index 3ead7ebf1..ba9a72e55 100644 --- a/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.ts @@ -22,6 +22,7 @@ import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; import { SettingsStore } from '@iptvnator/services'; import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state'; import { + foldSearchText, Channel, EpgProgram, epgProviderClockMs, @@ -97,16 +98,16 @@ export class FavoritesViewComponent { ); readonly filteredFavorites = computed(() => { const favorites = this.favorites(); - const term = this.searchTerm().trim().toLowerCase(); + const term = foldSearchText(this.searchTerm().trim()); if (!term) { return favorites; } return favorites.filter((channel) => - `${channel.name ?? ''} ${channel.group?.title ?? ''}` - .toLowerCase() - .includes(term) + foldSearchText( + `${channel.name ?? ''} ${channel.group?.title ?? ''}` + ).includes(term) ); }); diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/group-management-dialog/group-management-dialog.component.ts b/libs/ui/components/src/lib/channel-list-container/groups-view/group-management-dialog/group-management-dialog.component.ts index 65d577f99..d4a7501d2 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/group-management-dialog/group-management-dialog.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/group-management-dialog/group-management-dialog.component.ts @@ -15,6 +15,7 @@ import { } from '@angular/material/dialog'; import { MatIconModule } from '@angular/material/icon'; import { TranslatePipe } from '@ngx-translate/core'; +import { foldSearchText } from '@iptvnator/shared/interfaces'; export interface GroupManagementDialogGroup { readonly count: number; @@ -59,14 +60,14 @@ export class GroupManagementDialogComponent { ); readonly filteredGroups = computed(() => { - const term = this.searchTerm().trim().toLowerCase(); + const term = foldSearchText(this.searchTerm().trim()); if (!term) { return this.groups(); } return this.groups().filter((group) => - group.key.toLowerCase().includes(term) + foldSearchText(group.key).includes(term) ); }); diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts index b2d1f0302..85efc462a 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts @@ -24,6 +24,7 @@ import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state'; import { SettingsStore } from '@iptvnator/services'; import { + foldSearchText, Channel, EpgProgram, epgProviderClockMs, @@ -320,7 +321,7 @@ export class GroupsViewComponent { }); readonly workspaceFilteredGroups = computed(() => { - const term = this.searchTerm().trim().toLowerCase(); + const term = foldSearchText(this.searchTerm().trim()); const groups = this.visibleGroups(); if (!term) { @@ -333,11 +334,11 @@ export class GroupsViewComponent { } return groups.reduce((acc, group) => { - const titleMatches = group.key.toLowerCase().includes(term); + const titleMatches = foldSearchText(group.key).includes(term); const channels = titleMatches ? group.channels : group.channels.filter((channel) => - `${channel.name ?? ''}`.toLowerCase().includes(term) + foldSearchText(`${channel.name ?? ''}`).includes(term) ); if (channels.length === 0) { @@ -355,14 +356,16 @@ export class GroupsViewComponent { }); readonly filteredGroups = computed(() => { - const term = this.localGroupSearchTerm().trim().toLowerCase(); + const term = foldSearchText(this.localGroupSearchTerm().trim()); const groups = this.workspaceFilteredGroups(); if (!term) { return groups; } - return groups.filter((group) => group.key.toLowerCase().includes(term)); + return groups.filter((group) => + foldSearchText(group.key).includes(term) + ); }); readonly hasAnyGroups = computed(() => this.allGroups().length > 0); diff --git a/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.ts b/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.ts index 2ed5c55fc..ca7bb2c6e 100644 --- a/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.ts @@ -17,6 +17,7 @@ import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state'; import { SettingsStore } from '@iptvnator/services'; import { + foldSearchText, Channel, EpgProgram, epgProviderClockMs, @@ -78,16 +79,16 @@ export class RecentViewComponent { readonly filteredRecentItems = computed(() => { const recentItems = this.recentItems(); - const term = this.searchTerm().trim().toLowerCase(); + const term = foldSearchText(this.searchTerm().trim()); if (!term) { return recentItems; } return recentItems.filter(({ channel }) => - `${channel.name ?? ''} ${channel.group?.title ?? ''}` - .toLowerCase() - .includes(term) + foldSearchText( + `${channel.name ?? ''} ${channel.group?.title ?? ''}` + ).includes(term) ); }); diff --git a/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.ts b/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.ts index ebe6a15c5..ceeef0feb 100644 --- a/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.ts +++ b/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.ts @@ -14,6 +14,7 @@ import { } from '@angular/material/slide-toggle'; import { TranslatePipe } from '@ngx-translate/core'; import { + foldSearchText, playlistDisplayLabel, VodSourceDescriptor, VodSourceMatchKind, @@ -111,16 +112,16 @@ export class VodSourcesMenuComponent { /** Sources surviving the search box; what "check all" acts on. */ private readonly searchedSources = computed(() => { - const query = this.filter().trim().toLowerCase(); + const query = foldSearchText(this.filter().trim()); const sources = this.sources(); if (!query || !this.showFilter()) { return sources; } return sources.filter((source) => - playlistDisplayLabel(source.playlistName, source.playlistId) - .toLowerCase() - .includes(query) + foldSearchText( + playlistDisplayLabel(source.playlistName, source.playlistId) + ).includes(query) ); }); diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-view-state.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-view-state.ts index ba6bbcfba..c522f9d66 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide-view-state.ts +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-view-state.ts @@ -1,5 +1,5 @@ import { computed, effect, signal } from '@angular/core'; -import { EpgProgram } from '@iptvnator/shared/interfaces'; +import { foldSearchText, EpgProgram } from '@iptvnator/shared/interfaces'; import { EpgDateNavigationDirection, getTodayEpgDateKey, @@ -101,11 +101,11 @@ export class EpgGuideViewState { channels: readonly EpgGuideChannel[], isCovered: (channelId: string) => boolean ): EpgGuideChannel[] { - const needle = this.filter().trim().toLowerCase(); + const needle = foldSearchText(this.filter().trim()); const onlyWithEpg = this.onlyWithEpg(); return channels.filter( (channel) => - (!needle || channel.name.toLowerCase().includes(needle)) && + (!needle || foldSearchText(channel.name).includes(needle)) && (!onlyWithEpg || isCovered(channel.id)) ); } diff --git a/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.spec.ts index 42c1d6022..65b776133 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.spec.ts @@ -164,6 +164,78 @@ describe('WorkspaceCommandPaletteComponent', () => { }); }); +describe('WorkspaceCommandPaletteComponent - Turkish case folding', () => { + const turkishCommands: WorkspaceResolvedCommandItem[] = [ + { + id: 'open-downloads', + label: 'İndirilenleri aç', + description: '', + group: 'global', + icon: 'download', + keywords: [], + priority: 50, + visible: true, + enabled: true, + run: () => undefined, + }, + { + id: 'open-settings', + label: 'Ayarlar', + description: '', + group: 'global', + icon: 'settings', + keywords: [], + priority: 40, + visible: true, + enabled: true, + run: () => undefined, + }, + ]; + + function setup(query: string): WorkspaceCommandPaletteComponent { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [WorkspaceCommandPaletteComponent], + providers: [ + { provide: MatDialogRef, useValue: { close: jest.fn() } }, + { + provide: MAT_DIALOG_DATA, + useValue: { query, commands: turkishCommands }, + }, + { + provide: TranslateService, + useValue: { + instant: (key: string) => key, + get: (key: string) => of(key), + stream: (key: string) => of(key), + onLangChange: of(null), + onTranslationChange: of(null), + onDefaultLangChange: of(null), + currentLang: 'en', + defaultLang: 'en', + }, + }, + ], + }); + const fixture = TestBed.createComponent( + WorkspaceCommandPaletteComponent + ); + fixture.detectChanges(); + return fixture.componentInstance; + } + + it.each(['indir', 'İndir', 'İNDİR'])( + 'finds a command whose label carries the dotted capital İ for the query %s (issue #609)', + (query) => { + expect( + setup(query) + .flatCommands() + .map((command) => command.id) + ).toEqual(['open-downloads']); + } + ); +}); + describe('WorkspaceCommandPaletteComponent - recent section', () => { function setupComponent(options: { query: string; diff --git a/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.ts b/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.ts index 44f92aee7..2d35a1e8b 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.ts @@ -21,6 +21,7 @@ import { WorkspaceCommandSelection, WorkspaceResolvedCommandItem, } from '@iptvnator/portal/shared/util'; +import { foldSearchText } from '@iptvnator/shared/interfaces'; interface WorkspaceCommandPaletteData { commands: WorkspaceResolvedCommandItem[]; @@ -63,7 +64,7 @@ export class WorkspaceCommandPaletteComponent implements AfterViewInit { ); readonly filteredCommands = computed(() => { - const term = this.query().trim().toLowerCase(); + const term = foldSearchText(this.query().trim()); const commands = this.visibleCommands(); if (!term) { @@ -75,11 +76,9 @@ export class WorkspaceCommandPaletteComponent implements AfterViewInit { command.label, command.description, ...(command.keywords ?? []), - ] - .join(' ') - .toLowerCase(); + ].join(' '); - return haystack.includes(term); + return foldSearchText(haystack).includes(term); }); }); diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts index d505eb0aa..d3303bb23 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts @@ -34,6 +34,7 @@ import { WorkspaceContextCategoryViewComponent } from './components/workspace-co import { WorkspaceContextErrorViewComponent } from './components/workspace-context-error-view.component'; import { hasActiveLiveCategoryRoute } from './workspace-context-panel-route.utils'; import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/util'; +import { foldSearchText } from '@iptvnator/shared/interfaces'; type WorkspaceProvider = 'xtreams' | 'stalker' | 'playlists'; @@ -297,10 +298,10 @@ export class WorkspaceContextPanelComponent { readonly filteredXtreamCategories = computed(() => { const cats = this.xtreamCategories(); - const term = this.categorySearchTerm().trim().toLowerCase(); + const term = foldSearchText(this.categorySearchTerm().trim()); const filtered = term ? cats.filter((category) => - this.getCategoryLabel(category).toLowerCase().includes(term) + foldSearchText(this.getCategoryLabel(category)).includes(term) ) : cats; @@ -314,10 +315,10 @@ export class WorkspaceContextPanelComponent { readonly filteredStalkerCategories = computed(() => { const cats = this.stalkerCategories(); - const term = this.categorySearchTerm().trim().toLowerCase(); + const term = foldSearchText(this.categorySearchTerm().trim()); const filtered = term ? cats.filter((category) => - this.getCategoryLabel(category).toLowerCase().includes(term) + foldSearchText(this.getCategoryLabel(category)).includes(term) ) : cats; diff --git a/libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.ts b/libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.ts index 515577abd..76c60d2a3 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.ts @@ -15,6 +15,7 @@ import { } from '@iptvnator/portal/shared/util'; import { PlaylistActions } from '@iptvnator/m3u-state'; import { + foldSearchText, sourceHealthType, PlaylistUpdateState, } from '@iptvnator/shared/interfaces'; @@ -195,7 +196,7 @@ export class WorkspaceSourcesComponent { }); readonly visibleSourcesCount = computed(() => { - const query = this.searchQuery().trim().toLowerCase(); + const query = foldSearchText(this.searchQuery().trim()); const filters = this.activeTypeFilters(); const allPlaylists = this.playlists(); @@ -217,7 +218,7 @@ export class WorkspaceSourcesComponent { return isStalkerFilter || isXtreamFilter || isM3uFilter; }) - .filter((item) => (item.title || '').toLowerCase().includes(query)) + .filter((item) => foldSearchText(item.title || '').includes(query)) .length; });