FuelBoard Copy Editor: web editor + Save & Build for Localizable.strings
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
.token
|
||||
.venv/
|
||||
__pycache__/
|
||||
.DS_Store
|
||||
@@ -0,0 +1,74 @@
|
||||
# FuelBoard Copy Editor
|
||||
|
||||
A tiny, self-contained web editor for FuelBoard's `Localizable.strings`,
|
||||
running on the Mac Mini. Edit copy from the iPhone (Safari), save it straight
|
||||
into the FuelBoard repo, or save **and** kick off an immediate Release build +
|
||||
IPA.
|
||||
|
||||
## Why
|
||||
|
||||
All FuelBoard copy lives in `FuelBoard/en.lproj/Localizable.strings` (the
|
||||
`Localizable.strings` refactor). This editor lets you tweak wording from the
|
||||
phone without touching git, the Mac, or the build pipeline by hand — while the
|
||||
Mac Mini repo stays the canonical source of truth and Gitea stays a backup.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
./scripts/run.sh # port 8790 (or EDITOR_PORT)
|
||||
```
|
||||
|
||||
Then on the phone (same Wi-Fi, or anywhere via Tailscale):
|
||||
|
||||
```
|
||||
http://192.168.1.131:8790/
|
||||
http://100.120.217.98:8790/
|
||||
```
|
||||
|
||||
Enter the token once (kept in the browser's localStorage afterwards):
|
||||
|
||||
```bash
|
||||
cat /Users/apt/workspace/fuelboard-editor/.token
|
||||
```
|
||||
|
||||
Set `TOKEN=...` in the environment to pin your own.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Route | What it does |
|
||||
|-------------|-----------------------------------------------------------|
|
||||
| `GET /` | The editor page (no auth) |
|
||||
| `GET /file` | Current strings file + git dirty/clean banner |
|
||||
| `POST /save`| Write the file, then `plutil -lint` it |
|
||||
| `POST /save-build` | Write the file and start a background build job |
|
||||
| `GET /job/<id>` | Job progress (polled by the page, 2 s) |
|
||||
|
||||
Auth: `X-Key: <token>` on every route except the HTML page.
|
||||
|
||||
## What a "Save & Build" does
|
||||
|
||||
1. writes `FuelBoard/en.lproj/Localizable.strings`
|
||||
2. `plutil -lint` — aborts if the file is invalid
|
||||
3. `xcodebuild` Release, unsigned (same command as the manual pipeline)
|
||||
4. packages a fresh IPA to `/tmp/ipa-serve/FuelBoard.ipa`
|
||||
5. serves it at `http://192.168.1.131:8765/FuelBoard.ipa`
|
||||
|
||||
The page polls the job and shows the build log live.
|
||||
|
||||
## Safety
|
||||
|
||||
- Only ever writes the one strings file — no arbitrary paths.
|
||||
- It never commits or pushes: Gitea stays a backup, and commits happen
|
||||
afterwards via the normal workflow once the copy is approved.
|
||||
- One build at a time; the page shows the git dirty/clean state so you never
|
||||
save over in-flight work.
|
||||
- Token required for every write/build call.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
editor.py single-file server (stdlib only: http.server, subprocess)
|
||||
index.html the editor page (mobile-first, dark)
|
||||
scripts/run.sh launcher
|
||||
.token auto-generated shared token (gitignored)
|
||||
```
|
||||
@@ -0,0 +1,270 @@
|
||||
#!/usr/bin/env python3
|
||||
"""FuelBoard Copy Editor — a tiny self-contained web editor for
|
||||
Localizable.strings that writes straight into the FuelBoard repo on the
|
||||
Mac Mini and can kick off an immediate Release build + IPA.
|
||||
|
||||
Why stdlib only: no pip installs, one file to read, easy to see how it
|
||||
works. Routes:
|
||||
|
||||
GET / -> the editor page (HTML)
|
||||
GET /file -> current Localizable.strings + git dirty banner
|
||||
POST /save -> write the file + plutil lint {body: content}
|
||||
POST /save-build -> write the file, then start a background build job
|
||||
GET /job/<id> -> job progress (polled by the page)
|
||||
|
||||
Auth: every route except the HTML page needs the shared token. The page
|
||||
sends it as the X-Key header (kept in localStorage after first entry).
|
||||
|
||||
Config via env: FUELBOARD_REPO, TOKEN, PORT (default 8790).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Config
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
REPO = Path(os.environ.get("FUELBOARD_REPO", "/Users/apt/workspace/fuelboard"))
|
||||
STRINGS_FILE = REPO / "FuelBoard" / "en.lproj" / "Localizable.strings"
|
||||
IPA_SERVE_DIR = Path(os.environ.get("IPA_SERVE_DIR", "/tmp/ipa-serve"))
|
||||
IPA_PATH = IPA_SERVE_DIR / "FuelBoard.ipa"
|
||||
PORT = int(os.environ.get("PORT", "8790"))
|
||||
|
||||
# Token: env wins, else .token file (auto-generated on first run).
|
||||
TOKEN_FILE = Path(__file__).with_name(".token")
|
||||
TOKEN = os.environ.get("TOKEN", "").strip()
|
||||
if not TOKEN:
|
||||
if TOKEN_FILE.exists():
|
||||
TOKEN = TOKEN_FILE.read_text().strip()
|
||||
else:
|
||||
TOKEN = secrets.token_hex(16)
|
||||
TOKEN_FILE.write_text(TOKEN + "\n")
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Git helpers
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def git(*args: str, cwd: Path = REPO) -> str:
|
||||
"""Run a git command in the repo; return stdout, stripped."""
|
||||
out = subprocess.run(
|
||||
["git", *args], cwd=cwd, capture_output=True, text=True, timeout=30
|
||||
)
|
||||
return out.stdout.strip()
|
||||
|
||||
|
||||
def git_dirty_files() -> list[str]:
|
||||
"""Names of files with uncommitted changes (so the editor can warn
|
||||
about editing over in-flight work)."""
|
||||
out = subprocess.run(
|
||||
["git", "status", "--porcelain"], cwd=REPO, capture_output=True, text=True
|
||||
)
|
||||
return [line[3:] if line.startswith("?? ") else line[3:] for line in
|
||||
out.stdout.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def git_head() -> str:
|
||||
return git("log", "--oneline", "-1") or "no commits"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Build job (runs in a background thread so the phone doesn't wait on it)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
JOBS: dict[str, dict] = {} # job_id -> state
|
||||
JOBS_LOCK = threading.Lock()
|
||||
BUILD_LOCK = threading.Lock() # one build at a time
|
||||
|
||||
|
||||
def append_lines(state: dict, chunk: str) -> None:
|
||||
"""Keep a rolling window of the last 60 log lines per job."""
|
||||
lines = chunk.splitlines()
|
||||
if not lines:
|
||||
return
|
||||
state["lines"].extend(lines)
|
||||
state["lines"] = state["lines"][-60:]
|
||||
|
||||
|
||||
def run_shell(state: dict, cmd: list[str], cwd: Path = REPO) -> int:
|
||||
"""Run a command, streaming its output into the job's log."""
|
||||
proc = subprocess.Popen(
|
||||
cmd, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True
|
||||
)
|
||||
for line in proc.stdout:
|
||||
append_lines(state, line.rstrip())
|
||||
proc.wait()
|
||||
return proc.returncode
|
||||
|
||||
|
||||
def start_build_job(content: str) -> str:
|
||||
"""Write the file, then build + package in a background thread."""
|
||||
job_id = uuid.uuid4().hex[:12]
|
||||
state = {"lines": [], "running": True, "done": False, "success": False,
|
||||
"lint": None, "ipa_size": None, "finished_at": None}
|
||||
with JOBS_LOCK:
|
||||
JOBS[job_id] = state
|
||||
|
||||
def job():
|
||||
try:
|
||||
state["lines"].append(f"repo: {REPO} head: {git_head()}")
|
||||
# 1. Save the edit
|
||||
STRINGS_FILE.write_text(content, encoding="utf-8")
|
||||
append_lines(state, f"saved {STRINGS_FILE.relative_to(REPO)}")
|
||||
|
||||
# 2. Lint the strings file before anything else
|
||||
lint = subprocess.run(["plutil", "-lint", str(STRINGS_FILE)],
|
||||
capture_output=True, text=True)
|
||||
state["lint"] = lint.stdout.strip() or lint.stderr.strip()
|
||||
append_lines(state, state["lint"])
|
||||
if lint.returncode != 0:
|
||||
state["success"] = False
|
||||
append_lines(state, "✗ strings file is invalid — build aborted")
|
||||
return
|
||||
|
||||
# 3. Release build (same command as the manual pipeline)
|
||||
append_lines(state, "building (Release, unsigned)…")
|
||||
rc = run_shell(state, [
|
||||
"xcodebuild", "-project", "FuelBoard.xcodeproj", "-scheme",
|
||||
"FuelBoard", "-configuration", "Release",
|
||||
"-destination", "generic/platform=iOS",
|
||||
"-derivedDataPath", "build", "CODE_SIGNING_ALLOWED=NO", "build",
|
||||
])
|
||||
if rc != 0:
|
||||
append_lines(state, "✗ BUILD FAILED — see lines above")
|
||||
return
|
||||
|
||||
# 4. Package the IPA (fresh zip; remove the old one first)
|
||||
app = REPO / "build" / "Build" / "Products" / "Release-iphoneos" / "FuelBoard.app"
|
||||
work = Path("/tmp/ipa-work")
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
(work / "Payload").mkdir(parents=True)
|
||||
shutil.copytree(app, work / "Payload" / "FuelBoard.app")
|
||||
subprocess.run(["codesign", "--force", "--deep", "-s", "-",
|
||||
str(work / "Payload" / "FuelBoard.app")],
|
||||
check=True, capture_output=True)
|
||||
IPA_SERVE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
if IPA_PATH.exists():
|
||||
IPA_PATH.unlink()
|
||||
subprocess.run(["zip", "-rq", str(IPA_PATH), "Payload/"],
|
||||
cwd=work, check=True)
|
||||
|
||||
size = IPA_PATH.stat().st_size if IPA_PATH.exists() else 0
|
||||
state["ipa_size"] = size
|
||||
append_lines(state, f"✓ packaged {IPA_PATH} ({size/1e6:.2f} MB)")
|
||||
state["success"] = True
|
||||
except Exception as exc: # noqa: BLE001 — job must never hang the UI
|
||||
append_lines(state, f"✗ job error: {exc}")
|
||||
finally:
|
||||
state["running"] = False
|
||||
state["done"] = True
|
||||
state["finished_at"] = time.strftime("%H:%M:%S")
|
||||
|
||||
threading.Thread(target=job, daemon=True).start()
|
||||
return job_id
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# HTTP layer
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
PAGE = (Path(__file__).with_name("index.html")).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, fmt, *args): # quieter logs
|
||||
pass
|
||||
|
||||
def _send(self, code: int, body: bytes, ctype: str = "application/json"):
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", ctype)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _json(self, code: int, obj: dict):
|
||||
self._send(code, json.dumps(obj).encode(), "application/json")
|
||||
|
||||
def _authorized(self) -> bool:
|
||||
return self.headers.get("X-Key") == TOKEN
|
||||
|
||||
# -- routes -----------------------------------------------------------
|
||||
|
||||
def do_GET(self):
|
||||
if self.path in ("/", "/index.html"):
|
||||
self._send(200, PAGE.encode(), "text/html; charset=utf-8")
|
||||
return
|
||||
if self.path == "/file":
|
||||
if not self._authorized():
|
||||
self._json(401, {"error": "token required"})
|
||||
return
|
||||
try:
|
||||
content = STRINGS_FILE.read_text(encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
self._json(404, {"error": f"missing {STRINGS_FILE}"})
|
||||
return
|
||||
self._json(200, {"content": content, "dirty": git_dirty_files(),
|
||||
"head": git_head(), "repo": str(REPO)})
|
||||
return
|
||||
if self.path.startswith("/job/"):
|
||||
if not self._authorized():
|
||||
self._json(401, {"error": "token required"})
|
||||
return
|
||||
job_id = self.path.rsplit("/", 1)[-1]
|
||||
with JOBS_LOCK:
|
||||
state = JOBS.get(job_id)
|
||||
if state is None:
|
||||
self._json(404, {"error": "unknown job"})
|
||||
return
|
||||
self._json(200, dict(state))
|
||||
return
|
||||
self._json(404, {"error": "not found"})
|
||||
|
||||
def do_POST(self):
|
||||
if not self._authorized():
|
||||
self._json(401, {"error": "token required"})
|
||||
return
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(length).decode("utf-8") if length else ""
|
||||
|
||||
if self.path == "/save":
|
||||
STRINGS_FILE.write_text(body, encoding="utf-8")
|
||||
lint = subprocess.run(["plutil", "-lint", str(STRINGS_FILE)],
|
||||
capture_output=True, text=True)
|
||||
ok = lint.returncode == 0
|
||||
self._json(200, {"saved": True, "lint_ok": ok,
|
||||
"lint": lint.stdout.strip() or lint.stderr.strip(),
|
||||
"dirty": git_dirty_files()})
|
||||
return
|
||||
|
||||
if self.path == "/save-build":
|
||||
if BUILD_LOCK.locked():
|
||||
self._json(409, {"error": "a build is already running"})
|
||||
return
|
||||
with BUILD_LOCK:
|
||||
job_id = start_build_job(body)
|
||||
self._json(200, {"job_id": job_id})
|
||||
return
|
||||
|
||||
self._json(404, {"error": "not found"})
|
||||
|
||||
|
||||
def main():
|
||||
print(f"FuelBoard Copy Editor")
|
||||
print(f" repo : {REPO}")
|
||||
print(f" file : {STRINGS_FILE}")
|
||||
print(f" url : http://0.0.0.0:{PORT}/ (phone: http://192.168.1.131:{PORT}/ or http://100.120.217.98:{PORT}/)")
|
||||
print(f" token: {TOKEN}")
|
||||
ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>FuelBoard Copy Editor</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; padding: 16px; background: #111418; color: #e6e8eb;
|
||||
font: 15px/1.45 -apple-system, "SF Pro Text", system-ui, sans-serif;
|
||||
max-width: 900px; margin: 0 auto;
|
||||
}
|
||||
h1 { font-size: 20px; margin: 4px 0 12px; }
|
||||
.meta { color: #9aa3ad; font-size: 13px; margin-bottom: 12px; }
|
||||
.meta code { background: #1e242c; padding: 1px 6px; border-radius: 5px; }
|
||||
#dirty {
|
||||
display: none; background: #3a2b10; border: 1px solid #8a6410; color: #ffd479;
|
||||
padding: 8px 10px; border-radius: 8px; font-size: 13px; margin-bottom: 12px;
|
||||
}
|
||||
#dirty.ok { background: #12331c; border-color: #1f7a3d; color: #7ee2a0; }
|
||||
textarea {
|
||||
width: 100%; min-height: 46vh; resize: vertical;
|
||||
background: #0d0f12; color: #d7dce2; border: 1px solid #2a313a;
|
||||
border-radius: 10px; padding: 12px; font: 13px/1.5 ui-monospace, Menlo, monospace;
|
||||
}
|
||||
.row { display: flex; gap: 10px; margin: 12px 0; flex-wrap: wrap; }
|
||||
button {
|
||||
flex: 1; min-width: 140px; padding: 14px 16px; border: 0; border-radius: 12px;
|
||||
font-size: 16px; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
#save { background: #2a313a; color: #e6e8eb; }
|
||||
#saveBuild { background: #0a7d3a; color: #fff; }
|
||||
button:disabled { opacity: .45; }
|
||||
#status {
|
||||
display: none; background: #0d0f12; border: 1px solid #2a313a; border-radius: 10px;
|
||||
padding: 12px; font: 12px/1.5 ui-monospace, Menlo, monospace; white-space: pre-wrap;
|
||||
max-height: 40vh; overflow-y: auto; color: #9aa3ad;
|
||||
}
|
||||
#status.done { border-color: #1f7a3d; color: #d7dce2; }
|
||||
#status.fail { border-color: #8a2b2b; color: #ffb4b4; }
|
||||
#result { display: none; margin-top: 10px; padding: 12px; border-radius: 10px;
|
||||
font-size: 15px; font-weight: 600; }
|
||||
#result.ok { background: #12331c; color: #7ee2a0; }
|
||||
#result.fail { background: #331212; color: #ff8d8d; }
|
||||
#result a { color: #7ec8ff; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>⛽ FuelBoard Copy Editor</h1>
|
||||
<div class="meta"><code id="repo">…</code> · <span id="head">…</span></div>
|
||||
<div id="dirty"></div>
|
||||
<textarea id="src" spellcheck="false" placeholder="Loading Localizable.strings…"></textarea>
|
||||
<div class="row">
|
||||
<button id="save">Save</button>
|
||||
<button id="saveBuild">Save & Build</button>
|
||||
</div>
|
||||
<div id="status"></div>
|
||||
<div id="result"></div>
|
||||
|
||||
<script>
|
||||
const KEY = "fbEditorToken";
|
||||
let token = localStorage.getItem(KEY) || "";
|
||||
let pollTimer = null;
|
||||
|
||||
async function api(path, opts = {}) {
|
||||
const headers = { "X-Key": token, ...(opts.headers || {}) };
|
||||
let res = await fetch(path, { ...opts, headers });
|
||||
if (res.status === 401) {
|
||||
token = prompt("Editor token?");
|
||||
if (!token) throw new Error("token required");
|
||||
localStorage.setItem(KEY, token);
|
||||
return api(path, opts); // retry once with the new token
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const res = await api("/file");
|
||||
const data = await res.json();
|
||||
document.getElementById("src").value = data.content;
|
||||
document.getElementById("repo").textContent = data.repo;
|
||||
document.getElementById("head").textContent = data.head;
|
||||
const dirty = document.getElementById("dirty");
|
||||
if (data.dirty.length) {
|
||||
dirty.style.display = "block";
|
||||
dirty.classList.remove("ok");
|
||||
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"); }
|
||||
}
|
||||
|
||||
function statusBox(text, cls) {
|
||||
const s = document.getElementById("status");
|
||||
s.style.display = "block";
|
||||
s.className = cls || "";
|
||||
s.textContent = text;
|
||||
}
|
||||
|
||||
async function saveOnly() {
|
||||
const res = await api("/save", { method: "POST", body: document.getElementById("src").value });
|
||||
const data = await res.json();
|
||||
if (data.lint_ok) {
|
||||
statusBox("✓ saved — " + data.lint, "done");
|
||||
} else {
|
||||
statusBox("✗ saved but INVALID — " + data.lint, "fail");
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAndBuild() {
|
||||
const saveBtn = document.getElementById("save");
|
||||
const buildBtn = document.getElementById("saveBuild");
|
||||
saveBtn.disabled = buildBtn.disabled = true;
|
||||
document.getElementById("result").style.display = "none";
|
||||
statusBox("saving + starting build…");
|
||||
|
||||
let res = await api("/save-build", { method: "POST", body: document.getElementById("src").value });
|
||||
let data = await res.json();
|
||||
if (!data.job_id) { statusBox("✗ " + JSON.stringify(data), "fail"); enable(); return; }
|
||||
|
||||
// Poll the job until it finishes.
|
||||
pollTimer = setInterval(async () => {
|
||||
try {
|
||||
const r = await api("/job/" + data.job_id);
|
||||
const j = await r.json();
|
||||
statusBox(j.lines.join("\n") + (j.running ? "\n…building…" : ""), j.success ? "done" : "fail");
|
||||
if (j.done) {
|
||||
clearInterval(pollTimer);
|
||||
const result = document.getElementById("result");
|
||||
if (j.success) {
|
||||
result.className = "ok";
|
||||
result.innerHTML = "✓ Build OK — <a href=\"http://192.168.1.131:8765/FuelBoard.ipa\">FuelBoard.ipa</a> " +
|
||||
(j.ipa_size / 1e6).toFixed(2) + " MB";
|
||||
} else {
|
||||
result.className = "fail";
|
||||
result.textContent = "✗ Build failed — see log above";
|
||||
}
|
||||
result.style.display = "block";
|
||||
enable();
|
||||
}
|
||||
} catch (e) { clearInterval(pollTimer); statusBox("poll error: " + e, "fail"); enable(); }
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function enable() {
|
||||
document.getElementById("save").disabled = false;
|
||||
document.getElementById("saveBuild").disabled = false;
|
||||
}
|
||||
|
||||
document.getElementById("save").addEventListener("click", saveOnly);
|
||||
document.getElementById("saveBuild").addEventListener("click", saveAndBuild);
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
#!/bin/bash
|
||||
# FuelBoard Copy Editor launcher
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
PORT="${EDITOR_PORT:-8790}"
|
||||
export PORT
|
||||
exec python3 editor.py
|
||||
Reference in New Issue
Block a user