Moxfield import

This commit is contained in:
2026-07-10 20:54:39 -05:00
parent f22df0e1d4
commit f18c0c6564
7 changed files with 416 additions and 61 deletions
+3
View File
@@ -39,6 +39,7 @@ async def deck_cards(slug: str):
"back_filename": c["back_filename"],
"back_exists": bool(c["back_filename"] and (OUTPUTS / slug / c["back_filename"]).exists()),
"scry_url": c["scry_url"],
"is_token": bool(c["is_token"]),
"fetch_status": c["fetch_status"],
"price_usd": c["price_usd"],
}
@@ -52,6 +53,8 @@ async def upload_image(slug: str, name: str = Form(...), file: UploadFile = File
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")
name = name.strip()
if not name:
raise HTTPException(400, "name required")
+135
View File
@@ -1,9 +1,11 @@
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
@@ -42,6 +44,135 @@ async def get_deck(slug: str):
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()
@@ -73,6 +204,8 @@ async def create_deck(request: Request):
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(
@@ -97,6 +230,8 @@ async def edit_deck(slug: str, request: Request):
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")