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:
+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