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

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