From 8f1ee25053efe05be0821fb511e415452fbd20ca Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Wed, 25 Feb 2026 01:09:54 -0500 Subject: [PATCH] track list search results now sorted by relevance and highlighted --- frontend/src/components/track-list/columns.ts | 10 + .../components/track-list/search-ranking.ts | 262 ++++++++++++++++++ .../src/components/track-list/track-list.ts | 82 ++++-- frontend/wailsjs/runtime/package.json | 0 frontend/wailsjs/runtime/runtime.d.ts | 0 frontend/wailsjs/runtime/runtime.js | 0 6 files changed, 336 insertions(+), 18 deletions(-) create mode 100644 frontend/src/components/track-list/search-ranking.ts mode change 100755 => 100644 frontend/wailsjs/runtime/package.json mode change 100755 => 100644 frontend/wailsjs/runtime/runtime.d.ts mode change 100755 => 100644 frontend/wailsjs/runtime/runtime.js diff --git a/frontend/src/components/track-list/columns.ts b/frontend/src/components/track-list/columns.ts index 1b26c45..d1b40e4 100644 --- a/frontend/src/components/track-list/columns.ts +++ b/frontend/src/components/track-list/columns.ts @@ -194,6 +194,16 @@ export const COLUMN_DEFS: Record = { */ export const ALL_COLUMN_IDS: string[] = Object.keys(COLUMN_DEFS); +/** + * Column IDs that are always searched regardless of visibility. + * These represent the most common search targets. + */ +export const CORE_SEARCH_COLUMN_IDS: string[] = [ + 'trackName', + 'artistName', + 'album', +]; + /** Default column IDs matching the original hardcoded layout. */ export const DEFAULT_COLUMN_IDS: string[] = [ 'trackName', diff --git a/frontend/src/components/track-list/search-ranking.ts b/frontend/src/components/track-list/search-ranking.ts new file mode 100644 index 0000000..29cf5aa --- /dev/null +++ b/frontend/src/components/track-list/search-ranking.ts @@ -0,0 +1,262 @@ +import type { library } from '@go/models'; +import { html } from 'lit'; +import type { TemplateResult } from 'lit'; + +import { + COLUMN_DEFS, + CORE_SEARCH_COLUMN_IDS, +} from './columns'; +import type { ColumnDef } from './columns'; + +// ================================================================= +// Field weights — higher means more relevant when matched +// ================================================================= + +const FIELD_WEIGHTS: Record = { + trackName: 100, + artistName: 80, + album: 60, + composer: 40, + genre: 40, + year: 20, + filePath: 20, + fileType: 20, + trackNumber: 20, + discNumber: 20, + sampleRate: 20, + bitDepth: 20, + channels: 20, + bitrate: 20, + fileSize: 20, + trackLength: 20, +}; + +// ================================================================= +// Match quality multipliers +// ================================================================= + +/** Entire field value equals the search term. */ +const EXACT_MATCH = 4; + +/** Field value starts with the search term. */ +const PREFIX_MATCH = 3; + +/** Term appears at a word boundary within the field. */ +const WORD_BOUNDARY_MATCH = 2; + +/** Term is a substring somewhere in the field. */ +const CONTAINS_MATCH = 1; + +/** + * Pattern that matches common word-boundary characters. + * Used to test whether a substring match sits at the start of a + * "word" inside the field value. + */ +const WORD_BOUNDARY = /[\s\-_(/[\].,;:!?'"]/; + +// ================================================================= +// Scoring +// ================================================================= + +/** + * Compute the match quality multiplier for a single field value + * against the lowercased search term. + * + * @returns The quality multiplier (1–4), or 0 if no match. + */ +function matchQuality( + fieldLower: string, + termLower: string, +): number { + if (fieldLower === termLower) return EXACT_MATCH; + if (fieldLower.startsWith(termLower)) return PREFIX_MATCH; + + const idx = fieldLower.indexOf(termLower); + + if (idx === -1) return 0; + + // Check if the character before the match is a word boundary. + if ( + idx > 0 && + WORD_BOUNDARY.test(fieldLower[idx - 1]!) + ) { + return WORD_BOUNDARY_MATCH; + } + + return CONTAINS_MATCH; +} + +/** + * Score a single track against a search term. + * + * The score is the best `fieldWeight × matchQuality` across all + * searchable fields. Returns 0 if no field matches (the track + * should be filtered out). + * + * @param track The track to score. + * @param termLower The search term, already lowercased. + * @param columns The set of column defs to search. Core search + * fields are always included on top of these. + */ +function scoreTrack( + track: library.Track, + termLower: string, + columns: ColumnDef[], +): number { + let best = 0; + + // Build the deduplicated set of column IDs to check. + const seen = new Set(); + + const check = (col: ColumnDef) => { + if (seen.has(col.id)) return; + seen.add(col.id); + + const value = col.accessor(track).toLowerCase(); + + if (!value) return; + + const quality = matchQuality(value, termLower); + + if (quality === 0) return; + + const weight = FIELD_WEIGHTS[col.id] ?? 20; + const score = weight * quality; + + if (score > best) best = score; + }; + + // Always search core fields first. + for (const id of CORE_SEARCH_COLUMN_IDS) { + const col = COLUMN_DEFS[id]; + + if (col) check(col); + } + + // Then search any additional visible columns. + for (const col of columns) { + check(col); + } + + return best; +} + +// ================================================================= +// Public API +// ================================================================= + +/** A track paired with its relevance score. */ +export interface RankedTrack { + track: library.Track; + score: number; +} + +/** + * Filter and rank tracks by relevance to a search term. + * + * Tracks that don't match any searchable field are excluded. + * The returned array is sorted descending by score (best match + * first). A companion `Map` of FilePath → score is also returned + * so that `computeSortedTracks` can use relevance as a tiebreaker. + * + * @param tracks The full, unfiltered track list. + * @param term The raw search term (will be lowercased). + * @param activeColumns Currently visible column definitions. + * @returns An object with `tracks` (filtered & ranked) and + * `scores` (Map of FilePath → relevance score). + */ +export function rankTracks( + tracks: library.Track[], + term: string, + activeColumns: ColumnDef[], +): { tracks: library.Track[]; scores: Map } { + const termLower = term.toLowerCase(); + const ranked: RankedTrack[] = []; + + for (const track of tracks) { + const score = scoreTrack( + track, + termLower, + activeColumns, + ); + + if (score > 0) { + ranked.push({ track, score }); + } + } + + // Sort descending by score (highest relevance first). + ranked.sort((a, b) => b.score - a.score); + + const result: library.Track[] = []; + const scores = new Map(); + + for (const r of ranked) { + result.push(r.track); + scores.set(r.track.FilePath, r.score); + } + + return { tracks: result, scores }; +} + +// ================================================================= +// Search term highlighting +// ================================================================= + +/** + * Highlight all occurrences of a search term within a text value. + * + * Returns a Lit `TemplateResult` with matched substrings wrapped in + * ``. The matching is case-insensitive. + * If the term is empty or not found, the original string is returned + * as-is (no wrapper elements). + * + * @param text The cell display value. + * @param term The raw search term. + */ +export function highlightText( + text: string, + term: string, +): string | TemplateResult { + if (!term) return text; + + const termLower = term.toLowerCase(); + const textLower = text.toLowerCase(); + const firstIdx = textLower.indexOf(termLower); + + if (firstIdx === -1) return text; + + const parts: (string | TemplateResult)[] = []; + let cursor = 0; + + let idx = firstIdx; + + while (idx !== -1) { + // Text before the match. + if (idx > cursor) { + parts.push(text.slice(cursor, idx)); + } + + // The matched substring (preserving original case). + const matched = text.slice( + idx, + idx + term.length, + ); + + parts.push( + html`${matched}`, + ); + + cursor = idx + term.length; + idx = textLower.indexOf(termLower, cursor); + } + + // Remaining text after the last match. + if (cursor < text.length) { + parts.push(text.slice(cursor)); + } + + return html`${parts}`; +} diff --git a/frontend/src/components/track-list/track-list.ts b/frontend/src/components/track-list/track-list.ts index 373644a..74ea87a 100644 --- a/frontend/src/components/track-list/track-list.ts +++ b/frontend/src/components/track-list/track-list.ts @@ -23,6 +23,10 @@ import { DEFAULT_COLUMN_IDS, } from './columns'; import type { ColumnDef } from './columns'; +import { + rankTracks, + highlightText, +} from './search-ranking'; import { setDragPayload, emitDragActive, @@ -131,6 +135,10 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH // -- Memoisation caches for filtered / sorted tracks -- private cachedFilteredTracks: library.Track[] = []; private cachedSortedTracks: library.Track[] = []; + private cachedRelevanceScores = new Map< + string, + number + >(); private prevFilterTracks: library.Track[] = []; private prevFilterTerm = ''; private prevFilterColIds = ''; @@ -223,38 +231,66 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH } private computeFilteredTracks(): library.Track[] { - const term = - this.searchCtrl.term.toLowerCase(); + const term = this.searchCtrl.term; - if (!term) return this.tracks; + if (!term) { + this.cachedRelevanceScores.clear(); - const cols = this.activeColumns; + return this.tracks; + } - return this.tracks.filter((t) => - cols.some((col) => - col - .accessor(t) - .toLowerCase() - .includes(term), - ), + const result = rankTracks( + this.tracks, + term, + this.activeColumns, ); + + this.cachedRelevanceScores = result.scores; + + return result.tracks; } private computeSortedTracks(): library.Track[] { const tracks = this.cachedFilteredTracks; + const hasSearch = + this.cachedRelevanceScores.size > 0; + const col = this.sortField + ? COLUMN_DEFS[this.sortField] + : undefined; + const hasColSort = col?.comparator != null; - if (!this.sortField) return tracks; + // No search, no column sort — default order. + if (!hasSearch && !hasColSort) return tracks; - const col = COLUMN_DEFS[this.sortField]; + // No search, column sort only — sort by column. + if (!hasSearch && hasColSort) { + const dir = + this.sortDirection === 'asc' ? 1 : -1; - if (!col?.comparator) return tracks; + return [...tracks].sort( + (a, b) => + dir * col!.comparator!(a, b), + ); + } + // Search active — relevance is primary sort, + // column sort (if any) is the tiebreaker. + const scores = this.cachedRelevanceScores; const dir = this.sortDirection === 'asc' ? 1 : -1; - return [...tracks].sort( - (a, b) => dir * col.comparator!(a, b), - ); + return [...tracks].sort((a, b) => { + const sa = scores.get(a.FilePath) ?? 0; + const sb = scores.get(b.FilePath) ?? 0; + + if (sa !== sb) return sb - sa; + + if (hasColSort) { + return dir * col!.comparator!(a, b); + } + + return 0; + }); } // ================================================================= @@ -933,6 +969,11 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH text-align: center; } + .search-match { + background-color: rgba(255, 212, 59, 0.15); + border-radius: 2px; + } + `]; override connectedCallback() { @@ -1458,10 +1499,15 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH : col.align === 'right' ? 'cell-right' : ''; + const term = + this.searchCtrl.term; + const display = term + ? highlightText(val, term) + : val; return html`
- ${val} + ${display}
`; })} diff --git a/frontend/wailsjs/runtime/package.json b/frontend/wailsjs/runtime/package.json old mode 100755 new mode 100644 diff --git a/frontend/wailsjs/runtime/runtime.d.ts b/frontend/wailsjs/runtime/runtime.d.ts old mode 100755 new mode 100644 diff --git a/frontend/wailsjs/runtime/runtime.js b/frontend/wailsjs/runtime/runtime.js old mode 100755 new mode 100644