import re import shutil import httpx from fastapi import APIRouter, HTTPException, Request import db import moxfield import worker from config import OUTPUTS from worker import is_scry_url router = APIRouter() def slugify(name: str) -> str: return re.sub(r"[^a-z0-9]+", "_", name.lower().strip()).strip("_") def valid_slug(slug: str) -> str: if not re.match(r"^[a-z0-9_]+$", slug): raise HTTPException(400, "invalid slug") return slug def line_to_entry(line: str) -> dict: line = line.strip() if is_scry_url(line): return {"name": "", "scry_url": line} return {"name": line, "scry_url": None} @router.get("/decks") async def list_decks(): return [dict(d) for d in db.get_decks()] @router.get("/decks/{slug}") async def get_deck(slug: str): slug = valid_slug(slug) d = db.get_deck(slug) if not d: raise HTTPException(404) return {**dict(d), "log": db.get_logs(slug)} @router.post("/decks/moxfield", status_code=201) async def create_deck_from_moxfield(request: Request): body = await request.json() url = (body.get("url") or "").strip() include_tokens = bool(body.get("include_tokens")) if not url: raise HTTPException(400, "Moxfield URL required") try: deck_id = moxfield.parse_deck_id(url) except ValueError as e: raise HTTPException(400, str(e)) async with httpx.AsyncClient(timeout=20) as client: try: data = await moxfield.fetch_deck(client, deck_id) except ValueError as e: raise HTTPException(502, str(e)) parsed = moxfield.extract(data, include_tokens) if not parsed["entries"]: raise HTTPException(400, "Moxfield deck has no cards") name = parsed["name"] slug = slugify(name) existing = db.get_deck(slug) if existing and existing["status"] in ("queued", "running"): raise HTTPException(409, "deck already processing") if existing and existing["source"] != "moxfield": raise HTTPException(409, "a manually-created deck with this name already exists — rename or delete it first") commander_field = "\n".join(parsed["commanders"]) or None with db.conn() as c: c.execute( "INSERT OR REPLACE INTO decks " "(slug, deck_name, status, commander, price_usd, done, total, source, moxfield_id, include_tokens) " "VALUES (?,?,?,?,0,0,?,?,?,?)", (slug, name, "queued", commander_field, len(parsed["entries"]), "moxfield", deck_id, int(include_tokens)) ) c.execute("DELETE FROM cards WHERE deck_slug=?", (slug,)) c.execute("DELETE FROM logs WHERE deck_slug=?", (slug,)) for i, e in enumerate(parsed["entries"]): c.execute( "INSERT INTO cards (deck_slug, name, position, scry_id, is_token, fetch_status) VALUES (?,?,?,?,?,'pending')", (slug, e["name"], i + 1, e["scry_id"], int(e["is_token"])) ) c.execute("INSERT INTO logs (deck_slug, line) VALUES (?,?)", (slug, f"Imported from Moxfield ({deck_id}).")) await worker.queue.put(slug) return {"slug": slug} @router.post("/decks/{slug}/moxfield-sync") async def sync_moxfield_deck(slug: str): slug = valid_slug(slug) deck = db.get_deck(slug) if not deck: raise HTTPException(404) if deck["source"] != "moxfield" or not deck["moxfield_id"]: raise HTTPException(400, "not a Moxfield-linked deck") if deck["status"] in ("queued", "running"): raise HTTPException(409, "deck is processing") async with httpx.AsyncClient(timeout=20) as client: try: data = await moxfield.fetch_deck(client, deck["moxfield_id"]) except ValueError as e: raise HTTPException(502, str(e)) parsed = moxfield.extract(data, bool(deck["include_tokens"])) if not parsed["entries"]: raise HTTPException(400, "Moxfield deck has no cards") commander_field = "\n".join(parsed["commanders"]) or None with db.conn() as c: existing_rows = c.execute( "SELECT id, scry_id, filename, back_filename FROM cards WHERE deck_slug=?", (slug,) ).fetchall() by_scry: dict[str, list] = {} remove_ids = [row["id"] for row in existing_rows if not row["scry_id"]] for row in existing_rows: if row["scry_id"]: by_scry.setdefault(row["scry_id"], []).append(row) to_insert = [] for e in parsed["entries"]: pool = by_scry.get(e["scry_id"]) if e["scry_id"] else None if pool: pool.pop(0) else: to_insert.append(e) remove_ids += [row["id"] for rows in by_scry.values() for row in rows] for rid in remove_ids: row = c.execute("SELECT filename, back_filename FROM cards WHERE id=?", (rid,)).fetchone() if row: for fn in (row["filename"], row["back_filename"]): if fn and (OUTPUTS / slug / fn).exists(): (OUTPUTS / slug / fn).unlink() c.execute("DELETE FROM cards WHERE id=?", (rid,)) max_pos = c.execute( "SELECT COALESCE(MAX(position), 0) FROM cards WHERE deck_slug=?", (slug,) ).fetchone()[0] for i, e in enumerate(to_insert): c.execute( "INSERT INTO cards (deck_slug, name, position, scry_id, is_token, fetch_status) VALUES (?,?,?,?,?,'pending')", (slug, e["name"], max_pos + i + 1, e["scry_id"], int(e["is_token"])) ) c.execute( "UPDATE decks SET deck_name=?, commander=?, status='queued' WHERE slug=?", (parsed["name"], commander_field, slug) ) added, removed = len(to_insert), len(remove_ids) c.execute( "INSERT INTO logs (deck_slug, line) VALUES (?,?)", (slug, f"Synced from Moxfield — +{added} / -{removed}.") ) db.recalc_done(slug) await worker.queue.put(slug) return {"slug": slug, "added": added, "removed": removed} @router.post("/decks", status_code=201) async def create_deck(request: Request): body = await request.json() name = body.get("deck_name", "").strip() card_text = body.get("cards", "").strip() commander = body.get("commander", "").strip() is_tokens = bool(body.get("is_tokens")) if not name: raise HTTPException(400, "deck_name required") entries = [line_to_entry(l) for l in card_text.splitlines() if l.strip()] if not is_tokens and not entries: raise HTTPException(400, "cards required") if is_tokens: commander = "" else: if not commander: commander = next((e["name"] for e in entries if e["name"]), "") if commander: idx = next((i for i, e in enumerate(entries) if e["name"] == commander), None) if idx is not None: entries.insert(0, entries.pop(idx)) else: entries.insert(0, {"name": commander, "scry_url": None}) slug = slugify(name) existing = db.get_deck(slug) if existing and existing["status"] in ("queued", "running"): raise HTTPException(409, "deck already processing") if existing and existing["source"] == "moxfield": raise HTTPException(409, "a Moxfield-linked deck with this name already exists — sync or delete it first") with db.conn() as c: c.execute( "INSERT OR REPLACE INTO decks (slug, deck_name, status, commander, price_usd, done, total) VALUES (?,?,?,?,0,0,?)", (slug, name, "queued", commander or None, len(entries)) ) c.execute("DELETE FROM cards WHERE deck_slug=?", (slug,)) c.execute("DELETE FROM logs WHERE deck_slug=?", (slug,)) for i, e in enumerate(entries): c.execute( "INSERT INTO cards (deck_slug, name, position, scry_url, fetch_status) VALUES (?,?,?,?,'pending')", (slug, e["name"], i + 1, e["scry_url"]) ) await worker.queue.put(slug) return {"slug": slug} @router.patch("/decks/{slug}") async def edit_deck(slug: str, request: Request): slug = valid_slug(slug) deck = db.get_deck(slug) if not deck: raise HTTPException(404) if deck["source"] == "moxfield": raise HTTPException(409, "Moxfield-linked decks are updated by syncing, not manual edits") if deck["status"] in ("queued", "running"): raise HTTPException(409, "deck is processing") body = await request.json() remove_ids: list[int] = body.get("remove", []) add_entries = [line_to_entry(n) for n in body.get("add", []) if n.strip()] new_name: str = body.get("deck_name", deck["deck_name"]).strip() or deck["deck_name"] raw_cmd = body.get("commander", None) if raw_cmd is not None: new_commander = raw_cmd.strip() or (deck["commander"] or None) else: new_commander = deck["commander"] with db.conn() as c: for cid in remove_ids: row = c.execute( "SELECT filename, back_filename FROM cards WHERE id=? AND deck_slug=?", (cid, slug) ).fetchone() if row: for fn in (row["filename"], row["back_filename"]): if fn and (OUTPUTS / slug / fn).exists(): (OUTPUTS / slug / fn).unlink() c.execute("DELETE FROM cards WHERE id=? AND deck_slug=?", (cid, slug)) max_pos = c.execute( "SELECT COALESCE(MAX(position), 0) FROM cards WHERE deck_slug=?", (slug,) ).fetchone()[0] for i, e in enumerate(add_entries): c.execute( "INSERT INTO cards (deck_slug, name, position, scry_url, fetch_status) VALUES (?,?,?,?,'pending')", (slug, e["name"], max_pos + i + 1, e["scry_url"]) ) if new_commander: row = c.execute( "SELECT id FROM cards WHERE deck_slug=? AND name=? LIMIT 1", (slug, new_commander) ).fetchone() if not row: c.execute( "INSERT INTO cards (deck_slug, name, position, fetch_status) VALUES (?,?,0,'pending')", (slug, new_commander) ) c.execute( "UPDATE decks SET deck_name=?, commander=?, status='queued' WHERE slug=?", (new_name, new_commander, slug) ) c.execute("INSERT INTO logs (deck_slug, line) VALUES (?,?)", (slug, "Deck edited — re-queued.")) db.recalc_done(slug) await worker.queue.put(slug) return {"slug": slug} @router.delete("/decks/{slug}", status_code=204) async def delete_deck(slug: str): slug = valid_slug(slug) deck = db.get_deck(slug) if not deck: raise HTTPException(404) if deck["status"] in ("queued", "running"): raise HTTPException(409, "can't delete while processing") deck_dir = OUTPUTS / slug if deck_dir.exists(): shutil.rmtree(deck_dir) with db.conn() as c: c.execute("DELETE FROM decks WHERE slug=?", (slug,)) return None