import type { StepTechStepView } from "@batch-cooking/shared"; /** * One run of a step's `description` — either plain text, or part of a * detected technique (`techStep` set). A technique's own text is itself * split into up to three runs (see {@link splitDescriptionByTechSteps}): * the tight keyword span (`isKeyword: true`, e.g. "préchauffer") and, when * `StepTechStepView.contextStart`/`contextEnd` are present, the wider * surrounding clause around it (`isKeyword: false`, e.g. "Dans une poêle * chaude" around a keyword of "poêle chaude"). `StepDescription.tsx` * currently renders `isKeyword: false` segments as plain text (no visual * distinction from a segment with no technique at all) — the context split * still happens here so the data stays available, but its own dedicated * highlight was turned back off; see that component's doc comment. */ export interface DescriptionSegment { text: string; techStep: StepTechStepView["techStep"] | null; /** Always `false` when `techStep` is `null`. */ isKeyword: boolean; } /** * Splits `description` into an ordered sequence of plain/context/keyword * {@link DescriptionSegment}s using each `techSteps` entry's `start`/`end` * (the keyword) and, when present, `contextStart`/`contextEnd` (the wider * clause it was found in — see `StepTechStepView`, resolved server-side by * `tech-step-matcher.ts`'s `matchTechStepSpans`). An entry with no context * (older data, saved before that column pair existed — see * `StepTechStep`'s schema doc comment) degrades to a keyword-only segment, * same as before context spans existed at all. * * `techSteps` is expected already sorted by `start` (the API returns it in * `StepTechStep.order`, which *is* reading order — see that model's schema * doc comment) but this re-sorts defensively (by context start when * present, since context always starts at or before its own keyword) * rather than assuming it, and silently drops any entry whose bounds don't * make sense against `description` or a previously-accepted entry's own * bounds — a malformed/out-of-date span degrades to "just don't highlight * that one" rather than a garbled slice or a crash. */ export function splitDescriptionByTechSteps( description: string, techSteps: StepTechStepView[], ): DescriptionSegment[] { const sorted = [...techSteps].sort( (a, b) => (a.contextStart ?? a.start) - (b.contextStart ?? b.start), ); const segments: DescriptionSegment[] = []; let cursor = 0; for (const { techStep, start, end, contextStart, contextEnd } of sorted) { const wideStart = contextStart ?? start; const wideEnd = contextEnd ?? end; if ( wideStart < cursor || wideStart > start || start >= end || end > wideEnd || wideEnd > description.length ) { continue; } if (wideStart > cursor) { segments.push({ text: description.slice(cursor, wideStart), techStep: null, isKeyword: false, }); } if (start > wideStart) { segments.push({ text: description.slice(wideStart, start), techStep, isKeyword: false, }); } segments.push({ text: description.slice(start, end), techStep, isKeyword: true }); if (wideEnd > end) { segments.push({ text: description.slice(end, wideEnd), techStep, isKeyword: false }); } cursor = wideEnd; } if (cursor < description.length) { segments.push({ text: description.slice(cursor), techStep: null, isKeyword: false }); } return segments; }