owenrusk.dev

casebench

test cases straight onto the board.

git clone https://owenrusk.dev/casebench.git

commit 434ced1b47718423bc8daa51ee23350e5913f348
parent 775f4be5a1248eff9b99f45e095b1073b90c1c97
author Owen Rusk <owen@papermothgames.com>
date   2025-04-22 14:37:25 -0500
images on items

stored once by hash in board_media; items list the hashes.
casebench/casefile.py+27-1
casebench/db.py+17-4
diff --git a/casebench/casefile.py b/casebench/casefile.py
index fffaf6c..b28061c 100644
--- a/casebench/casefile.py
+++ b/casebench/casefile.py
@@ -5,17 +5,31 @@ from pathlib import Path
 import yaml
 
 ID = re.compile(r"[a-z0-9][a-z0-9-]*")
+MEDIA = {
+    ".png": "image/png",
+    ".jpg": "image/jpeg",
+    ".jpeg": "image/jpeg",
+    ".webp": "image/webp",
+}
 
 
 class CaseError(ValueError):
     pass
 
 
+@dataclass(frozen=True)
+class Image:
+    name: str
+    mime: str
+    data: bytes
+
+
 @dataclass(frozen=True)
 class Item:
     id: str
     title: str
     text: str
+    images: tuple[Image, ...] = ()
 
 
 @dataclass(frozen=True)
@@ -35,7 +49,10 @@ def load(folder: Path) -> Case:
         text = path.parent / entry["text"]
         if not text.is_file():
             raise CaseError(f"{path}: no file {entry['text']}")
-        items.append(Item(_id(entry.get("id"), path), entry["title"], text.read_text(encoding="utf-8")))
+        images = tuple(_image(path.parent / name, path) for name in entry.get("images") or [])
+        items.append(
+            Item(_id(entry.get("id"), path), entry["title"], text.read_text(encoding="utf-8"), images)
+        )
     if not items:
         raise CaseError(f"{path}: no items")
     return Case(_id(raw.get("id"), path), raw["title"], tuple(items))
@@ -45,3 +62,12 @@ def _id(value: object, path: Path) -> str:
     if not isinstance(value, str) or not ID.fullmatch(value):
         raise CaseError(f"{path}: bad id {value!r}")
     return value
+
+
+def _image(file: Path, path: Path) -> Image:
+    mime = MEDIA.get(file.suffix.lower())
+    if mime is None:
+        raise CaseError(f"{path}: can't serve {file.name}")
+    if not file.is_file():
+        raise CaseError(f"{path}: no file {file.name}")
+    return Image(file.name, mime, file.read_bytes())
diff --git a/casebench/db.py b/casebench/db.py
index 23357ff..5446104 100644
--- a/casebench/db.py
+++ b/casebench/db.py
@@ -1,6 +1,8 @@
+import hashlib
+
 import psycopg
 
-from .casefile import Case
+from .casefile import Case, Image
 
 
 def connect(dsn: str) -> psycopg.Connection:
@@ -29,13 +31,24 @@ def put(conn: psycopg.Connection, player: str, case: Case) -> None:
         )
         conn.execute("delete from board_items where player_id = %s and case_id = %s", (player, case.id))
         for position, item in enumerate(case.items):
+            images = [media(conn, image) for image in item.images]
             conn.execute(
-                "insert into board_items (player_id, case_id, item_id, position, title, body)"
-                " values (%s, %s, %s, %s, %s, %s)",
-                (player, case.id, item.id, position, item.title, item.text),
+                "insert into board_items (player_id, case_id, item_id, position, title, body, images)"
+                " values (%s, %s, %s, %s, %s, %s, %s)",
+                (player, case.id, item.id, position, item.title, item.text, images),
             )
 
 
+def media(conn: psycopg.Connection, image: Image) -> str:
+    # images are kept once by their hash, however many boards show them.
+    sha = hashlib.sha256(image.data).hexdigest()
+    conn.execute(
+        "insert into board_media (sha256, mime, data) values (%s, %s, %s) on conflict do nothing",
+        (sha, image.mime, image.data),
+    )
+    return sha
+
+
 def items(conn: psycopg.Connection, player: str, case_id: str) -> list[str]:
     rows = conn.execute(
         "select item_id from board_items where player_id = %s and case_id = %s order by position",