Files
yellowjacket/frontend/src/components/track-list/search-ranking.ts
T

263 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}`;
}