Initial commit - v1
This commit is contained in:
918
index.html
Normal file
918
index.html
Normal file
@@ -0,0 +1,918 @@
|
||||
<!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 <en-US>...</en-US></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 > TitleData > Primary Title Data > <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, it’s 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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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 doesn’t 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>
|
||||
Reference in New Issue
Block a user