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
+1 -1
View File
@@ -2,7 +2,7 @@
labels, leave the tag-sourced ones alone" logic was duplicated line-for-line between
the labels-picker API (notes.set_note_labels) and sync push (sync._apply_note_manual_labels).
Single home so both stay in lockstep. via_tag=True rows track the body #tags and are
governed by _reconcile_tags — this function never touches them."""
governed by _lift_and_reconcile_tags — this function never touches them."""
from __future__ import annotations
from sqlalchemy import select
+6 -6
View File
@@ -72,7 +72,7 @@ from .import_export import (
_usec_to_dt,
)
from .tags import (
_reconcile_tags,
_lift_and_reconcile_tags,
parse_tags,
)
from .recurrence import REMINDER_RECURRENCES, next_occurrence, normalize_recurrence
@@ -87,7 +87,7 @@ __all__ = [
"normalize_color",
"normalize_recurrence",
"next_occurrence",
"_reconcile_tags",
"_lift_and_reconcile_tags",
"_serialize_notes",
"_safe_filename",
"_attachment_ext",
@@ -418,7 +418,7 @@ async def create_note():
db.add(note)
await db.flush() # assign note.id before writing links
# The FOLDED body: an item can carry a #tag too.
await _reconcile_tags(db, note)
await _lift_and_reconcile_tags(db, note)
await db.commit()
await db.refresh(note)
# After the commit, never before it: the note is saved and the response is
@@ -473,7 +473,7 @@ async def update_note(note_id: str):
note.recurrence = normalize_recurrence(data["recurrence"])
if "body" in data:
note.display_title = derive_display_title(note.body)
await _reconcile_tags(db, note)
await _lift_and_reconcile_tags(db, note)
# Version history: snapshot the PRE-edit body, once per editing session
# rather than once per write — see revisions.should_snapshot. Writing often
# is what lets a client autosave instead of hoarding text until it closes.
@@ -532,7 +532,7 @@ async def restore_revision(note_id: str, rev_id: str):
db.add(NoteRevision(note_id=note.id, body=note.body))
note.body = rev.body
note.display_title = derive_display_title(note.body)
await _reconcile_tags(db, note)
await _lift_and_reconcile_tags(db, note)
await db.commit()
await db.refresh(note)
return jsonify(await _serialize_note(db, note))
@@ -586,7 +586,7 @@ async def _rewrite_body(db, note: Note, body: str):
db.add(NoteRevision(note_id=note.id, body=old_body))
note.body = body
note.display_title = derive_display_title(body)
await _reconcile_tags(db, note)
await _lift_and_reconcile_tags(db, note)
await db.commit()
await db.refresh(note)
if note.body != old_body:
+4 -4
View File
@@ -28,7 +28,7 @@ from .helpers import (
is_empty_note,
)
from .checklist import append_item
from .tags import _find_or_create_label, _reconcile_tags
from .tags import _find_or_create_label, _lift_and_reconcile_tags
from .recurrence import normalize_recurrence
@@ -293,7 +293,7 @@ async def _create_imported_note(
return False
# Items fold into the body, which is where a checklist lives now (M304). Done
# before the Note is built so display_title and _reconcile_tags both see the
# before the Note is built so display_title and _lift_and_reconcile_tags both see the
# finished text — an imported item can carry a #tag like any other line.
for it in items:
text = (it.get("text") or "").strip()
@@ -325,7 +325,7 @@ async def _create_imported_note(
await db.flush() # assign note.id before labels/attachments/links
# Explicit (picker-style) labels are manual — via_tag=False. Inline #tags in the
# body are handled by _reconcile_tags below, same as a normal create.
# body are handled by _lift_and_reconcile_tags below, same as a normal create.
for name in spec.get("labels") or []:
name = (name or "").strip()
if not name:
@@ -341,5 +341,5 @@ async def _create_imported_note(
if isinstance(att, dict):
_import_attachment(db, note, zf, att, budget)
await _reconcile_tags(db, note)
await _lift_and_reconcile_tags(db, note)
return True
+148 -31
View File
@@ -1,6 +1,11 @@
"""#tags — parsing note bodies and keeping the derived tag-sourced note_labels rows
in sync with the text. Manual (picker) labels are NOT touched here (see the labeling
module).
"""#tags — parsing note bodies, LIFTING the standalone ones out of the text, and
keeping the still-in-text ones in sync with the note_labels rows. Manual (picker)
labels are NOT touched here (see the labeling module).
A tag used to be shown twice: once as the `#todo` you typed and once as a chip. The
chip moved to the top of the card (M311) and the text now goes, but only when the tag
was standing on its own — see `split_body_tags` for the rule and why it is the
conservative one.
Was `links.py`, and also owned `[[wiki-links]]` until they were removed (note 2897):
this app is an intermediary surface for capture and recall, and a linking system is
@@ -15,6 +20,7 @@ from sqlalchemy import func, select
from ..models.label import Label, NoteLabel
from ..models.note import Note
from .helpers import derive_display_title
# A #tag: `#` at the start of the body or after whitespace, then a word char and
# word chars/hyphens. A URL fragment (foo#bar) or mid-word `#` is not preceded by
@@ -22,24 +28,93 @@ from ..models.note import Note
_TAG_RE = re.compile(r"(?:^|(?<=\s))#(\w[\w-]*)")
def parse_tags(body: str | None) -> list[str]:
"""Distinct #hashtags from a note body, in order, deduped case-insensitively.
A tag must contain a letter, so #2024 or #_ are ignored (avoids numeric noise)."""
if not body:
return []
# A fence opens or closes a code block. A `#tag` inside one is CODE — the shell
# comment in a snippet someone pasted — and lifting it would delete a line of their
# example. It still becomes a label, because it always has and that is a separate
# question from whether the text may be touched.
_FENCE_RE = re.compile(r"^\s*(?:```|~~~)")
def _is_tag(name: str) -> bool:
"""A tag must contain a letter, so #2024 and #_ are ignored (avoids numeric noise)."""
return any(c.isalpha() for c in name)
def _dedupe(names: list[str]) -> list[str]:
"""First-seen order, deduped case-insensitively — tags are case-insensitive."""
out: list[str] = []
seen: set[str] = set()
for match in _TAG_RE.finditer(body):
tag = match.group(1)
if not any(c.isalpha() for c in tag):
continue
norm = tag.lower()
for name in names:
norm = name.lower()
if norm not in seen:
seen.add(norm)
out.append(tag)
out.append(name)
return out
def parse_tags(body: str | None) -> list[str]:
"""Distinct #hashtags from a note body, in order, deduped case-insensitively."""
if not body:
return []
return _dedupe([m.group(1) for m in _TAG_RE.finditer(body) if _is_tag(m.group(1))])
def split_body_tags(body: str | None) -> tuple[list[str], list[str], str]:
"""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.
That is deliberately the conservative reading of "standalone". The looser one —
also stripping a trailing tag off a prose line — was rejected because 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". Mangling a sentence to save a duplicate chip is a bad
trade. A tag sharing a line with words keeps its words.
`standalone` tags become ORDINARY labels — the text no longer backs them, so
nothing can derive them any more, and the way to remove one becomes the chip's ×
rather than deleting the text. `inline` tags stay derived exactly as before. That
is the whole meaning of `via_tag` after this change: backed by text still present.
"""
if not body:
return [], [], body or ""
standalone: list[str] = []
inline: list[str] = []
kept: list[str] = []
in_fence = False
for line in body.split("\n"):
if _FENCE_RE.match(line):
in_fence = not in_fence
kept.append(line)
continue
matches = [m for m in _TAG_RE.finditer(line) if _is_tag(m.group(1))]
names = [m.group(1) for m in matches]
# Cutting the tags out and finding nothing left is what "standalone" means.
remainder = line
for m in reversed(matches):
remainder = remainder[: m.start()] + remainder[m.end() :]
if in_fence or not matches or remainder.strip():
inline.extend(names)
kept.append(line)
else:
standalone.extend(names)
lifted = re.sub(r"\n{3,}", "\n\n", "\n".join(kept)).strip("\n")
if body.strip() and not lifted.strip():
# 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 and let its tags
# stay derived.
return [], _dedupe(standalone + inline), body
# A tag that ALSO appears in prose stays derived: the prose copy still backs it,
# so deleting that copy should still detach the label.
inline_names = _dedupe(inline)
inline_lower = {n.lower() for n in inline_names}
standalone_names = [n for n in _dedupe(standalone) if n.lower() not in inline_lower]
return standalone_names, inline_names, lifted
async def _find_or_create_label(db, owner_id, name: str):
"""Owner's label id for `name` (case-insensitive match), creating it if absent."""
existing = await db.scalar(
@@ -53,23 +128,65 @@ async def _find_or_create_label(db, owner_id, name: str):
return label.id
async def _reconcile_tags(db, note: Note) -> None:
"""Sync tag-sourced labels (via_tag=True) with the #hashtags in the note body:
attach labels for current tags, detach tag-labels whose #tag was removed. Manual
picker labels (via_tag=False) are never touched."""
tag_label_ids: set = set()
for name in parse_tags(note.body):
tag_label_ids.add(await _find_or_create_label(db, note.owner_id, name))
async def _lift_and_lift_and_reconcile_tags(db, note: Note) -> None:
"""Attach the note's tag labels, LIFT its standalone tags out of the body, and
re-derive display_title if the body moved.
NAMED FOR THE MUTATION. It used to be `_lift_and_reconcile_tags` and only touched rows;
it now rewrites `note.body`, and a caller that does not expect that will compute
a display_title from text this function is about to delete.
Which is why the lift and the re-derivation both live HERE rather than at the
seven call sites that would each have to remember. Spreading a derived-value
update across every place a body can be written is precisely the failure #2965
named about label minting: "easy to miss, and it is the common one".
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 (via_tag=False). Nothing
derives it any more because nothing is left to derive it from, and
the way to remove it becomes the chip's × — which both editors
already offer for exactly this class of label.
inline left in place, attached via_tag=True, and still detached when its
text goes. Unchanged from before.
"""
standalone, inline, lifted = split_body_tags(note.body)
standalone_ids: set = set()
for name in standalone:
standalone_ids.add(await _find_or_create_label(db, note.owner_id, name))
inline_ids: set = set()
for name in inline:
inline_ids.add(await _find_or_create_label(db, note.owner_id, name))
rows = (await db.scalars(select(NoteLabel).where(NoteLabel.note_id == note.id))).all()
attached_ids = {r.label_id for r in rows}
# Detach tag-labels no longer backed by a #tag in the body.
attached: set = set()
for r in rows:
if r.via_tag and r.label_id not in tag_label_ids:
await db.delete(r)
attached_ids.discard(r.label_id)
# Attach new tags — skip labels already attached (in any form) to respect the PK
# and leave a manually-added label of the same name as-is.
for lid in tag_label_ids:
if lid not in attached_ids:
if not r.via_tag:
attached.add(r.label_id) # manual already: a #tag of the same name changes nothing
elif r.label_id in standalone_ids:
# It GRADUATED. The text backing it is about to be deleted, so the row has
# to become the record instead. This must run BEFORE the detach below, or
# the same row would be dropped for no longer being in the body — which is
# the bug that makes a naive lift delete every tag it touches.
r.via_tag = False
attached.add(r.label_id)
elif r.label_id in inline_ids:
attached.add(r.label_id)
else:
await db.delete(r) # its #tag was deleted from the text
# Skip anything already attached in ANY form: it respects the PK, and it leaves a
# manually-added label of the same name as the manual row it already is.
for lid in standalone_ids:
if lid not in attached:
db.add(NoteLabel(note_id=note.id, label_id=lid, via_tag=False))
attached.add(lid)
for lid in inline_ids:
if lid not in attached:
db.add(NoteLabel(note_id=note.id, label_id=lid, via_tag=True))
attached_ids.add(lid)
attached.add(lid)
if lifted != note.body:
note.body = lifted
note.display_title = derive_display_title(note.body)
+3 -3
View File
@@ -27,7 +27,7 @@ from .models.note import Note
from .models.note_revision import NoteRevision
from .revisions import should_snapshot
from .notes import (
_reconcile_tags,
_lift_and_reconcile_tags,
_serialize_notes,
derive_display_title,
normalize_color,
@@ -222,7 +222,7 @@ def _assign_note_fields(note: Note, ch: dict) -> None:
async def _apply_note_manual_labels(db, note: Note, ch: dict) -> None:
"""Set the note's MANUAL (picker) label memberships from client label_ids, leaving
tag-sourced (via_tag) rows to _reconcile_tags. Only labels the caller owns count."""
tag-sourced (via_tag) rows to _lift_and_reconcile_tags. Only labels the caller owns count."""
raw = ch.get("label_ids")
if not isinstance(raw, list):
return
@@ -289,7 +289,7 @@ async def _apply_note(db, ch: dict) -> dict:
if not creating and await should_snapshot(db, note.id, old_body, note.body):
db.add(NoteRevision(note_id=note.id, body=old_body))
await db.flush() # assign note.id before items/labels/links
await _reconcile_tags(db, note)
await _lift_and_reconcile_tags(db, note)
await _apply_note_manual_labels(db, note, ch)
await db.flush()
await db.refresh(note, ["sync_revision"])
+70
View File
@@ -25,8 +25,10 @@ from sqlalchemy import select, text
from thoughtsync import ratelimit
from thoughtsync.app import create_app
from thoughtsync.db import dispose_engine, session_scope
from thoughtsync.models.label import NoteLabel
from thoughtsync.models.note import Note
from thoughtsync.models.user import User
from thoughtsync.notes.tags import _lift_and_reconcile_tags
from thoughtsync.settings import get_setting, live, refresh_live, reset_live, set_settings
from thoughtsync.notes.checklist import parse_items, set_item_checked
from thoughtsync.notes.helpers import derive_display_title
@@ -199,6 +201,74 @@ async def test_ticking_an_item_is_a_body_edit(db, owner):
assert stored.splitlines()[0] == "packing"
async def test_a_standalone_tag_leaves_the_body_and_becomes_an_ordinary_label(db, owner):
"""M311. The tag was being shown twice — as text and as a chip — so the text goes.
`via_tag=False` is the load-bearing half. It is what makes the chip's × appear in
both editors (they gate it on exactly this), which matters because deleting the
text is no longer a way to remove the tag: there is no text.
"""
note = Note(owner_id=owner.id, body="#todo\nreorganize the homepage", display_title="#todo")
db.add(note)
await db.flush()
await _lift_and_reconcile_tags(db, note)
await db.commit()
assert note.body == "reorganize the homepage"
# Re-derived by the lift itself. Every caller sets display_title BEFORE calling,
# so if the function did not do this the note would be named after a line it had
# just deleted.
assert note.display_title == "reorganize the homepage"
rows = (await db.scalars(select(NoteLabel).where(NoteLabel.note_id == note.id))).all()
assert len(rows) == 1
assert rows[0].via_tag is False
async def test_a_tag_moved_onto_its_own_line_graduates_instead_of_vanishing(db, owner):
"""The bug a naive lift has, pinned.
A tag that is still in prose stays derived. Move it to its own line and it must
become an ordinary label — NOT be detached for no longer appearing in the body,
which is what happens if the row is dropped before it is graduated.
"""
note = Note(owner_id=owner.id, body="call #mom tomorrow", display_title="call #mom tomorrow")
db.add(note)
await db.flush()
await _lift_and_reconcile_tags(db, note)
await db.commit()
rows = (await db.scalars(select(NoteLabel).where(NoteLabel.note_id == note.id))).all()
assert len(rows) == 1
assert rows[0].via_tag is True
assert note.body == "call #mom tomorrow", "a tag inside a sentence is left alone"
note.body = "#mom\ncall tomorrow"
await _lift_and_reconcile_tags(db, note)
await db.commit()
rows = (await db.scalars(select(NoteLabel).where(NoteLabel.note_id == note.id))).all()
assert len(rows) == 1, "the label survived the move"
assert rows[0].via_tag is False
assert note.body == "call tomorrow"
async def test_deleting_an_inline_tag_still_detaches_it(db, owner):
"""The old behaviour, unchanged where the text is unchanged. A tag still living in
prose is still owned by that prose."""
note = Note(owner_id=owner.id, body="call #mom tomorrow", display_title="call #mom tomorrow")
db.add(note)
await db.flush()
await _lift_and_reconcile_tags(db, note)
await db.commit()
assert len((await db.scalars(select(NoteLabel).where(NoteLabel.note_id == note.id))).all()) == 1
note.body = "call tomorrow"
await _lift_and_reconcile_tags(db, note)
await db.commit()
assert (await db.scalars(select(NoteLabel).where(NoteLabel.note_id == note.id))).all() == []
async def test_a_note_with_only_a_list_still_has_a_name(db, owner):
"""The hole that made removing the title unsafe, still closed — by a different
mechanism. There is no item table to fall back to any more; the name comes from
+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