Filter and tokens update
This commit is contained in:
@@ -12,6 +12,12 @@ def conn():
|
||||
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():
|
||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
with conn() as c:
|
||||
@@ -31,6 +37,8 @@ def init():
|
||||
name TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
filename TEXT,
|
||||
back_filename TEXT,
|
||||
scry_url TEXT,
|
||||
price_usd REAL DEFAULT 0,
|
||||
fetch_status TEXT DEFAULT 'pending'
|
||||
);
|
||||
@@ -40,6 +48,8 @@ def init():
|
||||
line TEXT NOT NULL
|
||||
);
|
||||
""")
|
||||
_add_column(c, "cards", "back_filename", "TEXT")
|
||||
_add_column(c, "cards", "scry_url", "TEXT")
|
||||
|
||||
|
||||
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.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("/")
|
||||
async def index(_=Depends(auth)):
|
||||
return FileResponse("static/index.html")
|
||||
|
||||
@@ -33,6 +33,9 @@ async def deck_cards(slug: str):
|
||||
"position": c["position"],
|
||||
"filename": c["filename"],
|
||||
"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"],
|
||||
"price_usd": c["price_usd"],
|
||||
}
|
||||
@@ -94,6 +97,7 @@ async def search_cards(q: str = ""):
|
||||
"set_name": card.get("set_name", ""),
|
||||
"price": card.get("prices", {}).get("usd"),
|
||||
"image": img,
|
||||
"scry_url": card.get("scryfall_uri", "").split("?")[0],
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
+51
-31
@@ -6,6 +6,7 @@ from fastapi import APIRouter, HTTPException, Request
|
||||
import db
|
||||
import worker
|
||||
from config import OUTPUTS
|
||||
from worker import is_scry_url
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -20,6 +21,13 @@ def valid_slug(slug: str) -> str:
|
||||
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()]
|
||||
@@ -40,20 +48,28 @@ async def create_deck(request: Request):
|
||||
name = body.get("deck_name", "").strip()
|
||||
card_text = body.get("cards", "").strip()
|
||||
commander = body.get("commander", "").strip()
|
||||
if not name or not card_text:
|
||||
raise HTTPException(400, "deck_name and cards required")
|
||||
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)
|
||||
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)
|
||||
if existing and existing["status"] in ("queued", "running"):
|
||||
raise HTTPException(409, "deck already processing")
|
||||
@@ -61,14 +77,14 @@ async def create_deck(request: Request):
|
||||
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, len(card_names))
|
||||
(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, card_name in enumerate(card_names):
|
||||
for i, e in enumerate(entries):
|
||||
c.execute(
|
||||
"INSERT INTO cards (deck_slug, name, position, fetch_status) VALUES (?,?,?,'pending')",
|
||||
(slug, card_name, i + 1)
|
||||
"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)
|
||||
@@ -86,37 +102,41 @@ async def edit_deck(slug: str, request: Request):
|
||||
|
||||
body = await request.json()
|
||||
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_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:
|
||||
for cid in remove_ids:
|
||||
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()
|
||||
if row and row["filename"]:
|
||||
p = OUTPUTS / slug / row["filename"]
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
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, card_name in enumerate(add_names):
|
||||
for i, e in enumerate(add_entries):
|
||||
c.execute(
|
||||
"INSERT INTO cards (deck_slug, name, position, fetch_status) VALUES (?,?,?,'pending')",
|
||||
(slug, card_name, max_pos + i + 1)
|
||||
"INSERT INTO cards (deck_slug, name, position, scry_url, fetch_status) VALUES (?,?,?,?,'pending')",
|
||||
(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
|
||||
existing_commander = c.execute(
|
||||
"SELECT id FROM cards WHERE deck_slug=? AND name=? LIMIT 1",
|
||||
(slug, new_commander)
|
||||
if new_commander:
|
||||
row = c.execute(
|
||||
"SELECT id FROM cards WHERE deck_slug=? AND name=? LIMIT 1", (slug, new_commander)
|
||||
).fetchone()
|
||||
if not existing_commander:
|
||||
if not row:
|
||||
c.execute(
|
||||
"INSERT INTO cards (deck_slug, name, position, fetch_status) VALUES (?,?,0,'pending')",
|
||||
(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-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); }
|
||||
.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; }
|
||||
.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); }
|
||||
@@ -126,8 +132,9 @@ textarea { resize: vertical; }
|
||||
<div class="section">
|
||||
<div class="section-label">New Deck</div>
|
||||
<input id="deckName" placeholder="Deck name" />
|
||||
<input id="deckCommander" placeholder="Commander (defaults to first card)" />
|
||||
<textarea id="cardList" placeholder="Paste card list — one per line"></textarea>
|
||||
<label class="token-toggle"><input type="checkbox" id="isTokens" onchange="onTokenToggle()" /> Token bundle (no commander)</label>
|
||||
<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">
|
||||
<button class="btn btn-green" id="submitBtn" onclick="submitDeck()" style="flex:1">Submit</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-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 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>
|
||||
</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 editMode = false, editRemovals = new Set(), editCommander = 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) {
|
||||
const r = await fetch(path, {
|
||||
@@ -213,6 +238,8 @@ async function selectDeck(slug) {
|
||||
if (sse) { sse.close(); sse = null; }
|
||||
if (cardPollTimer) { clearInterval(cardPollTimer); cardPollTimer = null; }
|
||||
activeDeck = slug;
|
||||
sortOrder = "default";
|
||||
document.getElementById("sortBtn").textContent = "Sort: Default";
|
||||
renderList();
|
||||
const d = deckData[slug];
|
||||
if (!d) return;
|
||||
@@ -245,6 +272,7 @@ function updateHeader(d) {
|
||||
document.getElementById("editBtn").style.display = terminal && !editMode ? "" : "none";
|
||||
document.getElementById("saveBtn").style.display = editMode ? "" : "none";
|
||||
document.getElementById("cancelBtn").style.display = editMode ? "" : "none";
|
||||
document.getElementById("sortBtn").style.display = !editMode ? "" : "none";
|
||||
}
|
||||
|
||||
async function refreshCards(slug) {
|
||||
@@ -258,7 +286,8 @@ async function refreshCards(slug) {
|
||||
|
||||
function renderCards(slug, cards) {
|
||||
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");
|
||||
if (!grid) {
|
||||
@@ -270,15 +299,18 @@ function renderCards(slug, cards) {
|
||||
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
|
||||
const existing = Array.from(grid.querySelectorAll(".card-tile, .card-tile.missing"));
|
||||
cards.forEach((card, i) => {
|
||||
displayCards.forEach((card, i) => {
|
||||
let tile = existing[i];
|
||||
const isCommander = card.name === commander;
|
||||
const isCommander = !isBundle && card.name === commander;
|
||||
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 = ""; }
|
||||
|
||||
@@ -289,22 +321,25 @@ function renderCards(slug, cards) {
|
||||
const imgUrl = `/cards/${slug}/${card.filename}`;
|
||||
tile.className = "card-tile" + (isCommander ? " is-commander" : "") + (isRemoved ? " marked-remove" : "");
|
||||
tile.innerHTML = `
|
||||
<img src="${imgUrl}" loading="lazy" alt="${esc(card.name)}" onclick="showLightbox('${imgUrl}')">
|
||||
<span class="card-label">${isCommander ? "♛ " : ""}${esc(card.name)}</span>
|
||||
<img src="${imgUrl}" loading="lazy" alt="${esc(label)}" onclick="showLightbox('${imgUrl}')">
|
||||
<span class="card-label">${isCommander ? "♛ " : ""}${esc(label)}</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>
|
||||
${!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 {
|
||||
const hint = card.fetch_status === "failed" ? " — failed" : " …";
|
||||
tile.className = "card-tile missing" + (isRemoved ? " marked-remove" : "");
|
||||
tile.innerHTML = `
|
||||
${esc(card.name)}
|
||||
${esc(label)}${hint}
|
||||
<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
|
||||
let addSection = area.querySelector(".edit-add-section");
|
||||
@@ -319,7 +354,7 @@ function renderCards(slug, cards) {
|
||||
<div class="search-results" id="searchResults"></div>
|
||||
</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>
|
||||
`;
|
||||
area.appendChild(addSection);
|
||||
@@ -369,7 +404,7 @@ async function saveEdit() {
|
||||
const deck = deckData[activeDeck];
|
||||
const body = {
|
||||
deck_name: deck.deck_name,
|
||||
commander: editCommander || deck.commander,
|
||||
commander: deck.commander ? (editCommander || deck.commander) : null,
|
||||
remove: [...editRemovals],
|
||||
add: addNames,
|
||||
};
|
||||
@@ -494,14 +529,16 @@ document.addEventListener("keydown", e => { if (e.key === "Escape") closeLightbo
|
||||
async function submitDeck() {
|
||||
const name = document.getElementById("deckName").value.trim();
|
||||
const cards = document.getElementById("cardList").value.trim();
|
||||
const isTokens = document.getElementById("isTokens").checked;
|
||||
const commander = document.getElementById("deckCommander").value.trim();
|
||||
const err = document.getElementById("submitError");
|
||||
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");
|
||||
btn.disabled = true;
|
||||
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();
|
||||
await loadDecks();
|
||||
selectDeck(r.slug);
|
||||
@@ -511,8 +548,15 @@ async function submitDeck() {
|
||||
} finally { btn.disabled = false; }
|
||||
}
|
||||
|
||||
function onTokenToggle() {
|
||||
const on = document.getElementById("isTokens").checked;
|
||||
document.getElementById("commanderWrap").style.display = on ? "none" : "";
|
||||
}
|
||||
|
||||
function clearForm() {
|
||||
["deckName","deckCommander","cardList"].forEach(id => document.getElementById(id).value = "");
|
||||
document.getElementById("isTokens").checked = false;
|
||||
onTokenToggle();
|
||||
document.getElementById("submitError").style.display = "none";
|
||||
}
|
||||
|
||||
@@ -539,12 +583,33 @@ async function deleteDeck() {
|
||||
} 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) {
|
||||
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);
|
||||
loadDecks();
|
||||
</script>
|
||||
|
||||
@@ -2,7 +2,7 @@ import asyncio
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
from urllib.parse import quote
|
||||
from urllib.parse import quote, unquote, urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -16,6 +16,19 @@ def safe_filename(name: str) -> str:
|
||||
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:
|
||||
for attempt in range(4):
|
||||
r = await client.get(url)
|
||||
@@ -28,48 +41,68 @@ async def _scryfall_get(client: httpx.AsyncClient, url: str, slug: str) -> httpx
|
||||
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"):
|
||||
url = f"https://api.scryfall.com/cards/named?{param}={quote(name.strip())}"
|
||||
r = await _scryfall_get(client, url, slug)
|
||||
if r.status_code == 200:
|
||||
break
|
||||
return r.json()
|
||||
if param == "fuzzy":
|
||||
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:
|
||||
img_url = data["image_uris"]["normal"]
|
||||
elif data.get("card_faces") and data["card_faces"][0].get("image_uris"):
|
||||
img_url = data["card_faces"][0]["image_uris"]["normal"]
|
||||
else:
|
||||
return data["image_uris"]["normal"], None
|
||||
faces = data.get("card_faces") or []
|
||||
front = faces[0]["image_uris"]["normal"] if faces and faces[0].get("image_uris") else None
|
||||
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")
|
||||
|
||||
img_r = await _scryfall_get(client, img_url, slug)
|
||||
img_r.raise_for_status()
|
||||
return img_r.content, price
|
||||
return front, back
|
||||
|
||||
|
||||
def build_tts(slug: str) -> dict:
|
||||
deck = db.get_deck(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"]]
|
||||
if not done_cards:
|
||||
done = [c for c in cards if c["fetch_status"] == "done" and c["filename"]]
|
||||
if not done:
|
||||
raise ValueError("no successfully fetched cards")
|
||||
|
||||
commander = next((c for c in done_cards if c["name"] == commander_name), done_cards[0])
|
||||
rest = [c for c in done_cards if c["id"] != commander["id"]]
|
||||
def card_url(fn: str) -> str:
|
||||
return f"{BASE_URL}/cards/{slug}/{fn}"
|
||||
|
||||
def card_url(filename: str) -> str:
|
||||
return f"{BASE_URL}/cards/{slug}/{filename}"
|
||||
|
||||
def custom_entry(url: str) -> dict:
|
||||
def entry(c) -> dict:
|
||||
if c["back_filename"]:
|
||||
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,
|
||||
"BackIsHidden": True, "UniqueBack": False, "Type": 0,
|
||||
}
|
||||
@@ -85,40 +118,56 @@ def build_tts(slug: str) -> dict:
|
||||
"LuaScript": "", "LuaScriptState": "", "XmlUI": "",
|
||||
}
|
||||
|
||||
commander_obj = {
|
||||
"Name": "Card", "Transform": transform(x=-5.0, rz=0.0),
|
||||
"Nickname": commander["name"], **base,
|
||||
"CardID": 100,
|
||||
"CustomDeck": {"1": custom_entry(card_url(commander["filename"]))},
|
||||
k = 0
|
||||
|
||||
def next_k() -> int:
|
||||
nonlocal k
|
||||
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 = [], {}, []
|
||||
for i, card in enumerate(rest):
|
||||
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},
|
||||
})
|
||||
commander = next((c for c in done if c["name"] == commander_name), None) if commander_name else None
|
||||
rest = [c for c in done if commander is None or c["id"] != commander["id"]]
|
||||
|
||||
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"],
|
||||
"Description": "", "Locked": False, "Grid": True, "Snap": True,
|
||||
"Autoraise": True, "Sticky": True, "Tooltip": True, "GridProjection": False,
|
||||
"HideWhenFaceDown": True, "Hands": False, "SidewaysCard": False,
|
||||
"DeckIDs": deck_ids, "CustomDeck": custom_deck, "ContainedObjects": contained,
|
||||
"LuaScript": "", "LuaScriptState": "", "XmlUI": "",
|
||||
}
|
||||
})
|
||||
elif rest:
|
||||
objects.append(card_object(rest[0], rz=0.0))
|
||||
|
||||
return {
|
||||
"SaveName": deck["deck_name"], "Date": "", "VersionNumber": "",
|
||||
"GameMode": "Tabletop Simulator", "Gravity": 0.5, "PlayArea": 0.5,
|
||||
"Table": "", "Sky": "", "Note": "", "TabStates": {},
|
||||
"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:
|
||||
name = card["name"]
|
||||
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
|
||||
label = f"[{card['position']}] {name}"
|
||||
|
||||
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
|
||||
label = f"[{card['position']}] {name or scry_url}"
|
||||
|
||||
if dest.exists():
|
||||
db.add_log(slug, f"{label} — SKIP")
|
||||
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)
|
||||
bf = back_filename if (deck_dir / back_filename).exists() else None
|
||||
with db.conn() as c:
|
||||
price_row = c.execute(
|
||||
"SELECT price_usd FROM cards WHERE deck_slug=? AND name=? AND fetch_status='done' LIMIT 1",
|
||||
(slug, name)
|
||||
"SELECT price_usd FROM cards WHERE deck_slug=? AND name=? AND fetch_status='done' AND id!=? LIMIT 1",
|
||||
(slug, name, card_id)
|
||||
).fetchone()
|
||||
price = price_row["price_usd"] if price_row else 0.0
|
||||
c.execute("UPDATE cards SET fetch_status='done', filename=?, price_usd=? WHERE id=?",
|
||||
(filename, price, card_id))
|
||||
price = price_row["price_usd"] if price_row else None
|
||||
if price is not None:
|
||||
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")
|
||||
else:
|
||||
db.add_log(slug, f"{label} — FAIL: source missing")
|
||||
with db.conn() as c:
|
||||
c.execute("UPDATE cards SET fetch_status='failed' WHERE id=?", (card_id,))
|
||||
else:
|
||||
db.recalc_done(slug)
|
||||
continue
|
||||
|
||||
try:
|
||||
data, price = await fetch_card_data(client, name, slug)
|
||||
dest.write_bytes(data)
|
||||
data = await resolve_card(client, slug, name, scry_url)
|
||||
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:
|
||||
c.execute("UPDATE cards SET fetch_status='done', filename=?, price_usd=? WHERE id=?",
|
||||
(filename, price, card_id))
|
||||
db.add_log(slug, f"{label} — OK (${price:.2f})")
|
||||
c.execute("UPDATE cards SET fetch_status='done', name=?, filename=?, back_filename=?, price_usd=? WHERE id=?",
|
||||
(resolved_name, filename, bf, price, card_id))
|
||||
db.add_log(slug, f"[{card['position']}] {resolved_name} — OK (${price:.2f}){' 2-sided' if back_url else ''}")
|
||||
await asyncio.sleep(0.5)
|
||||
except Exception as e:
|
||||
with db.conn() as c:
|
||||
@@ -198,10 +269,16 @@ async def _process(client: httpx.AsyncClient, slug: str):
|
||||
|
||||
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:
|
||||
tts = build_tts(slug)
|
||||
(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.add_log(slug, f"Complete. Deck value: ${total_price:.2f}")
|
||||
except Exception as e:
|
||||
|
||||
Reference in New Issue
Block a user