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

@@ -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))
);
}
@@ -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)
);
}
@@ -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)
);
});
@@ -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);
});
};
@@ -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)
);
});
@@ -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);
@@ -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;
});
@@ -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
)
);
});
+1
View File
@@ -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;
}
@@ -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)
);
});
@@ -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))
);
}
@@ -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;
});