DRY across the "organize/recall" backend surface:
- serialize.py (new): serialize_label(label) — the base {id,name,color}
shape. labels.py builds on it (adds count); sync deltas will (S4).
- labeling.py (new): resolve_owned_label_ids() + reconcile_manual_labels()
— the "set a note's MANUAL (picker) labels, leave the via_tag rows alone"
logic was duplicated line-for-line between notes.set_note_labels and
sync._apply_note_manual_labels. Now one home; both adopt it (removes the
redundant `chosen`==owned recompute in notes). Behavior-preserving.
- labels.py: json_error/not_found/parse_uuid, colors.normalize_color, and
serialize_label; dropped local LABEL_COLORS + _normalize_label_color
(NOTE_COLORS is the single palette) and `import uuid` (rule 22).
- saved_filters.py: json_error/not_found/parse_uuid for its 2 uuid parses
+ error shapes.
- graph.py: no change — no error/uuid/palette-normalize duplication to fold.
Test: DB-free test_serialize_label_shape guards the base shape.
sync.py's reconciliation swap is behavior-identical; operator-verified on
deploy (no Postgres CI lane).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
152 lines
6.0 KiB
Python
152 lines
6.0 KiB
Python
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("/<label_id>")
|
|
@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("/<label_id>")
|
|
@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("/<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 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))
|