mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-10 00:29:41 +00:00
eb7b2a5c4b
Search every text field with optional filters, keep result selection stable, and expose clearer match context and counts. Cover query parsing, matching, mixed-entry indexing, and the reveal flow.
260 lines
8.5 KiB
TypeScript
260 lines
8.5 KiB
TypeScript
import {
|
|
CustomFields,
|
|
EntryId,
|
|
MaybeNumber,
|
|
MaybeString,
|
|
OntimeEntry,
|
|
OntimeEvent,
|
|
OntimeGroup,
|
|
OntimeMilestone,
|
|
isOntimeDelay,
|
|
isOntimeEvent,
|
|
} from 'ontime-types';
|
|
import { useCallback, useMemo } from 'react';
|
|
|
|
import useCustomFields from '../../../common/hooks-query/useCustomFields';
|
|
import { useFlatRundown } from '../../../common/hooks-query/useRundown';
|
|
import { useSelectAndRevealEntry } from '../../../features/rundown/useSelectAndRevealEntry';
|
|
|
|
/** How many results we render, the total number of matches is reported separately */
|
|
const maxResults = 50;
|
|
/** Notes can hold a whole script, we only show enough to explain the match */
|
|
const excerptPadding = 40;
|
|
|
|
const indexFilter = 'index';
|
|
|
|
/** Everything except delays, which carry no text to search */
|
|
type SearchableEntry = OntimeEvent | OntimeGroup | OntimeMilestone;
|
|
|
|
type FinderFilter = { key: string; label: string };
|
|
|
|
/**
|
|
* 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' },
|
|
{ key: 'title', label: 'Title' },
|
|
{ key: 'note', label: 'Note' },
|
|
];
|
|
|
|
/** Why an entry matched, so the UI can show the user */
|
|
type FinderMatch = { key: string; label: string; excerpt: string };
|
|
|
|
export type FinderResult = {
|
|
id: EntryId;
|
|
/** position in the flat rundown, which is how the rundown reveals an entry */
|
|
index: number;
|
|
/** 1-based position among events, null for groups and milestones */
|
|
eventIndex: MaybeNumber;
|
|
title: string;
|
|
/** groups have no cue */
|
|
cue: string;
|
|
colour: string;
|
|
parent: MaybeString;
|
|
/** absent when the entry was found by index rather than by matching text */
|
|
match: FinderMatch | null;
|
|
};
|
|
|
|
type SearchOutcome = { results: FinderResult[]; error: MaybeString; total: number };
|
|
|
|
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,
|
|
index,
|
|
eventIndex,
|
|
title: entry.title,
|
|
cue: 'cue' in entry ? entry.cue : '',
|
|
colour: entry.colour,
|
|
parent: 'parent' in entry ? entry.parent : null,
|
|
match,
|
|
} satisfies FinderResult;
|
|
}
|
|
|
|
/** 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);
|
|
const end = Math.min(value.length, matchIndex + searchLength + excerptPadding);
|
|
return `${start > 0 ? '…' : ''}${value.slice(start, end)}${end < value.length ? '…' : ''}`;
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
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 custom = check(key, definition.label || key, value);
|
|
if (custom) return custom;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Splits the raw search value into an optional field filter and the text to look for.
|
|
* Both `cue 12` and `cue:12` are accepted so that typing agrees with the filter badges.
|
|
*/
|
|
export function parseQuery(searchValue: string, filters: FinderFilter[]) {
|
|
for (const filter of filters) {
|
|
// the search value is already lowercased, custom field keys are not
|
|
const prefix = filter.key.toLowerCase();
|
|
if (searchValue === prefix) {
|
|
return { filterKey: filter.key, searchString: '' };
|
|
}
|
|
if (searchValue.startsWith(`${prefix} `) || searchValue.startsWith(`${prefix}:`)) {
|
|
return { filterKey: filter.key, searchString: searchValue.slice(prefix.length + 1).trim() };
|
|
}
|
|
}
|
|
return { filterKey: null, searchString: searchValue };
|
|
}
|
|
|
|
/** Finds the single event at a 1-based position in the rundown */
|
|
export function searchByIndex(data: OntimeEntry[], indexString: string): SearchOutcome {
|
|
const target = Number(indexString);
|
|
if (isNaN(target) || target < 1) {
|
|
return { ...noResults, error: 'Invalid index' };
|
|
}
|
|
|
|
let eventIndex = 0;
|
|
for (let i = 0; i < data.length; i++) {
|
|
const entry = data[i];
|
|
if (!isOntimeEvent(entry)) {
|
|
continue;
|
|
}
|
|
eventIndex++;
|
|
if (eventIndex === target) {
|
|
return { results: [toResult(entry, i, eventIndex, null)], error: null, total: 1 };
|
|
}
|
|
}
|
|
|
|
return noResults;
|
|
}
|
|
|
|
/**
|
|
* Matches entries on a single field when one is selected, otherwise on every text field.
|
|
* Results keep rundown order, which keeps them predictable during a show.
|
|
*/
|
|
export function searchByText(
|
|
data: OntimeEntry[],
|
|
customFields: CustomFields,
|
|
filterKey: MaybeString,
|
|
searchString: string,
|
|
): SearchOutcome {
|
|
const results: FinderResult[] = [];
|
|
let total = 0;
|
|
// indexes exposed to the UI are 1-based
|
|
let eventIndex = 0;
|
|
|
|
for (let i = 0; i < data.length; i++) {
|
|
const entry = data[i];
|
|
if (isOntimeDelay(entry)) {
|
|
continue;
|
|
}
|
|
const isEvent = isOntimeEvent(entry);
|
|
if (isEvent) {
|
|
eventIndex++;
|
|
}
|
|
|
|
const match = findMatch(entry, customFields, filterKey, searchString);
|
|
if (match === null) {
|
|
continue;
|
|
}
|
|
|
|
total++;
|
|
if (results.length < maxResults) {
|
|
results.push(toResult(entry, i, isEvent ? eventIndex : null, match));
|
|
}
|
|
}
|
|
|
|
return { results, error: null, total };
|
|
}
|
|
|
|
/**
|
|
* @param searchValue - the text the user is looking for
|
|
* @param activeFilter - a field selected from the filter badges, if any
|
|
*/
|
|
export default function useFinder(searchValue: string, activeFilter: MaybeString) {
|
|
const { data, rundownId } = useFlatRundown();
|
|
const { data: customFields } = useCustomFields();
|
|
|
|
const selectAndRevealEntry = useSelectAndRevealEntry(rundownId);
|
|
|
|
/** The filters offered to the user: the fixed fields plus whatever the project defines */
|
|
const filters = useMemo<FinderFilter[]>(() => {
|
|
const customFilters = Object.entries(customFields)
|
|
.filter(([_key, field]) => field.type === 'text')
|
|
.map(([key, field]) => ({ key, label: field.label || key }));
|
|
return [...staticFilters, ...customFilters];
|
|
}, [customFields]);
|
|
|
|
const { results, error, total, appliedFilter } = useMemo(() => {
|
|
if (data.length === 0) {
|
|
return { ...noResults, error: 'No data', appliedFilter: activeFilter };
|
|
}
|
|
|
|
const normalised = searchValue.trim().toLowerCase();
|
|
if (normalised === '') {
|
|
return { ...noResults, appliedFilter: activeFilter };
|
|
}
|
|
|
|
/**
|
|
* If a badge is selected it scopes the search; otherwise, keyword prefixes in the input
|
|
* (e.g. "cue:" / "title:") are parsed and the matching badge is highlighted.
|
|
*/
|
|
const { filterKey, searchString } = activeFilter
|
|
? { filterKey: activeFilter, searchString: normalised }
|
|
: parseQuery(normalised, filters);
|
|
|
|
if (filterKey === indexFilter) {
|
|
return { ...searchByIndex(data, searchString), appliedFilter: filterKey };
|
|
}
|
|
if (searchString === '') {
|
|
// a filter is selected, but there is nothing to match on yet
|
|
return { ...noResults, appliedFilter: filterKey };
|
|
}
|
|
return { ...searchByText(data, customFields, filterKey, searchString), appliedFilter: filterKey };
|
|
}, [data, customFields, filters, searchValue, activeFilter]);
|
|
|
|
const select = useCallback(
|
|
(result: FinderResult) => {
|
|
selectAndRevealEntry({ id: result.id, index: result.index, parent: result.parent });
|
|
},
|
|
[selectAndRevealEntry],
|
|
);
|
|
|
|
return { select, results, error, total, filters, appliedFilter };
|
|
}
|