Initial Commit
This commit is contained in:
commit
ef1c188807
16 files changed
+570
No files matched your search
@@ -0,0 +1,80 @@
|
||||
// Parse a standings export (Melee JSON, or a generic CSV/JSON) into rows. No dependencies.
|
||||
export type Wdl = [number, number, number];
|
||||
export type Row = { name: string; points: number; deck: string; wdl: Wdl | null };
|
||||
type Rec = Record<string, any>;
|
||||
export class InputError extends Error {}
|
||||
|
||||
const PLAYER = ["player", "playername", "username", "playerusername", "name", "displayname", "team", "teamname"];
|
||||
const POINTS = ["points", "matchpoints", "totalpoints", "pts"];
|
||||
const DECK = ["deck", "deckname", "archetype", "decklistname", "decklistarchetype"];
|
||||
|
||||
const norm = (k: string) => k.toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||
const flat = (o: Rec, prefix = ""): Rec =>
|
||||
Object.entries(o).reduce<Rec>((out, [k, v]) =>
|
||||
v && typeof v === "object" && !Array.isArray(v) ? { ...out, ...flat(v, prefix + k) } : { ...out, [prefix + k]: v }, {});
|
||||
|
||||
export function csvRows(text: string, delim: string): string[][] {
|
||||
const rows: string[][] = [];
|
||||
let row: string[] = [], cell = "", quoted = false;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (quoted) {
|
||||
if (ch !== '"') cell += ch;
|
||||
else if (text[i + 1] === '"') { cell += '"'; i++; } else quoted = false;
|
||||
} else if (ch === '"' && cell === "") quoted = true;
|
||||
else if (ch === delim) { row.push(cell); cell = ""; }
|
||||
else if (ch === "\n" || ch === "\r") {
|
||||
if (ch === "\r" && text[i + 1] === "\n") i++;
|
||||
row.push(cell); rows.push(row); row = []; cell = "";
|
||||
} else cell += ch;
|
||||
}
|
||||
if (cell !== "" || row.length) { row.push(cell); rows.push(row); }
|
||||
return rows.map((r) => r.map((c) => c.trim())).filter((r) => r.some((c) => c !== ""));
|
||||
}
|
||||
|
||||
export function parseTable(input: string): Rec[] {
|
||||
const text = input.trim().replace(/^\uFEFF/, "");
|
||||
if (!text) return [];
|
||||
if (text[0] === "[" || text[0] === "{") {
|
||||
let j: any = JSON.parse(text);
|
||||
while (j && !Array.isArray(j) && typeof j === "object") j = Object.values(j).find(Array.isArray); // unwrap {"data": [...]}
|
||||
return Array.isArray(j) ? j.filter((r) => r && typeof r === "object") : [];
|
||||
}
|
||||
const first = text.split(/\r?\n/)[0];
|
||||
const count = (c: string) => first.split(c).length - 1;
|
||||
const delim = count("\t") ? "\t" : count(";") > count(",") ? ";" : ",";
|
||||
const [head, ...body] = csvRows(text, delim);
|
||||
return body.map((r) => Object.fromEntries(head.map((h, i) => [h, r[i] ?? ""])));
|
||||
}
|
||||
|
||||
// Melee's standings export holds one snapshot per round, so use the final round. Real names are used
|
||||
// (falling back to display name, then username).
|
||||
function melee(rows: Rec[]): Row[] {
|
||||
const last = rows.reduce((m, r) => Math.max(m, Number(r.RoundNumber) || 0), 0);
|
||||
return rows.filter((r) => Number(r.RoundNumber) === last).flatMap((r): Row[] => {
|
||||
const p = r.Team?.Players?.[0] ?? {};
|
||||
const name = String(p.Name || p.DisplayName || p.Username || "").trim();
|
||||
if (!name) return [];
|
||||
const nums = [r.Points, r.MatchWins, r.MatchDraws, r.MatchLosses].map(Number);
|
||||
if (!nums.every((n) => Number.isInteger(n) && n >= 0 && n < 1e6)) throw new InputError(`Bad numbers for ${name}`);
|
||||
return [{ name, points: nums[0], deck: "", wdl: [nums[1], nums[2], nums[3]] }];
|
||||
});
|
||||
}
|
||||
|
||||
export function extract(input: Rec[]): Row[] {
|
||||
if (!input.length) throw new InputError("No rows found in that file");
|
||||
if ("Team" in input[0] && "MatchWins" in input[0] && "RoundNumber" in input[0]) return melee(input);
|
||||
const rows = input.map((r) => flat(r));
|
||||
const by = new Map(Object.keys(rows[0]).map((h) => [norm(h), h]));
|
||||
const col = (aliases: string[]) => by.get(aliases.find((a) => by.has(a)) ?? "");
|
||||
const [pc, sc, dc] = [col(PLAYER), col(POINTS), col(DECK)];
|
||||
if (!pc || !sc) throw new InputError("Couldn't find the player/points columns. Headers in the file: " + [...by.values()].join(", "));
|
||||
return rows.flatMap((r): Row[] => {
|
||||
const name = String(r[pc] ?? "").trim();
|
||||
if (!name) return [];
|
||||
const pts = Number(String(r[sc]).trim());
|
||||
if (!Number.isFinite(pts)) throw new InputError(`Bad points value for ${name}: ${r[sc]}`);
|
||||
if (pts < 0 || pts > 1e6) throw new InputError(`Bad points value for ${name}: ${r[sc]}`);
|
||||
return [{ name, points: Math.round(pts), deck: dc ? String(r[dc] ?? "").trim() : "", wdl: null }];
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user