m4.5: inline #tags — hashtags in a note become labels (two-way sync)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 37s

Fast, cross-device labelling: type #groceries in a note and it becomes
the "groceries" label. The body is the source of truth for tag-labels;
manual picker labels stay independent (rule 28 — additive).

- note_labels.via_tag (migration 0013) marks tag-sourced attachments.
- parse_tags(): #tag at start-of-body or after whitespace, needs a
  letter (so #2024, URL #frags, mid#word are ignored). unit-tested.
- _reconcile_tags() on create + body-update: attach labels for current
  #tags (find-or-create, case-insensitive), detach tag-labels whose tag
  was removed; never touches manual (via_tag=false) rows.
- label picker (set_note_labels + editor onLabelsChange) now preserves
  tag-labels on save, so a picker action can't strip a label the #tag
  still mandates.
- serialize via_tag; card/editor chips render tag-labels as "#name",
  and the editor hides the × on them (remove by editing the tag text).
- LabelPicker builds manual NoteLabels (via_tag:false).

Fourth and final item of M4.5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
2026-07-22 08:39:20 -04:00
co-authored by Claude Opus 4.8
parent 80324fba3b
commit c4914fe587
8 changed files with 137 additions and 17 deletions
+4 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Text, UniqueConstraint, func
from sqlalchemy import Boolean, DateTime, ForeignKey, Text, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
@@ -34,3 +34,6 @@ class NoteLabel(Base):
label_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("labels.id", ondelete="CASCADE"), primary_key=True
)
# True when this attachment came from a #tag in the note body (kept in sync with
# the body); False = added manually via the label picker (independent of #tags).
via_tag: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false())
+76 -8
View File
@@ -22,6 +22,29 @@ ALLOWED_IMAGE_MIMES = {"image/png": ".png", "image/jpeg": ".jpg", "image/gif": "
_LINK_RE = re.compile(r"\[\[([^\[\]]+)\]\]")
# 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
# whitespace, so it won't match.
_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 []
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()
if norm not in seen:
seen.add(norm)
out.append(tag)
return out
def parse_link_titles(body: str | None) -> list[str]:
"""Extract distinct normalized [[wiki-link]] titles from a note body."""
@@ -85,13 +108,15 @@ async def _labels_for_notes(db, note_ids: list) -> dict:
if not note_ids:
return result
rows = await db.execute(
select(NoteLabel.note_id, Label.id, Label.name, Label.color)
select(NoteLabel.note_id, Label.id, Label.name, Label.color, NoteLabel.via_tag)
.join(Label, Label.id == NoteLabel.label_id)
.where(NoteLabel.note_id.in_(note_ids))
.order_by(Label.name)
)
for note_id, label_id, name, color in rows.all():
result.setdefault(note_id, []).append({"id": str(label_id), "name": name, "color": color})
for note_id, label_id, name, color, via_tag in rows.all():
result.setdefault(note_id, []).append(
{"id": str(label_id), "name": name, "color": color, "via_tag": via_tag}
)
return result
@@ -176,6 +201,41 @@ async def _rewrite_links(db, note: Note) -> None:
db.add(NoteLink(source_id=note.id, target_norm=norm))
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(
select(Label.id).where(Label.owner_id == owner_id, func.lower(Label.name) == name.lower())
)
if existing is not None:
return existing
label = Label(owner_id=owner_id, name=name)
db.add(label)
await db.flush()
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))
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.
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:
db.add(NoteLabel(note_id=note.id, label_id=lid, via_tag=True))
attached_ids.add(lid)
def rewrite_link_title(body: str | None, old_norm: str, new_title: str) -> str:
"""Repoint every [[token]] whose normalized form == old_norm to [[new_title]]."""
if not body:
@@ -425,6 +485,7 @@ async def create_note():
for pos, text in enumerate(item_texts):
db.add(NoteItem(note_id=note.id, text=text, position=pos))
await _rewrite_links(db, note)
await _reconcile_tags(db, note)
await db.commit()
await db.refresh(note)
return jsonify(await _serialize_note(db, note)), 201
@@ -483,6 +544,7 @@ async def update_note(note_id: str):
note.display_title = derive_display_title(note.title, note.body)
if "body" in data:
await _rewrite_links(db, note)
await _reconcile_tags(db, note)
# The display NAME changing — via an explicit title OR the first body line —
# repoints inbound [[Old Name]] references so backlinks survive (skip pure
# case/whitespace changes, which still resolve).
@@ -520,11 +582,17 @@ async def set_note_labels(note_id: str):
)
).all()
)
# Replace the note's label set with the (validated, owned) ids provided.
await db.execute(delete(NoteLabel).where(NoteLabel.note_id == note.id))
for lid in label_ids:
if lid in owned:
db.add(NoteLabel(note_id=note.id, label_id=lid))
# The picker manages MANUAL labels only; tag-sourced (via_tag=True) rows are
# governed by the body #tags and must survive a picker save.
chosen = {lid for lid in label_ids if lid in owned}
existing = (await db.scalars(select(NoteLabel).where(NoteLabel.note_id == note.id))).all()
attached_ids = {r.label_id for r in existing}
for r in existing:
if not r.via_tag and r.label_id not in chosen:
await db.delete(r)
for lid in chosen:
if lid not in attached_ids:
db.add(NoteLabel(note_id=note.id, label_id=lid, via_tag=False))
await db.commit()
return jsonify(await _serialize_note(db, note))