core: derive checklist items from the body, the way tags already are
First step of M304. Additive on its own — nothing calls this yet — so it can be read and tested before anything depends on it. A checklist is currently a TABLE, and a table can only ever render after the body, because a row has no idea where in the note it belongs. That is why "inline with the note" is not a styling problem: prose, three checkboxes, then more prose is not expressible at all today. derive.rs already owns "structure derived from body text" for #tags and says so in its module doc. Task lines join it rather than opening a second home for the same idea. The difference between the two is worth stating and now is: tags MATERIALISE into label 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. The grammar is fixed here because three languages will implement it — derive.rs, notes/checklist.py, notes/markdown.ts — and any difference between two of them is a checklist that changes shape when it syncs. `*` is accepted since 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 parses as an empty item: that is what pressing Enter on a list leaves behind, and refusing it would make a half-typed list stop being a list. `- [X]` parses and normalises to lowercase on the first rewrite, so round trips are stable. append_item spaces its output exactly as import_export.py:_note_markdown does. That is not cosmetic — the server migration will fold existing rows into bodies with the same layout, so an export taken before it and one taken after have to agree byte for byte. A stale index is inert rather than fatal: the index comes from a UI that may be a moment behind the store, and a late tap should do nothing rather than panic.
This commit is contained in:
+336
-3
@@ -1,9 +1,18 @@
|
||||
//! Deriving `#tags` from a note's body — the local mirror of what the server computes
|
||||
//! on save. Pure string scanning (no regex dependency), kept in lockstep with the
|
||||
//! frontend's inline rules (see frontend notes/markdown.ts):
|
||||
//! Deriving structure from a note's body — the local mirror of what the server
|
||||
//! computes on save. Pure string scanning (no regex dependency), kept in lockstep
|
||||
//! with the frontend's inline rules (see frontend notes/markdown.ts):
|
||||
//!
|
||||
//! - `#tag`: `#` at a word boundary followed by tag characters (letter first).
|
||||
//! On save these become labels attached with `via_tag = true`.
|
||||
//! - `- [ ] item`: a checklist item. The body IS the checklist (M304) — there is no
|
||||
//! table of items beside it, so a list can sit between two paragraphs instead of
|
||||
//! only after them.
|
||||
//!
|
||||
//! The two are the same idea at different strengths. Tags MATERIALISE into label
|
||||
//! 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 `extract_items` is the whole storage layer for a checklist,
|
||||
//! and the rewriters below are how one is edited.
|
||||
//!
|
||||
//! Dedupes case-insensitively, preserving first-seen order.
|
||||
//!
|
||||
@@ -45,6 +54,206 @@ fn push_unique(out: &mut Vec<String>, candidate: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── checklist items ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// The grammar, in one place, because three languages implement it (here,
|
||||
// `notes/checklist.py`, `notes/markdown.ts`) and a difference between any two of
|
||||
// them is a checklist that changes shape when it syncs:
|
||||
//
|
||||
// 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 accepts it for a plain bullet, and a
|
||||
// grammar that takes `* item` but not `* [ ] item` would be a rule with no reason
|
||||
// anyone could guess. `- [ ]` with nothing after it IS an item with empty text:
|
||||
// that is exactly what pressing Enter on a list leaves behind, and refusing to
|
||||
// parse it would make a half-typed list stop being a list.
|
||||
|
||||
/// A checklist item, as found in the body. Its position in the returned vector is
|
||||
/// its identity — the same thing `position` meant when these were rows, and all the
|
||||
/// wire ever carried (`push.rs` sent text and checked, never an id).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DerivedItem {
|
||||
pub text: String,
|
||||
pub checked: bool,
|
||||
}
|
||||
|
||||
/// One parsed task line, holding enough to put it back exactly as it was found.
|
||||
struct TaskLine<'a> {
|
||||
indent: &'a str,
|
||||
/// Preserved rather than normalised to `-`: rewriting someone's `*` bullets
|
||||
/// because they ticked a box would be an edit they did not ask for.
|
||||
bullet: char,
|
||||
checked: bool,
|
||||
text: &'a str,
|
||||
}
|
||||
|
||||
fn parse_task_line(line: &str) -> Option<TaskLine<'_>> {
|
||||
let indent_len = line.len() - line.trim_start().len();
|
||||
let (indent, rest) = line.split_at(indent_len);
|
||||
|
||||
let bullet = rest.chars().next()?;
|
||||
if bullet != '-' && bullet != '*' {
|
||||
return None;
|
||||
}
|
||||
// At least one space after the bullet. `-[ ] x` is not a list item in any
|
||||
// markdown either, so it stays prose here too.
|
||||
let rest = &rest[bullet.len_utf8()..];
|
||||
let gap = rest.len() - rest.trim_start_matches(' ').len();
|
||||
if gap == 0 {
|
||||
return None;
|
||||
}
|
||||
let rest = &rest[gap..];
|
||||
|
||||
let mut chars = rest.chars();
|
||||
if chars.next()? != '[' {
|
||||
return None;
|
||||
}
|
||||
let mark = chars.next()?;
|
||||
if chars.next()? != ']' {
|
||||
return None;
|
||||
}
|
||||
// Decided BEFORE the slice below, which is what guarantees `mark` is one byte
|
||||
// and `[?]` is exactly three.
|
||||
let checked = match mark {
|
||||
' ' => false,
|
||||
'x' | 'X' => true,
|
||||
_ => return None,
|
||||
};
|
||||
let rest = &rest[3..];
|
||||
|
||||
let text = if rest.is_empty() {
|
||||
// "- [ ]" — an empty item, which is what an unfinished list line is.
|
||||
rest
|
||||
} else {
|
||||
let gap = rest.len() - rest.trim_start_matches(' ').len();
|
||||
// "- [ ]x" is prose: without the space this is not a marker, it is a
|
||||
// sentence that happens to start with brackets.
|
||||
if gap == 0 {
|
||||
return None;
|
||||
}
|
||||
&rest[gap..]
|
||||
};
|
||||
|
||||
Some(TaskLine {
|
||||
indent,
|
||||
bullet,
|
||||
checked,
|
||||
text,
|
||||
})
|
||||
}
|
||||
|
||||
fn render_task_line(indent: &str, bullet: char, checked: bool, text: &str) -> String {
|
||||
// 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.
|
||||
let mark = if checked { 'x' } else { ' ' };
|
||||
if text.is_empty() {
|
||||
format!("{indent}{bullet} [{mark}]")
|
||||
} else {
|
||||
format!("{indent}{bullet} [{mark}] {text}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Every checklist item in `body`, in the order they appear.
|
||||
pub fn extract_items(body: &str) -> Vec<DerivedItem> {
|
||||
let mut out = Vec::new();
|
||||
for line in body.split('\n') {
|
||||
if let Some(t) = parse_task_line(line) {
|
||||
out.push(DerivedItem {
|
||||
text: t.text.to_string(),
|
||||
checked: t.checked,
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Rewrite the `index`-th task line, or drop it when `f` returns None.
|
||||
///
|
||||
/// A body with fewer task lines than that is returned UNCHANGED rather than
|
||||
/// panicking: the index comes from a UI that may be a moment behind the store, and
|
||||
/// a stale tap should do nothing rather than take the app down.
|
||||
fn map_task_line<F>(body: &str, index: usize, f: F) -> String
|
||||
where
|
||||
F: FnOnce(&TaskLine<'_>) -> Option<String>,
|
||||
{
|
||||
let lines: Vec<&str> = body.split('\n').collect();
|
||||
let mut target: Option<usize> = None;
|
||||
let mut seen = 0usize;
|
||||
for (n, line) in lines.iter().enumerate() {
|
||||
if parse_task_line(line).is_some() {
|
||||
if seen == index {
|
||||
target = Some(n);
|
||||
break;
|
||||
}
|
||||
seen += 1;
|
||||
}
|
||||
}
|
||||
let target = match target {
|
||||
Some(n) => n,
|
||||
None => return body.to_string(),
|
||||
};
|
||||
let replacement = match parse_task_line(lines[target]) {
|
||||
Some(parsed) => f(&parsed),
|
||||
None => return body.to_string(),
|
||||
};
|
||||
|
||||
let mut out: Vec<String> = Vec::with_capacity(lines.len());
|
||||
for (n, line) in lines.iter().enumerate() {
|
||||
if n != target {
|
||||
out.push((*line).to_string());
|
||||
} else if let Some(new_line) = &replacement {
|
||||
out.push(new_line.clone());
|
||||
}
|
||||
// None at the target line drops it, which is `remove_item`.
|
||||
}
|
||||
out.join("\n")
|
||||
}
|
||||
|
||||
/// Tick or untick the `index`-th item.
|
||||
pub fn set_item_checked(body: &str, index: usize, checked: bool) -> String {
|
||||
map_task_line(body, index, |t| {
|
||||
Some(render_task_line(t.indent, t.bullet, checked, t.text))
|
||||
})
|
||||
}
|
||||
|
||||
/// Replace the text of the `index`-th item, keeping its state and its bullet.
|
||||
pub fn set_item_text(body: &str, index: usize, text: &str) -> String {
|
||||
map_task_line(body, index, |t| {
|
||||
Some(render_task_line(t.indent, t.bullet, t.checked, text.trim()))
|
||||
})
|
||||
}
|
||||
|
||||
/// Delete the `index`-th item, line and all.
|
||||
pub fn remove_item(body: &str, index: usize) -> String {
|
||||
map_task_line(body, index, |_| None)
|
||||
}
|
||||
|
||||
/// Add an item at the end of the body.
|
||||
///
|
||||
/// Spaced exactly as `import_export.py:_note_markdown` writes a list — a blank line
|
||||
/// between prose and the list, and nothing between consecutive items. That is not
|
||||
/// cosmetic: the server migration folds existing rows into bodies using the same
|
||||
/// layout, so an export taken before the migration and one taken after have to
|
||||
/// agree byte for byte.
|
||||
pub fn append_item(body: &str, text: &str) -> String {
|
||||
let line = render_task_line("", '-', false, text.trim());
|
||||
let trimmed = body.trim_end_matches('\n');
|
||||
if trimmed.trim().is_empty() {
|
||||
return line;
|
||||
}
|
||||
let follows_a_list = trimmed
|
||||
.split('\n')
|
||||
.next_back()
|
||||
.is_some_and(|l| parse_task_line(l).is_some());
|
||||
if follows_a_list {
|
||||
format!("{trimmed}\n{line}")
|
||||
} else {
|
||||
format!("{trimmed}\n\n{line}")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -72,4 +281,128 @@ mod tests {
|
||||
fn empty_body() {
|
||||
assert!(extract_tags("").is_empty());
|
||||
}
|
||||
|
||||
// ── checklist items ─────────────────────────────────────────────────────
|
||||
|
||||
fn item(text: &str, checked: bool) -> DerivedItem {
|
||||
DerivedItem {
|
||||
text: text.to_string(),
|
||||
checked,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn items_basic() {
|
||||
let body = "shopping\n\n- [ ] milk\n- [x] eggs";
|
||||
assert_eq!(extract_items(body), vec![item("milk", false), item("eggs", true)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn items_may_sit_between_paragraphs() {
|
||||
// The whole reason the body owns the list: a table of rows could only ever
|
||||
// render after the prose.
|
||||
let body = "before\n- [ ] middle\nafter";
|
||||
assert_eq!(extract_items(body), vec![item("middle", false)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn items_reject_near_misses() {
|
||||
// Each of these is prose, and each has been someone's bug report somewhere.
|
||||
for body in [
|
||||
"-[ ] no space after the dash",
|
||||
"- [] empty brackets",
|
||||
"- [ ]no space after the brackets",
|
||||
"- [y] not a mark",
|
||||
"a [ ] mid sentence",
|
||||
"[ ] no bullet at all",
|
||||
] {
|
||||
assert!(extract_items(body).is_empty(), "should be prose: {body}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn items_accept_star_bullets_and_indentation() {
|
||||
// `*` because markdown.ts already takes it for a plain bullet.
|
||||
let body = "* [ ] star\n - [x] indented";
|
||||
assert_eq!(extract_items(body), vec![item("star", false), item("indented", true)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_item_is_still_an_item() {
|
||||
// What pressing Enter on a list leaves behind.
|
||||
assert_eq!(extract_items("- [ ]"), vec![item("", false)]);
|
||||
assert_eq!(extract_items("- [ ] "), vec![item("", false)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uppercase_x_parses_and_normalises_on_rewrite() {
|
||||
assert_eq!(extract_items("- [X] done"), vec![item("done", true)]);
|
||||
// Touching it once canonicalises it, and never again.
|
||||
assert_eq!(set_item_checked("- [X] done", 0, true), "- [x] done");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checking_preserves_indent_bullet_and_text() {
|
||||
assert_eq!(set_item_checked(" * [ ] milk", 0, true), " * [x] milk");
|
||||
assert_eq!(set_item_checked("- [x] milk", 0, false), "- [ ] milk");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checking_addresses_items_not_lines() {
|
||||
let body = "note\n- [ ] a\nprose\n- [ ] b";
|
||||
assert_eq!(set_item_checked(body, 1, true), "note\n- [ ] a\nprose\n- [x] b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_text_keeps_state() {
|
||||
assert_eq!(set_item_text("- [x] old", 0, "new"), "- [x] new");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_takes_the_whole_line() {
|
||||
let body = "keep\n- [ ] drop\n- [ ] stay";
|
||||
assert_eq!(remove_item(body, 0), "keep\n- [ ] stay");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_spaces_like_the_exporter() {
|
||||
// Prose then a blank line then the list — byte-for-byte what
|
||||
// import_export.py:_note_markdown writes, which is what the server
|
||||
// migration will fold existing rows into.
|
||||
assert_eq!(append_item("a note", "milk"), "a note\n\n- [ ] milk");
|
||||
// Nothing between consecutive items.
|
||||
let one = "a note\n\n- [ ] milk";
|
||||
assert_eq!(append_item(one, "eggs"), format!("{one}\n- [ ] eggs"));
|
||||
// A list-only note starts at the first line.
|
||||
assert_eq!(append_item("", "milk"), "- [ ] milk");
|
||||
assert_eq!(append_item("\n\n", "milk"), "- [ ] milk");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stale_index_does_nothing() {
|
||||
// The index comes from a UI that may be a moment behind the store. A tap
|
||||
// that arrives late should be inert, not fatal.
|
||||
let body = "- [ ] only";
|
||||
assert_eq!(set_item_checked(body, 7, true), body);
|
||||
assert_eq!(remove_item(body, 7), body);
|
||||
assert_eq!(set_item_text(body, 7, "x"), body);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plain_body_is_returned_byte_identical() {
|
||||
let body = "just prose\nwith two lines";
|
||||
assert_eq!(set_item_checked(body, 0, true), body);
|
||||
assert_eq!(set_item_text(body, 0, "x"), body);
|
||||
assert_eq!(remove_item(body, 0), body);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_is_stable() {
|
||||
let body = "- [ ] a\n- [x] b\n- [ ] c";
|
||||
let items = extract_items(body);
|
||||
// Ticking and unticking returns the original bytes.
|
||||
let touched = set_item_checked(&set_item_checked(body, 0, true), 0, false);
|
||||
assert_eq!(touched, body);
|
||||
assert_eq!(extract_items(&touched), items);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user