frontend/wailsjs/ is deleted and frontend/bindings/ takes its place — a real TypeScript module tree nested by Go import path, generated by wails3's static analyser rather than by building the app and running it. The @go alias absorbs the constant prefix, so a call site imports '@go/library/library.js' and the codemod over all 93 sites was a specifier rewrite plus splitting @go/models' namespaces into one import per package. The 12 SetContext bindings and the fake `context` model are gone, as Phase 2's ServiceStartup port promised: 272 methods across 12 services, none of them plumbing. @runtime/runtime is now a local shim (src/wails/runtime.ts) over @wailsio/runtime, so the 22 EventsOn imports are untouched. It unwraps v3's WailsEvent into v2's callback shape, which is exact here: nothing in backend/events passes more than one data argument, and v3 only packs arguments into a slice when there is more than one. v3 tells the truth about two things v2 lied about, and that is most of the diff. A Go nil slice really does arrive as JSON null, and a Go named string type really is an enum; v2 typed them as T[] and string. utils/binding.ts states the app's actual contract — an absent list is an empty list — once, at the boundary where it is true, and also drops the CancellablePromise the app never cancels. Four test fixtures widen an enum field back to its value union. Not done, and Phase 5's to fix: frontend/test/support/wails-fake.ts still fakes window.go, which v3 does not have, so `make ui-test` is broken and harness.test.ts fails to compile on EventsEmit. That test also asserts v2 ordering that no longer holds — v3's Events.Emit calls the backend and does not notify in-page listeners at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm
263 lines
6.9 KiB
TypeScript
263 lines
6.9 KiB
TypeScript
import type * as library from '@go/library/models.js';
|
||
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}`;
|
||
}
|