M6: label management — usage counts + merge
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 8s
CI & Build / Python tests (push) Successful in 16s
CI & Build / Build & push image (push) Successful in 50s

Extends the existing label manager (create/rename/color/delete) with the two missing maintenance tools (task 1904), so the label list stays clean — which matters more now that #tags mint labels automatically.

Backend: GET /api/labels returns a per-label note count (one grouped query); new POST /api/labels/<id>/merge moves the source label's notes onto a target and deletes the source (repoint via delete+reinsert to avoid mutating the composite PK; preserves via_tag; dedupes notes already on the target). Body #tags are NOT rewritten, so a tag-sourced label re-mints on next edit if its #tag text remains — a documented nuance.

Frontend: LabelsModal shows each label's note count and a 'merge into…' picker; also restores the previously-missing close (x) and merge icons.

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 12:36:50 -04:00
co-authored by Claude Opus 4.8
parent 95b0e30fc7
commit ae0c748507
5 changed files with 158 additions and 15 deletions
+2
View File
@@ -20,6 +20,8 @@ const paths: Record<string, string> = {
bell: '<path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/>', bell: '<path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/>',
grip: '<circle cx="9" cy="5" r="1" fill="currentColor"/><circle cx="9" cy="12" r="1" fill="currentColor"/><circle cx="9" cy="19" r="1" fill="currentColor"/><circle cx="15" cy="5" r="1" fill="currentColor"/><circle cx="15" cy="12" r="1" fill="currentColor"/><circle cx="15" cy="19" r="1" fill="currentColor"/>', grip: '<circle cx="9" cy="5" r="1" fill="currentColor"/><circle cx="9" cy="12" r="1" fill="currentColor"/><circle cx="9" cy="19" r="1" fill="currentColor"/><circle cx="15" cy="5" r="1" fill="currentColor"/><circle cx="15" cy="12" r="1" fill="currentColor"/><circle cx="15" cy="19" r="1" fill="currentColor"/>',
calendar: '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/>', calendar: '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/>',
close: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
merge: '<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 0 9 9"/>',
}; };
</script> </script>
+66 -6
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue"; import { computed, ref } from "vue";
import { useLabelsStore } from "../stores/labels"; import { useLabelsStore, type Label } from "../stores/labels";
import { NOTE_COLOR_KEYS, NOTE_COLOR_LABELS, NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors"; import { NOTE_COLOR_KEYS, NOTE_COLOR_LABELS, NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors";
import Icon from "./Icon.vue"; import Icon from "./Icon.vue";
@@ -8,6 +8,9 @@ const emit = defineEmits<{ (e: "close"): void }>();
const labels = useLabelsStore(); const labels = useLabelsStore();
const newName = ref(""); const newName = ref("");
const pickerFor = ref<string | null>(null); const pickerFor = ref<string | null>(null);
const mergeFor = ref<string | null>(null);
const canMerge = computed(() => labels.items.length > 1);
async function add() { async function add() {
const name = newName.value.trim(); const name = newName.value.trim();
@@ -25,10 +28,29 @@ function labelDot(color: string): string {
return NOTE_SWATCH_CLASSES[color as NoteColor] ?? NOTE_SWATCH_CLASSES.default; return NOTE_SWATCH_CLASSES[color as NoteColor] ?? NOTE_SWATCH_CLASSES.default;
} }
function openColor(id: string) {
mergeFor.value = null;
pickerFor.value = pickerFor.value === id ? null : id;
}
async function pickColor(id: string, color: NoteColor) { async function pickColor(id: string, color: NoteColor) {
pickerFor.value = null; pickerFor.value = null;
await labels.setColor(id, color); await labels.setColor(id, color);
} }
function openMerge(id: string) {
pickerFor.value = null;
mergeFor.value = mergeFor.value === id ? null : id;
}
function otherLabels(id: string): Label[] {
return labels.items.filter((lb) => lb.id !== id);
}
async function doMerge(sourceId: string, targetId: string) {
mergeFor.value = null;
await labels.mergeInto(sourceId, targetId);
}
</script> </script>
<template> <template>
@@ -43,7 +65,7 @@ async function pickColor(id: string, color: NoteColor) {
@keydown.esc="emit('close')" @keydown.esc="emit('close')"
> >
<div class="flex items-center justify-between border-b border-neutral-100 px-4 py-3 dark:border-neutral-800"> <div class="flex items-center justify-between border-b border-neutral-100 px-4 py-3 dark:border-neutral-800">
<h2 class="text-sm font-semibold">Edit labels</h2> <h2 class="text-sm font-semibold">Manage labels</h2>
<button type="button" class="icon-btn" aria-label="Close" @click="emit('close')"><Icon name="close" /></button> <button type="button" class="icon-btn" aria-label="Close" @click="emit('close')"><Icon name="close" /></button>
</div> </div>
<div class="flex flex-col gap-2 p-4"> <div class="flex flex-col gap-2 p-4">
@@ -57,20 +79,35 @@ async function pickColor(id: string, color: NoteColor) {
/> />
</form> </form>
<ul class="flex flex-col gap-1 pt-1"> <ul class="flex flex-col gap-1 pt-1">
<li v-for="lb in labels.items" :key="lb.id" class="relative flex items-center gap-2"> <li v-for="lb in labels.items" :key="lb.id" class="relative flex items-center gap-1.5">
<button <button
type="button" type="button"
class="h-4 w-4 shrink-0 rounded-full border border-black/10 transition hover:scale-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-white/15" class="h-4 w-4 shrink-0 rounded-full border border-black/10 transition hover:scale-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-white/15"
:class="labelDot(lb.color)" :class="labelDot(lb.color)"
:title="`Color: ${NOTE_COLOR_LABELS[(lb.color as NoteColor)] ?? lb.color}`" :title="`Color: ${NOTE_COLOR_LABELS[(lb.color as NoteColor)] ?? lb.color}`"
aria-label="Change label color" aria-label="Change label color"
@click="pickerFor = pickerFor === lb.id ? null : lb.id" @click="openColor(lb.id)"
/> />
<input <input
:value="lb.name" :value="lb.name"
class="flex-1 rounded-md bg-transparent px-2 py-1.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-brand" class="min-w-0 flex-1 rounded-md bg-transparent px-2 py-1.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-brand"
@change="rename(lb.id, ($event.target as HTMLInputElement).value)" @change="rename(lb.id, ($event.target as HTMLInputElement).value)"
/> />
<span
class="shrink-0 tabular-nums text-xs text-neutral-400"
:title="`${lb.count ?? 0} note${(lb.count ?? 0) === 1 ? '' : 's'}`"
>{{ lb.count ?? 0 }}</span
>
<button
v-if="canMerge"
type="button"
class="icon-btn"
title="Merge into another label"
aria-label="Merge into another label"
@click="openMerge(lb.id)"
>
<Icon name="merge" />
</button>
<button <button
type="button" type="button"
class="icon-btn" class="icon-btn"
@@ -95,6 +132,29 @@ async function pickColor(id: string, color: NoteColor) {
@click="pickColor(lb.id, key)" @click="pickColor(lb.id, key)"
/> />
</div> </div>
<div
v-if="mergeFor === lb.id"
class="absolute right-0 top-full z-10 mt-1 w-52 rounded-lg border border-neutral-200 bg-white p-1 shadow-lg dark:border-neutral-700 dark:bg-neutral-800"
>
<p class="px-2 py-1 text-xs text-neutral-400">
Merge {{ lb.name }} into <span class="text-neutral-300 dark:text-neutral-500">(keeps notes)</span>
</p>
<ul class="max-h-44 overflow-y-auto">
<li v-for="t in otherLabels(lb.id)" :key="t.id">
<button
type="button"
class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-neutral-100 dark:hover:bg-neutral-700"
@click="doMerge(lb.id, t.id)"
>
<span
class="h-2.5 w-2.5 shrink-0 rounded-full border border-black/10 dark:border-white/15"
:class="labelDot(t.color)"
></span>
<span class="truncate">{{ t.name }}</span>
</button>
</li>
</ul>
</div>
</li> </li>
</ul> </ul>
<p v-if="!labels.items.length" class="py-2 text-center text-xs text-neutral-400"> <p v-if="!labels.items.length" class="py-2 text-center text-xs text-neutral-400">
+16 -3
View File
@@ -6,6 +6,8 @@ export interface Label {
id: string; id: string;
name: string; name: string;
color: string; color: string;
// Number of notes carrying this label (from GET /api/labels; used in label management).
count?: number;
} }
export const useLabelsStore = defineStore("labels", () => { export const useLabelsStore = defineStore("labels", () => {
@@ -34,14 +36,15 @@ export const useLabelsStore = defineStore("labels", () => {
async function rename(id: string, name: string): Promise<void> { async function rename(id: string, name: string): Promise<void> {
const updated = await api.patch<Label>(`/api/labels/${id}`, { name }); const updated = await api.patch<Label>(`/api/labels/${id}`, { name });
const idx = items.value.findIndex((lb) => lb.id === id); const idx = items.value.findIndex((lb) => lb.id === id);
if (idx >= 0) items.value[idx] = updated; // The single-label PATCH doesn't recompute the count — keep the one we have.
if (idx >= 0) items.value[idx] = { ...updated, count: items.value[idx].count };
sort(); sort();
} }
async function setColor(id: string, color: string): Promise<void> { async function setColor(id: string, color: string): Promise<void> {
const updated = await api.patch<Label>(`/api/labels/${id}`, { color }); const updated = await api.patch<Label>(`/api/labels/${id}`, { color });
const idx = items.value.findIndex((lb) => lb.id === id); const idx = items.value.findIndex((lb) => lb.id === id);
if (idx >= 0) items.value[idx] = updated; if (idx >= 0) items.value[idx] = { ...updated, count: items.value[idx].count };
} }
async function remove(id: string): Promise<void> { async function remove(id: string): Promise<void> {
@@ -49,5 +52,15 @@ export const useLabelsStore = defineStore("labels", () => {
items.value = items.value.filter((lb) => lb.id !== id); items.value = items.value.filter((lb) => lb.id !== id);
} }
return { items, loaded, load, create, rename, setColor, remove }; // Merge `sourceId` into `targetId`: the server moves the source's notes onto the
// target and deletes the source; the response carries the target's new count.
async function mergeInto(sourceId: string, targetId: string): Promise<void> {
const target = await api.post<Label>(`/api/labels/${sourceId}/merge`, { into: targetId });
items.value = items.value.filter((lb) => lb.id !== sourceId);
const idx = items.value.findIndex((lb) => lb.id === targetId);
if (idx >= 0) items.value[idx] = target;
sort();
}
return { items, loaded, load, create, rename, setColor, remove, mergeInto };
}); });
+65 -6
View File
@@ -3,11 +3,11 @@ from __future__ import annotations
import uuid import uuid
from quart import Blueprint, g, jsonify, request from quart import Blueprint, g, jsonify, request
from sqlalchemy import select from sqlalchemy import func, select
from .auth import login_required from .auth import login_required
from .db import session_scope from .db import session_scope
from .models.label import Label from .models.label import Label, NoteLabel
bp = Blueprint("labels", __name__, url_prefix="/api/labels") bp = Blueprint("labels", __name__, url_prefix="/api/labels")
@@ -19,8 +19,18 @@ def _normalize_label_color(color: object) -> str:
return color if color in LABEL_COLORS else "default" return color if color in LABEL_COLORS else "default"
def _serialize_label(label: Label) -> dict: def _serialize_label(label: Label, count: int | None = None) -> dict:
return {"id": str(label.id), "name": label.name, "color": label.color} data = {"id": str(label.id), "name": label.name, "color": label.color}
if count is not None:
data["count"] = count
return data
async def _label_note_count(db, label_id) -> int:
"""How many notes carry this label (distinct — note_labels PK is note+label)."""
return int(
await db.scalar(select(func.count()).select_from(NoteLabel).where(NoteLabel.label_id == label_id)) or 0
)
async def _get_owned_label(db, label_id: str) -> Label | None: async def _get_owned_label(db, label_id: str) -> Label | None:
@@ -36,7 +46,17 @@ async def _get_owned_label(db, label_id: str) -> Label | None:
async def list_labels(): async def list_labels():
async with session_scope() as db: async with session_scope() as db:
labels = (await db.scalars(select(Label).where(Label.owner_id == g.user_id).order_by(Label.name))).all() labels = (await db.scalars(select(Label).where(Label.owner_id == g.user_id).order_by(Label.name))).all()
return jsonify({"labels": [_serialize_label(lb) for lb in labels]}) # One grouped query for all usage counts (0 for labels attached to nothing).
counts = dict(
(
await db.execute(
select(NoteLabel.label_id, func.count(NoteLabel.note_id))
.where(NoteLabel.label_id.in_([lb.id for lb in labels]))
.group_by(NoteLabel.label_id)
)
).all()
) if labels else {}
return jsonify({"labels": [_serialize_label(lb, int(counts.get(lb.id, 0))) for lb in labels]})
@bp.post("") @bp.post("")
@@ -55,7 +75,7 @@ async def create_label():
db.add(label) db.add(label)
await db.commit() await db.commit()
await db.refresh(label) await db.refresh(label)
return jsonify(_serialize_label(label)), 201 return jsonify(_serialize_label(label, 0)), 201
@bp.patch("/<label_id>") @bp.patch("/<label_id>")
@@ -97,3 +117,42 @@ async def delete_label(label_id: str):
await db.delete(label) # note_labels rows cascade await db.delete(label) # note_labels rows cascade
await db.commit() await db.commit()
return jsonify({"ok": True}) return jsonify({"ok": True})
@bp.post("/<label_id>/merge")
@login_required
async def merge_label(label_id: str):
"""Merge `label_id` (source) INTO the label given by body {"into": <id>}: move
every note tagged with the source onto the target, then delete the source. Both
must be owned by the caller. Note-body `#tags` are NOT rewritten, so a note whose
body still literally contains the source #tag will re-mint that label on its next
edit — retire a tag by editing it out of the text (a known, documented nuance)."""
data = await request.get_json(silent=True) or {}
into = data.get("into")
async with session_scope() as db:
source = await _get_owned_label(db, label_id)
target = await _get_owned_label(db, str(into)) if into is not None else None
if source is None or target is None:
return jsonify({"error": "not found"}), 404
if source.id == target.id:
return jsonify({"error": "cannot merge a label into itself"}), 400
# Notes already carrying the target: a note can't hold the same label twice
# (composite PK), so the source attachment there is just dropped as a dup.
target_notes = set(
(await db.scalars(select(NoteLabel.note_id).where(NoteLabel.label_id == target.id))).all()
)
source_rows = (await db.scalars(select(NoteLabel).where(NoteLabel.label_id == source.id))).all()
by_note = {r.note_id: r.via_tag for r in source_rows}
# Delete the source attachments first, then re-insert under the target — moving
# by delete+insert avoids mutating a composite primary-key column in place.
for row in source_rows:
await db.delete(row)
await db.flush()
for note_id, via_tag in by_note.items():
if note_id not in target_notes:
db.add(NoteLabel(note_id=note_id, label_id=target.id, via_tag=via_tag))
await db.delete(source)
await db.flush()
count = await _label_note_count(db, target.id)
await db.commit()
return jsonify(_serialize_label(target, count))
+9
View File
@@ -21,3 +21,12 @@ async def test_set_note_labels_requires_auth(app):
json={"label_ids": []}, json={"label_ids": []},
) )
assert resp.status_code == 401 assert resp.status_code == 401
async def test_merge_label_requires_auth(app):
client = app.test_client()
resp = await client.post(
"/api/labels/00000000-0000-0000-0000-000000000000/merge",
json={"into": "00000000-0000-0000-0000-000000000001"},
)
assert resp.status_code == 401