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:
authored and GitHub committed 2026-09-20 21:14:02 +02:00
1 parent 975b1f7f74
commit b30c783e85
38 files changed
+736 -111

No files matched your search

@@ -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;
@@ -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);
});
});
@@ -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;
@@ -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;
});