Upload several Melee exports at once; let admins delete leagues
- The session import takes many files (every round's matches export plus the standings export) in one transaction; a bad file, a round repeated across files, or two standings exports rejects the whole batch. - Delete league (with its sessions and results; players are kept). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
985c85bcef
commit
0e14228b8f
3 files changed
+42
-15
No files matched your search
+37
-12
@@ -176,6 +176,15 @@ app.post("/admin/league/:id/session", admin, async (req, res) => {
|
||||
res.redirect(303, BASE + `/session/${s.id}`);
|
||||
});
|
||||
|
||||
app.post("/admin/league/:id/delete", admin, async (req, res) => {
|
||||
const id = idOf(req);
|
||||
await tx(async (c) => {
|
||||
await c.query("DELETE FROM sessions WHERE league_id=$1", [id]); // matches/results cascade; players are shared, so kept
|
||||
if (!(await c.query("DELETE FROM leagues WHERE id=$1", [id])).rowCount) throw new HttpError(404, "Not found");
|
||||
});
|
||||
res.redirect(303, BASE + "/");
|
||||
});
|
||||
|
||||
app.post("/admin/session/:id/delete", admin, async (req, res) => {
|
||||
const [s] = await q("DELETE FROM sessions WHERE id=$1 RETURNING league_id", [idOf(req)]); // matches/results cascade
|
||||
if (!s) throw new HttpError(404, "Not found");
|
||||
@@ -211,20 +220,36 @@ app.post("/admin/session/:id/import", admin, async (req, res) => {
|
||||
|
||||
// Import a Melee matches export (replaces only the rounds in the file) or standings export (replaces the
|
||||
// session's imported standings). Uploaded or pasted; decks already entered are kept.
|
||||
app.post("/admin/session/:id/import-melee", admin, upload.single("file"), async (req, res) => {
|
||||
// Several files at once (any mix of matches exports and at most one standings export) go in one transaction,
|
||||
// so a bad file imports nothing.
|
||||
app.post("/admin/session/:id/import-melee", admin, upload.array("files", 50), async (req, res) => {
|
||||
const sid = idOf(req);
|
||||
await find("sessions", sid);
|
||||
const text = req.file ? req.file.buffer.toString("utf8") : String(req.body.data ?? "");
|
||||
let parsed: { matches: Match[] } | { rows: Row[] };
|
||||
try {
|
||||
const table = parseTable(text);
|
||||
parsed = isMatchExport(table) ? { matches: extractMatches(table) } : { rows: extract(table) };
|
||||
} catch (e) { throw new HttpError(400, (e as Error).message); }
|
||||
await ("matches" in parsed ? importMatches(sid, parsed.matches) : importStandings(sid, parsed.rows));
|
||||
const inputs = ((req.files ?? []) as Express.Multer.File[]).map((f) => ({ name: f.originalname, text: f.buffer.toString("utf8") }));
|
||||
if (String(req.body.data ?? "").trim()) inputs.push({ name: "pasted text", text: String(req.body.data) });
|
||||
if (!inputs.length) throw new HttpError(400, "Choose at least one file or paste an export");
|
||||
const matches: Match[] = [], standings: { name: string; rows: Row[] }[] = [], roundFrom = new Map<number, string>();
|
||||
for (const { name, text } of inputs) {
|
||||
try {
|
||||
const table = parseTable(text);
|
||||
if (!isMatchExport(table)) { standings.push({ name, rows: extract(table) }); continue; }
|
||||
const ms = extractMatches(table);
|
||||
for (const r of new Set(ms.map((m) => m.round))) {
|
||||
if (roundFrom.has(r)) throw new Error(`round ${r} is also in ${roundFrom.get(r)}`);
|
||||
roundFrom.set(r, name);
|
||||
}
|
||||
matches.push(...ms);
|
||||
} catch (e) { throw new HttpError(400, `${name}: ${(e as Error).message}`); }
|
||||
}
|
||||
if (standings.length > 1) throw new HttpError(400, `Only one standings export per session: ${standings.map((s) => s.name).join(", ")}`);
|
||||
await tx(async (c) => {
|
||||
if (matches.length) await importMatches(c, sid, matches);
|
||||
if (standings.length) await importStandings(c, sid, standings[0].rows);
|
||||
});
|
||||
res.redirect(303, BASE + `/session/${sid}`);
|
||||
});
|
||||
|
||||
const importMatches = (sid: number, matches: Match[]) => tx(async (c) => {
|
||||
async function importMatches(c: Q, sid: number, matches: Match[]) {
|
||||
const old = new Map<number, string>((await c.query(
|
||||
`SELECT p1_id AS pid, p1_deck AS deck FROM matches WHERE session_id=$1 AND p1_deck IS NOT NULL
|
||||
UNION SELECT p2_id, p2_deck FROM matches WHERE session_id=$1 AND p2_deck IS NOT NULL`, [sid])).rows.map((r) => [r.pid, r.deck]));
|
||||
@@ -237,9 +262,9 @@ const importMatches = (sid: number, matches: Match[]) => tx(async (c) => {
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
|
||||
[sid, m.round, a, b, old.get(a) ?? null, b == null ? null : old.get(b) ?? null, m.w1, m.w2, m.draws]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const importStandings = (sid: number, rows: Row[]) => tx(async (c) => {
|
||||
async function importStandings(c: Q, sid: number, rows: Row[]) {
|
||||
const old = new Map<number, string | null>((await c.query("SELECT player_id, deck FROM session_results WHERE session_id=$1", [sid])).rows.map((r) => [r.player_id, r.deck]));
|
||||
const pid = await resolver(c);
|
||||
await c.query("DELETE FROM session_results WHERE session_id=$1", [sid]);
|
||||
@@ -250,7 +275,7 @@ const importStandings = (sid: number, rows: Row[]) => tx(async (c) => {
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7) ON CONFLICT (session_id, player_id) DO NOTHING`,
|
||||
[sid, p, r.points, r.deck || old.get(p) || null, ...(r.wdl ?? [null, null, null])]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
app.post("/admin/session/:id/match-decks", admin, async (req, res) => {
|
||||
const sid = idOf(req);
|
||||
|
||||
@@ -9,3 +9,4 @@
|
||||
<% if (admin) { %><form method="post" action="<%= base %>/admin/league/<%= lg.id %>/session"><input type="date" name="date" required> <button>New session</button></form><% } %>
|
||||
<h2>Deck performance <small>(match W-D-L)</small></h2>
|
||||
<table><tr><th>Deck</th><th>W-D-L</th></tr><% decks.forEach(([d, r]) => { %><tr><td><%= d %></td><td><%= r.join('-') %></td></tr><% }) %></table>
|
||||
<% if (admin) { %><form method="post" action="<%= base %>/admin/league/<%= lg.id %>/delete" data-confirm="Delete this league and all its sessions and results? Players are kept."><button>Delete league</button></form><% } %>
|
||||
+4
-3
@@ -23,10 +23,11 @@
|
||||
<p><small>If this session also has match rows, those are used for scoring instead.</small></p><% } %>
|
||||
<% if (admin) { %>
|
||||
<h2>Import Melee export</h2>
|
||||
<p>Upload a <b>matches</b> or <b>standings</b> export from Melee (JSON or CSV), or paste it. A matches export holds one round, so import each round in turn;
|
||||
re-importing a round replaces just that round. Decks you've entered are kept. If a session has matches, they are used for scoring.</p>
|
||||
<p>Upload the night's Melee exports (JSON or CSV): select every round's <b>matches</b> export and/or the <b>standings</b> export at once, or paste one.
|
||||
Re-importing a round replaces just that round; a standings export replaces the imported standings. Decks you've entered are kept.
|
||||
If anything in the batch is invalid, nothing is imported. If a session has matches, they are used for scoring.</p>
|
||||
<form method="post" enctype="multipart/form-data" action="<%= base %>/admin/session/<%= gs.id %>/import-melee">
|
||||
<input type="file" name="file" accept=".csv,.json,.txt"><br>
|
||||
<input type="file" name="files" accept=".csv,.json,.txt" multiple><br>
|
||||
<textarea name="data" rows="5" style="width:100%;font-family:monospace" placeholder="...or paste here"></textarea><br><button>Import</button></form>
|
||||
<h2>Import matches by hand</h2>
|
||||
<p>One match per line: <code>round, player1, deck1, player2, deck2, p1 wins, p2 wins, draws</code>. Leave player 2 blank for a bye.
|
||||
|
||||
Reference in new issue
Block a user