M311 step 1 — lift a tag that is standing on its own
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Failing after 8s
CI & Build / integration (push) Failing after 9s
CI & Build / Build & push image (push) Skipped

A tag was shown twice: once as the `#todo` you typed and once as a chip. The
chip moved to the top of the card in 23fd2da; now the text goes — but only
when the tag was the whole line.

THE RULE: a line containing nothing but tags and whitespace is removed.
Anything else is untouched.

That is the conservative reading of "standalone" and it is the operator's:
"only lift standalone tags, leave mid-sentence ones alone". The looser
reading, also stripping a trailing tag off a prose line, is rejected because
the text does not say which kind it is — `buy milk #grocery` is filing,
`remember to call #mom` is the sentence's object, and lifting the second
leaves "remember to call". Mangling a sentence to save a duplicate chip is a
bad trade.

Two guards. A line inside a ``` fence is never touched: a `#tag` there is a
shell comment in somebody's snippet, and deleting it would eat a line of
their example. And a note that is NOTHING but tags keeps its text rather than
being blanked — a duplicated chip beats an empty card.

WHY THIS IS NOT JUST A TEXT EDIT. `via_tag` labels are DERIVED from the body:
reconcile detaches any row no longer backed by a `#tag`, and the picker only
manages `via_tag=False` rows. So a naive lift deletes every tag on the next
save, and leaves them unremovable until then.

Resolved by giving `via_tag` a sharper meaning — backed by text still in the
body — rather than deleting it:

  standalone  lifted, attached as an ORDINARY label. Nothing derives it any
              more because nothing is left to derive it from.
  inline      left in place, still derived, still detached when its text goes.

Which costs nothing elsewhere, because both editors already gate their remove
button on `!via_tag` (NoteEditor.vue:618, EditorChrome.kt:349). A lifted tag
gets its × for free — and needs it, since deleting the text is no longer a
way to remove one. No wire change, no column drop, no UI change.

A tag that GRADUATES from inline to standalone is the sharp edge: its row has
to be flipped before the detach pass, or the same row is dropped for no longer
being in the body. That is the bug, and there is a test on it.

The lift and the display_title re-derivation both live inside the function,
which is renamed to admit it mutates the body. All seven call sites derive
display_title BEFORE calling, so anywhere else and every note would be named
after a line that had just been deleted. Spreading a derived-value update
across seven write paths is the failure #2965 named: "easy to miss, and it is
the common one".

Existing notes lift lazily, on their next save. The migration that does the
rest is step 2, and the core's own copy of the rule is step 3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 19:44:43 -04:00
co-authored by Claude Opus 5
parent 23fd2da91e
commit ad48d30c68
7 changed files with 284 additions and 45 deletions
+52
View File
@@ -31,6 +31,7 @@ from thoughtsync.notes import (
parse_list_items,
parse_tags,
)
from thoughtsync.notes.tags import split_body_tags
@pytest.fixture
@@ -168,6 +169,57 @@ def test_derive_display_title_caps_length():
assert derive_display_title(f"- [ ] {long}") == "x" * 200
def test_split_body_tags_lifts_only_a_line_that_is_nothing_else():
"""The rule, in the cases it exists to get right.
A tag on a line of its own is filing and the line can go. A tag sharing a line
with words is part of what was written, and taking it out would leave "remember to
call" — so the line is left exactly as typed. The trailing-tag case (`buy milk
#grocery`) is deliberately on the conservative side of the line: it reads like
filing, but nothing in the text distinguishes it from `remember to call #mom`, and
guessing wrong mangles a sentence to save a duplicate chip.
"""
assert split_body_tags("#todo\nreorganize the homepage") == (["todo"], [], "reorganize the homepage")
assert split_body_tags("needs a tauri app\n#todo") == (["todo"], [], "needs a tauri app")
assert split_body_tags("#todo #work\nreal text") == (["todo", "work"], [], "real text")
unchanged = "remember to call #mom tomorrow"
assert split_body_tags(unchanged) == ([], ["mom"], unchanged)
trailing = "buy milk #grocery"
assert split_body_tags(trailing) == ([], ["grocery"], trailing)
def test_split_body_tags_leaves_the_note_readable():
# Removing a line must not leave a hole where it was.
assert split_body_tags("foo\n\n#todo\n\nbar") == (["todo"], [], "foo\n\nbar")
# A note that is NOTHING but tags would be blanked. A duplicated chip beats an
# empty card, so it keeps its text and its tags stay derived.
assert split_body_tags("#todo") == ([], ["todo"], "#todo")
assert split_body_tags("#todo #work") == ([], ["todo", "work"], "#todo #work")
# A tag in a code fence is CODE — a shell comment in somebody's snippet. It still
# becomes a label, because it always has, but the line is never touched.
fenced = "code:\n```\n#!/bin/sh\n#deploy\n```\ndone"
assert split_body_tags(fenced) == ([], ["deploy"], fenced)
# Not a tag at all (no letter), so not a tag-only line either.
assert split_body_tags("#2024\nreal") == ([], [], "#2024\nreal")
assert split_body_tags("") == ([], [], "")
assert split_body_tags(None) == ([], [], "")
def test_split_body_tags_keeps_a_tag_derived_when_prose_still_carries_it():
"""Appearing on its own line does NOT lift a tag that is also written in a
sentence — the sentence still backs it, so deleting that sentence should still
detach the label. Standalone and inline are not both true of one tag."""
standalone, inline, body = split_body_tags("#todo\nremember the #todo list")
assert standalone == []
assert inline == ["todo"]
assert body == "remember the #todo list"
def test_parse_tags():
assert parse_tags("buy milk #groceries and #to-do now") == ["groceries", "to-do"]
# case-insensitive dedup, first spelling wins