Editor: jump-to-section dropdown (parses /* banner */ comments)

This commit is contained in:
FuelBoard Contributor
2026-08-13 12:45:36 +01:00
parent d47e4f2ead
commit a597157622
3 changed files with 43 additions and 2 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ Set `TOKEN=...` in the environment to pin your own.
| Route | What it does | | Route | What it does |
|-------------|-----------------------------------------------------------| |-------------|-----------------------------------------------------------|
| `GET /` | The editor page (no auth) | | `GET /` | The editor page (no auth) |
| `GET /file` | Current strings file + git dirty/clean banner | | `GET /file` | Current strings file + git dirty/clean banner + section list |
| `POST /save`| Write the file, then `plutil -lint` it | | `POST /save`| Write the file, then `plutil -lint` it |
| `POST /save-build` | Write the file and start a background build job | | `POST /save-build` | Write the file and start a background build job |
| `GET /job/<id>` | Job progress (polled by the page, 2 s) | | `GET /job/<id>` | Job progress (polled by the page, 2 s) |
+15 -1
View File
@@ -20,6 +20,7 @@ Config via env: FUELBOARD_REPO, TOKEN, PORT (default 8790).
import json import json
import os import os
import re
import secrets import secrets
import shutil import shutil
import subprocess import subprocess
@@ -76,6 +77,18 @@ def git_head() -> str:
return git("log", "--oneline", "-1") or "no commits" return git("log", "--oneline", "-1") or "no commits"
def parse_sections(content: str) -> list:
"""Find single-line comment banners like /* Stations tab */ so the
editor can offer a jump-to-section dropdown. The multi-line header
comment at the top never matches (it doesn't end with */ on one line)."""
sections = []
for i, line in enumerate(content.splitlines(), start=1):
m = re.match(r"^/\* (.+) \*/$", line.strip())
if m:
sections.append({"name": m.group(1).strip(), "line": i})
return sections
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# Build job (runs in a background thread so the phone doesn't wait on it) # Build job (runs in a background thread so the phone doesn't wait on it)
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@@ -212,7 +225,8 @@ class Handler(BaseHTTPRequestHandler):
self._json(404, {"error": f"missing {STRINGS_FILE}"}) self._json(404, {"error": f"missing {STRINGS_FILE}"})
return return
self._json(200, {"content": content, "dirty": git_dirty_files(), self._json(200, {"content": content, "dirty": git_dirty_files(),
"head": git_head(), "repo": str(REPO)}) "head": git_head(), "repo": str(REPO),
"sections": parse_sections(content)})
return return
if self.path.startswith("/job/"): if self.path.startswith("/job/"):
if not self._authorized(): if not self._authorized():
+27
View File
@@ -20,6 +20,11 @@
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; }
select {
width: 100%; margin-bottom: 12px; padding: 10px 12px; font-size: 15px;
background: #0d0f12; color: #e6e8eb; border: 1px solid #2a313a;
border-radius: 10px; -webkit-appearance: none; appearance: none;
}
textarea { textarea {
width: 100%; min-height: 46vh; resize: vertical; width: 100%; min-height: 46vh; resize: vertical;
background: #0d0f12; color: #d7dce2; border: 1px solid #2a313a; background: #0d0f12; color: #d7dce2; border: 1px solid #2a313a;
@@ -51,6 +56,9 @@
<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>
<select id="jump" disabled>
<option value="">Jump to section…</option>
</select>
<textarea id="src" spellcheck="false" placeholder="Loading Localizable.strings…"></textarea> <textarea id="src" spellcheck="false" placeholder="Loading Localizable.strings…"></textarea>
<div class="row"> <div class="row">
<button id="save">Save</button> <button id="save">Save</button>
@@ -83,6 +91,10 @@ async function load() {
document.getElementById("src").value = data.content; document.getElementById("src").value = data.content;
document.getElementById("repo").textContent = data.repo; document.getElementById("repo").textContent = data.repo;
document.getElementById("head").textContent = data.head; document.getElementById("head").textContent = data.head;
const jump = document.getElementById("jump");
jump.innerHTML = '<option value="">Jump to section…</option>' +
(data.sections || []).map(s => `<option value="${s.line}">${s.name}</option>`).join("");
jump.disabled = !(data.sections || []).length;
const dirty = document.getElementById("dirty"); const dirty = document.getElementById("dirty");
if (data.dirty.length) { if (data.dirty.length) {
dirty.style.display = "block"; dirty.style.display = "block";
@@ -155,6 +167,21 @@ function enable() {
document.getElementById("save").addEventListener("click", saveOnly); document.getElementById("save").addEventListener("click", saveOnly);
document.getElementById("saveBuild").addEventListener("click", saveAndBuild); document.getElementById("saveBuild").addEventListener("click", saveAndBuild);
document.getElementById("jump").addEventListener("change", (e) => {
const line = parseInt(e.target.value, 10);
e.target.value = ""; // act as a "go" control, not a persistent selection
if (!line) return;
const ta = document.getElementById("src");
const ls = ta.value.split("\n");
let offset = 0;
for (let i = 0; i < line - 1; i++) offset += ls[i].length + 1;
const bannerLen = ls[line - 1].length;
const lh = parseFloat(getComputedStyle(ta).lineHeight); // e.g. 19.5
const pad = parseFloat(getComputedStyle(ta).paddingTop); // 12
ta.focus({ preventScroll: true });
ta.scrollTop = Math.max(0, (line - 1) * lh - pad);
ta.setSelectionRange(offset, offset + bannerLen); // flash-highlight the banner
});
load(); load();
</script> </script>
</body> </body>