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())