M311 step 3 — the core lifts too, so a note never lifts twice
Step 2 rewrote the notes already on disk and, because the sync_revision trigger fires, every client pulls them. So this is not about existing notes. It is about the ones typed from now on. Without it: you type `#todo` on its own line, the core stores it as written, and a second later the push comes back and the text disappears under you. Offline it never lifts at all until you reconnect. Two surfaces disagreeing about what a note says is the thing this codebase mirrors rules to avoid. `lift_standalone_tags` in derive.rs is the mirror of `split_body_tags`, case for case, with the same two guards — a fenced line is code and is never touched, and a note that is nothing but tags keeps its text. ONE SCANNER, not two. `extract_tags` is rewritten over the same `line_tags` the lift uses, so the two cannot disagree about what a tag is. Line-by-line changes nothing, since a line start and a `\n` are both boundaries, and the existing tag tests still pin it. Char indices rather than byte offsets for the spans, because they are used to cut the tags back out of the line and a byte offset can land mid-codepoint. `sync_tags` becomes `lift_and_sync_tags` and is named for the mutation: it now rewrites notes.body, and all three callers write the body immediately before calling, so it overwrites what they wrote on purpose. The graduation case is handled the same way as on the server — flip the row before the delete pass, or the same row is dropped for no longer being in the body and the tag is silently lost. One thing the server needed and this does not: display_title. The core derives it on READ rather than storing it, so there is no persisted copy to go stale. The rename was done with a lookbehind rather than a plain substitution, after the same operation an hour ago turned the function it had just written into `_lift_and_lift_and_reconcile_tags`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+212
-6
@@ -19,10 +19,13 @@
|
|||||||
//! Also derived `[[wiki-links]]` until they were removed (note 2897) — this is a
|
//! Also derived `[[wiki-links]]` until they were removed (note 2897) — this is a
|
||||||
//! capture-and-recall surface, and a linking system is organization.
|
//! capture-and-recall surface, and a linking system is organization.
|
||||||
|
|
||||||
/// Extract every `#tag` name (without the leading `#`) from `body`.
|
/// Every `#tag` in ONE line, as `(start, end, name)` in char indices.
|
||||||
pub fn extract_tags(body: &str) -> Vec<String> {
|
///
|
||||||
let chars: Vec<char> = body.chars().collect();
|
/// Char indices rather than byte offsets so the spans can be used to cut the tags
|
||||||
let mut out: Vec<String> = Vec::new();
|
/// 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;
|
let mut i = 0;
|
||||||
while i < chars.len() {
|
while i < chars.len() {
|
||||||
if chars[i] == '#' {
|
if chars[i] == '#' {
|
||||||
@@ -33,8 +36,7 @@ pub fn extract_tags(body: &str) -> Vec<String> {
|
|||||||
while j < chars.len() && is_tag_char(chars[j]) {
|
while j < chars.len() && is_tag_char(chars[j]) {
|
||||||
j += 1;
|
j += 1;
|
||||||
}
|
}
|
||||||
let tag: String = chars[i + 1..j].iter().collect();
|
out.push((i, j, chars[i + 1..j].iter().collect()));
|
||||||
push_unique(&mut out, &tag);
|
|
||||||
i = j;
|
i = j;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -44,6 +46,127 @@ pub fn extract_tags(body: &str) -> Vec<String> {
|
|||||||
out
|
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 {
|
fn is_tag_char(c: char) -> bool {
|
||||||
c.is_alphanumeric() || c == '_' || c == '-'
|
c.is_alphanumeric() || c == '_' || c == '-'
|
||||||
}
|
}
|
||||||
@@ -315,6 +438,89 @@ mod tests {
|
|||||||
assert!(extract_tags("").is_empty());
|
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 ─────────────────────────────────────────────────────
|
// ── checklist items ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn item(text: &str, checked: bool, line: u32) -> DerivedItem {
|
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)
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-sync the note's `via_tag` labels to exactly the `#tags` in its body.
|
/// Attach the note's tag labels, LIFT its standalone tags out of the body, and write
|
||||||
fn sync_tags(conn: &Connection, note_id: &str, body: &str) -> rusqlite::Result<()> {
|
/// the shortened body back.
|
||||||
let tags = derive::extract_tags(body);
|
///
|
||||||
let mut desired: Vec<String> = Vec::with_capacity(tags.len());
|
/// NAMED FOR THE MUTATION. It used to be `sync_tags` and only touched label rows; it
|
||||||
for t in &tags {
|
/// now rewrites `notes.body`, and every caller writes the body just before calling —
|
||||||
desired.push(find_or_create_label(conn, t)?);
|
/// 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 =
|
let mut stmt =
|
||||||
conn.prepare("SELECT label_id FROM note_labels WHERE note_id = ?1 AND via_tag = 1")?;
|
conn.prepare("SELECT label_id, via_tag FROM note_labels WHERE note_id = ?1")?;
|
||||||
let rows = stmt.query_map([note_id], |r| r.get::<_, String>(0))?;
|
let rows = stmt.query_map([note_id], |r| {
|
||||||
rows.collect::<rusqlite::Result<Vec<String>>>()?
|
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(
|
conn.execute(
|
||||||
"DELETE FROM note_labels WHERE note_id = ?1 AND label_id = ?2 AND via_tag = 1",
|
"DELETE FROM note_labels WHERE note_id = ?1 AND label_id = ?2 AND via_tag = 1",
|
||||||
params![note_id, lid],
|
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(
|
conn.execute(
|
||||||
"INSERT OR IGNORE INTO note_labels (note_id, label_id, via_tag) VALUES (?1, ?2, 1)",
|
"INSERT OR IGNORE INTO note_labels (note_id, label_id, via_tag) VALUES (?1, ?2, 1)",
|
||||||
params![note_id, lid],
|
params![note_id, lid],
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if lifted != body {
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE notes SET body = ?1 WHERE id = ?2",
|
||||||
|
params![lifted, note_id],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,7 +433,7 @@ pub fn create_note(conn: &Connection, input: &NoteCreateInput) -> rusqlite::Resu
|
|||||||
params![id, body, input.color, position, ts],
|
params![id, body, input.color, position, ts],
|
||||||
)?;
|
)?;
|
||||||
// The FOLDED body, not the input one: an item can carry a #tag too.
|
// 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)
|
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",
|
"UPDATE notes SET body = ?1 WHERE id = ?2",
|
||||||
params![body, id],
|
params![body, id],
|
||||||
)?;
|
)?;
|
||||||
sync_tags(conn, id, body)?;
|
lift_and_sync_tags(conn, id, body)?;
|
||||||
}
|
}
|
||||||
"color" => {
|
"color" => {
|
||||||
if let Some(s) = v.as_str() {
|
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",
|
"UPDATE notes SET body = ?1 WHERE id = ?2",
|
||||||
params![body, id],
|
params![body, id],
|
||||||
)?;
|
)?;
|
||||||
sync_tags(conn, id, &body)?;
|
lift_and_sync_tags(conn, id, &body)?;
|
||||||
touch(conn, id)?;
|
touch(conn, id)?;
|
||||||
load_note(conn, id)
|
load_note(conn, id)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user