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