mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-20 06:29:07 +00:00
refactor(finder): name the searchable fields where they are matched
Collecting the fields of an entry into a list and then walking that list kept the two apart, but it needed a type pairing a filter with a value, built an array for every entry on every keystroke, and left the order fields are reported in a property of a list built elsewhere. Naming cue, title and note where they are matched drops both the type and the collecting step, and reads as the preference order it is. Custom fields stay generic because the project names them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DzALEq9gGWwFmwTdgAiFcY
This commit is contained in:
@@ -28,7 +28,10 @@ type SearchableEntry = OntimeEvent | OntimeGroup | OntimeMilestone;
|
||||
|
||||
type FinderFilter = { key: string; label: string };
|
||||
|
||||
/** The fields common to every project, custom fields are appended at runtime */
|
||||
/**
|
||||
* Offered to the user as filter badges. Index is a positional lookup rather than a
|
||||
* text field, so it is handled separately from the fields a search runs over.
|
||||
*/
|
||||
const staticFilters: FinderFilter[] = [
|
||||
{ key: indexFilter, label: 'Index' },
|
||||
{ key: 'cue', label: 'Cue' },
|
||||
@@ -58,6 +61,7 @@ type SearchOutcome = { results: FinderResult[]; error: MaybeString; total: numbe
|
||||
|
||||
const noResults: SearchOutcome = { results: [], error: null, total: 0 };
|
||||
|
||||
/** Groups are the only searchable entry with neither a cue nor a parent */
|
||||
function toResult(entry: SearchableEntry, index: number, eventIndex: MaybeNumber, match: FinderMatch | null) {
|
||||
return {
|
||||
id: entry.id,
|
||||
@@ -71,34 +75,6 @@ function toResult(entry: SearchableEntry, index: number, eventIndex: MaybeNumber
|
||||
} satisfies FinderResult;
|
||||
}
|
||||
|
||||
type SearchableField = FinderFilter & { value: string };
|
||||
|
||||
/**
|
||||
* The text fields of an entry, in the order we prefer to report a match.
|
||||
* Image custom fields hold a URL, which nobody searches for.
|
||||
*/
|
||||
function getSearchableFields(entry: SearchableEntry, customFields: CustomFields): SearchableField[] {
|
||||
const fields: SearchableField[] = [];
|
||||
|
||||
if ('cue' in entry && entry.cue) {
|
||||
fields.push({ key: 'cue', label: 'Cue', value: entry.cue });
|
||||
}
|
||||
if (entry.title) {
|
||||
fields.push({ key: 'title', label: 'Title', value: entry.title });
|
||||
}
|
||||
if (entry.note) {
|
||||
fields.push({ key: 'note', label: 'Note', value: entry.note });
|
||||
}
|
||||
for (const [key, value] of Object.entries(entry.custom)) {
|
||||
const definition = customFields[key];
|
||||
if (value && definition?.type === 'text') {
|
||||
fields.push({ key, label: definition.label || key, value });
|
||||
}
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
/** Shows enough of a long value for the user to see why it matched */
|
||||
function makeExcerpt(value: string, matchIndex: number, searchLength: number): string {
|
||||
const start = Math.max(0, matchIndex - excerptPadding);
|
||||
@@ -106,22 +82,44 @@ function makeExcerpt(value: string, matchIndex: number, searchLength: number): s
|
||||
return `${start > 0 ? '…' : ''}${value.slice(start, end)}${end < value.length ? '…' : ''}`;
|
||||
}
|
||||
|
||||
/** The first field of an entry to contain the search string, if any */
|
||||
/**
|
||||
* The first field of an entry to contain the search string, if any.
|
||||
* Fields are tried in the order we prefer to report a match.
|
||||
*/
|
||||
function findMatch(
|
||||
entry: SearchableEntry,
|
||||
customFields: CustomFields,
|
||||
filterKey: MaybeString,
|
||||
searchString: string,
|
||||
): FinderMatch | null {
|
||||
for (const field of getSearchableFields(entry, customFields)) {
|
||||
if (filterKey !== null && field.key !== filterKey) {
|
||||
function check(key: string, label: string, value: string): FinderMatch | null {
|
||||
if (!value || (filterKey !== null && key !== filterKey)) {
|
||||
return null;
|
||||
}
|
||||
const matchIndex = value.toLowerCase().indexOf(searchString);
|
||||
if (matchIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
return { key, label, excerpt: makeExcerpt(value, matchIndex, searchString.length) };
|
||||
}
|
||||
|
||||
// groups have no cue, the rest is common to every searchable entry
|
||||
const fromCue = 'cue' in entry ? check('cue', 'Cue', entry.cue) : null;
|
||||
const match = fromCue ?? check('title', 'Title', entry.title) ?? check('note', 'Note', entry.note);
|
||||
if (match !== null) {
|
||||
return match;
|
||||
}
|
||||
|
||||
// custom fields are named by the project, so these can only be reached generically
|
||||
for (const [key, value] of Object.entries(entry.custom)) {
|
||||
const definition = customFields[key];
|
||||
if (definition?.type !== 'text') {
|
||||
continue;
|
||||
}
|
||||
const matchIndex = field.value.toLowerCase().indexOf(searchString);
|
||||
if (matchIndex !== -1) {
|
||||
return { key: field.key, label: field.label, excerpt: makeExcerpt(field.value, matchIndex, searchString.length) };
|
||||
}
|
||||
const custom = check(key, definition.label || key, value);
|
||||
if (custom) return custom;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user