Files
ReleaseNotesFormatter/index.html
2026-01-16 17:24:59 -05:00

918 lines
31 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Release Notes Formatter</title>
<style>
body {
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
margin: 0;
line-height: 1.35;
background: #f6f7f9;
color: #111;
}
.container {
max-width: 1100px;
margin: 0 auto;
padding: 28px 28px 48px;
}
.row { display: grid; grid-template-columns: 1fr; gap: 14px; }
.card {
border: 1px solid rgba(0,0,0,0.08);
border-radius: 12px;
padding: 14px;
background: #fff;
box-shadow: 0 1px 2px rgba(0,0,0,0.06);
}
.muted { color: #666; font-size: 14px; }
.btn {
padding: 10px 12px;
border-radius: 10px;
border: 1px solid rgba(0,0,0,0.15);
background: #fff;
cursor: pointer;
}
.btn:hover { background: #f2f4f7; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn:active { transform: translateY(1px); }
textarea {
width: 100%;
min-height: 260px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 13px;
padding: 10px;
border-radius: 10px;
border: 1px solid rgba(0,0,0,0.15);
background: #fbfcfe;
}
.grid2 { display: grid; grid-template-columns: 1fr; gap: 12px; }
@media (min-width: 980px) { .grid2 { grid-template-columns: 1fr 1fr; } }
.topbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.navRow { display: flex; flex-wrap: wrap; gap: 10px; }
.navBtn {
padding: 8px 10px;
border-radius: 999px;
border: 1px solid rgba(0,0,0,0.12);
background: #fff;
cursor: pointer;
font-size: 13px;
}
.navBtn:hover { background: #f2f4f7; }
.ok { color: #0a7; }
.err { color: #b00; white-space: pre-wrap; }
.small { font-size: 12px; color: #666; }
label { font-weight: 600; }
input[type="file"] { padding: 6px; }
.ascGrid { display: grid; grid-template-columns: 1fr; gap: 12px; margin-top: 10px; }
@media (min-width: 980px) { .ascGrid { grid-template-columns: 1fr 1fr; } }
.singleCol { grid-template-columns: 1fr !important; }
.ascBoxHeader { display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom: 8px; }
.ascLocale { font-weight: 700; }
.twoCol { display: grid; grid-template-columns: 1fr; gap: 10px; }
@media (min-width: 980px) { .twoCol { grid-template-columns: 1fr 1fr; } }
.fieldLabel { font-size: 12px; color: #444; font-weight: 700; margin: 0 0 6px 0; }
.fieldHeader { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 8px; }
.fieldBox textarea { min-height: 140px; }
@keyframes uploadFlash {
0% { background-color: #eafff6; }
100% { background-color: #fff; }
}
#uploadCard.flash {
animation: uploadFlash 5s ease;
}
/* Smooth copy feedback */
.btnLabel {
display: inline-block;
transition: opacity 180ms ease, transform 180ms ease;
will-change: opacity, transform;
}
.btn.copied {
border-color: rgba(0,170,119,0.6);
background: #eafff6;
}
/* Section highlight pulse animation */
@keyframes sectionPulse {
0% { box-shadow: 0 0 0 0 rgba(0,170,119,0.0); border-color: rgba(0,0,0,0.08); }
20% { box-shadow: 0 0 0 6px rgba(0,170,119,0.22); border-color: rgba(0,170,119,0.55); }
60% { box-shadow: 0 0 0 10px rgba(0,170,119,0.10); border-color: rgba(0,170,119,0.35); }
100% { box-shadow: 0 0 0 0 rgba(0,170,119,0.0); border-color: rgba(0,0,0,0.08); }
}
.sectionHighlight {
animation: sectionPulse 1.4s ease;
}
</style>
</head>
<body>
<div class="container">
<h2 style="margin: 0 0 6px 0;">Release Notes Formatter</h2>
<p class="muted" style="margin: 0 0 14px 0;">Upload a Gridly CSV export.</p>
<div class="row">
<div class="card" id="uploadCard">
<div class="topbar">
<div>
<label for="file">CSV file</label><br/>
<input id="file" type="file" accept=".csv,text/csv" />
<div id="fileInfo" class="small"></div>
</div>
<div>
<label for="gameVersion">Game version</label><br/>
<input id="gameVersion" type="text" inputmode="decimal" placeholder="e.g. 6.51.0" style="padding: 8px 10px; border-radius: 10px; border: 1px solid rgba(0,0,0,0.15); background:#fff; min-width: 160px;" />
<div id="versionHelp" class="small muted">Used for PlayFab News titles only. Format: x.xx.x</div>
</div>
<div style="flex:1"></div>
<button class="btn" id="btnRefresh" disabled>Refresh</button>
</div>
</div>
<div class="card" style="padding: 12px;">
<div class="navRow">
<button class="navBtn" type="button" data-scroll="googleSection">Google</button>
<button class="navBtn" type="button" data-scroll="appleSection">Apple</button>
<button class="navBtn" type="button" data-scroll="playfabAdcapSection">Playfab (AdCap)</button>
<button class="navBtn" type="button" data-scroll="playfabAdcomSection">Playfab (AdCom/AdAges)</button>
</div>
</div>
<div class="grid2">
<div class="card" id="googleSection">
<div class="topbar" style="justify-content: space-between;">
<div>
<h3 style="margin:0 0 6px 0;">Google Play output</h3>
<div class="muted">Tagged format like &lt;en-US&gt;...&lt;/en-US&gt;</div>
</div>
<button class="btn" id="btnCopyGoogle" disabled><span class="btnLabel">Copy</span></button>
</div>
<textarea id="outGoogle" readonly></textarea>
</div>
<div class="card" id="appleSection">
<h3 style="margin:0 0 6px 0;">App Store Connect output</h3>
<div id="ascBoxes" class="ascGrid"></div>
</div>
<div class="card" id="playfabAdcapSection">
<h3 style="margin:0 0 6px 0;">PlayFab News output (AdCap only)</h3>
<div class="muted" style="margin-bottom: 10px;">Add this JSON to: AdCap &gt; TitleData &gt; Primary Title Data &gt; <code>newsfeed_mobile</code> (PlayFab).</div>
<div id="adcapHelp" class="small muted" style="margin-bottom: 10px;">Requires a valid game version above. Uses English text only.</div>
<div class="topbar" style="justify-content: space-between;">
<div class="small muted">Output JSON</div>
<button class="btn" id="btnCopyAdcap" disabled><span class="btnLabel">Copy</span></button>
</div>
<textarea id="outAdcap" readonly></textarea>
</div>
<div class="card" id="playfabAdcomSection">
<h3 style="margin:0 0 6px 0;">PlayFab News output (AdCom/AdAges)</h3>
<div class="muted">Per-language Title + Body. Requires a valid game version above.</div>
<div id="playfabBoxes" class="ascGrid singleCol"></div>
</div>
</div>
<div class="card">
<div class="muted">
Notes:
<ul>
<li>If a locale column is missing or empty, its skipped.</li>
</ul>
</div>
</div>
<div class="card">
<h3 style="margin:0 0 6px 0;">Skipped locales</h3>
<div class="muted" style="margin-bottom: 10px;">Shows any expected locales that were missing or empty in the uploaded CSV.</div>
<div id="skippedLocales" class="small">Upload a CSV to see results.</div>
</div>
</div>
</div>
<script>
// --- Config: map Gridly column headers -> output tags (Google).
// Use array-of-pairs so the same Gridly column can be emitted multiple times.
const GOOGLE_TAGS = [
["English", "en-US"],
["Arabic", "ar"],
["MSA", "ar"],
["German", "de-DE"],
["English", "en-GB"],
["Spanish (LatAm)", "es-419"],
["Spanish (Spain)", "es-ES"],
["Spanish (LatAm)", "es-US"],
["French (France)", "fr-FR"],
["Italian", "it-IT"],
["Indonesian", "id"],
["Japanese", "ja-JP"],
["Korean", "ko-KR"],
["Polish", "pl-PL"],
["Portuguese (Brazil)", "pt-BR"],
["Russian", "ru-RU"],
["Turkish", "tr-TR"],
["Chinese (Traditional)", "zh-TW"],
["Chinese (Simplified)", "zh-CN"],
["Chinese (Simplified)", "zh-HK"],
["Tr Chinese, Taiwan", "zh-TW"],
];
// App Store Connect order: (display label not used), [possible Gridly columns], output tag
const ASC_ORDER = [
["English (US)", ["English"], "English (US)"],
["Arabic", ["Arabic", "MSA"], "Arabic"],
["Chinese Traditional", ["Tr Chinese, Taiwan", "Chinese Traditional", "Chinese (Traditional)", "Traditional Chinese"], "Chinese Traditional"],
["Chinese Simplified", ["Chinese Simplified", "Chinese (Simplified)", "Simplified Chinese"], "Chinese Simplified"],
["English (Canada)", ["English", "English (Canada)", "English CA"], "English (Canada)"],
["French", ["French (France)", "French"], "French"],
["German", ["German"], "German"],
["Indonesian", ["Indonesian"], "Indonesian"],
["Italian", ["Italian"], "Italian"],
["Japanese", ["Japanese"], "Japanese"],
["Korean", ["Korean"], "Korean"],
["Mexican Spanish", ["Spanish (LatAm)", "Spanish (Mexico)", "Mexican Spanish", "Spanish (Spain)"], "Spanish (Mexico)"],
["Polish", ["Polish"], "Polish"],
["Portuguese", ["Portuguese (Brazil)", "Portuguese"], "Portuguese (Brazil)"],
["Russian", ["Russian"], "Russian"],
["Spanish Spain", ["Spanish (Spain)"], "Spanish (Spain)"],
["Turkish", ["Turkish"], "Turkish"],
["English UK", ["English", "English (UK)", "English UK"], "English (UK)"],
];
// PlayFab News language order + templates
const PLAYFAB_ORDER = [
{ label: "English", columns: ["English"], titleTemplate: "{v} Update Live!" },
{ label: "Spanish", columns: ["Spanish (Spain)", "Spanish (LatAm)", "Spanish"], titleTemplate: "¡Actualización {v} disponible!" },
{ label: "Portuguese", columns: ["Portuguese (Brazil)", "Portuguese"], titleTemplate: "Atualização {v} disponível!" },
{ label: "French", columns: ["French (France)", "French"], titleTemplate: "Mise à jour {v} disponible !" },
{ label: "German", columns: ["German"], titleTemplate: "{v} Update Live!" },
{ label: "Italian", columns: ["Italian"], titleTemplate: "L'aggiornamento {v} è qui!" },
{ label: "Russian", columns: ["Russian"], titleTemplate: "Вышло обновление {v}!" },
{ label: "Japanese", columns: ["Japanese"], titleTemplate: "アップデート {v} 配信中!" },
{ label: "Korean", columns: ["Korean"], titleTemplate: "업데이트 {v} 라이브!" },
{ label: "Turkish", columns: ["Turkish"], titleTemplate: "{v} Güncellemesi Geldi!" },
{ label: "Polish", columns: ["Polish"], titleTemplate: "{v} Aktualizacja na żywo!" },
{ label: "Latin", columns: ["Spanish (LatAm)", "Spanish (Mexico)", "Mexican Spanish"], titleTemplate: "¡Actualización {v} disponible!" },
];
const fileInput = document.getElementById("file");
const fileInfo = document.getElementById("fileInfo");
const uploadCard = document.getElementById("uploadCard");
const outGoogle = document.getElementById("outGoogle");
const ascBoxes = document.getElementById("ascBoxes");
const btnCopyGoogle = document.getElementById("btnCopyGoogle");
const btnRefresh = document.getElementById("btnRefresh");
const skippedLocalesEl = document.getElementById("skippedLocales");
const gameVersionInput = document.getElementById("gameVersion");
const versionHelpEl = document.getElementById("versionHelp");
const playfabBoxes = document.getElementById("playfabBoxes");
const outAdcap = document.getElementById("outAdcap");
const btnCopyAdcap = document.getElementById("btnCopyAdcap");
const adcapHelpEl = document.getElementById("adcapHelp");
clearAdcap();
function clearAdcap() {
outAdcap.value = "";
btnCopyAdcap.disabled = true;
adcapHelpEl.classList.remove("err");
adcapHelpEl.classList.add("muted");
adcapHelpEl.textContent = "Requires a valid game version above. Uses English text only.";
}
function buildAdcapJson(rowObj, headers, version) {
if (!version || !isValidVersion(version)) return "";
const normalizedColMap = {};
for (const h of headers) normalizedColMap[h.trim()] = h;
const englishCol = normalizedColMap["English"];
const body = englishCol ? normalizeText(rowObj[englishCol]) : "";
if (!body) return "";
const payload = {
newsArticleTitle: `${version.trim()} Update Live!`,
newsArticleBody: body,
};
return JSON.stringify(payload, null, 2);
}
function renderAdcap(rowObj, headers, version) {
const json = buildAdcapJson(rowObj, headers, version);
if (!json) {
clearAdcap();
if ((version || "").trim() && !isValidVersion(version)) {
adcapHelpEl.textContent = "Invalid version format. Use x.xx.x (e.g. 6.51.0).";
adcapHelpEl.classList.remove("muted");
adcapHelpEl.classList.add("err");
}
return;
}
outAdcap.value = json;
btnCopyAdcap.disabled = false;
}
function isValidVersion(v) {
// x.xx.x where the middle is exactly 2 digits (per requirement)
return /^\d+\.\d{2}\.\d+$/.test(v.trim());
}
function getPlayfabTitle(template, version) {
return template.replaceAll("{v}", version);
}
function pickFirstNonEmpty(rowObj, normalizedColMap, possibleCols) {
for (const c of possibleCols) {
const actualCol = normalizedColMap[c.trim()];
if (!actualCol) continue;
const t = normalizeText(rowObj[actualCol]);
if (t) return t;
}
return "";
}
function clearPlayfab() {
playfabBoxes.innerHTML = "";
}
function renderPlayfabBoxes(rowObj, headers, version) {
clearPlayfab();
if (!version || !isValidVersion(version)) {
// show nothing if version missing/invalid
return;
}
// map trimmed header -> actual header
const normalizedColMap = {};
for (const h of headers) normalizedColMap[h.trim()] = h;
let any = false;
for (const item of PLAYFAB_ORDER) {
const body = pickFirstNonEmpty(rowObj, normalizedColMap, item.columns);
if (!body) continue; // skip if body missing
any = true;
const title = getPlayfabTitle(item.titleTemplate, version.trim());
const card = document.createElement("div");
card.className = "card";
const header = document.createElement("div");
header.className = "ascBoxHeader";
const locale = document.createElement("div");
locale.className = "ascLocale";
locale.textContent = item.label;
header.appendChild(locale);
card.appendChild(header);
const grid = document.createElement("div");
grid.style.display = "grid";
grid.style.gridTemplateColumns = "1fr";
grid.style.gap = "12px";
// Title box
{
const wrap = document.createElement("div");
const headerRow = document.createElement("div");
headerRow.className = "fieldHeader";
const lbl = document.createElement("div");
lbl.className = "fieldLabel";
lbl.textContent = "Title";
const btn = document.createElement("button");
btn.className = "btn";
btn.innerHTML = '<span class="btnLabel">Copy</span>';
btn.addEventListener("click", async () => {
try {
await copyToClipboard(title);
pulseCopied(btn);
setStatus(`✅ Copied PlayFab ${item.label} title`, "ok");
} catch {
setStatus("❌ Could not copy. Select and copy manually.", "err");
}
});
headerRow.appendChild(lbl);
headerRow.appendChild(btn);
const ta = document.createElement("textarea");
ta.readOnly = true;
ta.value = title;
ta.rows = 1;
ta.style.minHeight = "40px";
wrap.className = "fieldBox";
wrap.appendChild(headerRow);
wrap.appendChild(ta);
grid.appendChild(wrap);
}
// Body box
{
const wrap = document.createElement("div");
const headerRow = document.createElement("div");
headerRow.className = "fieldHeader";
const lbl = document.createElement("div");
lbl.className = "fieldLabel";
lbl.textContent = "Body";
const btn = document.createElement("button");
btn.className = "btn";
btn.innerHTML = '<span class="btnLabel">Copy</span>';
btn.addEventListener("click", async () => {
try {
await copyToClipboard(body);
pulseCopied(btn);
setStatus(`✅ Copied PlayFab ${item.label} body`, "ok");
} catch {
setStatus("❌ Could not copy. Select and copy manually.", "err");
}
});
headerRow.appendChild(lbl);
headerRow.appendChild(btn);
const ta = document.createElement("textarea");
ta.readOnly = true;
ta.value = body;
ta.style.minHeight = "120px";
wrap.className = "fieldBox";
wrap.appendChild(headerRow);
wrap.appendChild(ta);
grid.appendChild(wrap);
}
card.appendChild(grid);
playfabBoxes.appendChild(card);
}
// If nothing rendered, keep empty (no message), per requirement.
if (!any) {
clearPlayfab();
}
}
function setStatus(_msg, _type="") {
// Status UI removed; keep function so existing calls don't error.
}
function isCharCountCol(name) {
const c = (name || "").trim().toLowerCase();
return c.endsWith(" chars") || c.endsWith(" chrs");
}
function normalizeText(s) {
if (s == null) return "";
return String(s).replaceAll("\\n", "\n").replaceAll("\r\n", "\n").trim();
}
// Simple CSV parser that handles quotes + commas + newlines.
// (Good enough for typical Gridly exports; if you run into edge cases,
// you can swap this with PapaParse later.)
function parseCSV(text) {
const rows = [];
let row = [];
let cell = "";
let inQuotes = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
const next = text[i + 1];
if (inQuotes) {
if (ch === '"' && next === '"') { // escaped quote
cell += '"';
i++;
} else if (ch === '"') {
inQuotes = false;
} else {
cell += ch;
}
} else {
if (ch === '"') {
inQuotes = true;
} else if (ch === ',') {
row.push(cell);
cell = "";
} else if (ch === '\n') {
row.push(cell);
rows.push(row);
row = [];
cell = "";
} else if (ch === '\r') {
// ignore
} else {
cell += ch;
}
}
}
// last cell
row.push(cell);
rows.push(row);
// remove trailing empty rows
while (rows.length && rows[rows.length - 1].every(v => String(v || "").trim() === "")) rows.pop();
return rows;
}
function escapeHtml(s) {
return String(s)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function renderSkippedLocales(missingCols, emptyCols) {
if ((!missingCols || missingCols.length === 0) && (!emptyCols || emptyCols.length === 0)) {
skippedLocalesEl.innerHTML = '<span class="ok">✅ No skipped locales.</span>';
return;
}
const parts = [];
if (missingCols && missingCols.length) {
parts.push(
'<div style="margin-bottom:8px;"><b>Missing columns</b><div class="small muted">Not present in the CSV headers.</div></div>' +
'<div style="margin-bottom:12px;">' +
missingCols.map(c => `<code>${escapeHtml(c)}</code>`).join(' ') +
'</div>'
);
}
if (emptyCols && emptyCols.length) {
parts.push(
'<div style="margin-bottom:8px;"><b>Empty values</b><div class="small muted">Column exists but the selected row is blank.</div></div>' +
'<div>' +
emptyCols.map(c => `<code>${escapeHtml(c)}</code>`).join(' ') +
'</div>'
);
}
skippedLocalesEl.innerHTML = parts.join('');
}
function findFirstMeaningfulRow(objects, headers) {
for (const obj of objects) {
let hasText = false;
for (const h of headers) {
if (h === "Record ID" || isCharCountCol(h)) continue;
if (normalizeText(obj[h])) { hasText = true; break; }
}
if (hasText) return obj;
}
return objects[0] || null;
}
function buildOutputs(rowObj, headers) {
// map trimmed header -> actual header (tolerate whitespace)
const normalizedColMap = {};
for (const h of headers) normalizedColMap[h.trim()] = h;
const missingCols = [];
const emptyCols = [];
// GOOGLE
const googleBlocks = [];
for (const [gridlyCol, tag] of GOOGLE_TAGS) {
const actualCol = normalizedColMap[gridlyCol.trim()];
if (!actualCol) {
missingCols.push(gridlyCol);
continue;
}
const text = normalizeText(rowObj[actualCol]);
if (!text) {
emptyCols.push(gridlyCol);
continue;
}
googleBlocks.push(`<${tag}>\n${text}\n</${tag}>`);
}
// APP STORE CONNECT (structured + ordered)
const ascItems = [];
for (const [_label, possibleCols, tag] of ASC_ORDER) {
let found = "";
let sawAnyColumn = false;
let sawAnyNonEmpty = false;
for (const c of possibleCols) {
const actualCol = normalizedColMap[c.trim()];
if (!actualCol) continue;
sawAnyColumn = true;
const t = normalizeText(rowObj[actualCol]);
if (t) {
sawAnyNonEmpty = true;
found = t;
break;
}
}
if (!found) {
// Pick the first name as the representative for reporting
const representative = possibleCols[0];
if (!sawAnyColumn) missingCols.push(representative);
else if (!sawAnyNonEmpty) emptyCols.push(representative);
continue;
}
ascItems.push({ tag, text: found });
}
const uniq = (arr) => Array.from(new Set(arr));
return {
google: googleBlocks.join("\n\n"),
asc: ascItems,
skipped: {
missing: uniq(missingCols),
empty: uniq(emptyCols),
}
};
}
async function copyToClipboard(text) {
await navigator.clipboard.writeText(text);
}
function flashUploadCard() {
if (!uploadCard) return;
uploadCard.classList.remove("flash");
void uploadCard.offsetHeight; // restart animation
uploadCard.classList.add("flash");
}
function pulseCopied(btn, doneText = "✓") {
if (!btn) return;
const labelEl = btn.querySelector(".btnLabel");
const hasLabel = !!labelEl;
const prevText = hasLabel ? labelEl.textContent : btn.textContent;
btn.classList.add("copied");
const fadeOut = () => {
if (!hasLabel) return;
labelEl.style.opacity = "0";
labelEl.style.transform = "scale(0.98)";
};
const fadeIn = (scale = "1.08") => {
if (!hasLabel) return;
labelEl.style.opacity = "1";
labelEl.style.transform = `scale(${scale})`;
};
// Swap to check
fadeOut();
window.setTimeout(() => {
if (hasLabel) labelEl.textContent = doneText;
else btn.textContent = doneText;
fadeIn("1.08");
}, 140);
// Revert after 2.5s
window.setTimeout(() => {
fadeOut();
window.setTimeout(() => {
if (hasLabel) labelEl.textContent = prevText;
else btn.textContent = prevText;
fadeIn("1.0");
btn.classList.remove("copied");
}, 140);
}, 2500);
}
function renderAscBoxes(items) {
// items: [{ tag: "en-US", text: "...." }, ...]
ascBoxes.innerHTML = "";
if (!items.length) {
ascBoxes.innerHTML = `<div class="muted">No App Store locales found in the CSV.</div>`;
return;
}
for (const { tag, text } of items) {
const block = text;
const card = document.createElement("div");
card.className = "card";
const header = document.createElement("div");
header.className = "ascBoxHeader";
const locale = document.createElement("div");
locale.className = "ascLocale";
locale.textContent = tag;
const btn = document.createElement("button");
btn.className = "btn";
btn.innerHTML = '<span class="btnLabel">Copy</span>';
btn.addEventListener("click", async () => {
try {
await copyToClipboard(block);
pulseCopied(btn);
setStatus(`✅ Copied ${tag} App Store block`, "ok");
} catch {
setStatus("❌ Could not copy (browser blocked clipboard). Select text and copy manually.", "err");
}
});
header.appendChild(locale);
header.appendChild(btn);
const ta = document.createElement("textarea");
ta.readOnly = true;
ta.value = block;
card.appendChild(header);
card.appendChild(ta);
ascBoxes.appendChild(card);
}
}
function getAllAscCombinedText() {
const blocks = Array.from(ascBoxes.querySelectorAll("textarea")).map(t => t.value);
return blocks.join("\n\n");
}
// Helper to regenerate all outputs from a stored payload
function regenerateFromPayload(payload) {
if (!payload) return;
const { rowObj, headers, recordId } = payload;
const { google, asc, skipped } = buildOutputs(rowObj, headers);
outGoogle.value = google;
renderAscBoxes(asc);
renderSkippedLocales(skipped.missing, skipped.empty);
btnCopyGoogle.disabled = !google;
const version = (gameVersionInput.value || "").trim();
renderPlayfabBoxes(rowObj, headers, version);
renderAdcap(rowObj, headers, version);
// Version help text (non-blocking)
if (version && !isValidVersion(version)) {
versionHelpEl.textContent = "Invalid version format. Use x.xx.x (e.g. 6.51.0). PlayFab output hidden.";
versionHelpEl.classList.remove("muted");
versionHelpEl.classList.add("err");
} else {
versionHelpEl.textContent = "Used for PlayFab News titles only. Format: x.xx.x";
versionHelpEl.classList.remove("err");
versionHelpEl.classList.add("muted");
}
flashUploadCard();
setStatus(`✅ Generated output${recordId ? ` (Record ID: ${recordId})` : ""}`, "ok");
}
fileInput.addEventListener("change", async () => {
const file = fileInput.files?.[0];
outGoogle.value = "";
ascBoxes.innerHTML = "";
btnCopyGoogle.disabled = true;
btnRefresh.disabled = true;
skippedLocalesEl.textContent = "Upload a CSV to see results.";
clearPlayfab();
clearAdcap();
if (!file) return;
fileInfo.textContent = `${file.name} (${Math.round(file.size/1024)} KB)`;
setStatus("Reading CSV...", "small");
try {
const text = await file.text();
const grid = parseCSV(text);
if (grid.length < 2) {
throw new Error("CSV doesnt look like it has a header row + data row.");
}
const headers = grid[0].map(h => String(h ?? "").trim());
const dataRows = grid.slice(1);
// Convert to objects
const objects = dataRows
.filter(r => r.some(v => String(v ?? "").trim() !== ""))
.map(r => {
const obj = {};
for (let i = 0; i < headers.length; i++) obj[headers[i]] = r[i] ?? "";
return obj;
});
const rowObj = findFirstMeaningfulRow(objects, headers);
if (!rowObj) throw new Error("No usable data row found in CSV.");
const recordId = normalizeText(rowObj["Record ID"] || "");
const { google, asc, skipped } = buildOutputs(rowObj, headers);
outGoogle.value = google;
renderAscBoxes(asc);
renderSkippedLocales(skipped.missing, skipped.empty);
btnCopyGoogle.disabled = !google;
// ---- PlayFab output ----
const version = (gameVersionInput.value || "").trim();
renderPlayfabBoxes(rowObj, headers, version);
renderAdcap(rowObj, headers, version);
// Update help text styling (non-blocking)
if (version && !isValidVersion(version)) {
versionHelpEl.textContent = "Invalid version format. Use x.xx.x (e.g. 6.51.0). PlayFab output hidden.";
versionHelpEl.classList.remove("muted");
versionHelpEl.classList.add("err");
} else {
versionHelpEl.textContent = "Used for PlayFab News titles only. Format: x.xx.x";
versionHelpEl.classList.remove("err");
versionHelpEl.classList.add("muted");
}
window.__lastParsedPayload = { rowObj, headers, recordId };
btnRefresh.disabled = false;
flashUploadCard();
flashUploadCard();
setStatus(`✅ Generated output${recordId ? ` (Record ID: ${recordId})` : ""}`, "ok");
} catch (e) {
setStatus(`${e.message || e}`, "err");
}
});
btnCopyGoogle.addEventListener("click", async () => {
try {
await copyToClipboard(outGoogle.value);
pulseCopied(btnCopyGoogle);
setStatus("✅ Copied Google output to clipboard", "ok");
} catch {
setStatus("❌ Could not copy (browser blocked clipboard). Select text and copy manually.", "err");
}
});
btnCopyAdcap.addEventListener("click", async () => {
try {
await copyToClipboard(outAdcap.value);
pulseCopied(btnCopyAdcap);
setStatus("✅ Copied AdCap JSON to clipboard", "ok");
} catch {
setStatus("❌ Could not copy (browser blocked clipboard). Select text and copy manually.", "err");
}
});
btnRefresh.addEventListener("click", () => {
const payload = window.__lastParsedPayload;
if (!payload) return;
regenerateFromPayload(payload);
});
gameVersionInput.addEventListener("input", () => {
const payload = window.__lastParsedPayload;
if (!payload) {
clearPlayfab();
clearAdcap();
return;
}
regenerateFromPayload(payload);
});
</script>
<script>
// Section navigation buttons scroll + highlight
document.querySelectorAll('[data-scroll]').forEach((btn) => {
btn.addEventListener('click', () => {
const id = btn.getAttribute('data-scroll');
const el = document.getElementById(id);
if (!el) return;
// Remove highlight from any previously highlighted section
document.querySelectorAll('.sectionHighlight').forEach((x) => x.classList.remove('sectionHighlight'));
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
// Wait briefly for the scroll to settle, then pulse-highlight the card
window.setTimeout(() => {
el.classList.remove('sectionHighlight');
void el.offsetHeight; // restart animation
el.classList.add('sectionHighlight');
// Clean up the class after the animation finishes
window.setTimeout(() => {
el.classList.remove('sectionHighlight');
}, 1500);
}, 250);
});
});
</script>
</body>
</html>