"""Character library REST API — backed by SQLite via core.database. Mirrors the IndexedDB API in characters-library.js so the JS swap is mechanical: GET /api/characters → { characters: [...] } GET /api/characters/{id} → record | 404 PUT /api/characters/{id} → upsert, returns record DELETE /api/characters/{id} → { ok: true } POST /api/characters/migrate → bulk import from JS (one-time migration) """ from __future__ import annotations import base64 import re from fastapi import APIRouter, HTTPException, Request from fastapi.responses import Response from core.database import char_get_all, char_get, char_put, char_delete from core.validation import _validate_character_record router = APIRouter() _DATA_URL_RE = re.compile(r"^data:(image/[\w.+-]+);base64,(.+)$", re.DOTALL) @router.get("/api/characters") async def characters_list(): # Swap each character's raw base64 portrait for a lightweight URL — # the list endpoint is fetched in bulk (e.g. rendering a full cast grid), # and re-sending every character's full image blob inline made that # payload/DOM balloon to tens of megabytes for a book with dozens of # portraits, blocking rendering with no visual feedback. Single-record # fetches (characters_get below) still return the real base64. # # sheet.concept_art_image (the reference-sheet image) needed the exact # same treatment and never got it: after the "Consistent profile + # reference" feature started populating it for a whole cast in one run, # this endpoint's payload measured 190MB across the library (mostly this # one field) — confirmed live as the cause of every character-list # action (including just clicking a table column to sort) appearing to # hang for minutes while the browser fetched and parsed it. chars = char_get_all() for c in chars: if c.get("image"): c["image"] = f"/api/characters/{c['id']}/image" sheet = c.get("sheet") if isinstance(sheet, dict) and sheet.get("concept_art_image"): c["sheet"] = {**sheet, "concept_art_image": f"/api/characters/{c['id']}/concept-art-image"} return {"characters": chars} @router.get("/api/characters/{char_id:path}/image") async def characters_image(char_id: str): # Character portraits are stored inline as base64 data: URLs (clUpsert/ # clSetImage write straight into the `image` column) — fine for a single # avatar per card, but Script Rehearser's Stage view renders one avatar # PER DIALOGUE LINE, and a character can speak hundreds of lines. Inlining # the raw data URL into every line's HTML re-embeds the same multi-KB/MB # blob hundreds of times, ballooning the page to hundreds of megabytes and # silently failing to render at all (confirmed live on a 1968-line book). # Serving it as a real URL means the browser fetches/caches it once. rec = char_get(char_id) if rec is None or not rec.get("image"): raise HTTPException(404, "No image") m = _DATA_URL_RE.match(rec["image"]) if not m: raise HTTPException(404, "Invalid image data") try: raw = base64.b64decode(m.group(2)) except Exception: raise HTTPException(404, "Invalid image data") return Response( content=raw, media_type=m.group(1), headers={"Cache-Control": "no-cache, no-store, must-revalidate", "Pragma": "no-cache", "Expires": "0"} ) @router.get("/api/characters/{char_id:path}/concept-art-image") async def characters_concept_art_image(char_id: str): """Same lightweight-URL treatment as /image, for the reference sheet.""" rec = char_get(char_id) sheet = (rec or {}).get("sheet") or {} concept_art_image = sheet.get("concept_art_image") if rec is None or not concept_art_image: raise HTTPException(404, "No concept art image") m = _DATA_URL_RE.match(concept_art_image) if not m: raise HTTPException(404, "Invalid image data") try: raw = base64.b64decode(m.group(2)) except Exception: raise HTTPException(404, "Invalid image data") return Response( content=raw, media_type=m.group(1), headers={"Cache-Control": "no-cache, no-store, must-revalidate", "Pragma": "no-cache", "Expires": "0"} ) @router.get("/api/characters/{char_id:path}") async def characters_get(char_id: str): rec = char_get(char_id) if rec is None: raise HTTPException(404, "Character not found") return rec @router.put("/api/characters/{char_id:path}") async def characters_put(char_id: str, request: Request): try: body = await request.json() except Exception: raise HTTPException(400, "Invalid JSON") body["id"] = char_id _validate_character_record(body) return char_put(body) @router.delete("/api/characters/{char_id:path}") async def characters_delete(char_id: str): if not char_delete(char_id): raise HTTPException(404, "Character not found") return {"ok": True, "backup_created": True} @router.post("/api/characters/migrate") async def characters_migrate(request: Request): """Accept a batch of character records from the browser's IndexedDB dump.""" try: body = await request.json() except Exception: raise HTTPException(400, "Invalid JSON") records = body if isinstance(body, list) else body.get("characters", []) n = 0 for rec in records: if rec.get("id"): _validate_character_record(rec) char_put(rec) n += 1 return {"imported": n}