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 _merge_into(db, source: Label, target: Label) -> None: """Move every note tagged with `source` onto `target`, then delete `source`. Shared by the explicit `/merge` route and by a rename that lands on a name another tag already holds — those are the same operation, and having one body is what stops them drifting into two answers for one question. Note-body `#tags` are NOT rewritten, so a note whose body still literally contains the source `#tag` will re-mint that tag on its next edit. Retiring a tag means editing it out of the text; a known, documented nuance. """ # 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() 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("tag name is required", 400) async with session_scope() as db: # Idempotent: creating an existing tag just returns it. Case-INSENSITIVE, # like the clients' `find_or_create_label` — a case-sensitive match here was # the path that MINTED the "Groceries" beside "groceries" pair that no synced # client can hold, since their `labels` index is unique on `lower(name)`. existing = await db.scalar( select(Label).where(Label.owner_id == g.user_id, func.lower(Label.name) == name.lower()) ) 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("tag 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: # Case-INSENSITIVE, matching the clients' `find_or_create_label` and the # local store's `lower(name)` unique index. The Postgres constraint here # is on the raw name, so the database would happily hold "Groceries" # beside "groceries" — but no synced client can store both, so letting # one be made is letting a pull fail later on a phone. clash = await db.scalar( select(Label).where( Label.owner_id == g.user_id, func.lower(Label.name) == name.lower(), Label.id != label.id, ) ) if clash is not None: # Renaming onto an existing tag MERGES the two rather than failing: # typing an existing tag's name onto this one says they are the same # thing. The OLDER row survives and takes the new spelling — age is # the one property that does not depend on which of the two the # caller happened to be renaming, so A→B and B→A agree. Ties go to # the incumbent. Mirrors `store::rename_label` exactly. if clash.created_at <= label.created_at: survivor, doomed = clash, label else: survivor, doomed = label, clash await _merge_into(db, doomed, survivor) label = survivor 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 tag into itself", 400) await _merge_into(db, source, target) count = await _label_note_count(db, target.id) await db.commit() return jsonify(_serialize_label(target, count))