M9 S3 (backend): organize routes adopt toolkit + shared label reconciliation
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 9s
CI & Build / Build & push image (push) Successful in 34s

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
This commit is contained in:
2026-07-23 21:26:59 -04:00
co-authored by Claude Opus 4.8
parent e8e0d86413
commit f1033da75e
7 changed files with 99 additions and 69 deletions
+16 -23
View File
@@ -1,26 +1,20 @@
from __future__ import annotations
import uuid
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")
LABEL_COLORS = {"default", "red", "orange", "yellow", "green", "teal", "blue", "purple", "pink", "gray"}
def _normalize_label_color(color: object) -> str:
return color if color in LABEL_COLORS else "default"
def _serialize_label(label: Label, count: int | None = None) -> dict:
data = {"id": str(label.id), "name": label.name, "color": label.color}
data = serialize_label(label)
if count is not None:
data["count"] = count
return data
@@ -34,9 +28,8 @@ async def _label_note_count(db, label_id) -> int:
async def _get_owned_label(db, label_id: str) -> Label | None:
try:
lid = uuid.UUID(label_id)
except (ValueError, TypeError):
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))
@@ -65,13 +58,13 @@ async def create_label():
data = await request.get_json(silent=True) or {}
name = (data.get("name") or "").strip()
if not name:
return jsonify({"error": "label name is required"}), 400
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_label_color(data.get("color")))
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)
@@ -85,23 +78,23 @@ async def update_label(label_id: str):
has_name = "name" in data
has_color = "color" in data
if not has_name and not has_color:
return jsonify({"error": "nothing to update"}), 400
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 jsonify({"error": "label name is required"}), 400
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 jsonify({"error": "not found"}), 404
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 jsonify({"error": "a label with that name already exists"}), 409
return json_error("a label with that name already exists", 409)
label.name = name
if has_color:
label.color = _normalize_label_color(data.get("color"))
label.color = normalize_color(data.get("color"))
await db.commit()
await db.refresh(label)
return jsonify(_serialize_label(label))
@@ -113,7 +106,7 @@ 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 jsonify({"error": "not found"}), 404
return not_found()
await db.delete(label) # note_labels rows cascade
await db.commit()
return jsonify({"ok": True})
@@ -133,9 +126,9 @@ async def merge_label(label_id: str):
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
return not_found()
if source.id == target.id:
return jsonify({"error": "cannot merge a label into itself"}), 400
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(