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:
@@ -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")
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}`"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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") == []
|
||||
|
||||
Reference in New Issue
Block a user