m4.5: inline #tags — hashtags in a note become labels (two-way sync)
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:
@@ -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())
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user