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