task-line.ts3.3 KBView on GitHub /**
* The one grammar for a task's text, shared by every surface that shows a task.
*
* A task description is PLAIN TEXT in two parts:
*
* Bolded concise headline — the rest of the sentence
*
* The headline is what the reader scans; everything after the first dash is the
* detail they read only if the headline earned it. There is no markdown in the
* string — no `**`, no card, no structured payload — because the same string has
* to render inside a contenteditable agenda row, a list row, a kanban card and a
* ticket header, and anything richer than "text with a dash in it" has to be
* parsed, escaped and round-tripped by all four.
*
* The agent writes descriptions in this shape (see `TaskFieldDescriptions.description`
* on the server); this parser is what makes an older description that predates the
* convention still look right, so nothing has to be migrated.
*/
/**
* Separators, longest-first so ` -- ` is never read as ` - ` with a stray dash.
* All of them require surrounding whitespace: a hyphen inside `follow-up` or a
* date range like `2-3pm` is not a separator.
*/
const SEPARATORS = [' — ', ' – ', ' -- ', ' - '];
/**
* A headline with no detail after it is only bolded when it is short enough to
* read as a headline. Descriptions written before this convention are often one
* long sentence, and a page of fully-bold sentences is heavier than the plain
* text it replaced — so past this length the line renders unstyled and simply
* looks the way it does today.
*/
const MAX_UNSPLIT_HEADLINE = 60;
export interface TaskLineParts {
/** The bold half. Always present (it is the whole string when there is no separator). */
headline: string;
/** Everything after the separator, or null when there is none. */
detail: string | null;
/**
* Offset, in the ORIGINAL string, of the first character after the headline —
* i.e. where the separator starts. `null` when there is no separator. The agenda's
* decoration plugin needs this to map the split onto document positions.
*/
splitIndex: number | null;
/** Whether the headline should render bold. See MAX_UNSPLIT_HEADLINE. */
emphasized: boolean;
}
/** Split a task description into its headline and its detail. */
export function parseTaskLine(text: string | null | undefined): TaskLineParts {
const raw = (text ?? '').trim();
if (!raw) return { headline: '', detail: null, splitIndex: null, emphasized: false };
let splitIndex: number | null = null;
let separatorLength = 0;
for (const separator of SEPARATORS) {
const index = raw.indexOf(separator);
if (index <= 0) continue;
if (splitIndex === null || index < splitIndex) {
splitIndex = index;
separatorLength = separator.length;
}
}
if (splitIndex === null) {
return {
headline: raw,
detail: null,
splitIndex: null,
emphasized: raw.length <= MAX_UNSPLIT_HEADLINE,
};
}
const headline = raw.slice(0, splitIndex);
const detail = raw.slice(splitIndex + separatorLength).trim();
// A separator with nothing after it is not a split — the dash is just trailing text.
if (!detail) {
return {
headline: raw,
detail: null,
splitIndex: null,
emphasized: raw.length <= MAX_UNSPLIT_HEADLINE,
};
}
return { headline, detail, splitIndex, emphasized: true };
}