Files
cardpuller/routes/decks.py
T
2026-07-10 20:54:39 -05:00

305 lines
11 KiB
Python

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