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
+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))