Checklists in the body, colour from tags, and commit-derived CalVer #4
+212
-6
@@ -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<String> {
|
||||
let chars: Vec<char> = body.chars().collect();
|
||||
let mut out: Vec<String> = 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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for line in body.split('\n') {
|
||||
let chars: Vec<char> = 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<String>, Vec<String>, String) {
|
||||
let mut standalone: Vec<String> = Vec::new();
|
||||
let mut inline: Vec<String> = 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<char> = 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<String> = 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 {
|
||||
|
||||
+71
-16
@@ -205,34 +205,89 @@ fn find_or_create_label(conn: &Connection, name: &str) -> rusqlite::Result<Strin
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Re-sync the note's `via_tag` labels to exactly the `#tags` in its body.
|
||||
fn sync_tags(conn: &Connection, note_id: &str, body: &str) -> rusqlite::Result<()> {
|
||||
let tags = derive::extract_tags(body);
|
||||
let mut desired: Vec<String> = 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<String> = Vec::with_capacity(standalone.len());
|
||||
for name in &standalone {
|
||||
standalone_ids.push(find_or_create_label(conn, name)?);
|
||||
}
|
||||
let mut inline_ids: Vec<String> = Vec::with_capacity(inline.len());
|
||||
for name in &inline {
|
||||
inline_ids.push(find_or_create_label(conn, name)?);
|
||||
}
|
||||
|
||||
let current: Vec<String> = {
|
||||
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::<rusqlite::Result<Vec<String>>>()?
|
||||
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::<rusqlite::Result<Vec<(String, bool)>>>()?
|
||||
};
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user