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 };
}