305 lines
11 KiB
Python
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 |