from __future__ import annotations from quart import Blueprint, g, jsonify, request from sqlalchemy import func, select from .auth import login_required from .colors import normalize_color from .db import session_scope from .models.label import Label, NoteLabel from .responses import json_error, not_found, parse_uuid from .serialize import serialize_label bp = Blueprint("labels", __name__, url_prefix="/api/labels") def _serialize_label(label: Label, count: int | None = None) -> dict: data = serialize_label(label) 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: lid = parse_uuid(label_id) if lid is None: return None return await db.scalar(select(Label).where(Label.id == lid, Label.owner_id == g.user_id)) @bp.get("") @login_required 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() # 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("") @login_required async def create_label(): data = await request.get_json(silent=True) or {} name = (data.get("name") or "").strip() if not name: return json_error("label name is required", 400) async with session_scope() as db: # Idempotent: creating an existing label just returns it. existing = await db.scalar(select(Label).where(Label.owner_id == g.user_id, Label.name == name)) if existing is not None: return jsonify(_serialize_label(existing)), 200 label = Label(owner_id=g.user_id, name=name, color=normalize_color(data.get("color"))) db.add(label) await db.commit() await db.refresh(label) return jsonify(_serialize_label(label, 0)), 201 @bp.patch("/") @login_required async def update_label(label_id: str): data = await request.get_json(silent=True) or {} has_name = "name" in data has_color = "color" in data if not has_name and not has_color: return json_error("nothing to update", 400) name = (data.get("name") or "").strip() if has_name else None if has_name and not name: return json_error("label name is required", 400) async with session_scope() as db: label = await _get_owned_label(db, label_id) if label is None: return not_found() if has_name: clash = await db.scalar( select(Label).where(Label.owner_id == g.user_id, Label.name == name, Label.id != label.id) ) if clash is not None: return json_error("a label with that name already exists", 409) label.name = name if has_color: label.color = normalize_color(data.get("color")) await db.commit() await db.refresh(label) return jsonify(_serialize_label(label)) @bp.delete("/") @login_required async def delete_label(label_id: str): async with session_scope() as db: label = await _get_owned_label(db, label_id) if label is None: return not_found() await db.delete(label) # note_labels rows cascade await db.commit() return jsonify({"ok": True}) @bp.post("//merge") @login_required async def merge_label(label_id: str): """Merge `label_id` (source) INTO the label given by body {"into": }: 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 not_found() if source.id == target.id: return json_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))