263 lines
6.9 KiB
TypeScript
263 lines
6.9 KiB
TypeScript
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 (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<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}`;
|
||
}
|