diff --git a/core/src/local/derive.rs b/core/src/local/derive.rs index 2588df3..4c9d852 100644 --- a/core/src/local/derive.rs +++ b/core/src/local/derive.rs @@ -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, 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> { + 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 { + 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(body: &str, index: usize, f: F) -> String +where + F: FnOnce(&TaskLine<'_>) -> Option, +{ + let lines: Vec<&str> = body.split('\n').collect(); + let mut target: Option = 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 = 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); + } }