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
|
||||
//! 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 {
|
||||
|
||||
Reference in New Issue
Block a user