diff --git a/core/src/local/derive.rs b/core/src/local/derive.rs index b27c8d8..cf36250 100644 --- a/core/src/local/derive.rs +++ b/core/src/local/derive.rs @@ -19,10 +19,13 @@ //! 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(); +/// Every `#tag` in ONE line, as `(start, end, name)` in char indices. +/// +/// Char indices rather than byte offsets so the spans can be used to cut the tags +/// back out of the line without ever landing mid-codepoint — see +/// [`lift_standalone_tags`], which is the only reason the spans exist. +fn line_tags(chars: &[char]) -> Vec<(usize, usize, String)> { + let mut out: Vec<(usize, usize, String)> = Vec::new(); let mut i = 0; while i < chars.len() { if chars[i] == '#' { @@ -33,8 +36,7 @@ pub fn extract_tags(body: &str) -> Vec { 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); + out.push((i, j, chars[i + 1..j].iter().collect())); i = j; continue; } @@ -44,6 +46,127 @@ pub fn extract_tags(body: &str) -> Vec { out } +/// Extract every `#tag` name (without the leading `#`) from `body`. +/// +/// Line by line, which changes nothing: a line start and a `\n` are both boundaries, +/// so the same tags come out. It means there is ONE scanner rather than two — this and +/// [`lift_standalone_tags`] cannot disagree about what a tag is. +pub fn extract_tags(body: &str) -> Vec { + let mut out: Vec = Vec::new(); + for line in body.split('\n') { + let chars: Vec = line.chars().collect(); + for (_, _, name) in line_tags(&chars) { + push_unique(&mut out, &name); + } + } + out +} + +/// Whether a line opens or closes a fenced code block. +fn is_fence(line: &str) -> bool { + let trimmed = line.trim_start(); + trimmed.starts_with("```") || trimmed.starts_with("~~~") +} + +/// Runs of three or more newlines become two, and the ends are trimmed. +/// +/// Removing a line must not leave a hole where it was. +fn collapse_blank_runs(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut run = 0; + for c in text.chars() { + if c == '\n' { + run += 1; + if run <= 2 { + out.push(c); + } + } else { + run = 0; + out.push(c); + } + } + out.trim_matches('\n').to_string() +} + +/// Split a body's tags by whether the text around them can be taken away. +/// +/// Returns `(standalone, inline, lifted_body)`. +/// +/// THE RULE: a line containing nothing but tags and whitespace is removed. Anything +/// else is left exactly as written. +/// +/// The MIRROR of `split_body_tags` in the server's `notes/tags.py`, and it has to stay +/// one: a note lifted differently here than there would change under the operator the +/// moment it synced. Same discipline, and the same reason, as `DerivedTint`. +/// +/// The conservative reading of "standalone" is deliberate. A trailing tag is +/// ambiguous and the text does not say which it is — `buy milk #grocery` is filing, +/// `remember to call #mom` is the sentence's object, and lifting the second leaves +/// "remember to call". A tag sharing a line with words keeps its words. +/// +/// `standalone` tags become ORDINARY labels (`via_tag = 0`): nothing is left to derive +/// them from, so the row becomes the record and the chip's × becomes the way to remove +/// one. `inline` tags stay derived exactly as before. That is what `via_tag` means from +/// here on — backed by text still in the body. +pub fn lift_standalone_tags(body: &str) -> (Vec, Vec, String) { + let mut standalone: Vec = Vec::new(); + let mut inline: Vec = Vec::new(); + let mut kept: Vec<&str> = Vec::new(); + let mut in_fence = false; + + for line in body.split('\n') { + if is_fence(line) { + in_fence = !in_fence; + kept.push(line); + continue; + } + let chars: Vec = line.chars().collect(); + let spans = line_tags(&chars); + // Cut the tags out and see whether anything is left. That is what + // "standalone" means, and it is the whole rule. + let mut remainder = String::new(); + let mut pos = 0; + for (start, end, _) in &spans { + remainder.extend(chars[pos..*start].iter()); + pos = *end; + } + remainder.extend(chars[pos..].iter()); + + // A fence's contents are CODE: a `#tag` there is a shell comment in somebody's + // snippet, and deleting the line would eat part of their example. + if in_fence || spans.is_empty() || !remainder.trim().is_empty() { + for (_, _, name) in &spans { + push_unique(&mut inline, name); + } + kept.push(line); + } else { + for (_, _, name) in &spans { + push_unique(&mut standalone, name); + } + } + } + + let lifted = collapse_blank_runs(&kept.join("\n")); + if !body.trim().is_empty() && lifted.trim().is_empty() { + // The note was NOTHING but tags. Lifting would leave a blank card, which is a + // worse outcome than a duplicated chip — so leave it alone. + let mut all = standalone; + for name in &inline { + push_unique(&mut all, name); + } + return (Vec::new(), all, body.to_string()); + } + + // A tag that ALSO appears in prose stays derived: the prose copy still backs it, + // so deleting that copy should still detach the label. + let inline_lower: Vec = inline.iter().map(|n| n.to_lowercase()).collect(); + let standalone = standalone + .into_iter() + .filter(|n| !inline_lower.contains(&n.to_lowercase())) + .collect(); + (standalone, inline, lifted) +} + fn is_tag_char(c: char) -> bool { c.is_alphanumeric() || c == '_' || c == '-' } @@ -315,6 +438,89 @@ mod tests { assert!(extract_tags("").is_empty()); } + // ── lifting standalone tags ────────────────────────────────────────────── + // + // The MIRROR of `split_body_tags` in the server's notes/tags.py, case for case. + // A note lifted differently here than there would change under the operator the + // moment it synced, so these are the cases that file agrees to. + + #[test] + fn lifts_a_line_that_is_nothing_but_tags() { + let (standalone, inline, body) = lift_standalone_tags("#todo\nreorganize the homepage"); + assert_eq!(standalone, vec!["todo"]); + assert!(inline.is_empty()); + assert_eq!(body, "reorganize the homepage"); + + let (standalone, _, body) = lift_standalone_tags("needs a tauri app\n#todo"); + assert_eq!(standalone, vec!["todo"]); + assert_eq!(body, "needs a tauri app"); + + let (standalone, _, body) = lift_standalone_tags("#todo #work\nreal text"); + assert_eq!(standalone, vec!["todo", "work"]); + assert_eq!(body, "real text"); + } + + /// The cases that must come back byte-identical. Getting any of these wrong + /// destroys somebody's words, which is why the rule is the conservative one: + /// a trailing tag is ambiguous and the text does not say which kind it is. + #[test] + fn leaves_a_tag_that_shares_its_line_with_words() { + for prose in [ + "remember to call #mom tomorrow", + "buy milk #grocery", + "#2024\nreal", + ] { + let (standalone, _, body) = lift_standalone_tags(prose); + assert!(standalone.is_empty(), "{prose}"); + assert_eq!(body, prose, "{prose}"); + } + } + + #[test] + fn removing_a_line_leaves_no_hole() { + let (_, _, body) = lift_standalone_tags("foo\n\n#todo\n\nbar"); + assert_eq!(body, "foo\n\nbar"); + } + + /// A `#tag` in a fence is a shell comment in somebody's snippet. It still becomes + /// a label — it always has — but the line is never touched. + #[test] + fn never_touches_a_fenced_line() { + let fenced = "code:\n```\n#!/bin/sh\n#deploy\n```\ndone"; + let (standalone, inline, body) = lift_standalone_tags(fenced); + assert!(standalone.is_empty()); + assert_eq!(inline, vec!["deploy"]); + assert_eq!(body, fenced); + } + + /// Lifting would leave a blank card, which is worse than the duplication this + /// removes. So the note keeps its text and its tags stay derived. + #[test] + fn will_not_blank_a_note_that_is_only_tags() { + let (standalone, inline, body) = lift_standalone_tags("#todo"); + assert!(standalone.is_empty()); + assert_eq!(inline, vec!["todo"]); + assert_eq!(body, "#todo"); + } + + /// Appearing on its own line does NOT lift a tag also written in a sentence — the + /// sentence still backs it, so deleting the sentence should still detach it. + #[test] + fn a_tag_still_in_prose_stays_derived() { + let (standalone, inline, body) = lift_standalone_tags("#todo\nremember the #todo list"); + assert!(standalone.is_empty()); + assert_eq!(inline, vec!["todo"]); + assert_eq!(body, "remember the #todo list"); + } + + #[test] + fn lifting_an_empty_body_is_a_no_op() { + let (standalone, inline, body) = lift_standalone_tags(""); + assert!(standalone.is_empty()); + assert!(inline.is_empty()); + assert_eq!(body, ""); + } + // ── checklist items ───────────────────────────────────────────────────── fn item(text: &str, checked: bool, line: u32) -> DerivedItem { diff --git a/core/src/local/store.rs b/core/src/local/store.rs index 07a8ee9..d71d7ad 100644 --- a/core/src/local/store.rs +++ b/core/src/local/store.rs @@ -205,34 +205,89 @@ fn find_or_create_label(conn: &Connection, name: &str) -> rusqlite::Result rusqlite::Result<()> { - let tags = derive::extract_tags(body); - let mut desired: Vec = Vec::with_capacity(tags.len()); - for t in &tags { - desired.push(find_or_create_label(conn, t)?); +/// Attach the note's tag labels, LIFT its standalone tags out of the body, and write +/// the shortened body back. +/// +/// NAMED FOR THE MUTATION. It used to be `sync_tags` and only touched label rows; it +/// now rewrites `notes.body`, and every caller writes the body just before calling — +/// so this overwrites what they wrote, on purpose. +/// +/// `display_title` needs no attention here, unlike on the server: the core derives it +/// on READ (see `display_title` above, called from `load_note`) rather than storing +/// it, so there is no persisted copy to go stale. +/// +/// The two kinds of tag are handled differently, and that difference IS what `via_tag` +/// means from here on — backed by text still in the body: +/// +/// standalone lifted out, attached as an ORDINARY label. Nothing derives it any +/// more, and the way to remove it becomes the chip's ×. +/// inline left in place, attached via_tag = 1, still detached when its text +/// goes. Unchanged from before. +/// +/// Mirrors `_lift_and_reconcile_tags` in the server's `notes/tags.py`. +fn lift_and_sync_tags(conn: &Connection, note_id: &str, body: &str) -> rusqlite::Result<()> { + let (standalone, inline, lifted) = derive::lift_standalone_tags(body); + + let mut standalone_ids: Vec = Vec::with_capacity(standalone.len()); + for name in &standalone { + standalone_ids.push(find_or_create_label(conn, name)?); + } + let mut inline_ids: Vec = Vec::with_capacity(inline.len()); + for name in &inline { + inline_ids.push(find_or_create_label(conn, name)?); } - let current: Vec = { + let current: Vec<(String, bool)> = { let mut stmt = - conn.prepare("SELECT label_id FROM note_labels WHERE note_id = ?1 AND via_tag = 1")?; - let rows = stmt.query_map([note_id], |r| r.get::<_, String>(0))?; - rows.collect::>>()? + conn.prepare("SELECT label_id, via_tag FROM note_labels WHERE note_id = ?1")?; + let rows = stmt.query_map([note_id], |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, bool>(1)?)) + })?; + rows.collect::>>()? }; - for lid in ¤t { - if !desired.contains(lid) { + + for (lid, via_tag) in ¤t { + if !*via_tag { + continue; // manual already: a #tag of the same name changes nothing + } + if standalone_ids.contains(lid) { + // It GRADUATED. The text backing it is about to go, so the row has to + // become the record instead — and BEFORE the delete below, or the same row + // is dropped for no longer being in the body. That is the bug a naive lift + // has, and it silently loses the tag. + conn.execute( + "UPDATE note_labels SET via_tag = 0 WHERE note_id = ?1 AND label_id = ?2", + params![note_id, lid], + )?; + } else if !inline_ids.contains(lid) { conn.execute( "DELETE FROM note_labels WHERE note_id = ?1 AND label_id = ?2 AND via_tag = 1", params![note_id, lid], )?; } } - for lid in &desired { + + // OR IGNORE leaves a label already attached in ANY form alone, which is what keeps + // a manually-added label of the same name manual. + for lid in &standalone_ids { + conn.execute( + "INSERT OR IGNORE INTO note_labels (note_id, label_id, via_tag) VALUES (?1, ?2, 0)", + params![note_id, lid], + )?; + } + for lid in &inline_ids { conn.execute( "INSERT OR IGNORE INTO note_labels (note_id, label_id, via_tag) VALUES (?1, ?2, 1)", params![note_id, lid], )?; } + + if lifted != body { + conn.execute( + "UPDATE notes SET body = ?1 WHERE id = ?2", + params![lifted, note_id], + )?; + } Ok(()) } @@ -378,7 +433,7 @@ pub fn create_note(conn: &Connection, input: &NoteCreateInput) -> rusqlite::Resu params![id, body, input.color, position, ts], )?; // The FOLDED body, not the input one: an item can carry a #tag too. - sync_tags(conn, &id, &body)?; + lift_and_sync_tags(conn, &id, &body)?; load_note(conn, &id) } @@ -451,7 +506,7 @@ pub fn update_note(conn: &Connection, id: &str, changes: &Value) -> rusqlite::Re "UPDATE notes SET body = ?1 WHERE id = ?2", params![body, id], )?; - sync_tags(conn, id, body)?; + lift_and_sync_tags(conn, id, body)?; } "color" => { if let Some(s) = v.as_str() { @@ -727,7 +782,7 @@ pub fn restore_revision(conn: &Connection, id: &str, rev_id: &str) -> rusqlite:: "UPDATE notes SET body = ?1 WHERE id = ?2", params![body, id], )?; - sync_tags(conn, id, &body)?; + lift_and_sync_tags(conn, id, &body)?; touch(conn, id)?; load_note(conn, id) }