Bilder aus dem Finder kommen jetzt ueber die Dateiauswahl oder per Ziehen direkt auf eine Slide herein. Beim Ablegen wird das Bild dort zum zweiten Bild und laesst sich im Bearbeiten-Modus frei schieben und in der Groesse aendern; ueber den Knopf importiert man nur und ordnet danach unter Inhalt zu. Das zweite Bild laesst sich dort auch wieder abwaehlen. Die Datei geht byteweise unveraendert an den neuen Endpunkt /upload und liegt danach unter assets/eigene/. Nichts wird neu kodiert, skaliert oder umbenannt -- nur Zeichen, die in einem Dateinamen nichts zu suchen haben, weichen einem Bindestrich. Der Umweg ueber den Server ist Absicht: ein Foto als Base64 im Projektstand waere groesser als der gesamte uebrige Stand und machte jeden Diff unlesbar. So steht dort nur der Pfad, und das Bild liegt mit im Repo wie jedes andere Asset auch. Die Auswahllisten holt der Generator beim Start ueber /bilder vom Server statt sie im Code zu pflegen. Importierte stehen deshalb sofort darin, mit einem Stern gekennzeichnet. Beim Zuweisen folgt das Rahmenformat dem Seitenverhaeltnis der Aufnahme. Geprueft ueber das DevTools-Protokoll: ablegen, Bytes gegen die Quelle vergleichen, Format, Verschieben im Bearbeiten-Modus, Export und Neustart. Die abgelegte Datei war byteweise identisch mit der Quelle. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
230 lines
9.0 KiB
Python
Executable File
230 lines
9.0 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""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/<projekt>.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.
|
|
|
|
Eigene Bilder kommen ueber /upload herein und landen unverfaelscht als Datei
|
|
unter assets/eigene/. Der Weg ueber den Projektstand waere der falsche: ein
|
|
2-MB-PNG als Base64 blaeht state/post1.json auf und macht jeden Diff
|
|
unlesbar. So steht im Stand nur der Pfad, und das Bild liegt mit im Repo --
|
|
dasselbe Prinzip wie bei allen anderen Assets.
|
|
"""
|
|
|
|
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"
|
|
EIGENE_DIR = ROOT / "assets" / "eigene"
|
|
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
|
|
MAX_BILD = 40 * 1024 * 1024
|
|
# Nur verlustfreie oder unveraenderte Rasterbilder. Die Bytes werden nicht
|
|
# angefasst, deshalb bleibt ein PNG genau das PNG, das der Finder geliefert hat.
|
|
BILD_ENDUNGEN = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif"}
|
|
BILD_ORDNER = ("assets/screens", "assets/eigene")
|
|
|
|
|
|
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_GET(self):
|
|
"""Vor der Dateiauslieferung nur die Bilderliste abfangen."""
|
|
if urllib.parse.urlparse(self.path).path == "/bilder":
|
|
self._liste_bilder()
|
|
return
|
|
super().do_GET()
|
|
|
|
def _liste_bilder(self):
|
|
"""Alle verwendbaren Bilder, damit die Auswahl im Generator nicht im
|
|
Code gepflegt werden muss und importierte sofort auftauchen."""
|
|
raus = []
|
|
for ordner in BILD_ORDNER:
|
|
verz = ROOT / ordner
|
|
if not verz.is_dir():
|
|
continue
|
|
for f in sorted(verz.iterdir()):
|
|
if f.is_file() and f.suffix.lower() in BILD_ENDUNGEN:
|
|
raus.append({"pfad": "./" + ordner + "/" + f.name,
|
|
"name": f.stem, "ordner": ordner.split("/")[-1]})
|
|
self._json(200, json.dumps(raus, ensure_ascii=False))
|
|
|
|
def _upload(self):
|
|
"""Ein eigenes Bild aufnehmen. Die Bytes gehen unveraendert auf die
|
|
Platte: nicht neu kodiert, nicht skaliert, nicht umbenannt ausser um
|
|
gefaehrliche Zeichen. Verlustfrei heisst hier woertlich unangetastet."""
|
|
name = os.path.basename(urllib.parse.unquote(self.headers.get("X-Filename", "")))
|
|
stamm, endung = os.path.splitext(name)
|
|
endung = endung.lower()
|
|
if endung not in BILD_ENDUNGEN:
|
|
self._json(400, '{"error":"kein Bildformat"}')
|
|
return
|
|
# Aus dem Stamm wird ein unverfaenglicher Dateiname, der Rest faellt weg.
|
|
stamm = re.sub(r"[^A-Za-z0-9._-]+", "-", stamm).strip("-.") or "bild"
|
|
stamm = stamm[:80]
|
|
|
|
length = int(self.headers.get("Content-Length", 0))
|
|
if length <= 0 or length > MAX_BILD:
|
|
self._json(400, '{"error":"ungueltige Groesse"}')
|
|
return
|
|
daten = self.rfile.read(length)
|
|
|
|
EIGENE_DIR.mkdir(parents=True, exist_ok=True)
|
|
ziel = (EIGENE_DIR / (stamm + endung)).resolve()
|
|
if ziel.parent != EIGENE_DIR.resolve():
|
|
self._json(400, '{"error":"ungueltiger Pfad"}')
|
|
return
|
|
# Gleicher Inhalt, gleicher Name: nicht doppelt ablegen.
|
|
n = 1
|
|
while ziel.exists() and ziel.read_bytes() != daten:
|
|
ziel = EIGENE_DIR / (stamm + "-" + str(n) + endung)
|
|
n += 1
|
|
ziel.write_bytes(daten)
|
|
pfad = "./assets/eigene/" + ziel.name
|
|
print(f"importiert: {ziel.name} ({len(daten)/1024:.0f} KB)")
|
|
self._json(200, json.dumps({"pfad": pfad, "name": ziel.stem}))
|
|
|
|
def do_POST(self):
|
|
path = urllib.parse.urlparse(self.path).path
|
|
if path == "/state":
|
|
self._put_state()
|
|
return
|
|
if path == "/upload":
|
|
self._upload()
|
|
return
|
|
if path != "/save":
|
|
self.send_error(404)
|
|
return
|
|
|
|
name = self.headers.get("X-Filename", "")
|
|
# The filename comes from the page, so it is never trusted: no paths, no
|
|
# traversal, plain basename only.
|
|
name = os.path.basename(name)
|
|
if not SAFE_NAME.match(name):
|
|
self._json(400, '{"error":"ungueltiger Dateiname"}')
|
|
return
|
|
|
|
length = int(self.headers.get("Content-Length", 0))
|
|
if length <= 0 or length > 80 * 1024 * 1024:
|
|
self._json(400, '{"error":"ungueltige Groesse"}')
|
|
return
|
|
|
|
data = self.rfile.read(length)
|
|
TARGET.mkdir(parents=True, exist_ok=True)
|
|
dest = TARGET / name
|
|
stem, suffix = dest.stem, dest.suffix
|
|
n = 1
|
|
while dest.exists():
|
|
dest = TARGET / f"{stem}-{n}{suffix}"
|
|
n += 1
|
|
dest.write_bytes(data)
|
|
print(f"gespeichert: {dest} ({len(data)/1024:.0f} KB)")
|
|
self._json(200, '{"path":"%s"}' % dest)
|
|
|
|
def _json(self, code, body):
|
|
payload = body.encode()
|
|
self.send_response(code)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(payload)))
|
|
self.end_headers()
|
|
self.wfile.write(payload)
|
|
|
|
def end_headers(self):
|
|
# Werkzeug, kein Auslieferungsserver: Assets werden hier laufend
|
|
# ausgetauscht, und ein gecachter Screenshot unter gleichem Dateinamen
|
|
# kostet mehr Zeit als jeder erneute Ladevorgang.
|
|
self.send_header("Cache-Control", "no-store, must-revalidate")
|
|
self.send_header("Pragma", "no-cache")
|
|
self.send_header("Expires", "0")
|
|
super().end_headers()
|
|
|
|
def log_message(self, fmt, *args):
|
|
first = args[0] if args else ""
|
|
if "POST /save" in first or "DELETE" in first:
|
|
super().log_message(fmt, *args)
|
|
|
|
|
|
class Server(socketserver.ThreadingTCPServer):
|
|
allow_reuse_address = True
|
|
|
|
|
|
if __name__ == "__main__":
|
|
print(f"Generator: http://127.0.0.1:{PORT}/post1.html")
|
|
print(f"Speichert nach: {TARGET}")
|
|
with Server(("127.0.0.1", PORT), Handler) as httpd:
|
|
httpd.serve_forever()
|