Form view: structured key/value editor (keys read-only, values editable; side-by-side desktop, stacked mobile) + testable strings-format.js
This commit is contained in:
@@ -7,6 +7,7 @@ Why stdlib only: no pip installs, one file to read, easy to see how it
|
|||||||
works. Routes:
|
works. Routes:
|
||||||
|
|
||||||
GET / -> the editor page (HTML)
|
GET / -> the editor page (HTML)
|
||||||
|
GET /strings-format.js -> .strings parse/serialize helpers (pure JS)
|
||||||
GET /file -> current Localizable.strings + git dirty banner
|
GET /file -> current Localizable.strings + git dirty banner
|
||||||
POST /save -> write the file + plutil lint {body: content}
|
POST /save -> write the file + plutil lint {body: content}
|
||||||
POST /save-build -> write the file, then start a background build job
|
POST /save-build -> write the file, then start a background build job
|
||||||
@@ -190,7 +191,8 @@ def start_build_job(content: str) -> str:
|
|||||||
# HTTP layer
|
# HTTP layer
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
PAGE = (Path(__file__).with_name("index.html")).read_text(encoding="utf-8")
|
PAGE = (Path(__file__).parent / "index.html").read_text(encoding="utf-8")
|
||||||
|
FORMAT_JS = (Path(__file__).parent / "strings-format.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
class Handler(BaseHTTPRequestHandler):
|
class Handler(BaseHTTPRequestHandler):
|
||||||
@@ -217,6 +219,9 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
if path in ("/", "/index.html"):
|
if path in ("/", "/index.html"):
|
||||||
self._send(200, PAGE.encode(), "text/html; charset=utf-8")
|
self._send(200, PAGE.encode(), "text/html; charset=utf-8")
|
||||||
return
|
return
|
||||||
|
if path == "/strings-format.js":
|
||||||
|
self._send(200, FORMAT_JS.encode(), "text/javascript; charset=utf-8")
|
||||||
|
return
|
||||||
if path == "/file":
|
if path == "/file":
|
||||||
if not self._authorized():
|
if not self._authorized():
|
||||||
self._json(401, {"error": "token required"})
|
self._json(401, {"error": "token required"})
|
||||||
|
|||||||
+186
-32
@@ -20,8 +20,15 @@
|
|||||||
padding: 8px 10px; border-radius: 8px; font-size: 13px; margin-bottom: 12px;
|
padding: 8px 10px; border-radius: 8px; font-size: 13px; margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
#dirty.ok { background: #12331c; border-color: #1f7a3d; color: #7ee2a0; }
|
#dirty.ok { background: #12331c; border-color: #1f7a3d; color: #7ee2a0; }
|
||||||
|
.toolbar { margin-bottom: 12px; }
|
||||||
|
.seg { display: flex; gap: 6px; margin-bottom: 10px; }
|
||||||
|
.seg-btn {
|
||||||
|
flex: 1; padding: 9px 0; border-radius: 10px; font-size: 14px; font-weight: 600;
|
||||||
|
background: #0d0f12; color: #8b93a1; border: 1px solid #2a313a; cursor: pointer;
|
||||||
|
}
|
||||||
|
.seg-btn.on { background: #12331c; border-color: #1f7a3d; color: #7ee2a0; }
|
||||||
select {
|
select {
|
||||||
width: 100%; margin-bottom: 12px; padding: 10px 12px; font-size: 15px;
|
width: 100%; margin-bottom: 10px; padding: 10px 12px; font-size: 15px;
|
||||||
background: #0d0f12; color: #e6e8eb; border: 1px solid #2a313a;
|
background: #0d0f12; color: #e6e8eb; border: 1px solid #2a313a;
|
||||||
border-radius: 10px; -webkit-appearance: none; appearance: none;
|
border-radius: 10px; -webkit-appearance: none; appearance: none;
|
||||||
}
|
}
|
||||||
@@ -30,6 +37,36 @@
|
|||||||
background: #0d0f12; color: #d7dce2; border: 1px solid #2a313a;
|
background: #0d0f12; color: #d7dce2; border: 1px solid #2a313a;
|
||||||
border-radius: 10px; padding: 12px; font: 13px/1.5 ui-monospace, Menlo, monospace;
|
border-radius: 10px; padding: 12px; font: 13px/1.5 ui-monospace, Menlo, monospace;
|
||||||
}
|
}
|
||||||
|
#notice {
|
||||||
|
display: none; background: #3a2b10; border: 1px solid #8a6410; color: #ffd479;
|
||||||
|
padding: 8px 10px; border-radius: 8px; font-size: 13px; margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
/* Form view ------------------------------------------------------------ */
|
||||||
|
.form { display: flex; flex-direction: column; }
|
||||||
|
.section {
|
||||||
|
font-size: 12px; font-weight: 700; letter-spacing: .07em; text-transform: uppercase;
|
||||||
|
color: #5fd68a; padding: 14px 2px 5px; margin-top: 8px;
|
||||||
|
border-bottom: 1px solid #1f7a3d33;
|
||||||
|
}
|
||||||
|
.comment { color: #5a6472; font-size: 12px; padding: 8px 2px; font-style: italic; }
|
||||||
|
.entry {
|
||||||
|
display: grid; grid-template-columns: minmax(0, 42%) minmax(0, 58%);
|
||||||
|
gap: 10px; align-items: start; padding: 7px 0; border-bottom: 1px solid #161a20;
|
||||||
|
}
|
||||||
|
.entry .k {
|
||||||
|
font: 12.5px/1.45 ui-monospace, Menlo, monospace; color: #9aa4b2;
|
||||||
|
word-break: break-word; padding-top: 8px; user-select: text;
|
||||||
|
}
|
||||||
|
.entry .v input {
|
||||||
|
width: 100%; background: #0d0f12; color: #e6e8eb; border: 1px solid #2a313a;
|
||||||
|
border-radius: 8px; padding: 7px 9px; font-size: 14px;
|
||||||
|
}
|
||||||
|
.entry .v input:focus { border-color: #1f7a3d; outline: none; }
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
/* Mobile: key on top, value below */
|
||||||
|
.entry { grid-template-columns: 1fr; gap: 3px; }
|
||||||
|
.entry .k { padding-top: 0; font-size: 11.5px; }
|
||||||
|
}
|
||||||
.row { display: flex; gap: 10px; margin: 12px 0; flex-wrap: wrap; }
|
.row { display: flex; gap: 10px; margin: 12px 0; flex-wrap: wrap; }
|
||||||
button {
|
button {
|
||||||
flex: 1; min-width: 140px; padding: 14px 16px; border: 0; border-radius: 12px;
|
flex: 1; min-width: 140px; padding: 14px 16px; border: 0; border-radius: 12px;
|
||||||
@@ -56,10 +93,18 @@
|
|||||||
<h1>⛽ FuelBoard Copy Editor</h1>
|
<h1>⛽ FuelBoard Copy Editor</h1>
|
||||||
<div class="meta"><code id="repo">…</code> · <span id="head">…</span></div>
|
<div class="meta"><code id="repo">…</code> · <span id="head">…</span></div>
|
||||||
<div id="dirty"></div>
|
<div id="dirty"></div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="seg">
|
||||||
|
<button id="btnForm" class="seg-btn on" type="button">Form</button>
|
||||||
|
<button id="btnRaw" class="seg-btn" type="button">Raw</button>
|
||||||
|
</div>
|
||||||
<select id="jump" disabled>
|
<select id="jump" disabled>
|
||||||
<option value="">Jump to section…</option>
|
<option value="">Jump to section…</option>
|
||||||
</select>
|
</select>
|
||||||
<textarea id="src" spellcheck="false" placeholder="Loading Localizable.strings…"></textarea>
|
</div>
|
||||||
|
<div id="notice"></div>
|
||||||
|
<div id="form" class="form"></div>
|
||||||
|
<textarea id="src" hidden spellcheck="false" placeholder="Loading Localizable.strings…"></textarea>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button id="save">Save</button>
|
<button id="save">Save</button>
|
||||||
<button id="saveBuild">Save & Build</button>
|
<button id="saveBuild">Save & Build</button>
|
||||||
@@ -67,6 +112,7 @@
|
|||||||
<div id="status"></div>
|
<div id="status"></div>
|
||||||
<div id="result"></div>
|
<div id="result"></div>
|
||||||
|
|
||||||
|
<script src="strings-format.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const KEY = "fbEditorToken";
|
const KEY = "fbEditorToken";
|
||||||
// Accept the token from the URL (?key=…): a bookmarked link survives storage
|
// Accept the token from the URL (?key=…): a bookmarked link survives storage
|
||||||
@@ -76,6 +122,13 @@ const urlKey = new URLSearchParams(location.search).get("key");
|
|||||||
let token = urlKey || localStorage.getItem(KEY) || "";
|
let token = urlKey || localStorage.getItem(KEY) || "";
|
||||||
if (urlKey) localStorage.setItem(KEY, urlKey);
|
if (urlKey) localStorage.setItem(KEY, urlKey);
|
||||||
let pollTimer = null;
|
let pollTimer = null;
|
||||||
|
let blocks = []; // parsed .strings blocks (StringsFormat)
|
||||||
|
let sections = []; // [{name, line}] from the server
|
||||||
|
let mode = "form"; // "form" | "raw"
|
||||||
|
let localDirty = false; // unsaved edits in this page session
|
||||||
|
let serverDirty = []; // git dirty files from the server
|
||||||
|
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
|
||||||
async function api(path, opts = {}) {
|
async function api(path, opts = {}) {
|
||||||
const headers = { "X-Key": token, ...(opts.headers || {}) };
|
const headers = { "X-Key": token, ...(opts.headers || {}) };
|
||||||
@@ -89,59 +142,149 @@ async function api(path, opts = {}) {
|
|||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- views ---------------------------------------------------------------
|
||||||
|
|
||||||
|
function renderForm() {
|
||||||
|
const el = $("form");
|
||||||
|
el.innerHTML = "";
|
||||||
|
const sec = new Map(sections.map(s => [s.line, s.name]));
|
||||||
|
let line = 1;
|
||||||
|
for (const b of blocks) {
|
||||||
|
const start = line;
|
||||||
|
line += b.raw.split("\n").length;
|
||||||
|
if (b.type === "blank") continue;
|
||||||
|
if (b.type === "comment") {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
if (sec.has(start)) {
|
||||||
|
div.className = "section";
|
||||||
|
div.dataset.sectionLine = start;
|
||||||
|
div.textContent = (b.raw.trim().match(/^\/\* (.+) \*\/$/) || [])[1] || b.raw.trim();
|
||||||
|
} else {
|
||||||
|
div.className = "comment";
|
||||||
|
div.textContent = b.raw;
|
||||||
|
}
|
||||||
|
el.appendChild(div);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (b.type === "entry") {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "entry";
|
||||||
|
const k = document.createElement("div");
|
||||||
|
k.className = "k";
|
||||||
|
k.textContent = b.key; // read-only by design
|
||||||
|
const v = document.createElement("div");
|
||||||
|
v.className = "v";
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "text";
|
||||||
|
input.spellcheck = false;
|
||||||
|
input.autocapitalize = "off";
|
||||||
|
input.autocorrect = "off";
|
||||||
|
input.value = b.value;
|
||||||
|
input.addEventListener("input", () => {
|
||||||
|
b.value = input.value;
|
||||||
|
localDirty = true;
|
||||||
|
updateBanner();
|
||||||
|
});
|
||||||
|
v.appendChild(input);
|
||||||
|
row.appendChild(k);
|
||||||
|
row.appendChild(v);
|
||||||
|
el.appendChild(row);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Unparseable data line — never let the form clobber a file we don't
|
||||||
|
// understand; drop to Raw editing with a notice.
|
||||||
|
$("notice").textContent = "Line not understood (see below) — switched to Raw view:\n" + b.raw.slice(0, 120);
|
||||||
|
$("notice").style.display = "block";
|
||||||
|
switchMode("raw");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$("notice").style.display = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchMode(m) {
|
||||||
|
mode = m;
|
||||||
|
$("btnForm").classList.toggle("on", m === "form");
|
||||||
|
$("btnRaw").classList.toggle("on", m === "raw");
|
||||||
|
$("form").hidden = m !== "form";
|
||||||
|
$("src").hidden = m !== "raw";
|
||||||
|
if (m === "form") {
|
||||||
|
blocks = StringsFormat.parseStrings($("src").value);
|
||||||
|
renderForm();
|
||||||
|
} else {
|
||||||
|
$("src").value = StringsFormat.serializeStrings(blocks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- load / save ----------------------------------------------------------
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
const res = await api("/file");
|
const res = await api("/file");
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
document.getElementById("src").value = data.content;
|
$("src").value = data.content;
|
||||||
document.getElementById("repo").textContent = data.repo;
|
blocks = StringsFormat.parseStrings(data.content);
|
||||||
document.getElementById("head").textContent = data.head;
|
sections = data.sections || [];
|
||||||
const jump = document.getElementById("jump");
|
$("repo").textContent = data.repo;
|
||||||
|
$("head").textContent = data.head;
|
||||||
|
const jump = $("jump");
|
||||||
jump.innerHTML = '<option value="">Jump to section…</option>' +
|
jump.innerHTML = '<option value="">Jump to section…</option>' +
|
||||||
(data.sections || []).map(s => `<option value="${s.line}">${s.name}</option>`).join("");
|
sections.map(s => `<option value="${s.line}">${s.name}</option>`).join("");
|
||||||
jump.disabled = !(data.sections || []).length;
|
jump.disabled = !sections.length;
|
||||||
const dirty = document.getElementById("dirty");
|
serverDirty = data.dirty || [];
|
||||||
if (data.dirty.length) {
|
localDirty = false;
|
||||||
dirty.style.display = "block";
|
updateBanner();
|
||||||
dirty.classList.remove("ok");
|
renderForm();
|
||||||
dirty.textContent = "⚠ working tree has uncommitted changes: " + data.dirty.join(", ");
|
|
||||||
} else {
|
|
||||||
dirty.style.display = "block";
|
|
||||||
dirty.classList.add("ok");
|
|
||||||
dirty.textContent = "✓ working tree clean — safe to edit";
|
|
||||||
}
|
|
||||||
} catch (e) { statusBox("error loading file: " + e, "fail"); }
|
} catch (e) { statusBox("error loading file: " + e, "fail"); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateBanner() {
|
||||||
|
const dirty = $("dirty");
|
||||||
|
dirty.style.display = "block";
|
||||||
|
if (localDirty) {
|
||||||
|
dirty.classList.remove("ok");
|
||||||
|
dirty.textContent = "⚠ unsaved edits — press Save (or Save & Build)";
|
||||||
|
} else if (serverDirty.length) {
|
||||||
|
dirty.classList.remove("ok");
|
||||||
|
dirty.textContent = "⚠ working tree has uncommitted changes: " + serverDirty.join(", ");
|
||||||
|
} else {
|
||||||
|
dirty.classList.add("ok");
|
||||||
|
dirty.textContent = "✓ working tree clean — safe to edit";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentContent() {
|
||||||
|
return mode === "form" ? StringsFormat.serializeStrings(blocks) : $("src").value;
|
||||||
|
}
|
||||||
|
|
||||||
function statusBox(text, cls) {
|
function statusBox(text, cls) {
|
||||||
const s = document.getElementById("status");
|
const s = $("status");
|
||||||
s.style.display = "block";
|
s.style.display = "block";
|
||||||
s.className = cls || "";
|
s.className = cls || "";
|
||||||
s.textContent = text;
|
s.textContent = text;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveOnly() {
|
async function saveOnly() {
|
||||||
const res = await api("/save", { method: "POST", body: document.getElementById("src").value });
|
const res = await api("/save", { method: "POST", body: currentContent() });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.lint_ok) {
|
if (data.lint_ok) {
|
||||||
statusBox("✓ saved — " + data.lint, "done");
|
statusBox("✓ saved — " + data.lint, "done");
|
||||||
|
await load(); // refresh clean/dirty + re-parse
|
||||||
} else {
|
} else {
|
||||||
statusBox("✗ saved but INVALID — " + data.lint, "fail");
|
statusBox("✗ saved but INVALID — " + data.lint, "fail");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveAndBuild() {
|
async function saveAndBuild() {
|
||||||
const saveBtn = document.getElementById("save");
|
const saveBtn = $("save");
|
||||||
const buildBtn = document.getElementById("saveBuild");
|
const buildBtn = $("saveBuild");
|
||||||
saveBtn.disabled = buildBtn.disabled = true;
|
saveBtn.disabled = buildBtn.disabled = true;
|
||||||
document.getElementById("result").style.display = "none";
|
$("result").style.display = "none";
|
||||||
statusBox("saving + starting build…");
|
statusBox("saving + starting build…");
|
||||||
|
|
||||||
let res = await api("/save-build", { method: "POST", body: document.getElementById("src").value });
|
let res = await api("/save-build", { method: "POST", body: currentContent() });
|
||||||
let data = await res.json();
|
let data = await res.json();
|
||||||
if (!data.job_id) { statusBox("✗ " + JSON.stringify(data), "fail"); enable(); return; }
|
if (!data.job_id) { statusBox("✗ " + JSON.stringify(data), "fail"); enable(); return; }
|
||||||
|
|
||||||
// Poll the job until it finishes.
|
|
||||||
pollTimer = setInterval(async () => {
|
pollTimer = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
const r = await api("/job/" + data.job_id);
|
const r = await api("/job/" + data.job_id);
|
||||||
@@ -149,7 +292,7 @@ async function saveAndBuild() {
|
|||||||
statusBox(j.lines.join("\n") + (j.running ? "\n…building…" : ""), j.success ? "done" : "fail");
|
statusBox(j.lines.join("\n") + (j.running ? "\n…building…" : ""), j.success ? "done" : "fail");
|
||||||
if (j.done) {
|
if (j.done) {
|
||||||
clearInterval(pollTimer);
|
clearInterval(pollTimer);
|
||||||
const result = document.getElementById("result");
|
const result = $("result");
|
||||||
if (j.success) {
|
if (j.success) {
|
||||||
result.className = "ok";
|
result.className = "ok";
|
||||||
result.innerHTML = "✓ Build OK — <a href=\"http://192.168.1.131:8765/FuelBoard.ipa\">FuelBoard.ipa</a> " +
|
result.innerHTML = "✓ Build OK — <a href=\"http://192.168.1.131:8765/FuelBoard.ipa\">FuelBoard.ipa</a> " +
|
||||||
@@ -160,23 +303,34 @@ async function saveAndBuild() {
|
|||||||
}
|
}
|
||||||
result.style.display = "block";
|
result.style.display = "block";
|
||||||
enable();
|
enable();
|
||||||
|
await load();
|
||||||
}
|
}
|
||||||
} catch (e) { clearInterval(pollTimer); statusBox("poll error: " + e, "fail"); enable(); }
|
} catch (e) { clearInterval(pollTimer); statusBox("poll error: " + e, "fail"); enable(); }
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function enable() {
|
function enable() {
|
||||||
document.getElementById("save").disabled = false;
|
$("save").disabled = false;
|
||||||
document.getElementById("saveBuild").disabled = false;
|
$("saveBuild").disabled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById("save").addEventListener("click", saveOnly);
|
// ---- events ----------------------------------------------------------------
|
||||||
document.getElementById("saveBuild").addEventListener("click", saveAndBuild);
|
|
||||||
document.getElementById("jump").addEventListener("change", (e) => {
|
$("save").addEventListener("click", saveOnly);
|
||||||
|
$("saveBuild").addEventListener("click", saveAndBuild);
|
||||||
|
$("btnForm").addEventListener("click", () => switchMode("form"));
|
||||||
|
$("btnRaw").addEventListener("click", () => switchMode("raw"));
|
||||||
|
$("src").addEventListener("input", () => { localDirty = true; updateBanner(); });
|
||||||
|
$("jump").addEventListener("change", (e) => {
|
||||||
const line = parseInt(e.target.value, 10);
|
const line = parseInt(e.target.value, 10);
|
||||||
e.target.value = ""; // act as a "go" control, not a persistent selection
|
e.target.value = ""; // act as a "go" control, not a persistent selection
|
||||||
if (!line) return;
|
if (!line) return;
|
||||||
const ta = document.getElementById("src");
|
if (mode === "form") {
|
||||||
|
const h = document.querySelector(`[data-section-line="${line}"]`);
|
||||||
|
if (h) h.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ta = $("src");
|
||||||
const ls = ta.value.split("\n");
|
const ls = ta.value.split("\n");
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
for (let i = 0; i < line - 1; i++) offset += ls[i].length + 1;
|
for (let i = 0; i < line - 1; i++) offset += ls[i].length + 1;
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/*
|
||||||
|
* FuelBoard Copy Editor — .strings format helpers (pure, no DOM).
|
||||||
|
*
|
||||||
|
* The editor can show Localizable.strings either as raw text or as a
|
||||||
|
* structured key/value form. These functions parse the file into blocks
|
||||||
|
* (entries + comments + blanks) and serialize them back, preserving every
|
||||||
|
* comment and blank line byte-for-byte.
|
||||||
|
*
|
||||||
|
* .strings shape (verified against the real file):
|
||||||
|
* "Key" = "Value"; -> entry (single line)
|
||||||
|
* /* Section banner *\/ -> comment
|
||||||
|
* (blank) -> blank
|
||||||
|
* Multi-line comment blocks (like the header) are kept verbatim. Any line
|
||||||
|
* that is not a valid entry, comment, or blank is kept verbatim as "raw"
|
||||||
|
* and the form view refuses to activate (falls back to Raw editing) so we
|
||||||
|
* can never clobber a file we don't understand.
|
||||||
|
*/
|
||||||
|
(function (root, factory) {
|
||||||
|
if (typeof module !== "undefined" && module.exports) {
|
||||||
|
module.exports = factory();
|
||||||
|
} else {
|
||||||
|
root.StringsFormat = factory();
|
||||||
|
}
|
||||||
|
})(this, function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
// Display value -> file-escaped value. Backslash FIRST, then quotes.
|
||||||
|
function escapeStr(s) {
|
||||||
|
return s
|
||||||
|
.replace(/\\/g, "\\\\")
|
||||||
|
.replace(/"/g, '\\"')
|
||||||
|
.replace(/\n/g, "\\n")
|
||||||
|
.replace(/\t/g, "\\t");
|
||||||
|
}
|
||||||
|
|
||||||
|
// File-escaped value -> display value. Unknown escapes (\uXXXX, \') are
|
||||||
|
// passed through untouched so round-trips never lose data.
|
||||||
|
function unescapeStr(s) {
|
||||||
|
return s.replace(/\\(.)/g, function (m, c) {
|
||||||
|
return c === "n" ? "\n" : c === "t" ? "\t" : c === '"' ? '"' : c === "\\" ? "\\" : m;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text -> [{type:"blank"|"comment"|"entry"|"raw", ...}]
|
||||||
|
// entry: {type, key, value (unescaped), raw}
|
||||||
|
function parseStrings(text) {
|
||||||
|
var lines = text.split("\n");
|
||||||
|
var blocks = [];
|
||||||
|
var i = 0;
|
||||||
|
while (i < lines.length) {
|
||||||
|
var raw = lines[i];
|
||||||
|
var s = raw.trim();
|
||||||
|
if (s === "") {
|
||||||
|
blocks.push({ type: "blank", raw: "" });
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (s.indexOf("/*") === 0) {
|
||||||
|
// Comment block — may span lines (the file header does).
|
||||||
|
var block = [raw];
|
||||||
|
i++;
|
||||||
|
while (i < lines.length && block[block.length - 1].indexOf("*/") === -1) {
|
||||||
|
block.push(lines[i]);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
blocks.push({ type: "comment", raw: block.join("\n") });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var m = s.match(/^"((?:[^"\\]|\\.)*)"\s*=\s*"((?:[^"\\]|\\.)*)"\s*;$/);
|
||||||
|
if (m) {
|
||||||
|
blocks.push({ type: "entry", key: m[1], value: unescapeStr(m[2]), raw: raw });
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Not something we understand — keep verbatim, flag for the caller.
|
||||||
|
blocks.push({ type: "raw", raw: raw });
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeStrings(blocks) {
|
||||||
|
return blocks
|
||||||
|
.map(function (b) {
|
||||||
|
if (b.type === "entry") {
|
||||||
|
return '"' + b.key + '" = "' + escapeStr(b.value) + '";';
|
||||||
|
}
|
||||||
|
return b.raw;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { parseStrings: parseStrings, serializeStrings: serializeStrings, escapeStr: escapeStr, unescapeStr: unescapeStr };
|
||||||
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// FuelBoard Copy Editor — strings-format round-trip tests (node)
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const { parseStrings, serializeStrings, escapeStr, unescapeStr } = require(
|
||||||
|
path.join(__dirname, "..", "strings-format.js"));
|
||||||
|
|
||||||
|
const REAL = "/Users/apt/workspace/fuelboard/FuelBoard/en.lproj/Localizable.strings";
|
||||||
|
const text = fs.readFileSync(REAL, "utf8");
|
||||||
|
let failures = 0;
|
||||||
|
function check(name, cond, extra) {
|
||||||
|
console.log((cond ? "PASS" : "FAIL") + " " + name + (extra ? " [" + extra + "]" : ""));
|
||||||
|
if (!cond) failures++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Round-trip: parse -> serialize must reproduce the file byte-for-byte.
|
||||||
|
const blocks = parseStrings(text);
|
||||||
|
const out = serializeStrings(blocks);
|
||||||
|
check("round-trip byte-identical", out === text,
|
||||||
|
out === text ? "" : `len ${out.length} vs ${text.length}, first diff: ` + firstDiff(out, text));
|
||||||
|
|
||||||
|
function firstDiff(a, b) {
|
||||||
|
const n = Math.min(a.length, b.length);
|
||||||
|
for (let i = 0; i < n; i++) if (a[i] !== b[i]) return `${i}: ${JSON.stringify(a.slice(i, i + 40))} vs ${JSON.stringify(b.slice(i, i + 40))}`;
|
||||||
|
return "length differs";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Content stats.
|
||||||
|
const entries = blocks.filter(b => b.type === "entry");
|
||||||
|
const comments = blocks.filter(b => b.type === "comment");
|
||||||
|
const raws = blocks.filter(b => b.type === "raw");
|
||||||
|
check("107 entries", entries.length === 107, `got ${entries.length}`);
|
||||||
|
check("no unparseable raw lines", raws.length === 0, `got ${raws.length}`);
|
||||||
|
check("comment blocks preserved (incl. 4-line header)", comments.length === 9, `got ${comments.length}`);
|
||||||
|
check("sections detectable (8 banners)", comments.filter(c => /^\/\* .+ \*\/$/.test(c.raw.trim())).length === 8);
|
||||||
|
|
||||||
|
// 3. Escape round-trips on tricky values (as found in the file).
|
||||||
|
const tricky = ['a\\"b', "it\\'s", "a\\\\b", 'say "hi"', "line1\nline2", "tab\there", "plain"];
|
||||||
|
for (const t of tricky) {
|
||||||
|
const esc = escapeStr(t);
|
||||||
|
const back = unescapeStr(esc);
|
||||||
|
check(`escape round-trip ${JSON.stringify(t)}`, back === t, `esc=${JSON.stringify(esc)} back=${JSON.stringify(back)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Edit a value -> serialize -> the file lint must pass and contain the edit.
|
||||||
|
const edited = blocks.map(b => b.type === "entry" && b.key === "Welcome to FuelBoard"
|
||||||
|
? { ...b, value: 'Welcome to FuelBoard — "edited" today \\\\ path' }
|
||||||
|
: b);
|
||||||
|
const editedText = serializeStrings(edited);
|
||||||
|
check("edited value written", editedText.includes('"Welcome to FuelBoard — \\"edited\\" today \\\\\\\\ path";'),
|
||||||
|
editedText.split("\n").find(l => l.startsWith('"Welcome to FuelBoard')));
|
||||||
|
|
||||||
|
fs.writeFileSync("/tmp/fb_strings_edited.strings", editedText);
|
||||||
|
console.log("edited file written to /tmp/fb_strings_edited.strings");
|
||||||
|
process.exit(failures ? 1 : 0);
|
||||||
Reference in New Issue
Block a user