139 lines
5.4 KiB
Python
139 lines
5.4 KiB
Python
"""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
|
|
|
|
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
|
|
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"):
|
|
char_put(rec)
|
|
n += 1
|
|
return {"imported": n}
|