diff --git a/.changes/search-token-partial-match.md b/.changes/search-token-partial-match.md new file mode 100644 index 0000000000..0d00833e21 --- /dev/null +++ b/.changes/search-token-partial-match.md @@ -0,0 +1,10 @@ +--- +type: feature +area: search +--- + +Advanced search now supports multiple search chips. Each chip is matched as a +whole (all its words must be present), and a result appears if it matches any +chip — ranked so results matching the most chips come first. Searching +`[FR beIN]` `[1968]` surfaces titles hitting both chips ahead of ones hitting +just one. diff --git a/apps/electron-backend/src/app/database/operations/content-search.util.spec.ts b/apps/electron-backend/src/app/database/operations/content-search.util.spec.ts index c668afcf6a..77134060c3 100644 --- a/apps/electron-backend/src/app/database/operations/content-search.util.spec.ts +++ b/apps/electron-backend/src/app/database/operations/content-search.util.spec.ts @@ -5,6 +5,8 @@ import { getCompoundResidualTokenGroups, getCompoundSearchWords, getSearchWordPlans, + parseSearchChips, + scoreGlobalSearchChips, scoreSearchTextMatch, shouldUseContentTitlePrefixIndex, } from './content-search.util'; @@ -148,4 +150,74 @@ describe('content-search.util', () => { expect(scoreSearchTextMatch('Test TV', 'tv')).toBeNull(); }); }); + + describe('parseSearchChips', () => { + it('splits committed chips on newlines and trims blanks', () => { + expect(parseSearchChips('fr bein\n1968')).toEqual([ + 'fr bein', + '1968', + ]); + expect(parseSearchChips(' fr \n\n 1968 \n')).toEqual([ + 'fr', + '1968', + ]); + }); + + it('treats a plain query as a single chip', () => { + expect(parseSearchChips('France 1968')).toEqual(['France 1968']); + }); + }); + + describe('scoreGlobalSearchChips', () => { + it('collapses a single chip to the plain text score', () => { + const chip = 'France 1968'; + expect(scoreGlobalSearchChips('France 1968', [chip])).toBe( + scoreSearchTextMatch('France 1968', chip) + ); + }); + + it('requires every word of a chip (joined, not segmented)', () => { + // "France 2000" is missing the "1968" word of the single chip. + expect( + scoreGlobalSearchChips('France 2000', ['France 1968']) + ).toBeNull(); + }); + + it('matches any chip and ranks more matched chips first', () => { + const chips = ['fr', 'bein', '1968']; + const three = scoreGlobalSearchChips('FR beIN 1968', chips); + const two = scoreGlobalSearchChips('FR beIN HD', chips); + const one = scoreGlobalSearchChips('FR Movies', chips); + expect(three).not.toBeNull(); + expect(two).not.toBeNull(); + expect(one).not.toBeNull(); + expect(three as number).toBeLessThan(two as number); + expect(two as number).toBeLessThan(one as number); + }); + + it('returns null when no chip matches', () => { + expect( + scoreGlobalSearchChips('Spain 2020', ['fr', 'bein', '1968']) + ).toBeNull(); + }); + + it('counts a chip as matched when it hits any of several fields', () => { + // "France" is in field 0, "1968" in field 2 -> both chips matched. + const both = scoreGlobalSearchChips( + ['France TV', '', '1968 Movies'], + ['France', '1968'] + ); + // Only "France" is present -> a one-chip (missing 1) match. + const one = scoreGlobalSearchChips( + ['France TV', '', 'Drama'], + ['France', '1968'] + ); + + expect(both).not.toBeNull(); + expect(one).not.toBeNull(); + expect(both as number).toBeLessThan(1000); + expect(one as number).toBeGreaterThanOrEqual(1000); + expect(both as number).toBeLessThan(one as number); + }); + }); }); diff --git a/apps/electron-backend/src/app/database/operations/content-search.util.ts b/apps/electron-backend/src/app/database/operations/content-search.util.ts index dc0d9488be..7d481c808f 100644 --- a/apps/electron-backend/src/app/database/operations/content-search.util.ts +++ b/apps/electron-backend/src/app/database/operations/content-search.util.ts @@ -368,3 +368,73 @@ export function scoreSearchTextMatch( return null; } + +/** + * Splits a global ("advanced") search query into chips. Chips are the + * user-committed search units of the header chip input, joined on the wire by + * newlines (a char the single-line chip input can't contain). A query without + * a newline is a single chip — the whole term — which keeps every non-chip + * caller (and older `?q=` links) behaving exactly as before. + */ +export function parseSearchChips(query: unknown): string[] { + if (typeof query !== 'string') { + return []; + } + + return query + .split('\n') + .map((chip) => chip.trim()) + .filter((chip) => chip.length > 0); +} + +/** + * OR-across-chips score for global ("advanced") search. Each chip is matched + * as a joined unit (all its words present, via {@link scoreSearchTextMatch}), + * and a candidate matches if ANY chip matches. Candidates are ranked by how + * many chips they satisfy — more matched chips sort first — with the strongest + * single-chip match breaking ties. Returns null when no chip matches. + * + * `value` may be several searchable fields (M3U channel name, TVG name, group + * title): a chip counts as matched when it matches ANY field, so a candidate + * satisfying different chips in different fields is still ranked by the total + * number of distinct chips matched. A single string field collapses to + * `scoreSearchTextMatch`, so one-chip / single-field searches keep the exact + * ordering they had before chips existed. + */ +export function scoreGlobalSearchChips( + value: string | readonly string[], + chips: readonly string[] +): number | null { + if (chips.length === 0) { + return null; + } + + const fields = typeof value === 'string' ? [value] : value; + let matchedChips = 0; + let bestChipScore = Number.POSITIVE_INFINITY; + for (const chip of chips) { + let chipScore: number | null = null; + for (const field of fields) { + const fieldScore = scoreSearchTextMatch(field, chip); + if (fieldScore !== null) { + chipScore = + chipScore === null + ? fieldScore + : Math.min(chipScore, fieldScore); + } + } + if (chipScore !== null) { + matchedChips += 1; + bestChipScore = Math.min(bestChipScore, chipScore); + } + } + + if (matchedChips === 0) { + return null; + } + + // Chip count dominates (fewer missing => lower => ranked first); the best + // single-chip score (0..50) breaks ties within the same count. + const missingChips = chips.length - matchedChips; + return missingChips * 1000 + bestChipScore; +} diff --git a/apps/electron-backend/src/app/database/operations/content.operations.spec.ts b/apps/electron-backend/src/app/database/operations/content.operations.spec.ts index 586dfd0658..f48950df2a 100644 --- a/apps/electron-backend/src/app/database/operations/content.operations.spec.ts +++ b/apps/electron-backend/src/app/database/operations/content.operations.spec.ts @@ -803,6 +803,48 @@ describe('content.operations', () => { expect(results.map((item) => item.title)).toEqual(['A&E', 'US: A&E']); }); + it('ranks multi-chip (OR) matches by chips matched and drops non-matches', () => { + const makeChannel = (id: string, name: string) => ({ + id, + url: `https://stream.test/${id}.m3u8`, + name, + group: { title: 'Movies' }, + tvg: { id, name: '', url: '', logo: '', rec: '' }, + http: { referrer: '', 'user-agent': '', origin: '' }, + radio: '', + }); + + const results = buildM3uGlobalSearchResults( + [ + { + id: 'm3u-1', + name: 'M3U One', + payload: JSON.stringify({ + playlist: { + items: [ + makeChannel('one-year', 'Spain 1968'), + makeChannel('both', 'France 1968 Movie'), + makeChannel('none', 'Germany 2020'), + makeChannel('one-name', 'France 2000'), + ], + }, + }), + }, + ], + // Two committed chips: [France] OR [1968]. + 'France\n1968', + false + ); + + // Both-chips match first; each single-chip match still appears (tie + // broken by title); the title matching neither chip is dropped. + expect(results.map((item) => item.title)).toEqual([ + 'France 1968 Movie', + 'France 2000', + 'Spain 1968', + ]); + }); + it('finds compound-word titles anywhere via per-playlist search (issue #1161)', async () => { const makeRow = (id: number, title: string) => ({ id, diff --git a/apps/electron-backend/src/app/database/operations/content.operations.ts b/apps/electron-backend/src/app/database/operations/content.operations.ts index f22822e3c2..88a0e04f64 100644 --- a/apps/electron-backend/src/app/database/operations/content.operations.ts +++ b/apps/electron-backend/src/app/database/operations/content.operations.ts @@ -28,7 +28,8 @@ import { getSqlSearchTokenGroups, isShortSearchTokenGroup, normalizeSearchMatchText, - scoreSearchTextMatch, + parseSearchChips, + scoreGlobalSearchChips, shouldUseContentTitleFts, shouldUseContentTitlePrefixIndex, } from './content-search.util'; @@ -399,6 +400,95 @@ async function selectXtreamGlobalSearchCandidatesWithContentScan( .limit(candidateLimit); } +/** + * Xtream candidate rows for a single search chip (all its words required), + * using the fastest applicable index strategy: short-first-token prefix GLOB + * plus a compound-word FTS supplement, trigram FTS, or a LIKE content scan. + * `globalSearch` calls this once per chip and unions the results, so each chip + * keeps the same well-indexed matching a whole one-chip query had. + */ +async function selectXtreamGlobalSearchCandidatesForChip( + db: AppDatabase, + chip: string, + types: string[], + excludeHidden: boolean, + candidateLimit: number +): Promise { + if (shouldUseContentTitlePrefixIndex(chip)) { + const candidates = + await selectXtreamGlobalSearchCandidatesWithTitleIndex( + db, + chip, + types, + excludeHidden, + candidateLimit + ); + + // The prefix arm only sees titles that start with the short first + // token ("A&E" -> GLOB 'a*'), so a compound word like "a&e" is + // additionally looked up as a trigram FTS substring to reach titles + // such as "US: A&E" (issue #1161). The non-compound words of the chip + // stay applied as LIKE conditions so this arm cannot fill the + // candidate limit with compound-only matches. + const compoundMatchQuery = buildCompoundFtsMatchQuery(chip); + if (!compoundMatchQuery) { + return candidates; + } + + try { + const compoundCandidates = + await selectXtreamGlobalSearchCandidatesWithFts( + db, + compoundMatchQuery, + types, + excludeHidden, + candidateLimit, + buildCompoundResidualTitleSql(chip) + ); + return dedupeXtreamCandidatesById([ + ...candidates, + ...compoundCandidates, + ]); + } catch { + return selectXtreamGlobalSearchCandidatesWithContentScan( + db, + chip, + types, + excludeHidden, + candidateLimit + ); + } + } + + if (shouldUseContentTitleFts(chip)) { + try { + return await selectXtreamGlobalSearchCandidatesWithFts( + db, + buildContentTitleFtsMatchQuery(chip), + types, + excludeHidden, + candidateLimit + ); + } catch { + return selectXtreamGlobalSearchCandidatesWithContentScan( + db, + chip, + types, + excludeHidden, + candidateLimit + ); + } + } + + return selectXtreamGlobalSearchCandidatesWithContentScan( + db, + chip, + types, + excludeHidden, + candidateLimit + ); +} + /** * Per-word M3U payload prefilter conditions, AND-ed by the caller — the same * compound-word composition as `buildContentTitleSearchConditions`: "A&E" @@ -533,14 +623,17 @@ function getM3uPayloadChannels(payload: ParsedM3uPlaylistPayload): Channel[] { .filter((item): item is Channel => item !== null); } -function scoreM3uChannel(channel: Channel, searchTerm: string): number | null { - const scores = [ - scoreSearchTextMatch(channel.name, searchTerm), - scoreSearchTextMatch(channel.tvg.name, searchTerm), - scoreSearchTextMatch(channel.group.title, searchTerm), - ].filter((score): score is number => score !== null); - - return scores.length > 0 ? Math.min(...scores) : null; +function scoreM3uChannel( + channel: Channel, + chips: readonly string[] +): number | null { + // One combined score across the searchable fields so a chip counts as + // matched when it hits any field — a channel satisfying different chips in + // different fields is ranked by the total chips matched, not per field. + return scoreGlobalSearchChips( + [channel.name, channel.tvg.name, channel.group.title], + chips + ); } function toM3uGlobalSearchResult( @@ -570,7 +663,7 @@ function toM3uGlobalSearchResult( function buildScoredM3uGlobalSearchResults( rows: readonly M3uPlaylistSearchRow[], - searchTerm: string, + chips: readonly string[], excludeHidden = false, maxResults = MAX_GLOBAL_SEARCH_CANDIDATE_LIMIT ): ScoredGlobalSearchResult[] { @@ -588,7 +681,7 @@ function buildScoredM3uGlobalSearchResults( continue; } - const score = scoreM3uChannel(channel, searchTerm); + const score = scoreM3uChannel(channel, chips); if (score === null) { continue; } @@ -614,7 +707,11 @@ export function buildM3uGlobalSearchResults( options?: GlobalSearchPaginationOptions | number ): M3uGlobalSearchResult[] { return paginateScoredResults( - buildScoredM3uGlobalSearchResults(rows, searchTerm, excludeHidden), + buildScoredM3uGlobalSearchResults( + rows, + parseSearchChips(searchTerm), + excludeHidden + ), normalizeGlobalSearchPagination(options) ); } @@ -1103,84 +1200,29 @@ export async function globalSearch( return []; } + // Each committed chip is its own search unit (all its words required); + // candidates matching any chip are unioned, then ranked by how many chips + // they satisfy. A plain one-chip query keeps the original behavior. + const chips = parseSearchChips(searchTerm); const pagination = normalizeGlobalSearchPagination(options); const candidateLimit = getGlobalSearchCandidateLimit(); const results: ScoredGlobalSearchResult[] = []; if (hasGlobalSearchSource(sources, GLOBAL_SEARCH_RESULT_SOURCES.Xtream)) { - let candidates: XtreamGlobalSearchCandidate[]; - - if (shouldUseContentTitlePrefixIndex(searchTerm)) { - candidates = await selectXtreamGlobalSearchCandidatesWithTitleIndex( - db, - searchTerm, - types, - excludeHidden, - candidateLimit - ); - - // The prefix arm only sees titles that start with the short first - // token ("A&E" -> GLOB 'a*'), so a compound word like "a&e" is - // additionally looked up as a trigram FTS substring to reach - // titles such as "US: A&E" (issue #1161). The non-compound words - // of the query stay applied as LIKE conditions so this arm cannot - // fill the candidate limit with compound-only matches. - const compoundMatchQuery = buildCompoundFtsMatchQuery(searchTerm); - if (compoundMatchQuery) { - try { - const compoundCandidates = - await selectXtreamGlobalSearchCandidatesWithFts( - db, - compoundMatchQuery, - types, - excludeHidden, - candidateLimit, - buildCompoundResidualTitleSql(searchTerm) - ); - candidates = dedupeXtreamCandidatesById([ - ...candidates, - ...compoundCandidates, - ]); - } catch { - candidates = - await selectXtreamGlobalSearchCandidatesWithContentScan( - db, - searchTerm, - types, - excludeHidden, - candidateLimit - ); - } - } - } else if (shouldUseContentTitleFts(searchTerm)) { - try { - candidates = await selectXtreamGlobalSearchCandidatesWithFts( + const perChipCandidates = await Promise.all( + chips.map((chip) => + selectXtreamGlobalSearchCandidatesForChip( db, - buildContentTitleFtsMatchQuery(searchTerm), + chip, types, excludeHidden, candidateLimit - ); - } catch { - candidates = - await selectXtreamGlobalSearchCandidatesWithContentScan( - db, - searchTerm, - types, - excludeHidden, - candidateLimit - ); - } - } else { - candidates = - await selectXtreamGlobalSearchCandidatesWithContentScan( - db, - searchTerm, - types, - excludeHidden, - candidateLimit - ); - } + ) + ) + ); + const candidates = dedupeXtreamCandidatesById( + perChipCandidates.flat() + ); results.push( ...candidates @@ -1188,9 +1230,9 @@ export async function globalSearch( ( item ): ScoredGlobalSearchResult | null => { - const score = scoreSearchTextMatch( + const score = scoreGlobalSearchChips( item.title ?? '', - searchTerm + chips ); if (score === null) { return null; @@ -1222,6 +1264,12 @@ export async function globalSearch( types.includes(GLOBAL_SEARCH_CONTENT_TYPES.Live) && hasGlobalSearchSource(sources, GLOBAL_SEARCH_RESULT_SOURCES.M3u) ) { + // A playlist is scanned if any chip's words are present in its payload + // (chips OR-ed); per-chip conditions keep each chip's words AND-ed. + const chipConditions = chips + .map((chip) => buildM3uPayloadSearchConditions(chip)) + .filter((conditions) => conditions.length > 0) + .map((conditions) => and(...conditions)); const rows = await db .select({ id: schema.playlists.id, @@ -1233,7 +1281,7 @@ export async function globalSearch( and( inArray(schema.playlists.type, [...M3U_PLAYLIST_TYPES]), sql`${schema.playlists.payload} IS NOT NULL`, - ...buildM3uPayloadSearchConditions(searchTerm) + chipConditions.length > 0 ? or(...chipConditions) : undefined ) ) .orderBy(schema.playlists.name) @@ -1242,7 +1290,7 @@ export async function globalSearch( results.push( ...buildScoredM3uGlobalSearchResults( rows, - searchTerm, + chips, excludeHidden, candidateLimit ) diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html index 153e50838c..9a0b802ce3 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html @@ -28,17 +28,47 @@ {{ searchScopeLabel() }} } - + @if (chipSearchMode()) { + + @for (chip of chips(); track $index) { + + {{ chip }} + + + } + + + } @else { + + } @if (searchStatusLabel()) { {{ searchStatusLabel() }} diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.scss b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.scss index b88f9f7fd7..6ac3d59b4f 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.scss +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.scss @@ -116,6 +116,18 @@ } } + // Global-search chip input: the committed search chips plus the entry + // field share the search bar. Keep it on one line — the header is a single + // compact row — and let overflow scroll rather than grow the header. + .search-chip-grid { + flex: 1; + min-width: 0; + + input { + min-width: 64px; + } + } + .command-trigger__icon { display: none; font-size: 16px; diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.spec.ts index 0062316544..cb3d26be9f 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.spec.ts @@ -4,6 +4,10 @@ import { Component, input, output } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { MatIconButton } from '@angular/material/button'; +import { + MatChipInputEvent, + MatChipsModule, +} from '@angular/material/chips'; import { MatIcon } from '@angular/material/icon'; import { MatTooltip } from '@angular/material/tooltip'; import { TranslateService } from '@ngx-translate/core'; @@ -56,6 +60,7 @@ describe('WorkspaceShellHeaderComponent', () => { .overrideComponent(WorkspaceShellHeaderComponent, { set: { imports: [ + MatChipsModule, MatIcon, MatIconButton, MatTooltip, @@ -224,6 +229,67 @@ describe('WorkspaceShellHeaderComponent', () => { } ); + const chipEvent = (value: string): MatChipInputEvent => + ({ + value, + chipInput: { clear: jest.fn() }, + }) as unknown as MatChipInputEvent; + + it('commits a typed value as a chip and emits the full list', () => { + fixture.componentRef.setInput('chipSearchMode', true); + fixture.componentRef.setInput('searchChips', []); + fixture.detectChanges(); + const emitted: string[][] = []; + component.searchChipsChanged.subscribe((value) => emitted.push(value)); + + component.addChip(chipEvent('fr bein')); + + expect(component.chips()).toEqual(['fr bein']); + expect(emitted).toEqual([['fr bein']]); + }); + + it('ignores blank and duplicate chips', () => { + fixture.componentRef.setInput('chipSearchMode', true); + fixture.componentRef.setInput('searchChips', ['fr']); + fixture.detectChanges(); + const emitted: string[][] = []; + component.searchChipsChanged.subscribe((value) => emitted.push(value)); + + component.addChip(chipEvent(' ')); + component.addChip(chipEvent('fr')); + + expect(emitted).toEqual([]); + expect(component.chips()).toEqual(['fr']); + }); + + it('removes a chip by index and emits the reduced list', () => { + fixture.componentRef.setInput('chipSearchMode', true); + fixture.componentRef.setInput('searchChips', ['fr', 'bein', '1968']); + fixture.detectChanges(); + const emitted: string[][] = []; + component.searchChipsChanged.subscribe((value) => emitted.push(value)); + + component.removeChip(1); + + expect(component.chips()).toEqual(['fr', '1968']); + expect(emitted).toEqual([['fr', '1968']]); + }); + + it('renders committed chips in the bar and hides the plain input in chip mode', () => { + fixture.componentRef.setInput('chipSearchMode', true); + fixture.componentRef.setInput('searchChips', ['fr bein', '1968']); + fixture.detectChanges(); + + const chipText = Array.from( + fixture.nativeElement.querySelectorAll('mat-chip-row') + ).map((element: Element) => element.textContent?.trim()); + expect(chipText.some((text) => text?.includes('fr bein'))).toBe(true); + expect(chipText.some((text) => text?.includes('1968'))).toBe(true); + expect( + fixture.nativeElement.querySelector('input[type="search"]') + ).toBeNull(); + }); + it('uses the paired Material primary tokens for the download badge', () => { const styleSource = readFileSync( join(__dirname, 'workspace-shell-header.component.scss'), diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.ts index 871b5f56f4..9a166919e9 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.ts @@ -4,10 +4,15 @@ import { computed, ElementRef, input, + linkedSignal, output, viewChild, } from '@angular/core'; import { MatIconButton } from '@angular/material/button'; +import { + MatChipInputEvent, + MatChipsModule, +} from '@angular/material/chips'; import { MatIcon } from '@angular/material/icon'; import { MatTooltip } from '@angular/material/tooltip'; import { TranslatePipe } from '@ngx-translate/core'; @@ -18,6 +23,7 @@ import { WorkspaceHeaderBulkAction } from '../../services/helpers/workspace-shel @Component({ selector: 'app-workspace-shell-header', imports: [ + MatChipsModule, MatIcon, MatIconButton, MatTooltip, @@ -46,6 +52,13 @@ export class WorkspaceShellHeaderComponent { readonly searchPlaceholder = input(''); readonly searchScopeLabel = input(''); readonly searchStatusLabel = input(''); + /** + * Chip mode is used only by the global ("advanced") search bar: the query + * is a set of committed chips instead of one text field. Each chip is a + * joined search unit and results match any chip (see `globalSearch`). + */ + readonly chipSearchMode = input(false); + readonly searchChips = input([]); readonly headerShortcut = input(null); readonly headerBulkAction = input(null); readonly canRefreshPlaylist = input(false); @@ -69,6 +82,14 @@ export class WorkspaceShellHeaderComponent { readonly searchChanged = output(); readonly searchSubmitted = output(); + readonly searchChipsChanged = output(); + + /** + * Local, editable copy of the committed chips. Reseeds from the input + * (e.g. when a `?q=` link is opened) but is mutated directly on add/remove + * so the chips render instantly; every edit re-emits the full list. + */ + readonly chips = linkedSignal(() => [...this.searchChips()]); readonly commandPaletteRequested = output(); readonly shortcutsRequested = output(); readonly addPlaylistRequested = output(); @@ -106,6 +127,24 @@ export class WorkspaceShellHeaderComponent { this.searchSubmitted.emit(target?.value ?? this.searchQuery()); } + addChip(event: MatChipInputEvent): void { + const value = event.value.trim(); + event.chipInput?.clear(); + if (!value || this.chips().includes(value)) { + return; + } + + const next = [...this.chips(), value]; + this.chips.set(next); + this.searchChipsChanged.emit(next); + } + + removeChip(index: number): void { + const next = this.chips().filter((_, position) => position !== index); + this.chips.set(next); + this.searchChipsChanged.emit(next); + } + onPlaylistInfoRequested(): void { this.playlistInfoRequested.emit(); } diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.service.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.service.ts index 5d374dc15f..860ce0c166 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.service.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.service.ts @@ -36,6 +36,19 @@ export class WorkspaceShellSearchService { readonly searchQuery = this.searchSync.searchQuery; readonly appliedSearchQuery = this.searchSync.appliedSearchQuery; + /** + * The global ("advanced") search bar is the only chip-input search: its + * query is a set of committed chips serialized as newline-delimited units. + */ + readonly isGlobalSearch = computed( + () => this.routeState.currentRoute().kind === 'global-search' + ); + readonly searchChips = computed(() => + this.appliedSearchQuery() + .split('\n') + .map((chip) => chip.trim()) + .filter((chip) => chip.length > 0) + ); readonly searchCapability = computed(() => { this.languageTick(); @@ -177,6 +190,19 @@ export class WorkspaceShellSearchService { this.searchSync.onSearchInput(value); } + /** + * Commits the global-search chips: joined by newlines into the string + * query so the existing `?q=` sync and IPC contract stay string-typed. + * `globalSearch` splits them back into chips. + */ + onSearchChips(chips: readonly string[]): void { + const value = chips + .map((chip) => chip.trim()) + .filter((chip) => chip.length > 0) + .join('\n'); + this.searchSync.setSearchState(value); + } + onSearchEnter(value: string): void { const trimmedValue = value.trim(); this.searchQuery.set(trimmedValue); diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts index aede79b457..def7b7af06 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts @@ -101,6 +101,8 @@ export class WorkspaceShellFacade { readonly searchPlaceholder = this.search.searchPlaceholder; readonly searchScopeLabel = this.search.searchScopeLabel; readonly searchStatusLabel = this.search.searchStatusLabel; + readonly isGlobalSearch = this.search.isGlobalSearch; + readonly searchChips = this.search.searchChips; readonly railProviderClass = this.routeState.railProviderClass; readonly primaryContextLinks = this.routeState.primaryContextLinks; readonly secondaryContextLinks = this.routeState.secondaryContextLinks; @@ -170,6 +172,10 @@ export class WorkspaceShellFacade { this.search.onSearchEnter(value); } + onSearchChips(chips: string[]): void { + this.search.onSearchChips(chips); + } + openAddPlaylistDialog(): void { this.header.openAddPlaylistDialog(); } diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html index 9d23b1d307..ba54d81516 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html @@ -28,6 +28,8 @@ [searchPlaceholder]="facade.searchPlaceholder()" [searchScopeLabel]="facade.searchScopeLabel()" [searchStatusLabel]="facade.searchStatusLabel()" + [chipSearchMode]="facade.isGlobalSearch()" + [searchChips]="facade.searchChips()" [headerShortcut]="facade.headerShortcut()" [headerBulkAction]="facade.headerBulkAction()" [canRefreshPlaylist]="facade.canRefreshPlaylist()" @@ -40,6 +42,7 @@ (refreshPlaylistRequested)="facade.refreshCurrentPlaylist()" (searchChanged)="facade.onSearchInput($event)" (searchSubmitted)="facade.onSearchEnter($event)" + (searchChipsChanged)="facade.onSearchChips($event)" (commandPaletteRequested)="facade.openCommandPalette()" (shortcutsRequested)="keyboardShortcuts.openShortcutsDialog()" (addPlaylistRequested)="facade.openAddPlaylistDialog()" diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.spec.ts index 330eaab96a..f44154b5a7 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.spec.ts @@ -46,6 +46,8 @@ class MockWorkspaceShellHeaderComponent { readonly searchPlaceholder = input(''); readonly searchScopeLabel = input(''); readonly searchStatusLabel = input(''); + readonly chipSearchMode = input(false); + readonly searchChips = input([]); readonly headerShortcut = input(null); readonly canRefreshPlaylist = input(false); readonly isRefreshingPlaylist = input(false); @@ -57,6 +59,7 @@ class MockWorkspaceShellHeaderComponent { readonly headerBulkAction = input(null); readonly searchChanged = output(); readonly searchSubmitted = output(); + readonly searchChipsChanged = output(); readonly commandPaletteRequested = output(); readonly shortcutsRequested = output(); readonly addPlaylistRequested = output(); @@ -143,6 +146,8 @@ class MockWorkspaceShellFacade { ); readonly searchScopeLabel = signal('Movies / All Items'); readonly searchStatusLabel = signal(''); + readonly isGlobalSearch = signal(false); + readonly searchChips = signal([]); readonly headerShortcut = signal(null); readonly canRefreshPlaylist = signal(false); readonly isRefreshingPlaylist = signal(false); @@ -183,6 +188,7 @@ class MockWorkspaceShellFacade { onSearchInput = jest.fn(); onSearchEnter = jest.fn(); + onSearchChips = jest.fn(); openCommandPalette = jest.fn(); openGlobalSearch = jest.fn(); openAddPlaylistDialog = jest.fn();