mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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>
This commit is contained in:
38 files changed
+736
-111
No files matched your search
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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<readonly [string, string]> = [
|
||||
['İ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);
|
||||
|
||||
@@ -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<Record<Arm, string[]>> {
|
||||
const out: Partial<Record<Arm, string[]>> = {};
|
||||
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']);
|
||||
});
|
||||
});
|
||||
@@ -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<string>([
|
||||
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<string>();
|
||||
|
||||
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<string>();
|
||||
|
||||
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, '""')}"`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -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<UnifiedCollectionItem>();
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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<T>(
|
||||
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;
|
||||
|
||||
|
||||
@@ -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]]);
|
||||
});
|
||||
});
|
||||
@@ -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<T>(
|
||||
: 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)
|
||||
);
|
||||
}
|
||||
+5
-4
@@ -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)
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
+7
-6
@@ -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<string>): 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)
|
||||
|
||||
@@ -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<XtreamContentItem[]> {
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
+3
-2
@@ -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)
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
+20
-3
@@ -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);
|
||||
|
||||
+5
-4
@@ -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;
|
||||
});
|
||||
|
||||
+5
-4
@@ -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
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
+24
@@ -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');
|
||||
|
||||
+5
-2
@@ -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(
|
||||
|
||||
+3
-2
@@ -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() : '';
|
||||
});
|
||||
|
||||
+5
-4
@@ -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)
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
+3
-2
@@ -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)
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
+8
-5
@@ -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<FilteredGroupView[]>(() => {
|
||||
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<FilteredGroupView[]>((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<FilteredGroupView[]>(() => {
|
||||
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);
|
||||
|
||||
+5
-4
@@ -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)
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
|
||||
+72
@@ -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;
|
||||
|
||||
+4
-5
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+5
-4
@@ -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;
|
||||
|
||||
|
||||
+3
-2
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user