"""Checklist items — a note's body IS its checklist (M304). A `- [ ] milk` line is the item. There is no `note_items` table beside the body any more, which is what lets a list sit BETWEEN two paragraphs: rows had a position in a table and no position in the text, so a separate list could only ever render after the prose no matter how it was styled. The same shape as `tags.py`, one strength further along. Tags are derived from the body too, but they MATERIALISE into `note_labels` rows because the board queries by label. Items materialise into nothing, because nothing queries them — their only readers are the card, the editor and `display_title`. So `parse_items` is the whole storage layer for a checklist, and the rewriters below are how one is edited. THE GRAMMAR IS SHARED. Three implementations exist and they have to agree, because a difference between any two of them is a checklist that changes shape when it syncs: core/src/local/derive.rs the native clients (desktop + Android) src/thoughtsync/notes/checklist.py this file, the server frontend/src/notes/markdown.ts the browser optional indent, `-` or `*`, one-or-more spaces, `[ ]`/`[x]`/`[X]`, then either end-of-line or one-or-more spaces and the text. `*` is accepted because markdown.ts already takes it for a plain bullet, and a rule that allowed `* item` but not `* [ ] item` would be one nobody could guess. `- [ ]` with nothing after it IS an item with empty text — that is what pressing Enter on a list leaves behind, and refusing to parse it would make a half-typed list stop being a list. `- [X]` parses as checked and renders back lowercase, so one canonical form survives a round trip. """ from __future__ import annotations import re from dataclasses import dataclass # Anchored at both ends: a `[ ]` mid-sentence is prose, and `- [ ]x` (no space after # the brackets) is a sentence that happens to start with brackets, not a marker. _TASK_RE = re.compile(r"^(?P\s*)(?P[-*]) +\[(?P[ xX])\](?: +(?P.*))?$") @dataclass(frozen=True) class Item: """One checklist item. Its position in the parsed list is its identity — the same thing `position` meant when these were rows, and all the wire ever carried.""" text: str checked: bool def parse_items(body: str | None) -> list[Item]: """Every checklist item in `body`, in the order they appear.""" out: list[Item] = [] for line in (body or "").split("\n"): match = _TASK_RE.match(line) if match: out.append(Item(text=match.group("text") or "", checked=match.group("mark") in "xX")) return out def render_item(text: str, checked: bool, indent: str = "", bullet: str = "-") -> str: """One item as the line that stores it. Always lowercase `x`, whatever was parsed: one canonical output is what makes a round trip stable, so `- [X]` normalises the first time it is touched and never again. """ mark = "x" if checked else " " if not text: return f"{indent}{bullet} [{mark}]" return f"{indent}{bullet} [{mark}] {text}" def strip_marker(line: str) -> str: """The text of a line with its task marker removed, or the line as it was. For naming a note: a list-only note is named by its first item, and calling one "- [ ] milk" would be showing someone the storage instead of the note. """ match = _TASK_RE.match(line) return (match.group("text") or "") if match else line def _rewrite(body: str, index: int, replace) -> str: """Rewrite the `index`-th task line with `replace`, or drop it when `replace` returns None. A body with fewer task lines than that is returned UNCHANGED rather than raising: the index comes from a client that may be a moment behind the server, and a stale request should do nothing rather than 500. """ lines = body.split("\n") target = None seen = 0 for n, line in enumerate(lines): if _TASK_RE.match(line): if seen == index: target = n break seen += 1 if target is None: return body match = _TASK_RE.match(lines[target]) replacement = replace(match) if replacement is None: del lines[target] else: lines[target] = replacement return "\n".join(lines) def set_item_checked(body: str, index: int, checked: bool) -> str: """Tick or untick the `index`-th item, keeping its text, indent and bullet.""" return _rewrite( body, index, lambda m: render_item(m.group("text") or "", checked, m.group("indent"), m.group("bullet")), ) def set_item_text(body: str, index: int, text: str) -> str: """Replace the text of the `index`-th item, keeping its state and its bullet.""" return _rewrite( body, index, lambda m: render_item(text.strip(), m.group("mark") in "xX", m.group("indent"), m.group("bullet")), ) def remove_item(body: str, index: int) -> str: """Delete the `index`-th item, line and all.""" return _rewrite(body, index, lambda _m: None) def append_item(body: str, text: str, checked: bool = False) -> str: """Add an item at the end of the body. A blank line between prose and the list, nothing between consecutive items — the layout `import_export._note_markdown` has always used when writing a checklist out. That is not cosmetic: it is what the Alembic migration folds existing `note_items` rows into AND what `derive::append_item` produces on every client, so all three land on identical bodies. An export taken before the migration and one taken after therefore differ in nothing. """ line = render_item(text.strip(), checked) trimmed = body.rstrip("\n") if not trimmed.strip(): return line follows_a_list = bool(_TASK_RE.match(trimmed.split("\n")[-1])) return f"{trimmed}\n{line}" if follows_a_list else f"{trimmed}\n\n{line}"