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
@@ -0,0 +1,26 @@
"""note_labels.via_tag
Revision ID: 0013
Revises: 0012
Create Date: 2026-07-22
"""
from alembic import op
import sqlalchemy as sa
revision = "0013"
down_revision = "0012"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Marks a note↔label attachment that came from a body #tag (vs. the picker).
# Existing attachments default to False = manual, which is correct.
op.add_column(
"note_labels",
sa.Column("via_tag", sa.Boolean(), nullable=False, server_default=sa.false()),
)
def downgrade() -> None:
op.drop_column("note_labels", "via_tag")
+7 -4
View File
@@ -1,9 +1,12 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useLabelsStore } from "../stores/labels";
import { useLabelsStore, type Label } from "../stores/labels";
import Icon from "./Icon.vue";
import type { NoteLabel } from "../stores/notes";
// A label attached via the picker is manual (via_tag: false).
const asManual = (lb: Label): NoteLabel => ({ id: lb.id, name: lb.name, color: lb.color, via_tag: false });
const props = defineProps<{ modelValue: NoteLabel[] }>();
const emit = defineEmits<{ (e: "update:modelValue", value: NoteLabel[]): void }>();
const labels = useLabelsStore();
@@ -21,17 +24,17 @@ const canCreate = computed(() => {
return name.length > 0 && !labels.items.some((lb) => lb.name.toLowerCase() === name.toLowerCase());
});
function toggle(lb: NoteLabel) {
function toggle(lb: Label) {
const next = selectedIds.value.has(lb.id)
? props.modelValue.filter((x) => x.id !== lb.id)
: [...props.modelValue, lb];
: [...props.modelValue, asManual(lb)];
emit("update:modelValue", next);
}
async function createAndAdd() {
const created = await labels.create(filter.value.trim());
filter.value = "";
if (!selectedIds.value.has(created.id)) emit("update:modelValue", [...props.modelValue, created]);
if (!selectedIds.value.has(created.id)) emit("update:modelValue", [...props.modelValue, asManual(created)]);
}
function onDocMousedown(e: MouseEvent) {
+1 -1
View File
@@ -188,7 +188,7 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
:key="lb.id"
class="rounded-full px-2 py-0.5 text-xs"
:class="labelChip(lb.color)"
>{{ lb.name }}</span
>{{ lb.via_tag ? "#" + lb.name : lb.name }}</span
>
</div>
+9 -3
View File
@@ -184,10 +184,15 @@ function onReminderChange(e: Event) {
}
async function onLabelsChange(next: NoteLabel[]) {
labelList.value = next;
// Tag-sourced labels are governed by the note body, not the picker — always keep
// them so a picker save can't strip a label the #tag still mandates.
const tagLabels = labelList.value.filter((lb) => lb.via_tag);
const tagIds = new Set(tagLabels.map((lb) => lb.id));
const merged = [...next.filter((lb) => !tagIds.has(lb.id)), ...tagLabels];
labelList.value = merged;
await notes.setLabels(
props.note.id,
next.map((lb) => lb.id),
merged.map((lb) => lb.id),
);
}
@@ -341,8 +346,9 @@ async function act(fn: () => Promise<void>) {
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs"
:class="labelChip(lb.color)"
>
{{ lb.name }}
{{ lb.via_tag ? "#" + lb.name : lb.name }}
<button
v-if="!lb.via_tag"
type="button"
class="text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-100"
:aria-label="`Remove ${lb.name}`"
+3
View File
@@ -11,6 +11,9 @@ export interface NoteLabel {
id: string;
name: string;
color: string;
// True when this label is attached because of a #tag in the note body (kept in
// sync with the text); false = added manually via the picker.
via_tag: boolean;
}
export interface ChecklistItem {
+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))
+11
View File
@@ -9,6 +9,7 @@ from thoughtsync.notes import (
normalize_color,
parse_link_titles,
parse_list_items,
parse_tags,
rewrite_link_title,
)
@@ -131,6 +132,16 @@ def test_derive_display_title_caps_length():
assert derive_display_title(long, "body") == "x" * 200
def test_parse_tags():
assert parse_tags("buy milk #groceries and #to-do now") == ["groceries", "to-do"]
# case-insensitive dedup, first spelling wins
assert parse_tags("#Work then #work") == ["Work"]
# url fragments, mid-word #, purely-numeric, and a bare # are not tags
assert parse_tags("frag http://x/#nope mid#word #2024 #") == []
assert parse_tags(None) == []
assert parse_tags("#a #b #a") == ["a", "b"]
def test_parse_list_items():
assert parse_list_items(["milk", " eggs ", "", " ", "bread"]) == ["milk", "eggs", "bread"]
assert parse_list_items("not a list") == []