Filter and tokens update
This commit is contained in:
@@ -12,6 +12,12 @@ def conn():
|
|||||||
return c
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
def _add_column(c, table, col, decl):
|
||||||
|
cols = [r[1] for r in c.execute(f"PRAGMA table_info({table})").fetchall()]
|
||||||
|
if col not in cols:
|
||||||
|
c.execute(f"ALTER TABLE {table} ADD COLUMN {col} {decl}")
|
||||||
|
|
||||||
|
|
||||||
def init():
|
def init():
|
||||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with conn() as c:
|
with conn() as c:
|
||||||
@@ -31,6 +37,8 @@ def init():
|
|||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
position INTEGER NOT NULL,
|
position INTEGER NOT NULL,
|
||||||
filename TEXT,
|
filename TEXT,
|
||||||
|
back_filename TEXT,
|
||||||
|
scry_url TEXT,
|
||||||
price_usd REAL DEFAULT 0,
|
price_usd REAL DEFAULT 0,
|
||||||
fetch_status TEXT DEFAULT 'pending'
|
fetch_status TEXT DEFAULT 'pending'
|
||||||
);
|
);
|
||||||
@@ -40,6 +48,8 @@ def init():
|
|||||||
line TEXT NOT NULL
|
line TEXT NOT NULL
|
||||||
);
|
);
|
||||||
""")
|
""")
|
||||||
|
_add_column(c, "cards", "back_filename", "TEXT")
|
||||||
|
_add_column(c, "cards", "scry_url", "TEXT")
|
||||||
|
|
||||||
|
|
||||||
def get_deck(slug: str):
|
def get_deck(slug: str):
|
||||||
|
|||||||
@@ -82,6 +82,14 @@ app.include_router(cards_router, dependencies=[Depends(auth)])
|
|||||||
app.include_router(public_router) # no auth — TTS app fetches card images directly
|
app.include_router(public_router) # no auth — TTS app fetches card images directly
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/decks/{slug}/tts.json")
|
||||||
|
async def tts_json(slug: str):
|
||||||
|
path = OUTPUTS / slug / "deck.tts.json"
|
||||||
|
if not path.exists():
|
||||||
|
raise HTTPException(404, "TTS file not found")
|
||||||
|
return FileResponse(path, media_type="application/json")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def index(_=Depends(auth)):
|
async def index(_=Depends(auth)):
|
||||||
return FileResponse("static/index.html")
|
return FileResponse("static/index.html")
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ async def deck_cards(slug: str):
|
|||||||
"position": c["position"],
|
"position": c["position"],
|
||||||
"filename": c["filename"],
|
"filename": c["filename"],
|
||||||
"exists": bool(c["filename"] and (OUTPUTS / slug / c["filename"]).exists()),
|
"exists": bool(c["filename"] and (OUTPUTS / slug / c["filename"]).exists()),
|
||||||
|
"back_filename": c["back_filename"],
|
||||||
|
"back_exists": bool(c["back_filename"] and (OUTPUTS / slug / c["back_filename"]).exists()),
|
||||||
|
"scry_url": c["scry_url"],
|
||||||
"fetch_status": c["fetch_status"],
|
"fetch_status": c["fetch_status"],
|
||||||
"price_usd": c["price_usd"],
|
"price_usd": c["price_usd"],
|
||||||
}
|
}
|
||||||
@@ -94,6 +97,7 @@ async def search_cards(q: str = ""):
|
|||||||
"set_name": card.get("set_name", ""),
|
"set_name": card.get("set_name", ""),
|
||||||
"price": card.get("prices", {}).get("usd"),
|
"price": card.get("prices", {}).get("usd"),
|
||||||
"image": img,
|
"image": img,
|
||||||
|
"scry_url": card.get("scryfall_uri", "").split("?")[0],
|
||||||
})
|
})
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|||||||
+51
-31
@@ -6,6 +6,7 @@ from fastapi import APIRouter, HTTPException, Request
|
|||||||
import db
|
import db
|
||||||
import worker
|
import worker
|
||||||
from config import OUTPUTS
|
from config import OUTPUTS
|
||||||
|
from worker import is_scry_url
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -20,6 +21,13 @@ def valid_slug(slug: str) -> str:
|
|||||||
return 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")
|
@router.get("/decks")
|
||||||
async def list_decks():
|
async def list_decks():
|
||||||
return [dict(d) for d in db.get_decks()]
|
return [dict(d) for d in db.get_decks()]
|
||||||
@@ -40,20 +48,28 @@ async def create_deck(request: Request):
|
|||||||
name = body.get("deck_name", "").strip()
|
name = body.get("deck_name", "").strip()
|
||||||
card_text = body.get("cards", "").strip()
|
card_text = body.get("cards", "").strip()
|
||||||
commander = body.get("commander", "").strip()
|
commander = body.get("commander", "").strip()
|
||||||
if not name or not card_text:
|
is_tokens = bool(body.get("is_tokens"))
|
||||||
raise HTTPException(400, "deck_name and cards required")
|
|
||||||
|
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)
|
slug = slugify(name)
|
||||||
card_names = [l.strip() for l in card_text.splitlines() if l.strip()]
|
|
||||||
if not card_names:
|
|
||||||
raise HTTPException(400, "no cards")
|
|
||||||
if not commander:
|
|
||||||
commander = card_names[0]
|
|
||||||
|
|
||||||
# Commander always at position 0; remove any existing occurrence first
|
|
||||||
card_names = [n for n in card_names if n != commander]
|
|
||||||
card_names.insert(0, commander)
|
|
||||||
|
|
||||||
existing = db.get_deck(slug)
|
existing = db.get_deck(slug)
|
||||||
if existing and existing["status"] in ("queued", "running"):
|
if existing and existing["status"] in ("queued", "running"):
|
||||||
raise HTTPException(409, "deck already processing")
|
raise HTTPException(409, "deck already processing")
|
||||||
@@ -61,14 +77,14 @@ async def create_deck(request: Request):
|
|||||||
with db.conn() as c:
|
with db.conn() as c:
|
||||||
c.execute(
|
c.execute(
|
||||||
"INSERT OR REPLACE INTO decks (slug, deck_name, status, commander, price_usd, done, total) VALUES (?,?,?,?,0,0,?)",
|
"INSERT OR REPLACE INTO decks (slug, deck_name, status, commander, price_usd, done, total) VALUES (?,?,?,?,0,0,?)",
|
||||||
(slug, name, "queued", commander, len(card_names))
|
(slug, name, "queued", commander or None, len(entries))
|
||||||
)
|
)
|
||||||
c.execute("DELETE FROM cards WHERE deck_slug=?", (slug,))
|
c.execute("DELETE FROM cards WHERE deck_slug=?", (slug,))
|
||||||
c.execute("DELETE FROM logs WHERE deck_slug=?", (slug,))
|
c.execute("DELETE FROM logs WHERE deck_slug=?", (slug,))
|
||||||
for i, card_name in enumerate(card_names):
|
for i, e in enumerate(entries):
|
||||||
c.execute(
|
c.execute(
|
||||||
"INSERT INTO cards (deck_slug, name, position, fetch_status) VALUES (?,?,?,'pending')",
|
"INSERT INTO cards (deck_slug, name, position, scry_url, fetch_status) VALUES (?,?,?,?,'pending')",
|
||||||
(slug, card_name, i + 1)
|
(slug, e["name"], i + 1, e["scry_url"])
|
||||||
)
|
)
|
||||||
|
|
||||||
await worker.queue.put(slug)
|
await worker.queue.put(slug)
|
||||||
@@ -86,37 +102,41 @@ async def edit_deck(slug: str, request: Request):
|
|||||||
|
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
remove_ids: list[int] = body.get("remove", [])
|
remove_ids: list[int] = body.get("remove", [])
|
||||||
add_names: list[str] = [n.strip() for n in body.get("add", []) if n.strip()]
|
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"]
|
new_name: str = body.get("deck_name", deck["deck_name"]).strip() or deck["deck_name"]
|
||||||
new_commander: str = body.get("commander", deck["commander"]).strip() or deck["commander"]
|
|
||||||
|
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:
|
with db.conn() as c:
|
||||||
for cid in remove_ids:
|
for cid in remove_ids:
|
||||||
row = c.execute(
|
row = c.execute(
|
||||||
"SELECT filename FROM cards WHERE id=? AND deck_slug=?", (cid, slug)
|
"SELECT filename, back_filename FROM cards WHERE id=? AND deck_slug=?", (cid, slug)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if row and row["filename"]:
|
if row:
|
||||||
p = OUTPUTS / slug / row["filename"]
|
for fn in (row["filename"], row["back_filename"]):
|
||||||
if p.exists():
|
if fn and (OUTPUTS / slug / fn).exists():
|
||||||
p.unlink()
|
(OUTPUTS / slug / fn).unlink()
|
||||||
c.execute("DELETE FROM cards WHERE id=? AND deck_slug=?", (cid, slug))
|
c.execute("DELETE FROM cards WHERE id=? AND deck_slug=?", (cid, slug))
|
||||||
|
|
||||||
max_pos = c.execute(
|
max_pos = c.execute(
|
||||||
"SELECT COALESCE(MAX(position), 0) FROM cards WHERE deck_slug=?", (slug,)
|
"SELECT COALESCE(MAX(position), 0) FROM cards WHERE deck_slug=?", (slug,)
|
||||||
).fetchone()[0]
|
).fetchone()[0]
|
||||||
|
|
||||||
for i, card_name in enumerate(add_names):
|
for i, e in enumerate(add_entries):
|
||||||
c.execute(
|
c.execute(
|
||||||
"INSERT INTO cards (deck_slug, name, position, fetch_status) VALUES (?,?,?,'pending')",
|
"INSERT INTO cards (deck_slug, name, position, scry_url, fetch_status) VALUES (?,?,?,?,'pending')",
|
||||||
(slug, card_name, max_pos + i + 1)
|
(slug, e["name"], max_pos + i + 1, e["scry_url"])
|
||||||
)
|
)
|
||||||
|
|
||||||
# Ensure commander has a card row; if not, insert at position 0 so it sorts first
|
if new_commander:
|
||||||
existing_commander = c.execute(
|
row = c.execute(
|
||||||
"SELECT id FROM cards WHERE deck_slug=? AND name=? LIMIT 1",
|
"SELECT id FROM cards WHERE deck_slug=? AND name=? LIMIT 1", (slug, new_commander)
|
||||||
(slug, new_commander)
|
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if not existing_commander:
|
if not row:
|
||||||
c.execute(
|
c.execute(
|
||||||
"INSERT INTO cards (deck_slug, name, position, fetch_status) VALUES (?,?,0,'pending')",
|
"INSERT INTO cards (deck_slug, name, position, fetch_status) VALUES (?,?,0,'pending')",
|
||||||
(slug, new_commander)
|
(slug, new_commander)
|
||||||
|
|||||||
+83
-18
@@ -65,6 +65,12 @@ textarea { resize: vertical; }
|
|||||||
.card-label { position: absolute; bottom: 0; left: 0; right: 0; background: rgba(0,0,0,0.82); color: #fff; font-size: 9px; padding: 3px 5px; opacity: 0; transition: opacity 0.15s; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.card-label { position: absolute; bottom: 0; left: 0; right: 0; background: rgba(0,0,0,0.82); color: #fff; font-size: 9px; padding: 3px 5px; opacity: 0; transition: opacity 0.15s; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.card-tile:hover .card-label { opacity: 1; }
|
.card-tile:hover .card-label { opacity: 1; }
|
||||||
.commander-crown { position: absolute; top: 3px; left: 3px; font-size: 13px; line-height: 1; pointer-events: none; text-shadow: 0 1px 3px rgba(0,0,0,0.9); }
|
.commander-crown { position: absolute; top: 3px; left: 3px; font-size: 13px; line-height: 1; pointer-events: none; text-shadow: 0 1px 3px rgba(0,0,0,0.9); }
|
||||||
|
.token-toggle { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--muted); margin-bottom: 6px; cursor: pointer; user-select: none; }
|
||||||
|
.token-toggle input { width: auto; margin: 0; }
|
||||||
|
.dfc-badge { position: absolute; bottom: 3px; left: 3px; background: rgba(0,0,0,0.78); color: var(--blue); font-size: 10px; line-height: 1; padding: 3px 5px; border-radius: 2px; cursor: pointer; z-index: 2; }
|
||||||
|
.dfc-badge:hover { background: rgba(20,28,40,0.95); }
|
||||||
|
.card-price { position: absolute; bottom: 0; right: 0; background: rgba(0,0,0,0.75); color: var(--green); font-size: 9px; padding: 2px 4px; line-height: 1.4; pointer-events: none; }
|
||||||
|
.card-price.free { color: var(--muted); }
|
||||||
.card-overlay-btn { position: absolute; display: none; border: none; cursor: pointer; font-size: 13px; line-height: 1; padding: 3px 5px; border-radius: 2px; }
|
.card-overlay-btn { position: absolute; display: none; border: none; cursor: pointer; font-size: 13px; line-height: 1; padding: 3px 5px; border-radius: 2px; }
|
||||||
.btn-remove-card { top: 3px; right: 3px; background: rgba(180,0,0,0.8); color: #fff; }
|
.btn-remove-card { top: 3px; right: 3px; background: rgba(180,0,0,0.8); color: #fff; }
|
||||||
.btn-remove-card:hover { background: rgba(220,0,0,0.95); }
|
.btn-remove-card:hover { background: rgba(220,0,0,0.95); }
|
||||||
@@ -126,8 +132,9 @@ textarea { resize: vertical; }
|
|||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="section-label">New Deck</div>
|
<div class="section-label">New Deck</div>
|
||||||
<input id="deckName" placeholder="Deck name" />
|
<input id="deckName" placeholder="Deck name" />
|
||||||
<input id="deckCommander" placeholder="Commander (defaults to first card)" />
|
<label class="token-toggle"><input type="checkbox" id="isTokens" onchange="onTokenToggle()" /> Token bundle (no commander)</label>
|
||||||
<textarea id="cardList" placeholder="Paste card list — one per line"></textarea>
|
<div id="commanderWrap"><input id="deckCommander" placeholder="Commander (defaults to first card)" /></div>
|
||||||
|
<textarea id="cardList" placeholder="One per line — card name or Scryfall card URL"></textarea>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button class="btn btn-green" id="submitBtn" onclick="submitDeck()" style="flex:1">Submit</button>
|
<button class="btn btn-green" id="submitBtn" onclick="submitDeck()" style="flex:1">Submit</button>
|
||||||
<button class="btn" onclick="clearForm()">Clear</button>
|
<button class="btn" onclick="clearForm()">Clear</button>
|
||||||
@@ -149,7 +156,8 @@ textarea { resize: vertical; }
|
|||||||
<button class="btn btn-gold" id="editBtn" onclick="enterEditMode()" style="display:none">Edit</button>
|
<button class="btn btn-gold" id="editBtn" onclick="enterEditMode()" style="display:none">Edit</button>
|
||||||
<button class="btn btn-green" id="saveBtn" onclick="saveEdit()" style="display:none">Save</button>
|
<button class="btn btn-green" id="saveBtn" onclick="saveEdit()" style="display:none">Save</button>
|
||||||
<button class="btn" id="cancelBtn" onclick="exitEditMode(true)" style="display:none">Cancel</button>
|
<button class="btn" id="cancelBtn" onclick="exitEditMode(true)" style="display:none">Cancel</button>
|
||||||
<button class="btn btn-blue" id="dlBtn" onclick="downloadTTS()" style="display:none">↓ TTS</button>
|
<button class="btn" id="sortBtn" onclick="toggleSort()" style="display:none">Sort: Default</button>
|
||||||
|
<button class="btn btn-blue" id="dlBtn" onclick="copyTTSUrl()" style="display:none">Copy URL</button>
|
||||||
<button class="btn btn-red" id="delBtn" onclick="deleteDeck()" style="display:none">Delete</button>
|
<button class="btn btn-red" id="delBtn" onclick="deleteDeck()" style="display:none">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-area" id="cardArea"><div class="placeholder">Select a deck or submit a new one.</div></div>
|
<div class="card-area" id="cardArea"><div class="placeholder">Select a deck or submit a new one.</div></div>
|
||||||
@@ -171,6 +179,23 @@ let activeDeck = null, sse = null, cardPollTimer = null;
|
|||||||
let deckData = {}, currentCards = [];
|
let deckData = {}, currentCards = [];
|
||||||
let editMode = false, editRemovals = new Set(), editCommander = null;
|
let editMode = false, editRemovals = new Set(), editCommander = null;
|
||||||
let searchTimer = null;
|
let searchTimer = null;
|
||||||
|
let sortOrder = "default"; // "default" | "cost-desc" | "cost-asc"
|
||||||
|
|
||||||
|
function toggleSort() {
|
||||||
|
sortOrder = sortOrder === "default" ? "cost-desc" : sortOrder === "cost-desc" ? "cost-asc" : "default";
|
||||||
|
const labels = { "default": "Sort: Default", "cost-desc": "Sort: $ High", "cost-asc": "Sort: $ Low" };
|
||||||
|
document.getElementById("sortBtn").textContent = labels[sortOrder];
|
||||||
|
if (activeDeck) renderCards(activeDeck, currentCards);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortedCards(cards) {
|
||||||
|
if (sortOrder === "default") return cards;
|
||||||
|
const sorted = [...cards];
|
||||||
|
sorted.sort((a, b) => sortOrder === "cost-desc"
|
||||||
|
? (b.price_usd || 0) - (a.price_usd || 0)
|
||||||
|
: (a.price_usd || 0) - (b.price_usd || 0));
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
async function api(method, path, body) {
|
async function api(method, path, body) {
|
||||||
const r = await fetch(path, {
|
const r = await fetch(path, {
|
||||||
@@ -213,6 +238,8 @@ async function selectDeck(slug) {
|
|||||||
if (sse) { sse.close(); sse = null; }
|
if (sse) { sse.close(); sse = null; }
|
||||||
if (cardPollTimer) { clearInterval(cardPollTimer); cardPollTimer = null; }
|
if (cardPollTimer) { clearInterval(cardPollTimer); cardPollTimer = null; }
|
||||||
activeDeck = slug;
|
activeDeck = slug;
|
||||||
|
sortOrder = "default";
|
||||||
|
document.getElementById("sortBtn").textContent = "Sort: Default";
|
||||||
renderList();
|
renderList();
|
||||||
const d = deckData[slug];
|
const d = deckData[slug];
|
||||||
if (!d) return;
|
if (!d) return;
|
||||||
@@ -245,6 +272,7 @@ function updateHeader(d) {
|
|||||||
document.getElementById("editBtn").style.display = terminal && !editMode ? "" : "none";
|
document.getElementById("editBtn").style.display = terminal && !editMode ? "" : "none";
|
||||||
document.getElementById("saveBtn").style.display = editMode ? "" : "none";
|
document.getElementById("saveBtn").style.display = editMode ? "" : "none";
|
||||||
document.getElementById("cancelBtn").style.display = editMode ? "" : "none";
|
document.getElementById("cancelBtn").style.display = editMode ? "" : "none";
|
||||||
|
document.getElementById("sortBtn").style.display = !editMode ? "" : "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshCards(slug) {
|
async function refreshCards(slug) {
|
||||||
@@ -258,7 +286,8 @@ async function refreshCards(slug) {
|
|||||||
|
|
||||||
function renderCards(slug, cards) {
|
function renderCards(slug, cards) {
|
||||||
const area = document.getElementById("cardArea");
|
const area = document.getElementById("cardArea");
|
||||||
if (!cards.length) { area.innerHTML = '<div class="placeholder">No cards yet.</div>'; return; }
|
const displayCards = sortedCards(cards);
|
||||||
|
if (!displayCards.length) { area.innerHTML = '<div class="placeholder">No cards yet.</div>'; return; }
|
||||||
|
|
||||||
let grid = area.querySelector(".card-grid");
|
let grid = area.querySelector(".card-grid");
|
||||||
if (!grid) {
|
if (!grid) {
|
||||||
@@ -270,15 +299,18 @@ function renderCards(slug, cards) {
|
|||||||
grid.className = "card-grid" + (editMode ? " edit-mode" : "");
|
grid.className = "card-grid" + (editMode ? " edit-mode" : "");
|
||||||
}
|
}
|
||||||
|
|
||||||
const commander = editCommander || (deckData[slug] && deckData[slug].commander) || "";
|
const isBundle = !(deckData[slug] && deckData[slug].commander);
|
||||||
|
const commander = isBundle ? "" : (editCommander || deckData[slug].commander || "");
|
||||||
|
|
||||||
// Sync tiles
|
// Sync tiles
|
||||||
const existing = Array.from(grid.querySelectorAll(".card-tile, .card-tile.missing"));
|
const existing = Array.from(grid.querySelectorAll(".card-tile, .card-tile.missing"));
|
||||||
cards.forEach((card, i) => {
|
displayCards.forEach((card, i) => {
|
||||||
let tile = existing[i];
|
let tile = existing[i];
|
||||||
const isCommander = card.name === commander;
|
const isCommander = !isBundle && card.name === commander;
|
||||||
const isRemoved = editRemovals.has(card.id);
|
const isRemoved = editRemovals.has(card.id);
|
||||||
const tileKey = card.filename + "|" + card.exists + "|" + isCommander + "|" + isRemoved + "|" + editMode;
|
const label = cardLabel(card);
|
||||||
|
const priceStr = card.price_usd > 0 ? `$${card.price_usd.toFixed(2)}` : null;
|
||||||
|
const tileKey = [card.filename, card.exists, card.back_exists, isCommander, isRemoved, editMode, card.fetch_status, label, (card.price_usd || 0)].join("|");
|
||||||
|
|
||||||
if (!tile) { tile = document.createElement("div"); grid.appendChild(tile); tile.dataset.key = ""; }
|
if (!tile) { tile = document.createElement("div"); grid.appendChild(tile); tile.dataset.key = ""; }
|
||||||
|
|
||||||
@@ -289,22 +321,25 @@ function renderCards(slug, cards) {
|
|||||||
const imgUrl = `/cards/${slug}/${card.filename}`;
|
const imgUrl = `/cards/${slug}/${card.filename}`;
|
||||||
tile.className = "card-tile" + (isCommander ? " is-commander" : "") + (isRemoved ? " marked-remove" : "");
|
tile.className = "card-tile" + (isCommander ? " is-commander" : "") + (isRemoved ? " marked-remove" : "");
|
||||||
tile.innerHTML = `
|
tile.innerHTML = `
|
||||||
<img src="${imgUrl}" loading="lazy" alt="${esc(card.name)}" onclick="showLightbox('${imgUrl}')">
|
<img src="${imgUrl}" loading="lazy" alt="${esc(label)}" onclick="showLightbox('${imgUrl}')">
|
||||||
<span class="card-label">${isCommander ? "♛ " : ""}${esc(card.name)}</span>
|
<span class="card-label">${isCommander ? "♛ " : ""}${esc(label)}</span>
|
||||||
${isCommander ? `<span class="commander-crown">♛</span>` : ""}
|
${isCommander ? `<span class="commander-crown">♛</span>` : ""}
|
||||||
|
${card.back_exists ? `<div class="dfc-badge" title="flip side" onclick="event.stopPropagation();showLightbox('/cards/${slug}/${esc(card.back_filename)}')">⇄</div>` : ""}
|
||||||
|
${priceStr ? `<span class="card-price">${priceStr}</span>` : `<span class="card-price free">$0</span>`}
|
||||||
<button class="card-overlay-btn btn-remove-card" onclick="toggleRemove(${card.id})">✕</button>
|
<button class="card-overlay-btn btn-remove-card" onclick="toggleRemove(${card.id})">✕</button>
|
||||||
${!isCommander ? `<button class="card-overlay-btn btn-set-commander" onclick="setCommander('${esc(card.name)}')">♛</button>` : ""}
|
${!isCommander && !isBundle ? `<button class="card-overlay-btn btn-set-commander" onclick="setCommander('${esc(card.name).replace(/'/g,"\\'")}')">♛</button>` : ""}
|
||||||
`;
|
`;
|
||||||
} else {
|
} else {
|
||||||
|
const hint = card.fetch_status === "failed" ? " — failed" : " …";
|
||||||
tile.className = "card-tile missing" + (isRemoved ? " marked-remove" : "");
|
tile.className = "card-tile missing" + (isRemoved ? " marked-remove" : "");
|
||||||
tile.innerHTML = `
|
tile.innerHTML = `
|
||||||
${esc(card.name)}
|
${esc(label)}${hint}
|
||||||
<button class="card-overlay-btn btn-remove-card" onclick="toggleRemove(${card.id})">✕</button>
|
<button class="card-overlay-btn btn-remove-card" onclick="toggleRemove(${card.id})">✕</button>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
while (grid.children.length > cards.length) grid.removeChild(grid.lastChild);
|
while (grid.children.length > displayCards.length) grid.removeChild(grid.lastChild);
|
||||||
|
|
||||||
// Edit add section
|
// Edit add section
|
||||||
let addSection = area.querySelector(".edit-add-section");
|
let addSection = area.querySelector(".edit-add-section");
|
||||||
@@ -319,7 +354,7 @@ function renderCards(slug, cards) {
|
|||||||
<div class="search-results" id="searchResults"></div>
|
<div class="search-results" id="searchResults"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="section-label" style="margin-top:12px">Bulk Add (one per line)</div>
|
<div class="section-label" style="margin-top:12px">Bulk Add (one per line)</div>
|
||||||
<textarea id="bulkAddArea" placeholder="Paste card names..."></textarea>
|
<textarea id="bulkAddArea" placeholder="Card names or Scryfall card URLs — one per line"></textarea>
|
||||||
<div class="row"><button class="btn btn-green" onclick="saveEdit()" style="margin-top:4px">Save Changes</button></div>
|
<div class="row"><button class="btn btn-green" onclick="saveEdit()" style="margin-top:4px">Save Changes</button></div>
|
||||||
`;
|
`;
|
||||||
area.appendChild(addSection);
|
area.appendChild(addSection);
|
||||||
@@ -369,7 +404,7 @@ async function saveEdit() {
|
|||||||
const deck = deckData[activeDeck];
|
const deck = deckData[activeDeck];
|
||||||
const body = {
|
const body = {
|
||||||
deck_name: deck.deck_name,
|
deck_name: deck.deck_name,
|
||||||
commander: editCommander || deck.commander,
|
commander: deck.commander ? (editCommander || deck.commander) : null,
|
||||||
remove: [...editRemovals],
|
remove: [...editRemovals],
|
||||||
add: addNames,
|
add: addNames,
|
||||||
};
|
};
|
||||||
@@ -494,14 +529,16 @@ document.addEventListener("keydown", e => { if (e.key === "Escape") closeLightbo
|
|||||||
async function submitDeck() {
|
async function submitDeck() {
|
||||||
const name = document.getElementById("deckName").value.trim();
|
const name = document.getElementById("deckName").value.trim();
|
||||||
const cards = document.getElementById("cardList").value.trim();
|
const cards = document.getElementById("cardList").value.trim();
|
||||||
|
const isTokens = document.getElementById("isTokens").checked;
|
||||||
const commander = document.getElementById("deckCommander").value.trim();
|
const commander = document.getElementById("deckCommander").value.trim();
|
||||||
const err = document.getElementById("submitError");
|
const err = document.getElementById("submitError");
|
||||||
err.style.display = "none";
|
err.style.display = "none";
|
||||||
if (!name || !cards) { err.textContent = "Need a name and card list."; err.style.display = ""; return; }
|
if (!name) { err.textContent = "Need a name."; err.style.display = ""; return; }
|
||||||
|
if (!isTokens && !cards) { err.textContent = "Need a card list (or check Token bundle)."; err.style.display = ""; return; }
|
||||||
const btn = document.getElementById("submitBtn");
|
const btn = document.getElementById("submitBtn");
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
try {
|
try {
|
||||||
const r = await api("POST", "/decks", { deck_name: name, cards, commander });
|
const r = await api("POST", "/decks", { deck_name: name, cards, commander, is_tokens: isTokens });
|
||||||
clearForm();
|
clearForm();
|
||||||
await loadDecks();
|
await loadDecks();
|
||||||
selectDeck(r.slug);
|
selectDeck(r.slug);
|
||||||
@@ -511,8 +548,15 @@ async function submitDeck() {
|
|||||||
} finally { btn.disabled = false; }
|
} finally { btn.disabled = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onTokenToggle() {
|
||||||
|
const on = document.getElementById("isTokens").checked;
|
||||||
|
document.getElementById("commanderWrap").style.display = on ? "none" : "";
|
||||||
|
}
|
||||||
|
|
||||||
function clearForm() {
|
function clearForm() {
|
||||||
["deckName","deckCommander","cardList"].forEach(id => document.getElementById(id).value = "");
|
["deckName","deckCommander","cardList"].forEach(id => document.getElementById(id).value = "");
|
||||||
|
document.getElementById("isTokens").checked = false;
|
||||||
|
onTokenToggle();
|
||||||
document.getElementById("submitError").style.display = "none";
|
document.getElementById("submitError").style.display = "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -539,12 +583,33 @@ async function deleteDeck() {
|
|||||||
} catch (e) { alert(e.message); }
|
} catch (e) { alert(e.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
function downloadTTS() { if (activeDeck) window.location.href = `/decks/${activeDeck}/tts`; }
|
async function copyTTSUrl() {
|
||||||
|
if (!activeDeck) return;
|
||||||
|
const url = `${window.location.origin}/decks/${activeDeck}/tts.json`;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
const btn = document.getElementById("dlBtn");
|
||||||
|
const orig = btn.textContent;
|
||||||
|
btn.textContent = "Copied!";
|
||||||
|
setTimeout(() => btn.textContent = orig, 1500);
|
||||||
|
} catch {
|
||||||
|
prompt("Copy this URL:", url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function esc(s) {
|
function esc(s) {
|
||||||
return String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
return String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cardLabel(card) {
|
||||||
|
if (card.name) return card.name;
|
||||||
|
if (card.scry_url) {
|
||||||
|
const tail = (card.scry_url.split("/card/")[1] || "").replace(/\/$/, "").split("/");
|
||||||
|
if (tail.length >= 2) return `${tail[0]} · ${tail[1]}`;
|
||||||
|
}
|
||||||
|
return "card";
|
||||||
|
}
|
||||||
|
|
||||||
setInterval(loadDecks, 5000);
|
setInterval(loadDecks, 5000);
|
||||||
loadDecks();
|
loadDecks();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote, unquote, urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
@@ -16,6 +16,19 @@ def safe_filename(name: str) -> str:
|
|||||||
return re.sub(r"[^a-z0-9_]", "", name.lower().replace(" ", "_"))
|
return re.sub(r"[^a-z0-9_]", "", name.lower().replace(" ", "_"))
|
||||||
|
|
||||||
|
|
||||||
|
def is_scry_url(s: str) -> bool:
|
||||||
|
s = s.strip().lower()
|
||||||
|
return s.startswith("http") and "scryfall.com/card/" in s
|
||||||
|
|
||||||
|
|
||||||
|
def parse_scry_url(url: str) -> tuple[str, str] | None:
|
||||||
|
# https://scryfall.com/card/{set}/{number}[/{lang}]/{name-slug}
|
||||||
|
parts = [p for p in urlparse(url.strip()).path.split("/") if p]
|
||||||
|
if len(parts) >= 3 and parts[0] == "card":
|
||||||
|
return parts[1], unquote(parts[2])
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def _scryfall_get(client: httpx.AsyncClient, url: str, slug: str) -> httpx.Response:
|
async def _scryfall_get(client: httpx.AsyncClient, url: str, slug: str) -> httpx.Response:
|
||||||
for attempt in range(4):
|
for attempt in range(4):
|
||||||
r = await client.get(url)
|
r = await client.get(url)
|
||||||
@@ -28,48 +41,68 @@ async def _scryfall_get(client: httpx.AsyncClient, url: str, slug: str) -> httpx
|
|||||||
return r
|
return r
|
||||||
|
|
||||||
|
|
||||||
async def fetch_card_data(client: httpx.AsyncClient, name: str, slug: str) -> tuple[bytes, float]:
|
async def _fetch_bytes(client: httpx.AsyncClient, url: str, slug: str) -> bytes:
|
||||||
|
r = await _scryfall_get(client, url, slug)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.content
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_card(client: httpx.AsyncClient, slug: str, name: str, scry_url: str | None) -> dict:
|
||||||
|
if scry_url:
|
||||||
|
parsed = parse_scry_url(scry_url)
|
||||||
|
if not parsed:
|
||||||
|
raise ValueError("unrecognized scryfall url")
|
||||||
|
setcode, num = parsed
|
||||||
|
url = f"https://api.scryfall.com/cards/{quote(setcode)}/{quote(num)}"
|
||||||
|
r = await _scryfall_get(client, url, slug)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
for param in ("exact", "fuzzy"):
|
for param in ("exact", "fuzzy"):
|
||||||
url = f"https://api.scryfall.com/cards/named?{param}={quote(name.strip())}"
|
url = f"https://api.scryfall.com/cards/named?{param}={quote(name.strip())}"
|
||||||
r = await _scryfall_get(client, url, slug)
|
r = await _scryfall_get(client, url, slug)
|
||||||
if r.status_code == 200:
|
if r.status_code == 200:
|
||||||
break
|
return r.json()
|
||||||
if param == "fuzzy":
|
if param == "fuzzy":
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
|
raise ValueError("not found")
|
||||||
|
|
||||||
data = r.json()
|
|
||||||
price = float(data.get("prices", {}).get("usd") or 0)
|
|
||||||
|
|
||||||
|
def extract_images(data: dict) -> tuple[str, str | None]:
|
||||||
|
# Single image when the root carries image_uris (normal, split, flip, meld parts).
|
||||||
|
# Two distinct faces (transform / modal_dfc / double_faced_token / reversible)
|
||||||
|
# carry per-face image_uris and no root image_uris.
|
||||||
if "image_uris" in data:
|
if "image_uris" in data:
|
||||||
img_url = data["image_uris"]["normal"]
|
return data["image_uris"]["normal"], None
|
||||||
elif data.get("card_faces") and data["card_faces"][0].get("image_uris"):
|
faces = data.get("card_faces") or []
|
||||||
img_url = data["card_faces"][0]["image_uris"]["normal"]
|
front = faces[0]["image_uris"]["normal"] if faces and faces[0].get("image_uris") else None
|
||||||
else:
|
back = faces[1]["image_uris"]["normal"] if len(faces) >= 2 and faces[1].get("image_uris") else None
|
||||||
|
if not front:
|
||||||
raise ValueError("no image_uris in response")
|
raise ValueError("no image_uris in response")
|
||||||
|
return front, back
|
||||||
img_r = await _scryfall_get(client, img_url, slug)
|
|
||||||
img_r.raise_for_status()
|
|
||||||
return img_r.content, price
|
|
||||||
|
|
||||||
|
|
||||||
def build_tts(slug: str) -> dict:
|
def build_tts(slug: str) -> dict:
|
||||||
deck = db.get_deck(slug)
|
deck = db.get_deck(slug)
|
||||||
cards = db.get_cards(slug)
|
cards = db.get_cards(slug)
|
||||||
commander_name = deck["commander"]
|
commander_name = (deck["commander"] or "").strip()
|
||||||
|
|
||||||
done_cards = [c for c in cards if c["fetch_status"] == "done" and c["filename"]]
|
done = [c for c in cards if c["fetch_status"] == "done" and c["filename"]]
|
||||||
if not done_cards:
|
if not done:
|
||||||
raise ValueError("no successfully fetched cards")
|
raise ValueError("no successfully fetched cards")
|
||||||
|
|
||||||
commander = next((c for c in done_cards if c["name"] == commander_name), done_cards[0])
|
def card_url(fn: str) -> str:
|
||||||
rest = [c for c in done_cards if c["id"] != commander["id"]]
|
return f"{BASE_URL}/cards/{slug}/{fn}"
|
||||||
|
|
||||||
def card_url(filename: str) -> str:
|
def entry(c) -> dict:
|
||||||
return f"{BASE_URL}/cards/{slug}/{filename}"
|
if c["back_filename"]:
|
||||||
|
|
||||||
def custom_entry(url: str) -> dict:
|
|
||||||
return {
|
return {
|
||||||
"FaceURL": url, "BackURL": MTG_BACK,
|
"FaceURL": card_url(c["filename"]), "BackURL": card_url(c["back_filename"]),
|
||||||
|
"NumWidth": 1, "NumHeight": 1,
|
||||||
|
"BackIsHidden": False, "UniqueBack": True, "Type": 0,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"FaceURL": card_url(c["filename"]), "BackURL": MTG_BACK,
|
||||||
"NumWidth": 1, "NumHeight": 1,
|
"NumWidth": 1, "NumHeight": 1,
|
||||||
"BackIsHidden": True, "UniqueBack": False, "Type": 0,
|
"BackIsHidden": True, "UniqueBack": False, "Type": 0,
|
||||||
}
|
}
|
||||||
@@ -85,40 +118,56 @@ def build_tts(slug: str) -> dict:
|
|||||||
"LuaScript": "", "LuaScriptState": "", "XmlUI": "",
|
"LuaScript": "", "LuaScriptState": "", "XmlUI": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
commander_obj = {
|
k = 0
|
||||||
"Name": "Card", "Transform": transform(x=-5.0, rz=0.0),
|
|
||||||
"Nickname": commander["name"], **base,
|
def next_k() -> int:
|
||||||
"CardID": 100,
|
nonlocal k
|
||||||
"CustomDeck": {"1": custom_entry(card_url(commander["filename"]))},
|
k += 1
|
||||||
|
return k
|
||||||
|
|
||||||
|
def card_object(c, x=0.0, rz=180.0) -> dict:
|
||||||
|
n = next_k()
|
||||||
|
return {
|
||||||
|
"Name": "Card", "Transform": transform(x=x, rz=rz),
|
||||||
|
"Nickname": c["name"], **base, "CardID": n * 100,
|
||||||
|
"CustomDeck": {str(n): entry(c)},
|
||||||
}
|
}
|
||||||
|
|
||||||
deck_ids, custom_deck, contained = [], {}, []
|
commander = next((c for c in done if c["name"] == commander_name), None) if commander_name else None
|
||||||
for i, card in enumerate(rest):
|
rest = [c for c in done if commander is None or c["id"] != commander["id"]]
|
||||||
key = str(i + 2)
|
|
||||||
cid = (i + 2) * 100
|
|
||||||
entry = custom_entry(card_url(card["filename"]))
|
|
||||||
custom_deck[key] = entry
|
|
||||||
deck_ids.append(cid)
|
|
||||||
contained.append({
|
|
||||||
"Name": "Card", "Transform": transform(), "Nickname": card["name"],
|
|
||||||
**base, "CardID": cid, "CustomDeck": {key: entry},
|
|
||||||
})
|
|
||||||
|
|
||||||
deck_obj = {
|
objects = []
|
||||||
|
if commander:
|
||||||
|
objects.append(card_object(commander, x=-5.0, rz=0.0))
|
||||||
|
|
||||||
|
if len(rest) >= 2:
|
||||||
|
deck_ids, custom_deck, contained = [], {}, []
|
||||||
|
for c in rest:
|
||||||
|
n = next_k()
|
||||||
|
e = entry(c)
|
||||||
|
custom_deck[str(n)] = e
|
||||||
|
deck_ids.append(n * 100)
|
||||||
|
contained.append({
|
||||||
|
"Name": "Card", "Transform": transform(), "Nickname": c["name"],
|
||||||
|
**base, "CardID": n * 100, "CustomDeck": {str(n): e},
|
||||||
|
})
|
||||||
|
objects.append({
|
||||||
"Name": "Deck", "Transform": transform(), "Nickname": deck["deck_name"],
|
"Name": "Deck", "Transform": transform(), "Nickname": deck["deck_name"],
|
||||||
"Description": "", "Locked": False, "Grid": True, "Snap": True,
|
"Description": "", "Locked": False, "Grid": True, "Snap": True,
|
||||||
"Autoraise": True, "Sticky": True, "Tooltip": True, "GridProjection": False,
|
"Autoraise": True, "Sticky": True, "Tooltip": True, "GridProjection": False,
|
||||||
"HideWhenFaceDown": True, "Hands": False, "SidewaysCard": False,
|
"HideWhenFaceDown": True, "Hands": False, "SidewaysCard": False,
|
||||||
"DeckIDs": deck_ids, "CustomDeck": custom_deck, "ContainedObjects": contained,
|
"DeckIDs": deck_ids, "CustomDeck": custom_deck, "ContainedObjects": contained,
|
||||||
"LuaScript": "", "LuaScriptState": "", "XmlUI": "",
|
"LuaScript": "", "LuaScriptState": "", "XmlUI": "",
|
||||||
}
|
})
|
||||||
|
elif rest:
|
||||||
|
objects.append(card_object(rest[0], rz=0.0))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"SaveName": deck["deck_name"], "Date": "", "VersionNumber": "",
|
"SaveName": deck["deck_name"], "Date": "", "VersionNumber": "",
|
||||||
"GameMode": "Tabletop Simulator", "Gravity": 0.5, "PlayArea": 0.5,
|
"GameMode": "Tabletop Simulator", "Gravity": 0.5, "PlayArea": 0.5,
|
||||||
"Table": "", "Sky": "", "Note": "", "TabStates": {},
|
"Table": "", "Sky": "", "Note": "", "TabStates": {},
|
||||||
"LuaScript": "", "LuaScriptState": "", "XmlUI": "",
|
"LuaScript": "", "LuaScriptState": "", "XmlUI": "",
|
||||||
"ObjectStates": [commander_obj, deck_obj],
|
"ObjectStates": objects,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -150,46 +199,68 @@ async def _process(client: httpx.AsyncClient, slug: str):
|
|||||||
for card in pending:
|
for card in pending:
|
||||||
name = card["name"]
|
name = card["name"]
|
||||||
card_id = card["id"]
|
card_id = card["id"]
|
||||||
filename = f"{safe_filename(name)}_{card_id}.png"
|
scry_url = card["scry_url"]
|
||||||
|
base = safe_filename(name) if name else f"card_{card_id}"
|
||||||
|
filename = f"{base}_{card_id}.png"
|
||||||
|
back_filename = f"{base}_{card_id}_back.png"
|
||||||
dest = deck_dir / filename
|
dest = deck_dir / filename
|
||||||
label = f"[{card['position']}] {name}"
|
label = f"[{card['position']}] {name or scry_url}"
|
||||||
|
|
||||||
with db.conn() as c:
|
|
||||||
existing_row = c.execute(
|
|
||||||
"SELECT filename FROM cards WHERE deck_slug=? AND name=? AND fetch_status='done' AND id!=? LIMIT 1",
|
|
||||||
(slug, name, card_id)
|
|
||||||
).fetchone()
|
|
||||||
existing_filename = existing_row["filename"] if existing_row else None
|
|
||||||
|
|
||||||
if dest.exists():
|
if dest.exists():
|
||||||
db.add_log(slug, f"{label} — SKIP")
|
bf = back_filename if (deck_dir / back_filename).exists() else None
|
||||||
with db.conn() as c:
|
|
||||||
c.execute("UPDATE cards SET fetch_status='done', filename=? WHERE id=?", (filename, card_id))
|
|
||||||
elif existing_filename:
|
|
||||||
src = deck_dir / existing_filename
|
|
||||||
if src.exists():
|
|
||||||
shutil.copy(src, dest)
|
|
||||||
with db.conn() as c:
|
with db.conn() as c:
|
||||||
price_row = c.execute(
|
price_row = c.execute(
|
||||||
"SELECT price_usd FROM cards WHERE deck_slug=? AND name=? AND fetch_status='done' LIMIT 1",
|
"SELECT price_usd FROM cards WHERE deck_slug=? AND name=? AND fetch_status='done' AND id!=? LIMIT 1",
|
||||||
(slug, name)
|
(slug, name, card_id)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
price = price_row["price_usd"] if price_row else 0.0
|
price = price_row["price_usd"] if price_row else None
|
||||||
c.execute("UPDATE cards SET fetch_status='done', filename=?, price_usd=? WHERE id=?",
|
if price is not None:
|
||||||
(filename, price, card_id))
|
c.execute("UPDATE cards SET fetch_status='done', filename=?, back_filename=?, price_usd=? WHERE id=?",
|
||||||
|
(filename, bf, price, card_id))
|
||||||
|
else:
|
||||||
|
c.execute("UPDATE cards SET fetch_status='done', filename=?, back_filename=? WHERE id=?",
|
||||||
|
(filename, bf, card_id))
|
||||||
|
db.add_log(slug, f"{label} — SKIP")
|
||||||
|
db.recalc_done(slug)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Name-based dedup only — URL adds are precise prints and must never be aliased
|
||||||
|
if not scry_url:
|
||||||
|
with db.conn() as c:
|
||||||
|
row = c.execute(
|
||||||
|
"SELECT filename, back_filename, price_usd FROM cards "
|
||||||
|
"WHERE deck_slug=? AND name=? AND fetch_status='done' AND id!=? LIMIT 1",
|
||||||
|
(slug, name, card_id)
|
||||||
|
).fetchone()
|
||||||
|
if row and row["filename"] and (deck_dir / row["filename"]).exists():
|
||||||
|
shutil.copy(deck_dir / row["filename"], dest)
|
||||||
|
bf = None
|
||||||
|
if row["back_filename"] and (deck_dir / row["back_filename"]).exists():
|
||||||
|
shutil.copy(deck_dir / row["back_filename"], deck_dir / back_filename)
|
||||||
|
bf = back_filename
|
||||||
|
with db.conn() as c:
|
||||||
|
c.execute("UPDATE cards SET fetch_status='done', filename=?, back_filename=?, price_usd=? WHERE id=?",
|
||||||
|
(filename, bf, row["price_usd"], card_id))
|
||||||
db.add_log(slug, f"{label} — COPY")
|
db.add_log(slug, f"{label} — COPY")
|
||||||
else:
|
db.recalc_done(slug)
|
||||||
db.add_log(slug, f"{label} — FAIL: source missing")
|
continue
|
||||||
with db.conn() as c:
|
|
||||||
c.execute("UPDATE cards SET fetch_status='failed' WHERE id=?", (card_id,))
|
|
||||||
else:
|
|
||||||
try:
|
try:
|
||||||
data, price = await fetch_card_data(client, name, slug)
|
data = await resolve_card(client, slug, name, scry_url)
|
||||||
dest.write_bytes(data)
|
front_url, back_url = extract_images(data)
|
||||||
|
price = float(data.get("prices", {}).get("usd") or 0)
|
||||||
|
resolved_name = name or data.get("name", "")
|
||||||
|
|
||||||
|
dest.write_bytes(await _fetch_bytes(client, front_url, slug))
|
||||||
|
bf = None
|
||||||
|
if back_url:
|
||||||
|
(deck_dir / back_filename).write_bytes(await _fetch_bytes(client, back_url, slug))
|
||||||
|
bf = back_filename
|
||||||
|
|
||||||
with db.conn() as c:
|
with db.conn() as c:
|
||||||
c.execute("UPDATE cards SET fetch_status='done', filename=?, price_usd=? WHERE id=?",
|
c.execute("UPDATE cards SET fetch_status='done', name=?, filename=?, back_filename=?, price_usd=? WHERE id=?",
|
||||||
(filename, price, card_id))
|
(resolved_name, filename, bf, price, card_id))
|
||||||
db.add_log(slug, f"{label} — OK (${price:.2f})")
|
db.add_log(slug, f"[{card['position']}] {resolved_name} — OK (${price:.2f}){' 2-sided' if back_url else ''}")
|
||||||
await asyncio.sleep(0.5)
|
await asyncio.sleep(0.5)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
with db.conn() as c:
|
with db.conn() as c:
|
||||||
@@ -198,10 +269,16 @@ async def _process(client: httpx.AsyncClient, slug: str):
|
|||||||
|
|
||||||
db.recalc_done(slug)
|
db.recalc_done(slug)
|
||||||
|
|
||||||
|
all_cards = db.get_cards(slug)
|
||||||
|
if not all_cards:
|
||||||
|
db.update_deck(slug, status="complete", price_usd=0)
|
||||||
|
db.add_log(slug, "Empty bundle — add cards to export.")
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
tts = build_tts(slug)
|
tts = build_tts(slug)
|
||||||
(deck_dir / "deck.tts.json").write_text(json.dumps(tts, indent=2))
|
(deck_dir / "deck.tts.json").write_text(json.dumps(tts, indent=2))
|
||||||
total_price = sum(c["price_usd"] for c in db.get_cards(slug))
|
total_price = sum(c["price_usd"] for c in all_cards)
|
||||||
db.update_deck(slug, status="complete", price_usd=round(total_price, 2))
|
db.update_deck(slug, status="complete", price_usd=round(total_price, 2))
|
||||||
db.add_log(slug, f"Complete. Deck value: ${total_price:.2f}")
|
db.add_log(slug, f"Complete. Deck value: ${total_price:.2f}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
Reference in New Issue
Block a user