diff --git a/src/server.ts b/src/server.ts index 83bf815..7902fc3 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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(); + 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((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((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); diff --git a/views/league.ejs b/views/league.ejs index aa54cf6..93e6005 100644 --- a/views/league.ejs +++ b/views/league.ejs @@ -9,3 +9,4 @@ <% if (admin) { %>
<% } %>

Deck performance (match W-D-L)

<% decks.forEach(([d, r]) => { %><% }) %>
DeckW-D-L
<%= d %><%= r.join('-') %>
+<% if (admin) { %>
<% } %> diff --git a/views/session.ejs b/views/session.ejs index 1fa6bbf..dff341b 100644 --- a/views/session.ejs +++ b/views/session.ejs @@ -23,10 +23,11 @@

If this session also has match rows, those are used for scoring instead.

<% } %> <% if (admin) { %>

Import Melee export

-

Upload a matches or standings 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.

+

Upload the night's Melee exports (JSON or CSV): select every round's matches export and/or the standings 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.

-
+

Import matches by hand

One match per line: round, player1, deck1, player2, deck2, p1 wins, p2 wins, draws. Leave player 2 blank for a bye.