//! 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. //! //! Also derived `[[wiki-links]]` until they were removed (note 2897) — this is a //! capture-and-recall surface, and a linking system is organization. /// Extract every `#tag` name (without the leading `#`) from `body`. pub fn extract_tags(body: &str) -> Vec { let chars: Vec = body.chars().collect(); let mut out: Vec = Vec::new(); let mut i = 0; while i < chars.len() { if chars[i] == '#' { let boundary = i == 0 || (!is_tag_char(chars[i - 1]) && chars[i - 1] != '#'); // A tag must start with a letter (so "#1" or a bare "#" is not a tag). if boundary && i + 1 < chars.len() && chars[i + 1].is_alphabetic() { let mut j = i + 1; while j < chars.len() && is_tag_char(chars[j]) { j += 1; } let tag: String = chars[i + 1..j].iter().collect(); push_unique(&mut out, &tag); i = j; continue; } } i += 1; } out } fn is_tag_char(c: char) -> bool { c.is_alphanumeric() || c == '_' || c == '-' } fn push_unique(out: &mut Vec, candidate: &str) { if !out.iter().any(|x| x.eq_ignore_ascii_case(candidate)) { out.push(candidate.to_string()); } } // ── 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}") } } /// 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. pub fn strip_marker(line: &str) -> &str { match parse_task_line(line) { Some(t) => t.text, None => line, } } /// 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. /// /// `checked` is a parameter rather than always false because the two migrations that /// fold existing rows into bodies have to carry the state those rows were in. A new /// item from the UI passes false. pub fn append_item(body: &str, text: &str, checked: bool) -> String { let line = render_task_line("", '-', checked, 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::*; #[test] fn tags_basic() { assert_eq!( extract_tags("a #todo and #Work-item_2 here"), vec!["todo", "Work-item_2"] ); } #[test] fn tags_require_letter_start_and_boundary() { // "#1" (digit) and an in-word "#" (email-ish) are not tags. assert_eq!(extract_tags("#1 nope a#b no but #Yes"), vec!["Yes"]); } #[test] fn tags_dedupe_case_insensitive() { assert_eq!(extract_tags("#Home #home #HOME"), vec!["Home"]); } #[test] 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", false), "a note\n\n- [ ] milk"); // Nothing between consecutive items. let one = "a note\n\n- [ ] milk"; assert_eq!(append_item(one, "eggs", false), format!("{one}\n- [ ] eggs")); // A list-only note starts at the first line. assert_eq!(append_item("", "milk", false), "- [ ] milk"); assert_eq!(append_item("\n\n", "milk", false), "- [ ] milk"); // Carries state, which is what the two migrations need of it. assert_eq!(append_item("", "done", true), "- [x] done"); } #[test] fn strip_marker_names_a_list_only_note() { assert_eq!(strip_marker("- [x] milk"), "milk"); assert_eq!(strip_marker("just prose"), "just prose"); } #[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); } }