mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 05:36:17 -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
+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)
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user