mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06: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
@@ -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))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -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)
|
||||
);
|
||||
}
|
||||
+5
-4
@@ -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)
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
+7
-6
@@ -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);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
+3
-2
@@ -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)
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
+20
-3
@@ -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);
|
||||
|
||||
+5
-4
@@ -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;
|
||||
});
|
||||
|
||||
+5
-4
@@ -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
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+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)
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
|
||||
+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