#!/usr/bin/env python3
"""
FFTCG Card Data Reviewer — Local web tool for browsing and editing scanned card data.
Usage:
python tools/card_reviewer.py # Start on port 8080
python tools/card_reviewer.py --port 9000 # Custom port
Opens a browser showing each card's image alongside its detected data.
Edit fields and save changes back to data/cards.json.
"""
import argparse
import json
import os
import sys
import webbrowser
from http.server import HTTPServer, BaseHTTPRequestHandler
from pathlib import Path
from urllib.parse import urlparse, parse_qs
PROJECT_ROOT = Path(__file__).parent.parent
CARDS_FILE = PROJECT_ROOT / "data" / "cards.json"
REVIEWED_FILE = PROJECT_ROOT / "data" / "reviewed.json"
SOURCE_CARDS_DIR = PROJECT_ROOT / "source-cards"
# Load cards into memory
cards_data = None
reviewed_data = None
def load_cards():
global cards_data
with open(CARDS_FILE, "r") as f:
cards_data = json.load(f)
def save_cards():
with open(CARDS_FILE, "w") as f:
json.dump(cards_data, f, indent=2, ensure_ascii=False)
def load_reviewed():
global reviewed_data
if REVIEWED_FILE.exists():
with open(REVIEWED_FILE, "r") as f:
reviewed_data = json.load(f)
else:
reviewed_data = {"reviewed": []}
def save_reviewed():
with open(REVIEWED_FILE, "w") as f:
json.dump(reviewed_data, f, indent=2, ensure_ascii=False)
HTML_PAGE = r"""
FFTCG Card Reviewer
0 cards
"""
class CardReviewerHandler(BaseHTTPRequestHandler):
def log_message(self, format, *args):
# Suppress default access logging
pass
def _send_json(self, data, status=200):
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _send_html(self, html):
body = html.encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _send_404(self):
self.send_response(404)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(b"Not Found")
def do_GET(self):
parsed = urlparse(self.path)
path = parsed.path
# Serve main page
if path == "/" or path == "":
self._send_html(HTML_PAGE)
return
# API: get all cards
if path == "/api/cards":
self._send_json(cards_data)
return
# API: get reviewed state
if path == "/api/reviewed":
self._send_json(reviewed_data)
return
# API: get single card
if path.startswith("/api/cards/"):
card_id = path[len("/api/cards/"):]
card = next((c for c in cards_data["cards"] if c["id"] == card_id), None)
if card:
self._send_json(card)
else:
self._send_404()
return
# Serve card images
if path.startswith("/images/"):
filename = path[len("/images/"):]
filepath = SOURCE_CARDS_DIR / filename
if filepath.exists() and filepath.is_file():
with open(filepath, "rb") as f:
data = f.read()
self.send_response(200)
ext = filepath.suffix.lower()
if ext in (".jpg", ".jpeg"):
self.send_header("Content-Type", "image/jpeg")
elif ext == ".png":
self.send_header("Content-Type", "image/png")
else:
self.send_header("Content-Type", "application/octet-stream")
self.send_header("Content-Length", str(len(data)))
self.send_header("Cache-Control", "public, max-age=86400")
self.end_headers()
self.wfile.write(data)
else:
self._send_404()
return
self._send_404()
def do_POST(self):
parsed = urlparse(self.path)
path = parsed.path
# API: save reviewed state
if path == "/api/reviewed":
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length)
try:
global reviewed_data
reviewed_data = json.loads(body)
save_reviewed()
self._send_json({"ok": True})
except json.JSONDecodeError:
self._send_json({"error": "Invalid JSON"}, 400)
return
# API: save card
if path.startswith("/api/cards/"):
card_id = path[len("/api/cards/"):]
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length)
try:
new_data = json.loads(body)
except json.JSONDecodeError:
self._send_json({"error": "Invalid JSON"}, 400)
return
# Find and update card
for i, card in enumerate(cards_data["cards"]):
if card["id"] == card_id:
# Preserve abilities from existing card if not provided
if "abilities" not in new_data:
new_data["abilities"] = card.get("abilities", [])
cards_data["cards"][i] = new_data
save_cards()
self._send_json({"ok": True})
return
self._send_json({"error": "Card not found"}, 404)
return
self._send_404()
def main():
parser = argparse.ArgumentParser(description="FFTCG Card Data Reviewer")
parser.add_argument("--port", type=int, default=8080, help="Port to serve on")
parser.add_argument("--no-browser", action="store_true", help="Don't open browser automatically")
args = parser.parse_args()
load_cards()
load_reviewed()
print(f"Loaded {len(cards_data['cards'])} cards from {CARDS_FILE}")
print(f"Reviewed: {len(reviewed_data.get('reviewed', []))} cards")
print(f"Serving card images from {SOURCE_CARDS_DIR}")
print(f"Starting server at http://localhost:{args.port}")
server = HTTPServer(("localhost", args.port), CardReviewerHandler)
if not args.no_browser:
webbrowser.open(f"http://localhost:{args.port}")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nShutting down.")
server.shutdown()
if __name__ == "__main__":
main()