Fix three matrix generation bugs found in a real generated sheet

Diagnosed directly against a real generated spreadsheet the user
shared, rather than guessing:

- Player/opponent names came out blank ("- Necrons" instead of "John
  Smith - Necrons"): fullName() takes the nested BCP user object
  ({firstName, lastName}), not the roster/teamplayer entry itself —
  same convention main.js's own playerRowHTML() already follows, now
  matched here via a shared nameOf() helper (with the same nickname
  fallback playerRowHTML uses too).

- That cascaded into a worse, harder-to-spot bug: with the name blank,
  a win-rate cell became literally " (62.5%)". Writes used
  valueInputOption: USER_ENTERED, which smart-parses values — Sheets
  read "(62.5%)" as accounting notation for a negative number and
  silently replaced the cell with e.g. "-62.50%" instead of the
  intended text. Same setting explains the "#ERROR!" cells: some raw
  list text got parsed as a formula. Switched to RAW, which stores
  exactly what's sent — correct for the plain descriptive labels this
  writes, never intended to be interpreted.

- Lists with a New Recruit "+++...+++" header came through raw,
  uncleaned: parseNRGW/parseNRTournament were vendored (needed for
  NR_GW/NR_TOURMENT detectFormat() results) but never actually wired
  into the format dispatch table, and aren't re-exported from the
  package's own index.js (only from the internal modules/parsers.js,
  which is now imported directly). Verified against the tool's own
  NR-GW/NR-Tournament sample fixtures with real Node before committing
  — both now correctly detected and cleaned.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SBzcNkW7JcYipnAX6HfgmK
This commit is contained in:
2026-09-04 18:26:57 -05:00
co-authored by Claude Sonnet 5
parent 5bb6268080
commit 30276c7ad5
+21 -10
View File
@@ -13,7 +13,10 @@
Drive's API returns 404, not 403, for a file outside the token's grant, Drive's API returns 404, not 403, for a file outside the token's grant,
so it looks identical to a bad file ID) and spreadsheets. */ so it looks identical to a bad file ID) and spreadsheets. */
import { detectFormat, parseGwAppV11, parseWarOrganV11, parseV11List, generateDiscordText, buildAbbreviationIndex } from "./vendor/40k-compactor/index.js"; import { generateDiscordText, buildAbbreviationIndex } from "./vendor/40k-compactor/index.js";
// parseNRGW/parseNRTournament aren't re-exported from the package's own
// index.js (only from this internal module) — pulled from here directly.
import { detectFormat, parseGwAppV11, parseWarOrganV11, parseV11List, parseNRGW, parseNRTournament } from "./vendor/40k-compactor/modules/parsers.js";
import skippableWargear from "./vendor/40k-compactor/skippable_wargear.json"; import skippableWargear from "./vendor/40k-compactor/skippable_wargear.json";
const GOOGLE_CLIENT_ID = "698705216189-rqbg3f7vlosi3077c3n410a7lpmsn16r.apps.googleusercontent.com"; const GOOGLE_CLIENT_ID = "698705216189-rqbg3f7vlosi3077c3n410a7lpmsn16r.apps.googleusercontent.com";
@@ -25,14 +28,12 @@ const SHEETS_API = "https://sheets.googleapis.com/v4/spreadsheets";
/* ---- List cleaning (vendored 40k-compactor — see vendor/40k-compactor/NOTICE.md) ---- */ /* ---- List cleaning (vendored 40k-compactor — see vendor/40k-compactor/NOTICE.md) ---- */
// detectFormat() only distinguishes the 11th-edition dialects this parser
// set understands; NR_TOURNAMENT isn't separately parseable from the
// functions this tool exports (no dedicated parser in the vendored code),
// so it falls back to the generic V11 parser same as an unrecognized format.
const PARSERS = { const PARSERS = {
GW_APP_V11: parseGwAppV11, GW_APP_V11: parseGwAppV11,
WAR_ORGAN_V11: parseWarOrganV11, WAR_ORGAN_V11: parseWarOrganV11,
V11_GENERIC: parseV11List, V11_GENERIC: parseV11List,
NR_GW: parseNRGW,
NR_TOURNAMENT: parseNRTournament,
}; };
// Returns cleaned, sheet-friendly (plain, not Discord-markdown) list text, // Returns cleaned, sheet-friendly (plain, not Discord-markdown) list text,
@@ -125,10 +126,17 @@ const batchUpdate = (token, fileId, requests) =>
body: JSON.stringify({ requests }), body: JSON.stringify({ requests }),
}); });
// RAW, not USER_ENTERED: everything written here (names, factions, win
// rates, list text) is a literal label, never meant to be interpreted.
// USER_ENTERED's smart-parsing was actively harmful — e.g. a blank name
// next to a win rate produced " (62.5%)", which Sheets read as accounting
// notation for a *negative* number and silently replaced the whole cell
// with e.g. "-62.50%"; list text starting with certain characters came
// back as "#ERROR!" for the same reason.
const batchUpdateValues = (token, fileId, data) => const batchUpdateValues = (token, fileId, data) =>
googleFetch(`${SHEETS_API}/${fileId}/values:batchUpdate`, token, { googleFetch(`${SHEETS_API}/${fileId}/values:batchUpdate`, token, {
method: "POST", method: "POST",
body: JSON.stringify({ valueInputOption: "USER_ENTERED", data }), body: JSON.stringify({ valueInputOption: "RAW", data }),
}); });
/* ---- Template layout ---- /* ---- Template layout ----
@@ -243,10 +251,14 @@ const a1 = (sheetTitle, col, row) => `'${sheetTitle.replace(/'/g, "''")}'!${colL
/* ---- Row/column value builders ---- */ /* ---- Row/column value builders ---- */
// fullName() takes the nested BCP user object ({firstName, lastName}), not
// the roster/teamplayer entry itself — same convention main.js's own
// playerRowHTML() uses, matched here.
const nameOf = member => fullNameOf(member.user || {}) || member.user?.nickname || "Unknown";
function playerLabel(member) { function playerLabel(member) {
const name = fullNameOf(member);
const faction = member.faction?.name || "Unknown"; const faction = member.faction?.name || "Unknown";
return `${name} - ${faction}`; return `${nameOf(member)} - ${faction}`;
} }
// fullName/fmtWpct live in main.js today; passed in rather than duplicated. // fullName/fmtWpct live in main.js today; passed in rather than duplicated.
@@ -258,11 +270,10 @@ export function configureMatrixHelpers({ fullName, fmtWpct }) {
} }
function opponentNameAndWinRate(member, careerRecords) { function opponentNameAndWinRate(member, careerRecords) {
const name = fullNameOf(member);
const uid = member.userId || member.user?.id; const uid = member.userId || member.user?.id;
const rec = careerRecords.get(uid); const rec = careerRecords.get(uid);
const wpct = rec ? fmtWpctOf(rec.w, rec.l, rec.t) : "—"; const wpct = rec ? fmtWpctOf(rec.w, rec.l, rec.t) : "—";
return `${name} (${wpct})`; return `${nameOf(member)} (${wpct})`;
} }
/* ---- Orchestration ---- */ /* ---- Orchestration ---- */