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
+40
-13
No files matched your search
+35
-10
@@ -176,6 +176,15 @@ app.post("/admin/league/:id/session", admin, async (req, res) => {
|
|||||||
res.redirect(303, BASE + `/session/${s.id}`);
|
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) => {
|
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
|
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");
|
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
|
// 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.
|
// 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);
|
const sid = idOf(req);
|
||||||
await find("sessions", sid);
|
await find("sessions", sid);
|
||||||
const text = req.file ? req.file.buffer.toString("utf8") : String(req.body.data ?? "");
|
const inputs = ((req.files ?? []) as Express.Multer.File[]).map((f) => ({ name: f.originalname, text: f.buffer.toString("utf8") }));
|
||||||
let parsed: { matches: Match[] } | { rows: Row[] };
|
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 {
|
try {
|
||||||
const table = parseTable(text);
|
const table = parseTable(text);
|
||||||
parsed = isMatchExport(table) ? { matches: extractMatches(table) } : { rows: extract(table) };
|
if (!isMatchExport(table)) { standings.push({ name, rows: extract(table) }); continue; }
|
||||||
} catch (e) { throw new HttpError(400, (e as Error).message); }
|
const ms = extractMatches(table);
|
||||||
await ("matches" in parsed ? importMatches(sid, parsed.matches) : importStandings(sid, parsed.rows));
|
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}`);
|
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(
|
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
|
`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]));
|
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)`,
|
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]);
|
[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 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);
|
const pid = await resolver(c);
|
||||||
await c.query("DELETE FROM session_results WHERE session_id=$1", [sid]);
|
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`,
|
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])]);
|
[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) => {
|
app.post("/admin/session/:id/match-decks", admin, async (req, res) => {
|
||||||
const sid = idOf(req);
|
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><% } %>
|
<% 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>
|
<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>
|
<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><% } %>
|
<p><small>If this session also has match rows, those are used for scoring instead.</small></p><% } %>
|
||||||
<% if (admin) { %>
|
<% if (admin) { %>
|
||||||
<h2>Import Melee export</h2>
|
<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;
|
<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. Decks you've entered are kept. If a session has matches, they are used for scoring.</p>
|
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">
|
<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>
|
<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>
|
<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.
|
<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