Editor: jump-to-section dropdown (parses /* banner */ comments)
This commit is contained in:
@@ -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) |
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user