From 1359c6eba86464288ea4cc9760e5898bcb1c97ef Mon Sep 17 00:00:00 2001 From: DeeCee03 Date: Thu, 6 Aug 2026 17:36:49 +0200 Subject: [PATCH] Projektstand liegt im Repo statt nur im Browser Der Stand hing am localStorage und damit an einem Rechner. Wer das Projekt woanders zog, sah einen anderen Stand. Jede Aenderung geht jetzt zusaetzlich in state/.json, gebuendelt nach 600 ms. Beim Laden gewinnt die Datei, der localStorage ist nur noch der Rueckfall fuer den Fall, dass der Server nicht laeuft. Gibt es die Datei noch nicht, wird der vorhandene Browser-Stand einmalig hineingeschrieben. Ein Reload verschluckt nichts: was im Timer haengt, geht per sendBeacon raus. Der Server nimmt den Stand unter POST /state entgegen und loescht ihn unter DELETE /state. Der Projektname wird gegen ein enges Muster geprueft und der Zielpfad zusaetzlich gegen state/ gegengerechnet. Geschrieben wird erst in eine Nebendatei und dann umbenannt, eingerueckt und mit sortierten Schluesseln, damit git einen lesbaren Unterschied zeigt. state/*.json gehoeren ab jetzt in jeden Commit, sonst sieht die andere Maschine die Aenderung nicht. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 20 ++++++- index.html | 83 ++++++++++++++++++++++------ post1-alt.html | 75 +++++++++++++++++++++----- post1.html | 74 ++++++++++++++++++++----- serve.py | 77 ++++++++++++++++++++++++-- state/post1.json | 138 +++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 420 insertions(+), 47 deletions(-) create mode 100644 state/post1.json diff --git a/README.md b/README.md index 366fd2a..0171062 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,23 @@ Oben im Bedienfeld wechselt man zwischen ihnen. **Jedes Projekt speichert seinen Stand** — Einstellungen, Layouts, Muster und alle Verschiebungen aus dem Canvas überleben einen Reload. „Projekt zurücksetzen" wirft ihn weg. +### Der Stand liegt im Repo + +Nicht nur im Browser: jede Änderung landet auch in `state/.json`. +**Diese Dateien gehören mit in den Commit.** Nur so hat die andere Maschine +nach `git pull` denselben Stand. + +``` +state/post1.json Post 1 · Lösung +state/post1-alt.json Post 1 · Alt +state/post2.json Post 2 · Statement +``` + +Beim Laden gewinnt die Datei, der localStorage ist nur der Rückfall, wenn der +Server nicht läuft. Wer den Stand ändert, muss also committen und pushen — +sonst sieht ihn niemand sonst. Gibt es die Datei noch nicht, wird der Stand +aus dem Browser einmalig hineingeschrieben. + ## Bedienung | Taste | Wirkung | @@ -52,7 +69,8 @@ assets/ ├── screens/ Software-Screenshots └── fonts/ Manrope 400–800, lokal vendor/ html-to-image, JSZip -serve.py Statischer Server + /save-Endpunkt +state/ Projektstände, gehören ins Repo +serve.py Statischer Server + /save- und /state-Endpunkt rules.md Gestaltungsregeln, Farbwerte, Export-Wissen ``` diff --git a/index.html b/index.html index b40e529..df3ac5d 100644 --- a/index.html +++ b/index.html @@ -352,7 +352,9 @@ /* ─── Projektstand ──────────────────────────────────────────────────────── Gleiches Prinzip wie bei Post 1: Das Werkzeug hält den Stand. */ - const STORE = "hivegrid.projekt.post2"; + const PROJECT = "post2"; + const STORE = "hivegrid.projekt." + PROJECT; + const STATE_URL = "./state/" + PROJECT + ".json"; let savedTimer = null; function markSaved() { @@ -364,23 +366,65 @@ savedTimer = setTimeout(() => el.classList.remove("on"), 1200); } - function saveState() { - try { - localStorage.setItem(STORE, JSON.stringify({ - v: 1, - variant: slide.dataset.variant, - pattern: slide.dataset.pattern, - frame: slide.dataset.frame, - density: currentDensity, - })); - markSaved(); - } catch (err) { console.warn("Speichern fehlgeschlagen", err); } + function currentState() { + return { + v: 1, + variant: slide.dataset.variant, + pattern: slide.dataset.pattern, + frame: slide.dataset.frame, + density: currentDensity, + }; } - function loadState() { - let d = null; - try { d = JSON.parse(localStorage.getItem(STORE) || "null"); } catch (_) {} + /* Der Stand geht in zwei Ablagen. Sofort in den localStorage, damit die + Rückmeldung nicht auf das Netz wartet, und gebündelt in + state/.json. Nur die Datei lässt sich pushen, also ist sie die + Wahrheit; der localStorage ist der Rückfall, wenn der Server nicht läuft. */ + let pushTimer = null; + function saveState() { + const state = currentState(); + try { localStorage.setItem(STORE, JSON.stringify(state)); } + catch (err) { console.warn("Speichern fehlgeschlagen", err); } + markSaved(); + clearTimeout(pushTimer); + pushTimer = setTimeout(() => pushState(state), 600); + } + + async function pushState(state) { + pushTimer = null; + try { + const res = await fetch("/state?project=" + PROJECT, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(state), + }); + if (!res.ok) throw new Error("HTTP " + res.status); + } catch (err) { console.warn("Stand nicht in die Datei geschrieben", err); } + } + + // Ein Reload darf keine Änderung verschlucken, die noch im Timer hängt. + addEventListener("beforeunload", () => { + if (!pushTimer) return; + clearTimeout(pushTimer); + navigator.sendBeacon("/state?project=" + PROJECT, + new Blob([JSON.stringify(currentState())], { type: "application/json" })); + }); + + async function loadState() { + let d = null, fromFile = false; + // Erst die Datei aus dem Repo, dann der Browser. Wer das Projekt frisch + // gezogen hat, soll den gepushten Stand sehen und nicht seinen eigenen alten. + try { + const res = await fetch(STATE_URL, { cache: "no-store" }); + if (res.ok) { d = await res.json(); fromFile = true; } + } catch (_) {} + if (!d) { + try { d = JSON.parse(localStorage.getItem(STORE) || "null"); } catch (_) {} + } if (!d || d.v !== 1) return false; + // Stand aus dem Browser, aber noch keine Datei: einmalig hinueberschreiben, + // sonst bliebe ein ueber Wochen gewachsener Stand ungeteilt liegen. + if (!fromFile) setTimeout(() => pushState(currentState()), 0); if (d.variant) setVariant(d.variant); if (d.pattern) setPattern(d.pattern); if (d.frame) setFrame(d.frame); @@ -389,8 +433,13 @@ } function forgetProject() { + clearTimeout(pushTimer); pushTimer = null; try { localStorage.removeItem(STORE); } catch (_) {} - location.reload(); + // Auch die Datei muss weg, sonst spielt sie den alten Stand beim nächsten + // Laden wieder ein. + fetch("/state?project=" + PROJECT, { method: "DELETE" }) + .catch(() => {}) + .then(() => location.reload()); } function setStatus(msg, kind) { @@ -560,7 +609,7 @@ (async function boot() { try { buildPattern(0.55); - const restored = loadState(); + const restored = await loadState(); document.getElementById("forget").addEventListener("click", forgetProject); await document.fonts.ready; fontEmbedCss = await htmlToImage.getFontEmbedCSS(slide); diff --git a/post1-alt.html b/post1-alt.html index 5d1ca27..7198bf5 100644 --- a/post1-alt.html +++ b/post1-alt.html @@ -1025,20 +1025,64 @@ const PROJECT = "post1-alt"; const STORE = "hivegrid.projekt." + PROJECT; - function saveState() { - try { - localStorage.setItem(STORE, JSON.stringify({ - v: 1, variant, tilt, superSample, continuous, view, tagOut, frameStyle, - current, layoutState, bgState, edits, - })); - markSaved(); - } catch (err) { console.warn("Speichern fehlgeschlagen", err); } + const STATE_URL = "./state/" + PROJECT + ".json"; + + function currentState() { + return { + v: 1, variant, tilt, superSample, continuous, view, tagOut, frameStyle, + current, layoutState, bgState, edits, + }; } - function loadState() { - let d = null; - try { d = JSON.parse(localStorage.getItem(STORE) || "null"); } catch (_) {} + /* Der Stand geht in zwei Ablagen. Sofort in den localStorage, damit die + Rückmeldung nicht auf das Netz wartet, und gebündelt in + state/.json. Nur die Datei lässt sich pushen, also ist sie die + Wahrheit; der localStorage ist der Rückfall, wenn der Server nicht läuft. */ + let pushTimer = null; + function saveState() { + const state = currentState(); + try { localStorage.setItem(STORE, JSON.stringify(state)); } + catch (err) { console.warn("Speichern fehlgeschlagen", err); } + markSaved(); + clearTimeout(pushTimer); + pushTimer = setTimeout(() => pushState(state), 600); + } + + async function pushState(state) { + pushTimer = null; + try { + const res = await fetch("/state?project=" + PROJECT, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(state), + }); + if (!res.ok) throw new Error("HTTP " + res.status); + } catch (err) { console.warn("Stand nicht in die Datei geschrieben", err); } + } + + // Ein Reload darf keine Änderung verschlucken, die noch im Timer hängt. + addEventListener("beforeunload", () => { + if (!pushTimer) return; + clearTimeout(pushTimer); + navigator.sendBeacon("/state?project=" + PROJECT, + new Blob([JSON.stringify(currentState())], { type: "application/json" })); + }); + + async function loadState() { + let d = null, fromFile = false; + // Erst die Datei aus dem Repo, dann der Browser. Wer das Projekt frisch + // gezogen hat, soll den gepushten Stand sehen und nicht seinen eigenen alten. + try { + const res = await fetch(STATE_URL, { cache: "no-store" }); + if (res.ok) { d = await res.json(); fromFile = true; } + } catch (_) {} + if (!d) { + try { d = JSON.parse(localStorage.getItem(STORE) || "null"); } catch (_) {} + } if (!d || d.v !== 1) return false; + // Stand aus dem Browser, aber noch keine Datei: einmalig hinueberschreiben, + // sonst bliebe ein ueber Wochen gewachsener Stand ungeteilt liegen. + if (!fromFile) setTimeout(() => pushState(currentState()), 0); // Nur übernehmen, was zur aktuellen Slide-Anzahl passt: Änderungen am // Aufbau dürfen keinen kaputten Stand einspielen. if (Array.isArray(d.layoutState) && d.layoutState.length === SLIDES.length) { @@ -1072,8 +1116,13 @@ } function forgetProject() { + clearTimeout(pushTimer); pushTimer = null; try { localStorage.removeItem(STORE); } catch (_) {} - location.reload(); + // Auch die Datei muss weg, sonst spielt sie den alten Stand beim nächsten + // Laden wieder ein. + fetch("/state?project=" + PROJECT, { method: "DELETE" }) + .catch(() => {}) + .then(() => location.reload()); } // Per-slide so a single slide can carry a different ground. const bgState = SLIDES.map(() => ({ bg: "formen", int: "dezent" })); @@ -2124,7 +2173,7 @@ try { setStatus("Lade Screens ..."); await preloadAssets(); - const restored = loadState(); + const restored = await loadState(); buildDeck(); deck.dataset.view = view; press("variants", "v", variant); diff --git a/post1.html b/post1.html index 3aa7441..c5b44d3 100644 --- a/post1.html +++ b/post1.html @@ -1072,21 +1072,64 @@ halten, nicht der Kopf. */ const PROJECT = "post1"; const STORE = "hivegrid.projekt." + PROJECT; + const STATE_URL = "./state/" + PROJECT + ".json"; - function saveState() { - try { - localStorage.setItem(STORE, JSON.stringify({ - v: 1, variant, tilt, superSample, continuous, view, tagOut, frameStyle, - current, layoutState, bgState, edits, - })); - markSaved(); - } catch (err) { console.warn("Speichern fehlgeschlagen", err); } + function currentState() { + return { + v: 1, variant, tilt, superSample, continuous, view, tagOut, frameStyle, + current, layoutState, bgState, edits, + }; } - function loadState() { - let d = null; - try { d = JSON.parse(localStorage.getItem(STORE) || "null"); } catch (_) {} + /* Der Stand geht in zwei Ablagen. Sofort in den localStorage, damit die + Rückmeldung nicht auf das Netz wartet, und gebündelt in + state/.json. Nur die Datei lässt sich pushen, also ist sie die + Wahrheit; der localStorage ist der Rückfall, wenn der Server nicht läuft. */ + let pushTimer = null; + function saveState() { + const state = currentState(); + try { localStorage.setItem(STORE, JSON.stringify(state)); } + catch (err) { console.warn("Speichern fehlgeschlagen", err); } + markSaved(); + clearTimeout(pushTimer); + pushTimer = setTimeout(() => pushState(state), 600); + } + + async function pushState(state) { + pushTimer = null; + try { + const res = await fetch("/state?project=" + PROJECT, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(state), + }); + if (!res.ok) throw new Error("HTTP " + res.status); + } catch (err) { console.warn("Stand nicht in die Datei geschrieben", err); } + } + + // Ein Reload darf keine Änderung verschlucken, die noch im Timer hängt. + addEventListener("beforeunload", () => { + if (!pushTimer) return; + clearTimeout(pushTimer); + navigator.sendBeacon("/state?project=" + PROJECT, + new Blob([JSON.stringify(currentState())], { type: "application/json" })); + }); + + async function loadState() { + let d = null, fromFile = false; + // Erst die Datei aus dem Repo, dann der Browser. Wer das Projekt frisch + // gezogen hat, soll den gepushten Stand sehen und nicht seinen eigenen alten. + try { + const res = await fetch(STATE_URL, { cache: "no-store" }); + if (res.ok) { d = await res.json(); fromFile = true; } + } catch (_) {} + if (!d) { + try { d = JSON.parse(localStorage.getItem(STORE) || "null"); } catch (_) {} + } if (!d || d.v !== 1) return false; + // Stand aus dem Browser, aber noch keine Datei: einmalig hinueberschreiben, + // sonst bliebe ein ueber Wochen gewachsener Stand ungeteilt liegen. + if (!fromFile) setTimeout(() => pushState(currentState()), 0); // Nur übernehmen, was zur aktuellen Slide-Anzahl passt: Änderungen am // Aufbau dürfen keinen kaputten Stand einspielen. if (Array.isArray(d.layoutState) && d.layoutState.length === SLIDES.length) { @@ -1120,8 +1163,13 @@ } function forgetProject() { + clearTimeout(pushTimer); pushTimer = null; try { localStorage.removeItem(STORE); } catch (_) {} - location.reload(); + // Auch die Datei muss weg, sonst spielt sie den alten Stand beim nächsten + // Laden wieder ein. + fetch("/state?project=" + PROJECT, { method: "DELETE" }) + .catch(() => {}) + .then(() => location.reload()); } // Per-slide so a single slide can carry a different ground. const bgState = SLIDES.map(() => ({ bg: "formen", int: "dezent" })); @@ -2115,7 +2163,7 @@ try { setStatus("Lade Screens ..."); await preloadAssets(); - const restored = loadState(); + const restored = await loadState(); buildDeck(); deck.dataset.view = view; press("variants", "v", variant); diff --git a/serve.py b/serve.py index e66256d..353c74b 100755 --- a/serve.py +++ b/serve.py @@ -1,31 +1,101 @@ #!/usr/bin/env python3 -"""Static server for the post generator, plus a save endpoint. +"""Static server for the post generator, plus a save and a state endpoint. The browser download route turned out to be unreliable here: with a data: URL it opened a blank tab, with a blob: URL it opened the image in a tab. Both times the download attribute was ignored. Since this only ever runs locally, the generator now POSTs the rendered bytes to /save and the file is written straight to the Downloads folder. No browser download machinery involved. + +Der Projektstand liegt nicht mehr nur im localStorage des Browsers, sondern +unter state/.json im Repo. Nur so ueberlebt er einen Rechnerwechsel: +gepusht und gezogen hat die andere Maschine denselben Stand. Der localStorage +bleibt als Zwischenspeicher, die Datei ist die Wahrheit. """ import http.server +import json import os import re import socketserver +import urllib.parse from pathlib import Path PORT = 4321 ROOT = Path(__file__).resolve().parent TARGET = Path.home() / "Downloads" +STATE_DIR = ROOT / "state" SAFE_NAME = re.compile(r"^[A-Za-z0-9._-]{1,120}$") +SAFE_PROJECT = re.compile(r"^[a-z0-9][a-z0-9-]{0,39}$") +MAX_STATE = 4 * 1024 * 1024 class Handler(http.server.SimpleHTTPRequestHandler): def __init__(self, *a, **kw): super().__init__(*a, directory=str(ROOT), **kw) + def _project(self): + """Projektname aus der Anfrage, oder None wenn er nicht taugt.""" + q = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query) + name = (q.get("project") or [""])[0] + return name if SAFE_PROJECT.match(name) else None + + def _state_path(self, name): + # Der Name ist geprueft, aber der Pfad wird trotzdem gegengerechnet: + # ein Schreibziel ausserhalb von state/ darf es nicht geben. + p = (STATE_DIR / f"{name}.json").resolve() + return p if p.parent == STATE_DIR.resolve() else None + + def do_DELETE(self): + if urllib.parse.urlparse(self.path).path != "/state": + self.send_error(404) + return + name = self._project() + if not name: + self._json(400, '{"error":"ungueltiges Projekt"}') + return + p = self._state_path(name) + if p and p.exists(): + p.unlink() + print(f"Stand verworfen: {p.name}") + self._json(200, '{"ok":true}') + + def _put_state(self): + name = self._project() + if not name: + self._json(400, '{"error":"ungueltiges Projekt"}') + return + length = int(self.headers.get("Content-Length", 0)) + if length <= 0 or length > MAX_STATE: + self._json(400, '{"error":"ungueltige Groesse"}') + return + raw = self.rfile.read(length) + try: + data = json.loads(raw) + except ValueError: + self._json(400, '{"error":"kein JSON"}') + return + p = self._state_path(name) + if p is None: + self._json(400, '{"error":"ungueltiger Pfad"}') + return + STATE_DIR.mkdir(parents=True, exist_ok=True) + # Eingerueckt und mit sortierten Schluesseln, damit git einen lesbaren + # Unterschied zeigt statt einer einzigen langen Zeile. Erst in eine + # Nebendatei, dann umbenennen: ein Abbruch mittendrin darf keinen + # halben Stand hinterlassen. + tmp = p.with_suffix(".json.tmp") + tmp.write_text(json.dumps(data, indent=1, sort_keys=True, + ensure_ascii=False) + "\n", encoding="utf-8") + tmp.replace(p) + self._json(200, '{"ok":true}') + def do_POST(self): - if self.path.split("?")[0] != "/save": + path = urllib.parse.urlparse(self.path).path + if path == "/state": + self._put_state() + return + if path != "/save": self.send_error(404) return @@ -72,7 +142,8 @@ class Handler(http.server.SimpleHTTPRequestHandler): super().end_headers() def log_message(self, fmt, *args): - if "POST" in (args[0] if args else ""): + first = args[0] if args else "" + if "POST /save" in first or "DELETE" in first: super().log_message(fmt, *args) diff --git a/state/post1.json b/state/post1.json new file mode 100644 index 0000000..acb487f --- /dev/null +++ b/state/post1.json @@ -0,0 +1,138 @@ +{ + "bgState": [ + { + "bg": "raster", + "int": "dezent" + }, + { + "bg": "raster", + "int": "dezent" + }, + { + "bg": "raster", + "int": "dezent" + }, + { + "bg": "raster", + "int": "dezent" + }, + { + "bg": "raster", + "int": "dezent" + } + ], + "continuous": "aus", + "current": 0, + "edits": [ + { + "laptop": { + "dx": -15, + "dy": -39, + "size": 827 + }, + "logo-full": { + "dx": -40, + "dy": 597 + }, + "szene": { + "dx": -10, + "dy": -82, + "size": 1020 + }, + "wabe-claim": { + "dx": 16, + "dy": -902 + }, + "wabe-logo": { + "dx": 209, + "dy": 833 + }, + "wabe-tagline": { + "dx": 141, + "dy": -3 + } + }, + { + "badge": { + "dx": -32, + "dy": -73 + }, + "body": { + "dx": 0, + "dy": -93 + }, + "headline": { + "dx": 0, + "dy": -84 + }, + "mock": { + "dx": 232, + "dy": -58, + "size": 706 + }, + "mock-2": { + "dx": -465, + "dy": -267, + "size": 360 + } + }, + { + "badge": { + "dx": -6, + "dy": -30 + }, + "headline": { + "dx": 0, + "dy": 7 + }, + "mock": { + "dx": 6, + "dy": -382, + "size": 870 + }, + "mock-2": { + "dx": -117, + "dy": -6, + "size": 354 + }, + "note": { + "dx": 144, + "dy": -129, + "size": 320 + } + }, + { + "headline": {}, + "mock": { + "dx": 28, + "dy": -141, + "size": 931 + }, + "mock-2": { + "dx": -8, + "dy": -76 + } + }, + { + "mock": { + "dx": -9, + "dy": 16, + "size": 584 + } + } + ], + "frameStyle": "stapel", + "layoutState": [ + "szene", + "standard", + "standard", + "standard", + "standard" + ], + "superSample": 3, + "tagOut": "an", + "tilt": "an", + "v": 1, + "variant": "hell", + "view": "einzeln" +}