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">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
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 Icon from "./Icon.vue";
|
||||||
import type { NoteLabel } from "../stores/notes";
|
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 props = defineProps<{ modelValue: NoteLabel[] }>();
|
||||||
const emit = defineEmits<{ (e: "update:modelValue", value: NoteLabel[]): void }>();
|
const emit = defineEmits<{ (e: "update:modelValue", value: NoteLabel[]): void }>();
|
||||||
const labels = useLabelsStore();
|
const labels = useLabelsStore();
|
||||||
@@ -21,17 +24,17 @@ const canCreate = computed(() => {
|
|||||||
return name.length > 0 && !labels.items.some((lb) => lb.name.toLowerCase() === name.toLowerCase());
|
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)
|
const next = selectedIds.value.has(lb.id)
|
||||||
? props.modelValue.filter((x) => x.id !== lb.id)
|
? props.modelValue.filter((x) => x.id !== lb.id)
|
||||||
: [...props.modelValue, lb];
|
: [...props.modelValue, asManual(lb)];
|
||||||
emit("update:modelValue", next);
|
emit("update:modelValue", next);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createAndAdd() {
|
async function createAndAdd() {
|
||||||
const created = await labels.create(filter.value.trim());
|
const created = await labels.create(filter.value.trim());
|
||||||
filter.value = "";
|
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) {
|
function onDocMousedown(e: MouseEvent) {
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
|
|||||||
:key="lb.id"
|
:key="lb.id"
|
||||||
class="rounded-full px-2 py-0.5 text-xs"
|
class="rounded-full px-2 py-0.5 text-xs"
|
||||||
:class="labelChip(lb.color)"
|
:class="labelChip(lb.color)"
|
||||||
>{{ lb.name }}</span
|
>{{ lb.via_tag ? "#" + lb.name : lb.name }}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -184,10 +184,15 @@ function onReminderChange(e: Event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function onLabelsChange(next: NoteLabel[]) {
|
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(
|
await notes.setLabels(
|
||||||
props.note.id,
|
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="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs"
|
||||||
:class="labelChip(lb.color)"
|
:class="labelChip(lb.color)"
|
||||||
>
|
>
|
||||||
{{ lb.name }}
|
{{ lb.via_tag ? "#" + lb.name : lb.name }}
|
||||||
<button
|
<button
|
||||||
|
v-if="!lb.via_tag"
|
||||||
type="button"
|
type="button"
|
||||||
class="text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-100"
|
class="text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-100"
|
||||||
:aria-label="`Remove ${lb.name}`"
|
:aria-label="`Remove ${lb.name}`"
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ export interface NoteLabel {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
color: 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 {
|
export interface ChecklistItem {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
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.dialects.postgresql import UUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
@@ -34,3 +34,6 @@ class NoteLabel(Base):
|
|||||||
label_id: Mapped[uuid.UUID] = mapped_column(
|
label_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("labels.id", ondelete="CASCADE"), primary_key=True
|
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"\[\[([^\[\]]+)\]\]")
|
_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]:
|
def parse_link_titles(body: str | None) -> list[str]:
|
||||||
"""Extract distinct normalized [[wiki-link]] titles from a note body."""
|
"""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:
|
if not note_ids:
|
||||||
return result
|
return result
|
||||||
rows = await db.execute(
|
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)
|
.join(Label, Label.id == NoteLabel.label_id)
|
||||||
.where(NoteLabel.note_id.in_(note_ids))
|
.where(NoteLabel.note_id.in_(note_ids))
|
||||||
.order_by(Label.name)
|
.order_by(Label.name)
|
||||||
)
|
)
|
||||||
for note_id, label_id, name, color in rows.all():
|
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})
|
result.setdefault(note_id, []).append(
|
||||||
|
{"id": str(label_id), "name": name, "color": color, "via_tag": via_tag}
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -176,6 +201,41 @@ async def _rewrite_links(db, note: Note) -> None:
|
|||||||
db.add(NoteLink(source_id=note.id, target_norm=norm))
|
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:
|
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]]."""
|
"""Repoint every [[token]] whose normalized form == old_norm to [[new_title]]."""
|
||||||
if not body:
|
if not body:
|
||||||
@@ -425,6 +485,7 @@ async def create_note():
|
|||||||
for pos, text in enumerate(item_texts):
|
for pos, text in enumerate(item_texts):
|
||||||
db.add(NoteItem(note_id=note.id, text=text, position=pos))
|
db.add(NoteItem(note_id=note.id, text=text, position=pos))
|
||||||
await _rewrite_links(db, note)
|
await _rewrite_links(db, note)
|
||||||
|
await _reconcile_tags(db, note)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(note)
|
await db.refresh(note)
|
||||||
return jsonify(await _serialize_note(db, note)), 201
|
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)
|
note.display_title = derive_display_title(note.title, note.body)
|
||||||
if "body" in data:
|
if "body" in data:
|
||||||
await _rewrite_links(db, note)
|
await _rewrite_links(db, note)
|
||||||
|
await _reconcile_tags(db, note)
|
||||||
# The display NAME changing — via an explicit title OR the first body line —
|
# The display NAME changing — via an explicit title OR the first body line —
|
||||||
# repoints inbound [[Old Name]] references so backlinks survive (skip pure
|
# repoints inbound [[Old Name]] references so backlinks survive (skip pure
|
||||||
# case/whitespace changes, which still resolve).
|
# case/whitespace changes, which still resolve).
|
||||||
@@ -520,11 +582,17 @@ async def set_note_labels(note_id: str):
|
|||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
)
|
)
|
||||||
# Replace the note's label set with the (validated, owned) ids provided.
|
# The picker manages MANUAL labels only; tag-sourced (via_tag=True) rows are
|
||||||
await db.execute(delete(NoteLabel).where(NoteLabel.note_id == note.id))
|
# governed by the body #tags and must survive a picker save.
|
||||||
for lid in label_ids:
|
chosen = {lid for lid in label_ids if lid in owned}
|
||||||
if lid in owned:
|
existing = (await db.scalars(select(NoteLabel).where(NoteLabel.note_id == note.id))).all()
|
||||||
db.add(NoteLabel(note_id=note.id, label_id=lid))
|
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()
|
await db.commit()
|
||||||
return jsonify(await _serialize_note(db, note))
|
return jsonify(await _serialize_note(db, note))
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from thoughtsync.notes import (
|
|||||||
normalize_color,
|
normalize_color,
|
||||||
parse_link_titles,
|
parse_link_titles,
|
||||||
parse_list_items,
|
parse_list_items,
|
||||||
|
parse_tags,
|
||||||
rewrite_link_title,
|
rewrite_link_title,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -131,6 +132,16 @@ def test_derive_display_title_caps_length():
|
|||||||
assert derive_display_title(long, "body") == "x" * 200
|
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():
|
def test_parse_list_items():
|
||||||
assert parse_list_items(["milk", " eggs ", "", " ", "bread"]) == ["milk", "eggs", "bread"]
|
assert parse_list_items(["milk", " eggs ", "", " ", "bread"]) == ["milk", "eggs", "bread"]
|
||||||
assert parse_list_items("not a list") == []
|
assert parse_list_items("not a list") == []
|
||||||
|
|||||||
Reference in New Issue
Block a user