this repo has no description
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add label review web app and apply-edits CLI

- src/review_app.py: FastAPI app serving a browser UI for paging through
dataset items, marking reviewed/flagged, and editing labels inline.
State persists in data/review.db (SQLite, git-ignored). Items table
reloads from manifests each startup; reviews/flags/edits are durable.
Cursor-based pagination, server-side filtering (pending/reviewed/
flagged/edited/all), per-split stats in toolbar.

- src/static/review.html: vanilla JS frontend. All interactions go
through an `actions` object; keyboard shortcut hook is stubbed out
(commented) for easy wiring later.

- src/apply_edits.py: CLI to flush edits table -> manifest.jsonl files,
with --dry-run and per-split filtering. Prints dvc add / git commit
instructions on completion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

+833
+1
.gitignore
··· 2 2 unsloth_compiled_cache/ 3 3 *.swp 4 4 *.swo 5 + data/review.db
+2
pyproject.toml
··· 31 31 probe = "src.probe:main" 32 32 app = "src.app:main" 33 33 probe-deepseek = "src.probe_deepseek:main" 34 + review = "src.review_app:main" 35 + apply-edits = "src.apply_edits:main" 34 36 eff-mer-evaluate = "src.eff_mer.infer:main" 35 37 36 38 [build-system]
+105
src/apply_edits.py
··· 1 + """ 2 + Flush label edits from review.db into manifest.jsonl files. 3 + 4 + Reads the edits table, groups by split, and rewrites each affected 5 + manifest in-place (original backed up as manifest.jsonl.bak). 6 + 7 + After running this, track the changes with DVC: 8 + dvc add data/<split> (for each modified split) 9 + git add data/<split>.dvc 10 + git commit -m "dataset-v2 label corrections" 11 + git tag dataset-v2 12 + 13 + Usage: 14 + uv run apply-edits [--db data/review.db] [--split NAME] [--dry-run] 15 + """ 16 + 17 + import argparse 18 + import json 19 + import shutil 20 + import sqlite3 21 + from collections import defaultdict 22 + from pathlib import Path 23 + 24 + from .data import DATA_ROOT 25 + 26 + 27 + def main() -> None: 28 + parser = argparse.ArgumentParser( 29 + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter 30 + ) 31 + parser.add_argument("--db", default=str(DATA_ROOT / "review.db"), 32 + help="Path to review SQLite database") 33 + parser.add_argument("--split", default=None, 34 + help="Only apply edits for this split (default: all)") 35 + parser.add_argument("--dry-run", action="store_true", 36 + help="Print what would change without writing files") 37 + args = parser.parse_args() 38 + 39 + conn = sqlite3.connect(args.db) 40 + conn.row_factory = sqlite3.Row 41 + 42 + query = "SELECT image_path, split, typst_edited FROM edits" 43 + params: tuple = () 44 + if args.split: 45 + query += " WHERE split = ?" 46 + params = (args.split,) 47 + 48 + rows = conn.execute(query, params).fetchall() 49 + conn.close() 50 + 51 + if not rows: 52 + print("No edits found.") 53 + return 54 + 55 + # Group: split -> {absolute_image_path: typst_edited} 56 + by_split: defaultdict[str, dict[str, str]] = defaultdict(dict) 57 + for r in rows: 58 + by_split[r["split"]][r["image_path"]] = r["typst_edited"] 59 + 60 + total_written = 0 61 + for split_name, edit_map in sorted(by_split.items()): 62 + manifest = DATA_ROOT / split_name / "manifest.jsonl" 63 + if not manifest.exists(): 64 + print(f"[{split_name}] WARN: manifest not found, skipping") 65 + continue 66 + 67 + base = (DATA_ROOT / split_name).resolve() 68 + records = [json.loads(l) for l in manifest.read_text().splitlines() if l.strip()] 69 + 70 + n_changed = 0 71 + new_records = [] 72 + for rec in records: 73 + abs_path = str(base / rec["image"]) 74 + if abs_path in edit_map: 75 + rec = dict(rec) 76 + rec["typst"] = edit_map[abs_path] 77 + n_changed += 1 78 + new_records.append(rec) 79 + 80 + print(f"[{split_name}] {n_changed} / {len(records)} records updated") 81 + 82 + if not args.dry_run and n_changed: 83 + bak = manifest.with_suffix(".jsonl.bak") 84 + shutil.copy2(manifest, bak) 85 + with manifest.open("w") as f: 86 + for rec in new_records: 87 + f.write(json.dumps(rec) + "\n") 88 + 89 + total_written += n_changed 90 + 91 + print(f"\nTotal: {total_written} records updated") 92 + if args.dry_run: 93 + print("(dry run -- no files written)") 94 + else: 95 + modified = sorted(by_split) 96 + print("\nNext steps:") 97 + for s in modified: 98 + print(f" dvc add data/{s}") 99 + print(f" git add {' '.join(f'data/{s}.dvc' for s in modified)}") 100 + print(' git commit -m "dataset-v2 label corrections"') 101 + print(" git tag dataset-v2") 102 + 103 + 104 + if __name__ == "__main__": 105 + main()
+303
src/review_app.py
··· 1 + """ 2 + Label review and editing web app. 3 + 4 + Serves a browser UI for paging through dataset items, marking reviewed, 5 + flagging for attention, and editing labels inline. State persists in 6 + data/review.db (SQLite). Labels are NOT written back to manifests until 7 + you run `uv run apply-edits`. 8 + 9 + Usage: 10 + uv run review [--host 127.0.0.1] [--port 8001] [--db data/review.db] 11 + """ 12 + 13 + import argparse 14 + import json 15 + import sqlite3 16 + import time 17 + from contextlib import asynccontextmanager 18 + from pathlib import Path 19 + from typing import Annotated 20 + 21 + from fastapi import FastAPI, HTTPException, Query 22 + from fastapi.responses import FileResponse, HTMLResponse 23 + from fastapi.staticfiles import StaticFiles 24 + from pydantic import BaseModel 25 + 26 + from .data import DATA_ROOT, TRAIN_SPLITS, VAL_SPLITS, TEST_SPLITS 27 + 28 + ALL_SPLITS = TRAIN_SPLITS + VAL_SPLITS + TEST_SPLITS 29 + 30 + _db: sqlite3.Connection | None = None 31 + 32 + 33 + def _get_db() -> sqlite3.Connection: 34 + assert _db is not None, "DB not initialised" 35 + return _db 36 + 37 + 38 + # ── Schema ──────────────────────────────────────────────────────────────── 39 + 40 + def _init_schema(conn: sqlite3.Connection) -> None: 41 + conn.executescript(""" 42 + CREATE TABLE IF NOT EXISTS items ( 43 + id INTEGER PRIMARY KEY AUTOINCREMENT, 44 + split TEXT NOT NULL, 45 + image_path TEXT NOT NULL UNIQUE, 46 + typst TEXT NOT NULL 47 + ); 48 + CREATE INDEX IF NOT EXISTS items_split ON items(split); 49 + 50 + CREATE TABLE IF NOT EXISTS reviews ( 51 + image_path TEXT PRIMARY KEY, 52 + reviewed_at REAL NOT NULL 53 + ); 54 + 55 + CREATE TABLE IF NOT EXISTS flags ( 56 + image_path TEXT PRIMARY KEY, 57 + flagged_at REAL NOT NULL 58 + ); 59 + 60 + CREATE TABLE IF NOT EXISTS edits ( 61 + image_path TEXT PRIMARY KEY, 62 + split TEXT NOT NULL, 63 + typst_orig TEXT NOT NULL, 64 + typst_edited TEXT NOT NULL, 65 + edited_at REAL NOT NULL 66 + ); 67 + """) 68 + conn.commit() 69 + 70 + 71 + def _load_items(conn: sqlite3.Connection) -> int: 72 + """Reload items table from all manifests. Called every startup.""" 73 + rows: list[tuple[str, str, str]] = [] 74 + for split_name in ALL_SPLITS: 75 + manifest = DATA_ROOT / split_name / "manifest.jsonl" 76 + if not manifest.exists(): 77 + continue 78 + base = (DATA_ROOT / split_name).resolve() 79 + for line in manifest.read_text().splitlines(): 80 + if not line.strip(): 81 + continue 82 + r = json.loads(line) 83 + typst = r.get("typst", "") 84 + if not typst or typst.startswith("ERROR:"): 85 + continue 86 + rows.append((split_name, str(base / r["image"]), typst)) 87 + 88 + conn.execute("DELETE FROM items") 89 + conn.executemany( 90 + "INSERT INTO items (split, image_path, typst) VALUES (?, ?, ?)", rows 91 + ) 92 + conn.commit() 93 + return len(rows) 94 + 95 + 96 + # ── Request bodies ───────────────────────────────────────────────────────── 97 + 98 + class EditBody(BaseModel): 99 + typst_edited: str 100 + 101 + 102 + # ── App factory ──────────────────────────────────────────────────────────── 103 + 104 + def create_app(db_path: str) -> FastAPI: 105 + global _db 106 + 107 + @asynccontextmanager 108 + async def lifespan(app: FastAPI): 109 + global _db 110 + _db = sqlite3.connect(db_path, check_same_thread=False) 111 + _db.row_factory = sqlite3.Row 112 + _init_schema(_db) 113 + n = _load_items(_db) 114 + print(f"Review DB ready: {n:,} items across {len(ALL_SPLITS)} splits") 115 + yield 116 + _db.close() 117 + _db = None 118 + 119 + app = FastAPI(lifespan=lifespan) 120 + 121 + static_dir = Path(__file__).parent / "static" 122 + app.mount("/static", StaticFiles(directory=static_dir), name="static") 123 + 124 + @app.get("/", response_class=HTMLResponse) 125 + async def index(): 126 + return (static_dir / "review.html").read_text() 127 + 128 + # ── Splits summary ────────────────────────────────────────────────── 129 + 130 + @app.get("/splits") 131 + async def list_splits(): 132 + db = _get_db() 133 + rows = db.execute(""" 134 + SELECT i.split AS name, 135 + COUNT(*) AS total, 136 + COUNT(*) - COUNT(r.image_path) AS pending, 137 + COUNT(f.image_path) AS flagged, 138 + COUNT(e.image_path) AS edited 139 + FROM items i 140 + LEFT JOIN reviews r ON i.image_path = r.image_path 141 + LEFT JOIN flags f ON i.image_path = f.image_path 142 + LEFT JOIN edits e ON i.image_path = e.image_path 143 + GROUP BY i.split 144 + ORDER BY i.split 145 + """).fetchall() 146 + return [dict(r) for r in rows] 147 + 148 + # ── Item listing ───────────────────────────────────────────────────── 149 + 150 + @app.get("/items") 151 + async def list_items( 152 + split: Annotated[str | None, Query()] = None, 153 + filter: Annotated[str, Query()] = "pending", 154 + after: Annotated[int, Query()] = 0, 155 + limit: Annotated[int, Query()] = 20, 156 + ): 157 + db = _get_db() 158 + limit = min(limit, 100) 159 + 160 + clauses = ["i.id > :after"] 161 + params: dict = {"after": after, "limit": limit} 162 + 163 + if split: 164 + clauses.append("i.split = :split") 165 + params["split"] = split 166 + 167 + if filter == "pending": 168 + clauses.append("r.image_path IS NULL") 169 + elif filter == "reviewed": 170 + clauses.append("r.image_path IS NOT NULL") 171 + elif filter == "flagged": 172 + clauses.append("f.image_path IS NOT NULL") 173 + elif filter == "edited": 174 + clauses.append("e.image_path IS NOT NULL") 175 + # "all" -- no extra clause 176 + 177 + where = " AND ".join(clauses) 178 + 179 + rows = db.execute(f""" 180 + SELECT i.id, 181 + i.split, 182 + COALESCE(e.typst_edited, i.typst) AS typst, 183 + i.typst AS typst_orig, 184 + r.image_path IS NOT NULL AS reviewed, 185 + f.image_path IS NOT NULL AS flagged, 186 + e.image_path IS NOT NULL AS edited 187 + FROM items i 188 + LEFT JOIN reviews r ON i.image_path = r.image_path 189 + LEFT JOIN flags f ON i.image_path = f.image_path 190 + LEFT JOIN edits e ON i.image_path = e.image_path 191 + WHERE {where} 192 + ORDER BY i.id 193 + LIMIT :limit 194 + """, params).fetchall() 195 + 196 + items = [dict(r) for r in rows] 197 + next_cursor = items[-1]["id"] if len(items) == limit else None 198 + return {"items": items, "next_cursor": next_cursor} 199 + 200 + # ── Image serving ───────────────────────────────────────────────────── 201 + 202 + @app.get("/image/{item_id}") 203 + async def get_image(item_id: int): 204 + db = _get_db() 205 + row = db.execute( 206 + "SELECT image_path FROM items WHERE id = ?", (item_id,) 207 + ).fetchone() 208 + if not row: 209 + raise HTTPException(404, "Item not found") 210 + path = Path(row["image_path"]) 211 + if not path.exists(): 212 + raise HTTPException(404, f"Image file missing: {path}") 213 + suffix = path.suffix.lstrip(".") or "png" 214 + return FileResponse(path, media_type=f"image/{suffix}") 215 + 216 + # ── Review actions ──────────────────────────────────────────────────── 217 + 218 + def _get_image_path(item_id: int) -> str: 219 + row = _get_db().execute( 220 + "SELECT image_path FROM items WHERE id = ?", (item_id,) 221 + ).fetchone() 222 + if not row: 223 + raise HTTPException(404, "Item not found") 224 + return row["image_path"] 225 + 226 + @app.post("/items/{item_id}/review") 227 + async def mark_reviewed(item_id: int): 228 + ip = _get_image_path(item_id) 229 + _get_db().execute( 230 + "INSERT OR REPLACE INTO reviews (image_path, reviewed_at) VALUES (?, ?)", 231 + (ip, time.time()) 232 + ) 233 + _get_db().commit() 234 + return {"ok": True} 235 + 236 + @app.post("/items/{item_id}/unreview") 237 + async def mark_unreviewed(item_id: int): 238 + ip = _get_image_path(item_id) 239 + _get_db().execute("DELETE FROM reviews WHERE image_path = ?", (ip,)) 240 + _get_db().commit() 241 + return {"ok": True} 242 + 243 + @app.post("/items/{item_id}/flag") 244 + async def flag_item(item_id: int): 245 + ip = _get_image_path(item_id) 246 + _get_db().execute( 247 + "INSERT OR REPLACE INTO flags (image_path, flagged_at) VALUES (?, ?)", 248 + (ip, time.time()) 249 + ) 250 + _get_db().commit() 251 + return {"ok": True} 252 + 253 + @app.post("/items/{item_id}/unflag") 254 + async def unflag_item(item_id: int): 255 + ip = _get_image_path(item_id) 256 + _get_db().execute("DELETE FROM flags WHERE image_path = ?", (ip,)) 257 + _get_db().commit() 258 + return {"ok": True} 259 + 260 + @app.post("/items/{item_id}/edit") 261 + async def save_edit(item_id: int, body: EditBody): 262 + db = _get_db() 263 + row = db.execute( 264 + "SELECT image_path, split, typst FROM items WHERE id = ?", (item_id,) 265 + ).fetchone() 266 + if not row: 267 + raise HTTPException(404, "Item not found") 268 + 269 + # Preserve original label on first edit 270 + existing = db.execute( 271 + "SELECT typst_orig FROM edits WHERE image_path = ?", (row["image_path"],) 272 + ).fetchone() 273 + orig = existing["typst_orig"] if existing else row["typst"] 274 + 275 + edited = body.typst_edited.strip() 276 + db.execute( 277 + """INSERT OR REPLACE INTO edits 278 + (image_path, split, typst_orig, typst_edited, edited_at) 279 + VALUES (?, ?, ?, ?, ?)""", 280 + (row["image_path"], row["split"], orig, edited, time.time()) 281 + ) 282 + db.commit() 283 + return {"ok": True, "typst_edited": edited} 284 + 285 + return app 286 + 287 + 288 + def main() -> None: 289 + parser = argparse.ArgumentParser( 290 + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter 291 + ) 292 + parser.add_argument("--host", default="127.0.0.1") 293 + parser.add_argument("--port", type=int, default=8001) 294 + parser.add_argument("--db", default=str(DATA_ROOT / "review.db"), 295 + help="Path to SQLite review database") 296 + args = parser.parse_args() 297 + 298 + import uvicorn 299 + uvicorn.run(create_app(args.db), host=args.host, port=args.port) 300 + 301 + 302 + if __name__ == "__main__": 303 + main()
+422
src/static/review.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <title>Label Review</title> 6 + <style> 7 + * { box-sizing: border-box; margin: 0; padding: 0; } 8 + body { background: #1a1a1a; color: #e0e0e0; font-family: monospace; min-height: 100vh; } 9 + 10 + .toolbar { 11 + position: sticky; top: 0; z-index: 10; 12 + background: #111; border-bottom: 1px solid #333; 13 + padding: 0.6rem 1rem; 14 + display: flex; gap: 0.8rem; align-items: center; flex-wrap: wrap; 15 + } 16 + .toolbar h1 { font-size: 0.95rem; color: #888; font-weight: normal; } 17 + .toolbar select { 18 + background: #222; color: #ccc; border: 1px solid #444; 19 + padding: 0.3rem 0.6rem; font-family: monospace; font-size: 0.82rem; 20 + border-radius: 3px; cursor: pointer; 21 + } 22 + #stats { margin-left: auto; font-size: 0.78rem; color: #555; } 23 + 24 + #item-list { 25 + padding: 0.8rem 1rem; 26 + display: flex; flex-direction: column; gap: 0.5rem; 27 + max-width: 1100px; margin: 0 auto; 28 + } 29 + 30 + .card { 31 + display: flex; background: #222; border: 1px solid #2e2e2e; 32 + border-radius: 4px; overflow: hidden; 33 + transition: opacity 0.25s ease, transform 0.25s ease; 34 + } 35 + .card.fading { opacity: 0; transform: translateX(30px); pointer-events: none; } 36 + .card.is-reviewed { border-left: 3px solid #3a6a3a; } 37 + .card.is-flagged { border-left: 3px solid #6a3a3a; } 38 + 39 + .card-img { 40 + flex: 0 0 auto; background: #fff; 41 + display: flex; align-items: center; justify-content: center; 42 + padding: 6px; min-width: 60px; 43 + } 44 + .card-img img { 45 + max-width: 360px; max-height: 160px; 46 + object-fit: contain; display: block; 47 + } 48 + 49 + .card-body { 50 + flex: 1; padding: 0.5rem 0.75rem; 51 + display: flex; flex-direction: column; gap: 0.35rem; 52 + min-width: 0; 53 + } 54 + 55 + .card-meta { display: flex; gap: 0.3rem; align-items: center; flex-wrap: wrap; } 56 + .badge { 57 + font-size: 0.68rem; padding: 0.1rem 0.35rem; 58 + border-radius: 2px; border: 1px solid #3a3a3a; color: #666; 59 + } 60 + .badge.b-split { color: #888; border-color: #3e3e3e; } 61 + .badge.b-reviewed { color: #5a9a5a; border-color: #3a6a3a; background: #161e16; } 62 + .badge.b-flagged { color: #9a5a5a; border-color: #6a3a3a; background: #1e1616; } 63 + .badge.b-edited { color: #5a7a9a; border-color: #3a5a7a; background: #161820; } 64 + 65 + .label-text { 66 + font-size: 0.88rem; color: #b8f0a0; background: #111; 67 + padding: 0.3rem 0.5rem; border-radius: 2px; 68 + white-space: pre-wrap; word-break: break-all; min-height: 1.8rem; 69 + } 70 + .label-edit { 71 + font-size: 0.88rem; font-family: monospace; 72 + color: #b8f0a0; background: #111; 73 + border: 1px solid #3a6a8a; border-radius: 2px; 74 + padding: 0.3rem 0.5rem; resize: vertical; width: 100%; min-height: 3rem; 75 + } 76 + .label-edit:focus { outline: none; border-color: #5a8aaa; } 77 + 78 + .card-actions { display: flex; gap: 0.35rem; flex-wrap: wrap; } 79 + .card-actions button { 80 + font-family: monospace; font-size: 0.75rem; 81 + padding: 0.2rem 0.55rem; border-radius: 3px; cursor: pointer; 82 + border: 1px solid #3a3a3a; background: #2a2a2a; color: #aaa; 83 + transition: background 0.15s; 84 + } 85 + .card-actions button:hover { background: #383838; } 86 + .btn-rev { border-color: #3a6a3a !important; color: #7ab07a !important; } 87 + .btn-rev:hover { background: #162016 !important; } 88 + .btn-rev.on { background: #162016 !important; color: #5a9a5a !important; } 89 + .btn-flag { border-color: #6a3a3a !important; color: #b07a7a !important; } 90 + .btn-flag:hover { background: #201616 !important; } 91 + .btn-flag.on { background: #201616 !important; color: #9a5a5a !important; } 92 + .btn-edit { border-color: #3a5a7a !important; color: #7a9ab0 !important; } 93 + .btn-save { border-color: #3a6a3a !important; color: #7ab07a !important; background: #162016 !important; } 94 + .btn-cancel { } 95 + 96 + #footer { max-width: 1100px; margin: 0 auto; padding: 0.8rem 1rem 2rem; } 97 + #load-more { 98 + display: block; width: 100%; padding: 0.5rem; 99 + font-family: monospace; font-size: 0.85rem; 100 + background: #252525; color: #888; border: 1px solid #383838; 101 + border-radius: 3px; cursor: pointer; text-align: center; 102 + } 103 + #load-more:hover:not(:disabled) { background: #2e2e2e; } 104 + #load-more:disabled { opacity: 0.4; cursor: default; } 105 + #empty-msg { text-align: center; color: #444; padding: 4rem 1rem; font-size: 0.9rem; } 106 + </style> 107 + </head> 108 + <body> 109 + 110 + <div class="toolbar"> 111 + <h1>Label Review</h1> 112 + <select id="split-sel"><option value="">All splits</option></select> 113 + <select id="filter-sel"> 114 + <option value="pending">Pending</option> 115 + <option value="flagged">Flagged</option> 116 + <option value="reviewed">Reviewed</option> 117 + <option value="edited">Edited</option> 118 + <option value="all">All</option> 119 + </select> 120 + <span id="stats"></span> 121 + </div> 122 + 123 + <div id="item-list"></div> 124 + <div id="empty-msg" hidden>No items match the current filter.</div> 125 + <div id="footer"><button id="load-more" hidden disabled>Load more</button></div> 126 + 127 + <script> 128 + // ── State ──────────────────────────────────────────────────────────────── 129 + 130 + const state = { 131 + split: '', 132 + filter: 'pending', 133 + items: [], // each item: {id, split, typst, typst_orig, reviewed, flagged, edited, _editing, _draft} 134 + nextCursor: null, 135 + loading: false, 136 + }; 137 + 138 + // ── Actions ────────────────────────────────────────────────────────────── 139 + // All user interactions go through this object. 140 + // To add keyboard shortcuts later, add a keydown listener that calls these. 141 + // 142 + // Stub signatures for shortcuts (implement bodies when wiring keys): 143 + // focusNext() -- move focus to next card 144 + // focusPrev() -- move focus to previous card 145 + // reviewFocused() -- markReviewed / markUnreviewed on focused card 146 + // flagFocused() -- toggleFlag on focused card 147 + // editFocused() -- enterEditMode on focused card 148 + 149 + const actions = { 150 + 151 + async markReviewed(id) { 152 + await api(`/items/${id}/review`, 'POST'); 153 + _patch(id, { reviewed: 1 }); 154 + _maybeFade(id); 155 + _autoLoadIfLow(); 156 + }, 157 + 158 + async markUnreviewed(id) { 159 + await api(`/items/${id}/unreview`, 'POST'); 160 + _patch(id, { reviewed: 0 }); 161 + _maybeFade(id); 162 + _rerender(id); 163 + }, 164 + 165 + async toggleFlag(id) { 166 + const item = _find(id); 167 + if (item.flagged) { 168 + await api(`/items/${id}/unflag`, 'POST'); 169 + _patch(id, { flagged: 0 }); 170 + } else { 171 + await api(`/items/${id}/flag`, 'POST'); 172 + _patch(id, { flagged: 1 }); 173 + } 174 + _maybeFade(id); 175 + _rerender(id); 176 + }, 177 + 178 + enterEditMode(id) { 179 + _patch(id, { _editing: true, _draft: _find(id).typst }); 180 + _rerender(id); 181 + // Focus the textarea after render 182 + requestAnimationFrame(() => document.getElementById(`edit-${id}`)?.focus()); 183 + }, 184 + 185 + cancelEdit(id) { 186 + _patch(id, { _editing: false, _draft: null }); 187 + _rerender(id); 188 + }, 189 + 190 + async saveEdit(id) { 191 + const ta = document.getElementById(`edit-${id}`); 192 + const draft = (ta?.value ?? _find(id)._draft ?? '').trim(); 193 + if (!draft) return; 194 + const data = await api(`/items/${id}/edit`, 'POST', { typst_edited: draft }); 195 + _patch(id, { typst: data.typst_edited, edited: 1, _editing: false, _draft: null }); 196 + _rerender(id); 197 + }, 198 + 199 + async loadMore() { 200 + if (state.loading || state.nextCursor === null) return; 201 + state.loading = true; 202 + _setLoadMoreState(); 203 + const data = await _fetchPage(state.nextCursor); 204 + state.items.push(...data.items.map(_ext)); 205 + state.nextCursor = data.next_cursor; 206 + state.loading = false; 207 + _renderList(); 208 + }, 209 + 210 + async reload() { 211 + state.items = []; 212 + state.nextCursor = null; 213 + state.loading = true; 214 + _renderList(); 215 + const data = await _fetchPage(0); 216 + state.items = data.items.map(_ext); 217 + state.nextCursor = data.next_cursor; 218 + state.loading = false; 219 + _renderList(); 220 + _refreshStats(); 221 + }, 222 + 223 + // Keyboard stubs -- add bodies when wiring shortcuts: 224 + // focusNext() { /* scroll to next card, set state.focusedId */ }, 225 + // focusPrev() { /* scroll to prev card, set state.focusedId */ }, 226 + // reviewFocused() { if (state.focusedId) { const i = _find(state.focusedId); i.reviewed ? actions.markUnreviewed(state.focusedId) : actions.markReviewed(state.focusedId); } }, 227 + // flagFocused() { if (state.focusedId) actions.toggleFlag(state.focusedId); }, 228 + // editFocused() { if (state.focusedId) actions.enterEditMode(state.focusedId); }, 229 + }; 230 + 231 + // ── Keyboard shortcut hook (stub) ───────────────────────────────────────── 232 + // Uncomment and fill in bodies to enable keyboard navigation: 233 + // 234 + // document.addEventListener('keydown', e => { 235 + // if (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT') return; 236 + // if (e.key === 'ArrowRight' || e.key === ' ') { e.preventDefault(); actions.focusNext(); } 237 + // if (e.key === 'ArrowLeft') { e.preventDefault(); actions.focusPrev(); } 238 + // if (e.key === 'r') actions.reviewFocused(); 239 + // if (e.key === 'f') actions.flagFocused(); 240 + // if (e.key === 'e') actions.editFocused(); 241 + // }); 242 + 243 + // ── API helpers ─────────────────────────────────────────────────────────── 244 + 245 + async function api(path, method = 'GET', body = null) { 246 + const opts = { method, headers: {} }; 247 + if (body) { 248 + opts.body = JSON.stringify(body); 249 + opts.headers['Content-Type'] = 'application/json'; 250 + } 251 + const r = await fetch(path, opts); 252 + if (!r.ok) throw new Error(`${method} ${path} -> ${r.status}`); 253 + return r.json(); 254 + } 255 + 256 + async function _fetchPage(after) { 257 + const p = new URLSearchParams({ filter: state.filter, after, limit: 20 }); 258 + if (state.split) p.set('split', state.split); 259 + return api(`/items?${p}`); 260 + } 261 + 262 + async function _refreshStats() { 263 + const stats = await api('/splits'); 264 + const sel = document.getElementById('split-sel'); 265 + const cur = sel.value; 266 + sel.innerHTML = '<option value="">All splits</option>'; 267 + let totalPending = 0, totalAll = 0; 268 + for (const s of stats) { 269 + const o = document.createElement('option'); 270 + o.value = s.name; 271 + o.textContent = `${s.name} (${s.pending.toLocaleString()} / ${s.total.toLocaleString()})`; 272 + sel.appendChild(o); 273 + totalPending += s.pending; 274 + totalAll += s.total; 275 + } 276 + sel.value = cur; 277 + document.getElementById('stats').textContent = 278 + `${totalPending.toLocaleString()} pending / ${totalAll.toLocaleString()} total`; 279 + } 280 + 281 + // ── Item helpers ────────────────────────────────────────────────────────── 282 + 283 + function _ext(raw) { return { ...raw, _editing: false, _draft: null }; } 284 + function _find(id) { return state.items.find(i => i.id === id); } 285 + 286 + function _patch(id, changes) { 287 + const idx = state.items.findIndex(i => i.id === id); 288 + if (idx >= 0) state.items[idx] = { ...state.items[idx], ...changes }; 289 + } 290 + 291 + function _maybeFade(id) { 292 + const item = _find(id); 293 + if (!item) return; 294 + const remove = 295 + (state.filter === 'pending' && item.reviewed) || 296 + (state.filter === 'reviewed' && !item.reviewed) || 297 + (state.filter === 'flagged' && !item.flagged); 298 + if (!remove) { _rerender(id); return; } 299 + const card = document.getElementById(`card-${id}`); 300 + if (card) card.classList.add('fading'); 301 + setTimeout(() => { 302 + state.items = state.items.filter(i => i.id !== id); 303 + _renderList(); 304 + }, 280); 305 + } 306 + 307 + function _autoLoadIfLow() { 308 + if (state.items.length < 6 && state.nextCursor !== null) actions.loadMore(); 309 + } 310 + 311 + // ── Rendering ───────────────────────────────────────────────────────────── 312 + 313 + function _buildCard(item) { 314 + const card = document.createElement('div'); 315 + card.id = `card-${item.id}`; 316 + card.className = 'card' + 317 + (item.reviewed ? ' is-reviewed' : '') + 318 + (item.flagged ? ' is-flagged' : ''); 319 + 320 + // Image panel 321 + const imgDiv = document.createElement('div'); 322 + imgDiv.className = 'card-img'; 323 + const img = document.createElement('img'); 324 + img.src = `/image/${item.id}`; 325 + img.alt = ''; 326 + img.loading = 'lazy'; 327 + imgDiv.appendChild(img); 328 + card.appendChild(imgDiv); 329 + 330 + // Body 331 + const body = document.createElement('div'); 332 + body.className = 'card-body'; 333 + 334 + // Meta row 335 + const meta = document.createElement('div'); 336 + meta.className = 'card-meta'; 337 + meta.innerHTML = 338 + `<span class="badge b-split">${item.split}</span>` + 339 + (item.reviewed ? `<span class="badge b-reviewed">reviewed</span>` : '') + 340 + (item.flagged ? `<span class="badge b-flagged">flagged</span>` : '') + 341 + (item.edited ? `<span class="badge b-edited">edited</span>` : ''); 342 + body.appendChild(meta); 343 + 344 + // Label or textarea 345 + if (item._editing) { 346 + const ta = document.createElement('textarea'); 347 + ta.className = 'label-edit'; 348 + ta.id = `edit-${item.id}`; 349 + ta.value = item._draft ?? item.typst; 350 + body.appendChild(ta); 351 + } else { 352 + const lbl = document.createElement('div'); 353 + lbl.className = 'label-text'; 354 + lbl.textContent = item.typst; 355 + body.appendChild(lbl); 356 + } 357 + 358 + // Action buttons 359 + const acts = document.createElement('div'); 360 + acts.className = 'card-actions'; 361 + const id = item.id; 362 + 363 + if (item._editing) { 364 + acts.innerHTML = 365 + `<button class="btn-save" onclick="actions.saveEdit(${id})">Save</button>` + 366 + `<button class="btn-cancel" onclick="actions.cancelEdit(${id})">Cancel</button>`; 367 + } else { 368 + const revLabel = item.reviewed ? 'Unmark Reviewed' : 'Mark Reviewed'; 369 + const revFn = item.reviewed ? 'markUnreviewed' : 'markReviewed'; 370 + const revOn = item.reviewed ? ' on' : ''; 371 + const flagLabel = item.flagged ? 'Unflag' : 'Flag'; 372 + const flagOn = item.flagged ? ' on' : ''; 373 + acts.innerHTML = 374 + `<button class="btn-rev${revOn}" onclick="actions.${revFn}(${id})">${revLabel}</button>` + 375 + `<button class="btn-flag${flagOn}" onclick="actions.toggleFlag(${id})">${flagLabel}</button>` + 376 + `<button class="btn-edit" onclick="actions.enterEditMode(${id})">Edit</button>`; 377 + } 378 + body.appendChild(acts); 379 + card.appendChild(body); 380 + return card; 381 + } 382 + 383 + function _rerender(id) { 384 + const item = _find(id); 385 + const old = document.getElementById(`card-${id}`); 386 + if (item && old) old.replaceWith(_buildCard(item)); 387 + } 388 + 389 + function _setLoadMoreState() { 390 + const btn = document.getElementById('load-more'); 391 + btn.hidden = state.nextCursor === null && !state.loading; 392 + btn.disabled = state.loading; 393 + btn.textContent = state.loading ? 'Loading...' : 'Load more'; 394 + } 395 + 396 + function _renderList() { 397 + const list = document.getElementById('item-list'); 398 + list.innerHTML = ''; 399 + for (const item of state.items) list.appendChild(_buildCard(item)); 400 + document.getElementById('empty-msg').hidden = state.items.length > 0 || state.loading; 401 + _setLoadMoreState(); 402 + } 403 + 404 + // ── Controls ────────────────────────────────────────────────────────────── 405 + 406 + document.getElementById('split-sel').addEventListener('change', e => { 407 + state.split = e.target.value; 408 + actions.reload(); 409 + }); 410 + 411 + document.getElementById('filter-sel').addEventListener('change', e => { 412 + state.filter = e.target.value; 413 + actions.reload(); 414 + }); 415 + 416 + document.getElementById('load-more').addEventListener('click', () => actions.loadMore()); 417 + 418 + // ── Boot ────────────────────────────────────────────────────────────────── 419 + actions.reload(); 420 + </script> 421 + </body> 422 + </html>