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"/>',
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"/>',
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>
+66 -6
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref } from "vue";
import { useLabelsStore } from "../stores/labels";
import { computed, ref } from "vue";
import { useLabelsStore, type Label } from "../stores/labels";
import { NOTE_COLOR_KEYS, NOTE_COLOR_LABELS, NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors";
import Icon from "./Icon.vue";
@@ -8,6 +8,9 @@ const emit = defineEmits<{ (e: "close"): void }>();
const labels = useLabelsStore();
const newName = ref("");
const pickerFor = ref<string | null>(null);
const mergeFor = ref<string | null>(null);
const canMerge = computed(() => labels.items.length > 1);
async function add() {
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;
}
function openColor(id: string) {
mergeFor.value = null;
pickerFor.value = pickerFor.value === id ? null : id;
}
async function pickColor(id: string, color: NoteColor) {
pickerFor.value = null;
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>
<template>
@@ -43,7 +65,7 @@ async function pickColor(id: string, color: NoteColor) {
@keydown.esc="emit('close')"
>
<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>
</div>
<div class="flex flex-col gap-2 p-4">
@@ -57,20 +79,35 @@ async function pickColor(id: string, color: NoteColor) {
/>
</form>
<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
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="labelDot(lb.color)"
:title="`Color: ${NOTE_COLOR_LABELS[(lb.color as NoteColor)] ?? lb.color}`"
aria-label="Change label color"
@click="pickerFor = pickerFor === lb.id ? null : lb.id"
@click="openColor(lb.id)"
/>
<input
: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)"
/>
<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
type="button"
class="icon-btn"
@@ -95,6 +132,29 @@ async function pickColor(id: string, color: NoteColor) {
@click="pickColor(lb.id, key)"
/>
</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>
</ul>
<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;
name: string;
color: string;
// Number of notes carrying this label (from GET /api/labels; used in label management).
count?: number;
}
export const useLabelsStore = defineStore("labels", () => {
@@ -34,14 +36,15 @@ export const useLabelsStore = defineStore("labels", () => {
async function rename(id: string, name: string): Promise<void> {
const updated = await api.patch<Label>(`/api/labels/${id}`, { name });
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();
}
async function setColor(id: string, color: string): Promise<void> {
const updated = await api.patch<Label>(`/api/labels/${id}`, { color });
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> {
@@ -49,5 +52,15 @@ export const useLabelsStore = defineStore("labels", () => {
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
from quart import Blueprint, g, jsonify, request
from sqlalchemy import select
from sqlalchemy import func, select
from .auth import login_required
from .db import session_scope
from .models.label import Label
from .models.label import Label, NoteLabel
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"
def _serialize_label(label: Label) -> dict:
return {"id": str(label.id), "name": label.name, "color": label.color}
def _serialize_label(label: Label, count: int | None = None) -> dict:
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:
@@ -36,7 +46,17 @@ async def _get_owned_label(db, label_id: str) -> Label | None:
async def list_labels():
async with session_scope() as db:
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("")
@@ -55,7 +75,7 @@ async def create_label():
db.add(label)
await db.commit()
await db.refresh(label)
return jsonify(_serialize_label(label)), 201
return jsonify(_serialize_label(label, 0)), 201
@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.commit()
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": []},
)
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