M6: label management — usage counts + merge
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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user