track list search results now sorted by relevance and highlighted

This commit is contained in:
2026-02-25 01:09:54 -05:00
parent e192d4625e
commit 8f1ee25053
6 changed files with 336 additions and 18 deletions
@@ -194,6 +194,16 @@ export const COLUMN_DEFS: Record<string, ColumnDef> = {
*/
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',
@@ -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<string, number> = {
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 (14), 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<string>();
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<string, number> } {
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<string, number>();
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
* `<span class="search-match">`. 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`<span class="search-match"
>${matched}</span
>`,
);
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}`;
}
@@ -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`
<div class="cell ${align}">
${val}
${display}
</div>
`;
})}
View File
Vendored Executable → Regular
View File
View File