mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 20:16: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
+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