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
+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}`"