Initiale Ablage der umsortierten Skrift-Programme

Struktur: Docker/ (skrift-backend, signature-service), Webseite/
(skrift-configurator, skrift-job-manager), Produktion/ (skrift-produktion,
AutoRecover). Aufgeräumt: signature-to-svg entfernt, Backend-PayPal-Service
entfernt (PayPal läuft client-seitig im Configurator), Dev-/Test-Artefakte
und Deployment-Dokus bereinigt, .dockerignore repariert. CLAUDE.md mit
Architektur ergänzt. Secrets (.env, config.json) via .gitignore ausgeschlossen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Lucas Orth
2026-07-20 12:21:08 +02:00
commit 0341c4eaea
90 changed files with 40401 additions and 0 deletions

38
.gitignore vendored Normal file
View File

@@ -0,0 +1,38 @@
# ── Secrets (nicht ins Repo!) ───────────────────────────────
.env
.env.*
!.env.example
# Produktions-Config mit SFTP-Passwort (Template: config.default.json)
Produktion/skrift-produktion/config.json
# ── Node ────────────────────────────────────────────────────
node_modules/
npm-debug.log*
# ── Python ──────────────────────────────────────────────────
.venv/
venv/
__pycache__/
*.py[cod]
.pytest_cache/
# ── Laufzeit-/generierte Daten ──────────────────────────────
cache/
output/
*.log
# Produktions-Laufzeitzustand
Produktion/skrift-produktion/processed_orders.json
Produktion/skrift-produktion/archive.json
Produktion/skrift-produktion/order_data.json
Produktion/skrift-produktion/temp_signature/
# ── Build-Artefakte ─────────────────────────────────────────
build/
dist/
*.spec
# ── OS / Editor ─────────────────────────────────────────────
.DS_Store
Thumbs.db
.vscode/
.claude/settings.local.json

127
CLAUDE.md Normal file
View File

@@ -0,0 +1,127 @@
# CLAUDE.md
Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
## 1. Think Before Coding
**Don't assume. Don't hide confusion. Surface tradeoffs.**
Before implementing:
- State your assumptions explicitly. If uncertain, ask.
- If multiple interpretations exist, present them - don't pick silently.
- If a simpler approach exists, say so. Push back when warranted.
- If something is unclear, stop. Name what's confusing. Ask.
## 2. Simplicity First
**Minimum code that solves the problem. Nothing speculative.**
- No features beyond what was asked.
- No abstractions for single-use code.
- No "flexibility" or "configurability" that wasn't requested.
- No error handling for impossible scenarios.
- If you write 200 lines and it could be 50, rewrite it.
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
## 3. Surgical Changes
**Touch only what you must. Clean up only your own mess.**
When editing existing code:
- Don't "improve" adjacent code, comments, or formatting.
- Don't refactor things that aren't broken.
- Match existing style, even if you'd do it differently.
- If you notice unrelated dead code, mention it - don't delete it.
When your changes create orphans:
- Remove imports/variables/functions that YOUR changes made unused.
- Don't remove pre-existing dead code unless asked.
The test: Every changed line should trace directly to the user's request.
## 4. Goal-Driven Execution
**Define success criteria. Loop until verified.**
Transform tasks into verifiable goals:
- "Add validation" → "Write tests for invalid inputs, then make them pass"
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
- "Refactor X" → "Ensure tests pass before and after"
For multi-step tasks, state a brief plan:
```
1. [Step] → verify: [check]
2. [Step] → verify: [check]
3. [Step] → verify: [check]
```
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
---
**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
---
# Projekt: Skrift
Handschriftliche Dokumente als Service. Kunden konfigurieren Briefe/Postkarten/Umschläge im Web; ein Backend rendert sie als SVG in echter Handschrift; ein Schreibroboter (Pen-Plotter) schreibt sie physisch. Alle Software in diesem Ordner gehört zu Skrift. Sprache im Projekt: Deutsch.
## Ebenen und Komponenten
Drei Ebenen, getrennt gehalten für einfachere Wartung:
### `Docker/` — serverseitig, hinter Nginx Proxy Manager (NPM)
- **skrift-backend** (Node/Express, `:4000`, Domain `backend.skrift.de`) — **Main-Backend**. Generiert Brief-/Umschlag-SVGs: Text → Scriptalizer-API (natürliche Handschrift-Variation) → eigene Font-Engine (`src/lib/svg-font-engine.js`). Preview-Cache + finaler Order-Output. Platzhalter-System (`[[Name]]` → CSV). Kein PayPal im Backend — die PayPal-Zahlung läuft **rein client-seitig** im configurator über das PayPal-JS-SDK (`actions.order.create/capture` direkt gegen PayPal). SVG-Fonts unter `fonts/` (`tilda`=PremiumUltra79, `alva`=PremiumUltra23, `ellie`=PremiumUltra39). Struktur: `src/api/{routes,controllers,middleware}`, `src/services` (scriptalizer, placeholder), `src/lib` (svg-generator, svg-font-engine, page-layout).
- **signature-service** (FastAPI, `:8000`, Domain `api-sign.skrift.de`) — **zustandslos**. Findet unsichtbare Marker-Token (`§§SIG1§§`) in einem gedruckten PDF, platziert die zugeordnete Unterschrift-SVG relativ dazu und liefert **eine A4-SVG pro Seite** (`/render`, ZIP) bzw. ein Vorschau-PDF (`/preview`). Keine Plotter-Kalibrierung/Offsets im Service — Mechanik wird in der Druckersoftware behandelt. Geometrie-Logik in `core.py`.
### `Webseite/` — WordPress-Plugins (öffentlich)
- **skrift-configurator** — **öffentlicher Haupt-Konfigurator**, Shortcode `[skrift_konfigurator]`. Spricht das Backend nur über einen **serverseitigen PHP-Proxy** an (`includes/api-proxy.php`, REST-Namespace `skrift/v1/proxy/*`), damit der API-Token nie ins Frontend gelangt. Liefert außerdem das Basis-CSS (`--sk-*`-Variablen, `.sk-*`-Klassen) sowie Admin-Settings (Backend-Verbindung, Preise, Produkte, Gutscheine).
- **skrift-job-manager** — Kunden-Auftragsportal, Shortcode `[skrift_kundenauftrag]`. **Hängt hart am configurator** (nutzt dessen CSS + Backend-Verbindungseinstellungen). Eigener Namespace `skrift/v1/job/*`; ruft Backend u.a. `/api/order/register`, `/api/order/files`. Signaturen: Kunde zeichnet auf einem `<canvas>` → Striche werden client-seitig via `pathsToSvg()` als Polyline-SVG (`M/L`-Pfade) erzeugt und als Platzhalter `[[signatur1..3]]` mitgeschickt.
### `Produktion/` — lokale Windows-Software
- **skrift-produktion** — **Main-Produktion**. FastAPI auf `127.0.0.1:8765` (öffnet Browser automatisch). Worker: `FTPPollWorker` pollt SFTP nach neuen Aufträgen, `MachinePollWorker` überwacht die Plotter-Maschine, `QueueWorker` arbeitet die Queue ab. Sendet SVGs an die Maschinen-API (Default `http://192.168.2.32:90`) mit Format-Templates (Maße/Position/Rotation) aus `config.json`. **Signaturen-Tab** ruft `signature-service /render` über den eigenen Endpunkt `POST /api/signature/print` und schickt die A4-Signaturen an den Plotter (Service-URL in `config.json` unter `app.signature_backend_url`, Default `http://localhost:8000`).
- **AutoRecover** — kleines win32-Skript (`auto_recover.py`). Erkennt das Java-„Error"-Fenster des Plotters (kein Papier) und klickt max. 3× „Recover".
## Datenfluss (End-to-End)
1. Kunde konfiguriert im **configurator** (oder **job-manager**) → WordPress-Proxy → **skrift-backend** generiert SVGs.
2. **skrift-backend** schreibt den fertigen Order-Ordner nach `/var/skrift-output` (docker-compose-Volume). **Dieses Verzeichnis ist gleichzeitig das SFTP-Ziel**, auf das per SFTP zugegriffen wird — kein Zwischenschritt.
3. **skrift-produktion** holt den Auftrag per SFTP und schickt die SVGs formatgerecht an den **Plotter**.
4. Bei Unterschriften: gedrucktes PDF mit Marker-Token → **signature-service** platziert Signaturen → produktion sendet die A4-Signatur-SVGs an den Plotter.
5. **AutoRecover** hält den Druck bei Papierende am Laufen.
## Befehle
**skrift-backend** (`Docker/skrift-backend`)
```bash
npm install
npm start # node src/server.js → :4000
npm run dev # nodemon
docker-compose up -d # Prod (Volume: /var/skrift-output, fonts/ read-only)
```
**signature-service** (`Docker/signature-service`)
```bash
pip install -r requirements.txt
uvicorn app:app --host 0.0.0.0 --port 8000
pytest # ein Test: pytest tests/test_x.py::test_name
docker compose up -d --build
```
**signature-to-svg** (`Docker/signature-to-svg`) — `uvicorn app.main:app --host 0.0.0.0 --port 8765` bzw. `docker-compose up -d`.
**skrift-produktion** (`Produktion/skrift-produktion`) — `Einrichten.bat` (venv + deps einmalig), dann `Starten.bat` (bzw. `Starten (Debug).bat`). Läuft auf `127.0.0.1:8765`.
**AutoRecover** — `python auto_recover.py`; EXE-Build via `build_exe.bat`.
WordPress-Plugins haben keinen Build-Schritt (reines PHP/JS).
## Wichtige Hinweise / Fallstricke
- **NPM-Netz**: `signature-service` hängt im externen Docker-Netz `nginx-proxy-manager_default` und veröffentlicht keinen Host-Port — NPM proxyt intern per `container_name`.
- **`SCRIPTALIZER_ERR_FREQUENCY=0`** setzen: sonst erzeugt Scriptalizer absichtlich durchgestrichene „Fehler"-Wörter.
- **Secrets liegen im Repo**: `Docker/skrift-backend/.env` enthält reale Keys — nicht in Ausgaben/Commits streuen.
- Der job-manager ist **ohne den configurator nicht lauffähig** (CSS + Backend-Settings kommen von dort).
- `FRONTEND_BACKEND_ZUSAMMENFASSUNG.md` stammt aus der Zeit vor dieser Ordnerstruktur (spricht von „WordPress Plugin" / „Docker Backend") und ist teils veraltet — diese CLAUDE.md hat Vorrang.

View File

@@ -0,0 +1,6 @@
__pycache__/
*.pyc
tests/
conftest.py
.pytest_cache/
README.md

View File

@@ -0,0 +1,15 @@
# Zustandsloser Signatur-Plotter-Service
FROM python:3.12-slim
WORKDIR /app
# Abhängigkeiten zuerst (besseres Layer-Caching)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Quellcode
COPY models.py core.py app.py ./
EXPOSE 8000
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -0,0 +1,197 @@
# Signatur-Plotter-Service
Zustandsloser FastAPI-Service, der für einen Schreibroboter **A4-SVGs** erzeugt,
auf denen handschriftliche Unterschriften an exakt der richtigen Position sitzen.
Im Druck-PDF stehen an jeder Signaturstelle unsichtbare Marker-Token in
transparenter Schrift (z.B. `§§SIG1§§`). Der Service sucht jeden Token im PDF,
liest dessen Koordinaten aus und platziert die zugeordnete SVG-Unterschrift
relativ dazu. Der Plotter druckt nur die erzeugte SVG – das bereits gedruckte
Dokument liefert den Rest.
> **Keine Plotter-Kalibrierung im Service.** Mechanischer Versatz wird komplett in
> der Druckersoftware behandelt. Es gibt keine Offset-Logik und keine ENV-Variablen
> für Versatz.
## Funktionsweise / Geometrie
- PDF-Punkte werden in mm umgerechnet (`1 pt = 25.4/72 mm`).
- PyMuPDF nutzt den Ursprung oben links, Y wächst nach unten.
- Referenzpunkt ist die **linke Oberkante** des Marker-Tokens (`rect.x0`, `rect.y0`).
- `position: "genau"` → `y_sig = y_token` (Unterschrift sitzt **direkt** am Marker, **Standard**)
- `position: "ueber"` → `y_sig = y_token - abstand_mm - signatur_hoehe`
- `position: "unter"` → `y_sig = y_token + abstand_mm`
- Skalierung gleichmäßig: `skala = breite_mm / svg_originalbreite`
(Originalbreite aus der **viewBox**, nicht aus `width`/`height`).
- Eingebettet wird per `<g transform="translate(x,y) scale(skala)">`.
Die Vorschau legt die fertige A4-Signatur-SVG vektoriell (via PyMuPDF
`convert_to_pdf` + `show_pdf_page`) über eine Kopie der betroffenen Originalseiten
und gibt ein zusammengeführtes PDF zurück. Begründung siehe Kommentar in
[`core.py`](core.py) (`build_preview_pdf`): voll vektoriell, gleiche Geometrie wie
`/render`, ohne zusätzliche Pillow-Abhängigkeit.
## Job-Definition
Pro Eintrag. Ein Token darf **mehrfach** im PDF vorkommen (z.B. dieselbe Unterschrift
auf jeder Seite) – jedes Vorkommen wird platziert:
| Feld | Typ | Default | Bedeutung |
|--------------|-------|---------|--------------------------------------------------|
| `token` | str | – | Unsichtbarer Token im PDF, z.B. `§§SIG1§§` |
| `svg_id` | str | – | Dateiname der hochgeladenen SVG |
| `position` | str | `genau` | `genau` (direkt am Marker), `ueber` oder `unter` |
| `breite_mm` | float | `45` | Zielbreite der Unterschrift |
| `abstand_mm` | float | `8` | Abstand zum Token (nur bei `ueber`/`unter` relevant) |
| `versatz_x_mm` | float | `0` | Feinjustierung links/rechts (+ = rechts, − = links) |
| `versatz_y_mm` | float | `0` | Feinjustierung oben/unten (+ = unten, − = oben) |
| `linienstaerke_mm` | float | `0.35` | Linienstärke der Kontur (Signaturen werden immer als Single-Stroke gezeichnet) |
Beispiel (`job.json`):
```json
[
{ "token": "§§SIG1§§", "svg_id": "unterschrift_a.svg", "position": "unter", "breite_mm": 45, "abstand_mm": 8 },
{ "token": "§§SIG2§§", "svg_id": "unterschrift_b.svg", "position": "ueber" }
]
```
## Endpunkte
### `POST /render`
Findet **jedes Vorkommen** jedes Tokens und platziert die zugeordnete SVG an jeder
Stelle. **Rückgabe:** ZIP-Download mit **einer A4-SVG pro betroffener Seite**
(`210×297mm`, `viewBox "0 0 210 297"`), Dateiname `seite_<n>.svg`. Jede Seiten-SVG
enthält alle Signaturen dieser Seite – nur die Unterschriften, Rest leer.
### `POST /preview`
Gleiche Platzierungslogik. **Rückgabe:** zusammengeführtes PDF mit allen
betroffenen Seiten und der Unterschrift als Overlay – nur zur Sichtkontrolle.
Übertragung jeweils als `multipart/form-data`: `pdf` als Datei, `svgs` als Dateien
(max. 3) und `job` als JSON-Form-Feld.
## Fehlerbehandlung
Es wird nie still übersprungen – Fehler kommen als klare Liste zurück:
- Token gar nicht gefunden (0 Treffer) → **422** (Mehrfachtreffer sind erlaubt)
- derselbe Token mehrfach in der Job-Definition → **422**
- `svg_id` ohne hochgeladene Datei → **422**
- SVG ohne `viewBox` → **422**
- Mehr als 3 SVGs → **400**
- Ungültiges Job-JSON / fehlendes/leeres PDF → **400**
- Ungültige Felder (`position`, `breite_mm` …) → **422**
Antwort-Format bei Fehlern: `{ "detail": [ "…", "…" ] }`.
## Beispiel-curl
`/render` (lädt das ZIP herunter):
```bash
curl -X POST http://localhost:8000/render \
-F "pdf=@dokument.pdf;type=application/pdf" \
-F "svgs=@unterschrift_a.svg;type=image/svg+xml" \
-F "svgs=@unterschrift_b.svg;type=image/svg+xml" \
-F 'job=[{"token":"§§SIG1§§","svg_id":"unterschrift_a.svg","position":"unter","breite_mm":45,"abstand_mm":8},{"token":"§§SIG2§§","svg_id":"unterschrift_b.svg","position":"ueber"}]' \
-o signaturen.zip
```
`/preview` (lädt das Vorschau-PDF herunter):
```bash
curl -X POST http://localhost:8000/preview \
-F "pdf=@dokument.pdf;type=application/pdf" \
-F "svgs=@unterschrift_a.svg;type=image/svg+xml" \
-F 'job=[{"token":"§§SIG1§§","svg_id":"unterschrift_a.svg","position":"unter"}]' \
-o vorschau.pdf
```
## Lokal starten
```bash
pip install -r requirements.txt
uvicorn app:app --host 0.0.0.0 --port 8000
```
## Docker (lokal)
```bash
docker build -t signatur-plotter .
docker run --rm -p 8000:8000 signatur-plotter
```
## Deployment hinter Nginx Proxy Manager
Der Service läuft im selben Docker-Netz wie der NPM (`nginx-proxy-manager_default`)
und wird per Domain **`api-sign.skrift.de`** erreichbar gemacht. Start via Compose:
```bash
docker compose up -d --build
```
Die [`docker-compose.yml`](docker-compose.yml) hängt den Container ins externe Netz
`nginx-proxy-manager_default` (kein Host-Port – NPM proxyt intern). Anschließend im
NPM einen **Proxy Host** anlegen:
| Feld | Wert |
|------------------|-------------------------------|
| Domain Names | `api-sign.skrift.de` |
| Scheme | `http` |
| Forward Hostname | `skrift-signature-service` |
| Forward Port | `8000` |
| SSL | Let's Encrypt-Zertifikat aktivieren |
Im Frontend wird die Backend-URL dann auf `https://api-sign.skrift.de` gesetzt
(siehe unten).
## Tests
```bash
pip install pytest
pytest
```
Die Tests decken Token-Suche, Geometrie (`ueber`/`unter`, viewBox-Offset) und die
Fehlerfälle (Token fehlt/mehrfach, SVG fehlt, keine viewBox) ab.
## Frontend-Tab in der Skrift-Anwendung
Der Upload-Bereich ist als eigener Tab **„Signaturen“** in die bestehende
Skrift-FTP-Anwendung integriert (`app/static/index.html`). Er ist über die
Hauptnavigation neben „Manuell drucken“ erreichbar und bietet:
- Upload für genau **ein PDF** und bis zu **3 SVG-Unterschriften**,
- pro SVG eine Zuordnungszeile (Marker-Token, Position **genau** (Standard) / über / unter,
Breite, Abstand, **X-/Y-Versatz** zur Feinjustierung sowie **Linienstärke**;
Signaturen werden immer als Single-Stroke gezeichnet),
- die zuletzt verwendeten Werte werden **pro Signatur** (Dateiname) im Browser
gespeichert und beim erneuten Hochladen automatisch wiederhergestellt,
- ein **Standard-Template** für den Direktdruck ist in den App-Einstellungen unter
*Zuordnung → Signaturen* festlegbar (wird im Tab vorausgewählt),
- Buttons **Vorschau** (zeigt das Vorschau-PDF inline) und **Rendern** (ZIP-Download),
- **Direkt drucken**: Template + Maschine auswählen und die gerenderten A4-Signaturen
sofort an den Schreibroboter senden,
- eine gut sichtbare Fehlerliste (Token nicht/mehrfach gefunden, fehlende Zuordnung …).
### Direktdruck-Pfad
`Direkt drucken` ruft den Skrift-Backend-Endpunkt `POST /api/signature/print` auf.
Dieser rendert die Signaturen serverseitig über den Signatur-Service (`/render`),
entpackt das ZIP und schickt die A4-SVGs mit den Parametern des gewählten Templates
über den vorhandenen Maschinen-Client an den Plotter (gleicher Ablauf wie
„Manuell drucken“, inkl. Formatwechsel-Prüfung und Bestätigung im Tab
„Druckvorgang“). Da dies über den gleichen Origin läuft, ist hierfür kein CORS nötig.
### Backend-URL setzen
Die URL des Signatur-Service ist konfigurierbar (Default `http://localhost:8000`,
hinter dem Nginx Proxy Manager z.B. `https://api-sign.skrift.de`):
- **Im Tab:** Feld *„Signatur-Service (Backend-URL)“* ausfüllen und *Speichern*.
- Persistiert wird in `config.json` unter `app.signature_backend_url`
(Endpunkte `GET`/`POST /api/signature/backend-url` der Skrift-App).
Damit das Frontend (Port `8765`) den Service (Port `8000`) erreichen darf, ist im
Service **CORS** offen konfiguriert.

View File

@@ -0,0 +1,139 @@
"""
Signatur-Plotter-Service (FastAPI).
Zustandsloser Service: nimmt ein PDF mit unsichtbaren Marker-Token, bis zu drei
Signatur-SVGs und eine Job-Definition entgegen und erzeugt A4-Plot-SVGs bzw. ein
Vorschau-PDF. Keine Datenbank, keine Persistenz, keine Plotter-Kalibrierung.
"""
import io
import json
import zipfile
from typing import List
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from pydantic import ValidationError
from core import build_preview_pdf, place_all
from models import SignatureJob
app = FastAPI(title="Signatur-Plotter-Service", version="1.0.0")
# CORS offen halten, damit das Upload-Frontend (anderer Port/Origin) den Service erreicht.
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
MAX_SVGS = 3
# ── Eingaben aufbereiten ────────────────────────────────────────────────
def _parse_jobs(job_raw: str) -> List[SignatureJob]:
"""Parst das Job-Definition-JSON-Formfeld zu einer Liste von SignatureJob."""
try:
data = json.loads(job_raw)
except json.JSONDecodeError as e:
raise HTTPException(400, f"Job-Definition ist kein gültiges JSON: {e}")
# Sowohl eine reine Liste als auch ein Wrapper-Objekt akzeptieren.
if isinstance(data, dict):
data = data.get("signaturen") or data.get("jobs") or [data]
if not isinstance(data, list) or not data:
raise HTTPException(400, "Job-Definition muss eine nicht-leere Liste sein.")
jobs: List[SignatureJob] = []
errors: List[str] = []
for i, entry in enumerate(data, start=1):
try:
jobs.append(SignatureJob(**entry))
except ValidationError as e:
for err in e.errors():
loc = ".".join(str(x) for x in err["loc"])
errors.append(f"Eintrag {i} ({loc}): {err['msg']}")
except TypeError:
errors.append(f"Eintrag {i}: ungültiges Format.")
if errors:
# 422 bei Validierungsfehlern der Job-Definition.
raise HTTPException(422, detail=errors)
# Doppelte Token in der Job-Definition selbst sind ein Eingabefehler.
seen = {}
for j in jobs:
seen[j.token] = seen.get(j.token, 0) + 1
dups = [t for t, c in seen.items() if c > 1]
if dups:
raise HTTPException(422, detail=[f"Token '{t}' mehrfach in der Job-Definition." for t in dups])
return jobs
async def _read_svgs(svgs: List[UploadFile]) -> dict:
"""Liest die hochgeladenen SVGs in ein {dateiname: bytes}-Dict. Max. 3."""
if len(svgs) > MAX_SVGS:
raise HTTPException(400, f"Maximal {MAX_SVGS} SVG-Dateien erlaubt (es waren {len(svgs)}).")
out = {}
for f in svgs:
out[f.filename] = await f.read()
return out
async def _prepare(pdf: UploadFile, svgs: List[UploadFile], job: str):
"""Gemeinsame Aufbereitung für /render und /preview."""
jobs = _parse_jobs(job)
svg_map = await _read_svgs(svgs)
pdf_bytes = await pdf.read()
if not pdf_bytes:
raise HTTPException(400, "PDF-Datei ist leer.")
pages, errors = place_all(pdf_bytes, svg_map, jobs)
if errors:
# 422: fachliche Validierung (Token nicht gefunden, SVG fehlt, keine viewBox …)
raise HTTPException(422, detail=errors)
return pdf_bytes, pages
# ── Endpunkte ───────────────────────────────────────────────────────────
@app.get("/")
def health():
return {"service": "Signatur-Plotter-Service", "status": "ok"}
@app.post("/render")
async def render(
pdf: UploadFile = File(...),
svgs: List[UploadFile] = File(default=[]),
job: str = Form(...),
):
"""Erzeugt pro betroffener Seite eine A4-SVG und liefert alle als ZIP."""
_, pages = await _prepare(pdf, svgs, job)
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
for p in pages:
zf.writestr(p.filename, p.a4_svg)
buf.seek(0)
return StreamingResponse(
buf,
media_type="application/zip",
headers={"Content-Disposition": 'attachment; filename="signaturen.zip"'},
)
@app.post("/preview")
async def preview(
pdf: UploadFile = File(...),
svgs: List[UploadFile] = File(default=[]),
job: str = Form(...),
):
"""Erzeugt ein Vorschau-PDF mit allen betroffenen Seiten und Signatur-Overlay."""
pdf_bytes, pages = await _prepare(pdf, svgs, job)
out_pdf = build_preview_pdf(pdf_bytes, pages)
return StreamingResponse(
io.BytesIO(out_pdf),
media_type="application/pdf",
headers={"Content-Disposition": 'inline; filename="vorschau.pdf"'},
)

View File

@@ -0,0 +1,2 @@
# Leer – sorgt dafür, dass pytest das Service-Verzeichnis auf sys.path legt,
# damit `import core` / `import models` in den Tests funktioniert.

View File

@@ -0,0 +1,315 @@
"""
Kernlogik des Signatur-Plotter-Service.
Hier liegt die gemeinsame Logik, die /render und /preview beide nutzen:
1. Marker-Token im PDF finden (eindeutig) und Koordinaten auslesen.
2. Die zugeordnete SVG geometrisch korrekt auf einer A4-SVG platzieren.
Der Service ist zustandslos: alles arbeitet auf den übergebenen Bytes.
"""
import re
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from typing import List, Tuple
import fitz # PyMuPDF
from models import SignatureJob
# ── Konstanten ──────────────────────────────────────────────────────────
PT_TO_MM = 25.4 / 72.0 # 1 pt = 25.4/72 mm
A4_WIDTH_MM = 210.0
A4_HEIGHT_MM = 297.0
SVG_NS = "http://www.w3.org/2000/svg"
XLINK_NS = "http://www.w3.org/1999/xlink"
# Saubere Namensräume beim Serialisieren (kein ns0:-Präfix im Output)
ET.register_namespace("", SVG_NS)
ET.register_namespace("xlink", XLINK_NS)
# Strukturelle Attribute der Wurzel-<svg>, die NICHT auf die Gruppe übernommen werden
_SKIP_ROOT_ATTRS = {
"viewbox", "width", "height", "xmlns", "x", "y", "id",
"version", "preserveaspectratio", "baseprofile",
# transform auf der äußersten <svg> wird laut Spec ignoriert – darf daher NICHT
# auf die Gruppe übernommen werden, sonst verzieht/verschiebt es die Signatur.
"transform",
}
class PlacementError(Exception):
"""Eingabe-/Validierungsfehler, der dem Aufrufer als Fehlermeldung gemeldet wird."""
@dataclass
class ViewBox:
"""Aus der SVG gelesene viewBox plus eingebetteter Inhalt."""
minx: float
miny: float
vw: float
vh: float
inner: str # serialisierte Kind-Elemente der <svg>
group_attrs: dict = field(default_factory=dict) # übernommene Styling-Attribute
@dataclass
class PageRender:
"""Ergebnis pro betroffener Seite: eine A4-SVG mit allen Signaturen dieser Seite."""
page_index: int # 0-basiert
a4_svg: str # fertige A4-SVG als String
filename: str # seite_<n>.svg
tokens: list = field(default_factory=list) # welche Token auf der Seite platziert wurden
# ── SVG einlesen ────────────────────────────────────────────────────────
def parse_svg(svg_bytes: bytes) -> ViewBox:
"""Liest viewBox und Inhalt einer SVG. Wirft PlacementError bei fehlender viewBox."""
try:
root = ET.fromstring(svg_bytes)
except ET.ParseError as e:
raise PlacementError(f"SVG nicht lesbar ({e}).")
# viewBox case-insensitiv suchen (XML ist eigentlich case-sensitiv, aber wir sind tolerant)
vb_raw = root.get("viewBox") or root.get("viewbox")
if not vb_raw:
raise PlacementError("SVG ohne viewBox – Skalierung nicht möglich.")
parts = re.split(r"[\s,]+", vb_raw.strip())
if len(parts) != 4:
raise PlacementError("viewBox hat nicht genau 4 Werte.")
try:
minx, miny, vw, vh = (float(p) for p in parts)
except ValueError:
raise PlacementError("viewBox enthält ungültige Zahlen.")
if vw <= 0 or vh <= 0:
raise PlacementError("viewBox-Breite/Höhe muss > 0 sein.")
# Inhalt der <svg> (alle Kinder) als String – Wrapper-<svg> entfällt, wir nutzen <g>.
inner = "".join(ET.tostring(child, encoding="unicode") for child in root)
# Styling-Attribute der Wurzel (z.B. fill/stroke) auf die spätere Gruppe übertragen,
# damit als Präsentationsattribut gesetzte Standardfarben nicht verloren gehen.
group_attrs = {}
for k, v in root.attrib.items():
local = k.split("}")[-1].lower()
if local not in _SKIP_ROOT_ATTRS:
group_attrs[k.split("}")[-1]] = v
return ViewBox(minx, miny, vw, vh, inner, group_attrs)
# ── Token-Suche ─────────────────────────────────────────────────────────
def find_token_occurrences(doc: fitz.Document, token: str) -> List[Tuple[int, fitz.Rect]]:
"""
Sucht den Token im gesamten PDF und liefert ALLE Vorkommen.
Der Token darf mehrfach vorkommen (z.B. dieselbe Unterschrift auf jeder Seite) –
jedes Vorkommen wird platziert. Rückgabe: Liste von (page_index, rect), evtl. leer.
"""
hits: List[Tuple[int, fitz.Rect]] = []
for page_index in range(doc.page_count):
for rect in doc[page_index].search_for(token):
hits.append((page_index, rect))
return hits
# ── Geometrie ───────────────────────────────────────────────────────────
def signatur_geometrie(x0_pt: float, y0_pt: float, vb: ViewBox,
position: str, breite_mm: float, abstand_mm: float,
versatz_x_mm: float = 0.0, versatz_y_mm: float = 0.0):
"""
Berechnet Platzierung der Signatur in mm.
Referenzpunkt ist die linke Oberkante des Tokens (rect.x0, rect.y0).
PyMuPDF: Ursprung oben links, Y wächst nach unten.
versatz_x_mm/versatz_y_mm verschieben das Ergebnis zusätzlich
(+x = rechts, +y = unten) zur Feinjustierung.
Rückgabe: (x_sig_mm, y_sig_mm, skala, signatur_hoehe_mm)
"""
# Token-Position von Punkten in mm
x_token_mm = x0_pt * PT_TO_MM
y_token_mm = y0_pt * PT_TO_MM
# Gleichmäßige Skalierung anhand der viewBox-Breite (nicht width/height!)
skala = breite_mm / vb.vw
signatur_hoehe_mm = vb.vh * skala
if position == "ueber":
y_sig = y_token_mm - abstand_mm - signatur_hoehe_mm
elif position == "unter":
y_sig = y_token_mm + abstand_mm
else: # "genau" – Signatur sitzt direkt an der Marker-Position (kein Abstand)
y_sig = y_token_mm
# Feinjustierung links/rechts/oben/unten
x_sig = x_token_mm + versatz_x_mm
y_sig = y_sig + versatz_y_mm
return x_sig, y_sig, skala, signatur_hoehe_mm
def build_signature_group(vb: ViewBox, rect: fitz.Rect, job: SignatureJob) -> str:
"""Erzeugt die positionierte <g>-Gruppe einer einzelnen Signatur (ohne A4-Wrapper)."""
x_sig, y_sig, skala, _ = signatur_geometrie(
rect.x0, rect.y0, vb, job.position, job.breite_mm, job.abstand_mm,
job.versatz_x_mm, job.versatz_y_mm,
)
# viewBox-Offset (minx/miny) in die Translation einrechnen, damit die linke
# Oberkante des Signatur-Inhalts exakt auf (x_sig, y_sig) liegt.
tx = x_sig - vb.minx * skala
ty = y_sig - vb.miny * skala
# Positionierung und Signatur-eigene Attribute getrennt halten:
# äußere Gruppe = nur translate/scale, innere Gruppe = Styling der Original-<svg>
# (fill/stroke …). So kollidiert nie ein mitgeführtes Attribut mit dem transform.
attrs = dict(vb.group_attrs)
# Signaturen werden immer als Single-Stroke gezeichnet: offene Kurven als Linie
# statt gefüllt. stroke-width in viewBox-Einheiten, damit nach scale(skala) die
# gewünschte mm-Stärke herauskommt. Greift auf Pfade ohne eigene fill/stroke-Angabe.
sw = job.linienstaerke_mm / skala if skala else job.linienstaerke_mm
stroke = attrs.get("stroke")
if not stroke or stroke == "none":
stroke = "#000000"
attrs.update({
"fill": "none",
"stroke": stroke,
"stroke-width": f"{sw:.4f}",
"stroke-linecap": "round",
"stroke-linejoin": "round",
})
attr = "".join(f' {k}="{_xml_escape(str(v))}"' for k, v in attrs.items())
inner_group = f"<g{attr}>{vb.inner}</g>" if attr else vb.inner
return (
f'<g transform="translate({tx:.4f},{ty:.4f}) scale({skala:.6f})">'
f"{inner_group}</g>"
)
def wrap_a4(groups: List[str]) -> str:
"""Verpackt Signatur-Gruppen in eine A4-Plot-SVG (nur Signaturen, Rest transparent)."""
return (
f'<svg xmlns="{SVG_NS}" xmlns:xlink="{XLINK_NS}" '
f'width="{A4_WIDTH_MM}mm" height="{A4_HEIGHT_MM}mm" '
f'viewBox="0 0 {A4_WIDTH_MM:g} {A4_HEIGHT_MM:g}">'
f"{''.join(groups)}</svg>"
)
def build_a4_svg(vb: ViewBox, rect: fitz.Rect, job: SignatureJob) -> str:
"""Bequemlichkeit: A4-SVG mit genau einer Signatur (v.a. für Tests)."""
return wrap_a4([build_signature_group(vb, rect, job)])
def _xml_escape(v: str) -> str:
return (v.replace("&", "&amp;").replace('"', "&quot;")
.replace("<", "&lt;").replace(">", "&gt;"))
# ── Hauptlogik: alle Jobs platzieren ────────────────────────────────────
def place_all(pdf_bytes: bytes, svgs: dict, jobs: List[SignatureJob]):
"""
Platziert alle Jobs. Sammelt ALLE Fehler (kein stilles Überspringen).
Ein Token darf MEHRFACH vorkommen (Unterschrift auf mehreren Seiten) – jedes
Vorkommen wird platziert. Pro betroffener Seite entsteht eine A4-SVG, die alle
Signaturen dieser Seite enthält. Nur 0 Treffer ist ein Fehler.
Rückgabe: (pages, errors)
pages: List[PageRender] – je betroffene Seite eine A4-SVG
errors: List[str] – leer, wenn alles geklappt hat
"""
errors: List[str] = []
# Pro Seite die platzierten Signatur-Gruppen sammeln: page_index -> [(token, group)]
page_items: dict = {}
try:
doc = fitz.open(stream=pdf_bytes, filetype="pdf")
except Exception as e:
return [], [f"PDF konnte nicht geöffnet werden: {e}"]
try:
for job in jobs:
# 1. Ist die zugeordnete SVG hochgeladen?
if job.svg_id not in svgs:
errors.append(
f"Token '{job.token}': SVG '{job.svg_id}' wurde nicht hochgeladen."
)
continue
# 2. viewBox lesbar?
try:
vb = parse_svg(svgs[job.svg_id])
except PlacementError as e:
errors.append(f"Token '{job.token}': {e}")
continue
# 3. Token im PDF vorhanden? (Mehrfachtreffer sind ausdrücklich erlaubt.)
occurrences = find_token_occurrences(doc, job.token)
if not occurrences:
errors.append(f"Token '{job.token}' nicht im PDF gefunden.")
continue
for page_index, rect in occurrences:
group = build_signature_group(vb, rect, job)
# rect.x0 (x-Position des Tokens) mitführen, um später links→rechts zu ordnen
page_items.setdefault(page_index, []).append((rect.x0, job.token, group))
finally:
doc.close()
if errors:
return [], errors
pages: List[PageRender] = []
for page_index in sorted(page_items):
# Signaturen einer Seite links→rechts sortieren: der Plotter schreibt in
# dieser Richtung, also linke Signatur immer vor der rechten abarbeiten.
items = sorted(page_items[page_index], key=lambda t: t[0])
a4 = wrap_a4([group for _, _, group in items])
pages.append(PageRender(
page_index=page_index,
a4_svg=a4,
filename=f"seite_{page_index + 1}.svg",
tokens=[token for _, token, _ in items],
))
return pages, errors
# ── Vorschau (vektoriell zusammengeführtes PDF) ─────────────────────────
def build_preview_pdf(pdf_bytes: bytes, pages: List[PageRender]) -> bytes:
"""
Baut ein Vorschau-PDF mit allen betroffenen Seiten und den Signaturen als Overlay.
Gewählter Weg: Die fertige A4-Signatur-SVG (pro Seite) wird via PyMuPDF nach PDF
konvertiert und mit show_pdf_page() deckungsgleich über eine Kopie der
Originalseite gelegt. Begründung: bleibt voll vektoriell (keine Rasterungs-
Artefakte), nutzt exakt dieselbe Geometrie wie /render und kommt ohne zusätzliche
Pillow-Abhängigkeit aus. Es wird vorausgesetzt, dass die PDF-Seiten A4 sind.
"""
src = fitz.open(stream=pdf_bytes, filetype="pdf")
out = fitz.open()
try:
for p in sorted(pages, key=lambda x: x.page_index):
# Originalseite 1:1 übernehmen …
out.insert_pdf(src, from_page=p.page_index, to_page=p.page_index)
page = out[-1]
# … und die A4-Signatur-SVG dieser Seite darüberlegen.
sig_svg = fitz.open(stream=p.a4_svg.encode("utf-8"), filetype="svg")
try:
sig_pdf = fitz.open("pdf", sig_svg.convert_to_pdf())
page.show_pdf_page(page.rect, sig_pdf, 0)
sig_pdf.close()
finally:
sig_svg.close()
data = out.tobytes()
finally:
out.close()
src.close()
return data

View File

@@ -0,0 +1,30 @@
# Signatur-Plotter-Service hinter Nginx Proxy Manager.
#
# Der Container hängt im selben Docker-Netz wie der NPM (nginx-proxy-manager_default)
# und wird NICHT direkt nach außen veröffentlicht – NPM terminiert TLS und leitet die
# Domain api-sign.skrift.de an diesen Container weiter.
#
# Im NPM als Proxy Host anlegen:
# Domain Names : api-sign.skrift.de
# Scheme : http
# Forward Hostname : skrift-signature-service (= container_name)
# Forward Port : 8000
# Websockets/Block : nach Bedarf, SSL-Zertifikat (Let's Encrypt) aktivieren.
services:
signature-service:
build: .
image: skrift-signature-service
container_name: skrift-signature-service
restart: unless-stopped
# Nur im Docker-Netz erreichbar (kein host-port nötig, NPM proxyt intern).
expose:
- "8000"
networks:
- npm
networks:
# Bereits vorhandenes Netz des Nginx Proxy Managers mitbenutzen.
npm:
name: nginx-proxy-manager_default
external: true

View File

@@ -0,0 +1,29 @@
"""Pydantic-Modelle für die Job-Definition des Signatur-Plotter-Service."""
from typing import Literal
from pydantic import BaseModel, Field, field_validator
class SignatureJob(BaseModel):
"""Ein Eintrag der Job-Definition: ordnet genau einen Marker-Token einer SVG zu."""
token: str # unsichtbarer Token im PDF, z.B. "§§SIG1§§"
svg_id: str # Dateiname der hochgeladenen SVG
# Lage relativ zum Token. "genau" = direkt an der Marker-Position (Standard),
# "ueber"/"unter" = mit Abstand darüber/darunter.
position: Literal["genau", "ueber", "unter"] = "genau"
breite_mm: float = Field(default=45.0, gt=0) # Zielbreite der Unterschrift
abstand_mm: float = Field(default=8.0, ge=0) # Abstand (nur bei ueber/unter relevant)
# Feinjustierung relativ zur berechneten Position (darf negativ sein):
versatz_x_mm: float = 0.0 # + = nach rechts, − = nach links
versatz_y_mm: float = 0.0 # + = nach unten, − = nach oben
# Signaturen werden grundsätzlich als Single-Stroke gezeichnet (keine Füllung).
linienstaerke_mm: float = Field(default=0.35, gt=0) # Linienstärke der Kontur
@field_validator("token", "svg_id")
@classmethod
def _not_blank(cls, v: str) -> str:
if not v or not v.strip():
raise ValueError("darf nicht leer sein")
return v

View File

@@ -0,0 +1,5 @@
fastapi>=0.111.0
uvicorn>=0.29.0
PyMuPDF>=1.24.0
python-multipart>=0.0.9
pydantic>=2.0.0

View File

@@ -0,0 +1,286 @@
"""Minimale Tests für die Kernlogik: Token-Suche, Geometrie, Fehlerfälle."""
import re
import fitz
import pytest
from core import (
PT_TO_MM,
PlacementError,
build_a4_svg,
find_token_occurrences,
parse_svg,
place_all,
signatur_geometrie,
)
from models import SignatureJob
# ── Test-Hilfen ─────────────────────────────────────────────────────────
SVG_OK = (
b'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 40">'
b'<path d="M0 20 L100 20" stroke="black"/></svg>'
)
SVG_NO_VIEWBOX = (
b'<svg xmlns="http://www.w3.org/2000/svg" width="100" height="40">'
b'<path d="M0 20 L100 20"/></svg>'
)
def make_pdf(tokens_at):
"""Erzeugt ein A4-PDF (1 Seite) mit den Token an gegebenen Punkt-Positionen."""
doc = fitz.open()
page = doc.new_page(width=595.0, height=842.0) # A4 in pt
for token, (x, y) in tokens_at:
page.insert_text((x, y), token, fontsize=11)
pdf_bytes = doc.tobytes()
doc.close()
return pdf_bytes
def _translate_from_svg(a4_svg):
"""Liest tx, ty aus dem ersten translate(...) des A4-SVG."""
m = re.search(r"translate\(([-\d.]+),([-\d.]+)\)", a4_svg)
assert m, "kein translate gefunden"
return float(m.group(1)), float(m.group(2))
def _scale_from_svg(a4_svg):
m = re.search(r"scale\(([-\d.]+)\)", a4_svg)
assert m, "kein scale gefunden"
return float(m.group(1))
# ── viewBox / SVG ───────────────────────────────────────────────────────
def test_parse_svg_liest_viewbox():
vb = parse_svg(SVG_OK)
assert (vb.minx, vb.miny, vb.vw, vb.vh) == (0.0, 0.0, 100.0, 40.0)
assert "<path" in vb.inner
def test_parse_svg_ohne_viewbox_fehler():
with pytest.raises(PlacementError):
parse_svg(SVG_NO_VIEWBOX)
# ── Geometrie ───────────────────────────────────────────────────────────
def test_geometrie_unter():
vb = parse_svg(SVG_OK)
# Token bei y0 = 72 pt = 25.4 mm
x_mm, y_sig, skala, h = signatur_geometrie(
x0_pt=72.0, y0_pt=72.0, vb=vb, position="unter", breite_mm=45.0, abstand_mm=8.0
)
assert skala == pytest.approx(45.0 / 100.0)
assert h == pytest.approx(40.0 * 0.45) # 18 mm
assert x_mm == pytest.approx(72.0 * PT_TO_MM) # 25.4 mm
# unter: y_token + abstand
assert y_sig == pytest.approx(72.0 * PT_TO_MM + 8.0) # 33.4 mm
def test_geometrie_genau():
vb = parse_svg(SVG_OK)
# "genau": Signatur sitzt exakt an der Marker-Position (kein Abstand).
x_mm, y_sig, skala, h = signatur_geometrie(
x0_pt=72.0, y0_pt=72.0, vb=vb, position="genau", breite_mm=45.0, abstand_mm=8.0
)
assert y_sig == pytest.approx(72.0 * PT_TO_MM) # == y_token, abstand ignoriert
assert x_mm == pytest.approx(72.0 * PT_TO_MM)
def test_position_default_ist_genau():
job = SignatureJob(token="§§SIG1§§", svg_id="s.svg")
assert job.position == "genau"
def test_geometrie_ueber():
vb = parse_svg(SVG_OK)
x_mm, y_sig, skala, h = signatur_geometrie(
x0_pt=72.0, y0_pt=72.0, vb=vb, position="ueber", breite_mm=45.0, abstand_mm=8.0
)
# ueber: y_token - abstand - hoehe
expected = 72.0 * PT_TO_MM - 8.0 - h
assert y_sig == pytest.approx(expected)
# über muss deutlich höher (kleineres y) liegen als unter
assert y_sig < 72.0 * PT_TO_MM
def test_geometrie_viewbox_offset_in_translate():
"""Ein viewBox-Offset muss in die Translation eingerechnet werden."""
svg = (
b'<svg xmlns="http://www.w3.org/2000/svg" viewBox="10 5 100 40">'
b'<path d="M10 25 L110 25"/></svg>'
)
vb = parse_svg(svg)
rect = fitz.Rect(72.0, 72.0, 120.0, 84.0)
job = SignatureJob(token="§§SIG1§§", svg_id="s.svg", position="unter")
a4 = build_a4_svg(vb, rect, job)
tx, ty = _translate_from_svg(a4)
skala = _scale_from_svg(a4)
# tx = x_sig - minx*skala
assert tx == pytest.approx(72.0 * PT_TO_MM - 10.0 * skala, abs=1e-3)
def test_versatz_verschiebt_links_rechts_oben_unten():
vb = parse_svg(SVG_OK)
rect = fitz.Rect(72.0, 72.0, 120.0, 84.0)
base = SignatureJob(token="§§S§§", svg_id="s.svg", position="genau")
moved = SignatureJob(token="§§S§§", svg_id="s.svg", position="genau",
versatz_x_mm=5.0, versatz_y_mm=-3.0)
a4_base = build_a4_svg(vb, rect, base)
a4_moved = build_a4_svg(vb, rect, moved)
bx, by = _translate_from_svg(a4_base)
mx, my = _translate_from_svg(a4_moved)
assert mx == pytest.approx(bx + 5.0, abs=1e-3) # +x → nach rechts
assert my == pytest.approx(by - 3.0, abs=1e-3) # −y → nach oben
def test_immer_single_stroke_fill_none_und_stroke():
"""Signaturen werden immer als Single-Stroke gezeichnet (fill:none + stroke)."""
vb = parse_svg(SVG_OK)
rect = fitz.Rect(72.0, 72.0, 120.0, 84.0)
job = SignatureJob(token="§§S§§", svg_id="s.svg", position="genau",
breite_mm=50.0, linienstaerke_mm=0.5)
a4 = build_a4_svg(vb, rect, job)
assert 'fill="none"' in a4
assert "stroke=" in a4 and "stroke-width=" in a4
# stroke-width in viewBox-Einheiten: 0.5mm / skala (skala = 50/100 = 0.5) = 1.0
import re
sw = float(re.search(r'stroke-width="([\d.]+)"', a4).group(1))
assert sw == pytest.approx(1.0, abs=1e-3)
def test_root_transform_wird_nicht_uebernommen():
"""Ein transform auf der äußersten <svg> darf die Platzierung nicht verfälschen."""
svg = (
b'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 40" '
b'transform="rotate(45)" fill="blue"><path d="M0 20 L100 20"/></svg>'
)
vb = parse_svg(svg)
assert "transform" not in vb.group_attrs # nicht mitgeführt
assert vb.group_attrs.get("fill") == "blue" # echtes Styling bleibt
rect = fitz.Rect(72.0, 72.0, 120.0, 84.0)
a4 = build_a4_svg(vb, rect, SignatureJob(token="§§S§§", svg_id="s.svg"))
# Genau ein transform (die Positionierung), nicht zwei.
assert a4.count("transform=") == 1
# ── Token-Suche ─────────────────────────────────────────────────────────
def test_find_token_einmal():
pdf = make_pdf([("§§SIG1§§", (100.0, 200.0))])
doc = fitz.open(stream=pdf, filetype="pdf")
occ = find_token_occurrences(doc, "§§SIG1§§")
doc.close()
assert len(occ) == 1
page_index, rect = occ[0]
assert page_index == 0
assert rect.x0 == pytest.approx(100.0, abs=2.0) # nahe Einfügepunkt
def test_find_token_nicht_gefunden():
pdf = make_pdf([("§§SIG1§§", (100.0, 200.0))])
doc = fitz.open(stream=pdf, filetype="pdf")
assert find_token_occurrences(doc, "§§SIG9§§") == []
doc.close()
def test_find_token_mehrfach_erlaubt():
# Derselbe Token mehrfach (z.B. Unterschrift auf mehreren Stellen) → alle Treffer.
pdf = make_pdf([("§§SIG1§§", (100.0, 200.0)), ("§§SIG1§§", (100.0, 400.0))])
doc = fitz.open(stream=pdf, filetype="pdf")
occ = find_token_occurrences(doc, "§§SIG1§§")
doc.close()
assert len(occ) == 2
# ── place_all: Gesamtfluss + Fehlerliste ────────────────────────────────
def make_multipage_pdf(per_page_tokens):
"""Erzeugt ein A4-PDF mit mehreren Seiten; pro Seite eine Liste (token, (x,y))."""
doc = fitz.open()
for tokens in per_page_tokens:
page = doc.new_page(width=595.0, height=842.0)
for token, (x, y) in tokens:
page.insert_text((x, y), token, fontsize=11)
data = doc.tobytes()
doc.close()
return data
def test_place_all_erfolg():
pdf = make_pdf([("§§SIG1§§", (100.0, 200.0))])
jobs = [SignatureJob(token="§§SIG1§§", svg_id="unterschrift.svg", position="unter")]
pages, errors = place_all(pdf, {"unterschrift.svg": SVG_OK}, jobs)
assert errors == []
assert len(pages) == 1
p = pages[0]
assert p.filename == "seite_1.svg"
assert p.tokens == ["§§SIG1§§"]
assert p.a4_svg.startswith("<svg")
assert 'viewBox="0 0 210 297"' in p.a4_svg
def test_place_all_token_auf_mehreren_seiten():
"""Derselbe Token auf mehreren Seiten → pro Seite eine A4-SVG."""
pdf = make_multipage_pdf([
[("[[sign]]", (100.0, 300.0))],
[("[[sign]]", (100.0, 300.0))],
[("[[sign]]", (100.0, 300.0))],
])
jobs = [SignatureJob(token="[[sign]]", svg_id="u.svg", position="genau")]
pages, errors = place_all(pdf, {"u.svg": SVG_OK}, jobs)
assert errors == []
assert [p.filename for p in pages] == ["seite_1.svg", "seite_2.svg", "seite_3.svg"]
def test_place_all_zwei_tokens_pro_seite_kombiniert():
"""Zwei verschiedene Token auf jeder Seite → eine A4-SVG pro Seite mit beiden."""
pdf = make_multipage_pdf([
[("[[fk]]", (100.0, 600.0)), ("[[tv]]", (300.0, 600.0))],
[("[[fk]]", (100.0, 600.0)), ("[[tv]]", (300.0, 600.0))],
])
jobs = [
SignatureJob(token="[[fk]]", svg_id="a.svg", position="genau"),
SignatureJob(token="[[tv]]", svg_id="b.svg", position="genau"),
]
pages, errors = place_all(pdf, {"a.svg": SVG_OK, "b.svg": SVG_OK}, jobs)
assert errors == []
assert len(pages) == 2
# Jede Seiten-SVG enthält beide Signaturen (zwei positionierte Gruppen).
for p in pages:
assert sorted(p.tokens) == ["[[fk]]", "[[tv]]"]
assert p.a4_svg.count("translate(") == 2
def test_place_all_svg_fehlt():
pdf = make_pdf([("§§SIG1§§", (100.0, 200.0))])
jobs = [SignatureJob(token="§§SIG1§§", svg_id="fehlt.svg", position="unter")]
placements, errors = place_all(pdf, {}, jobs)
assert placements == []
assert any("nicht hochgeladen" in e for e in errors)
def test_place_all_token_fehlt():
pdf = make_pdf([("§§SIG1§§", (100.0, 200.0))])
jobs = [SignatureJob(token="§§SIG2§§", svg_id="s.svg", position="unter")]
placements, errors = place_all(pdf, {"s.svg": SVG_OK}, jobs)
assert placements == []
assert any("nicht im PDF" in e for e in errors)
def test_place_all_sammelt_mehrere_fehler():
"""Mehrere fehlerhafte Jobs → alle Fehler werden gemeldet, nicht nur der erste."""
pdf = make_pdf([("§§SIG1§§", (100.0, 200.0))])
jobs = [
SignatureJob(token="§§SIGX§§", svg_id="s.svg", position="unter"), # Token fehlt
SignatureJob(token="§§SIG1§§", svg_id="fehlt.svg", position="unter"), # SVG fehlt
]
placements, errors = place_all(pdf, {"s.svg": SVG_OK}, jobs)
assert placements == []
assert len(errors) == 2
def test_place_all_svg_ohne_viewbox():
pdf = make_pdf([("§§SIG1§§", (100.0, 200.0))])
jobs = [SignatureJob(token="§§SIG1§§", svg_id="s.svg", position="unter")]
placements, errors = place_all(pdf, {"s.svg": SVG_NO_VIEWBOX}, jobs)
assert placements == []
assert any("viewBox" in e for e in errors)

View File

@@ -0,0 +1,30 @@
# Dependencies (werden im Image per npm install neu erzeugt)
node_modules
npm-debug.log
# Secrets & lokale Config (kommen zur Laufzeit via docker-compose environment)
.env
.env.example
# Git
.git
.gitignore
# Laufzeit-/generierte Daten (output ist in Prod ein Volume)
cache
output
server.log
*.log
# Doku
*.md
*.txt
# Dev-/Test-Artefakte
bruno-tests
deploy.sh
test-*.js
test-*.json
test-*.sh
generate-9-orders.js
prepare-deployment.js

View File

@@ -0,0 +1,21 @@
# Environment Configuration Example
# Copy this file to .env and fill in your values
# Node Environment
NODE_ENV=production
# Scriptalizer API Configuration
SCRIPTALIZER_LICENSE_KEY=your-license-key-here
SCRIPTALIZER_ERR_FREQUENCY=0
# Preview Configuration
# No batch size limit - frontend can send unlimited letters
# Backend automatically splits into 25-letter batches for Scriptalizer API
# No cache expiration
# No rate limiting
# Server Configuration
PORT=4000
# API Authentication (optional)
API_TOKEN=your-api-token-here

7
Docker/skrift-backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
.env
cache/
output/
npm-debug.log
.DS_Store
*.log

View File

@@ -0,0 +1,31 @@
FROM node:20-alpine
# Install dependencies for better-sqlite3 if needed later
RUN apk add --no-cache python3 make g++
# Create app directory
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies (npm install instead of ci for flexibility)
RUN npm install --omit=dev
# Copy application files
COPY . .
# Create necessary directories
RUN mkdir -p /app/cache/previews \
&& mkdir -p /app/output \
&& mkdir -p /app/fonts
# Expose port
EXPOSE 4000
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:4000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"
# Start application
CMD ["node", "src/server.js"]

View File

@@ -0,0 +1,198 @@
# Handschrift-Variationen - Konfiguration
## Aktuelle Einstellungen
### 1. Scriptalizer Error Frequency
```
SCRIPTALIZER_ERR_FREQUENCY=0
```
- **Bedeutung:** Keine durchgestrichenen Wörter oder Tippfehler
- **Ergebnis:** Saubere, fehlerfreie Handschrift
---
### 2. Wortabstands-Variation: **15%**
**Implementierung:** `src/lib/svg-font-engine.js`
```javascript
// Leerzeichen mit 15% Variation
const baseSpace = fontSizePx * 0.4;
const variation = baseSpace * 0.15 * (Math.sin(wordIndex * 2.5) * 0.5 + 0.5);
const spacePx = baseSpace + variation;
```
**Effekt:**
- Basis-Wortabstand: 40% der Schriftgröße (≈10.4 px bei 26px Font)
- Variation: ±1.56 px (15% von 10.4px)
- Natürliche, ungleichmäßige Abstände zwischen Wörtern
---
### 3. Wort-Rotation (Schräglage): **±5%** (≈±2.5°)
**Implementierung:** `src/lib/svg-font-engine.js`
```javascript
// Rotation zwischen -2.5° und +2.5° (±0.044 radians)
const rotationVariation = 0.044 * (Math.sin(wordIndex * 3.7) * 0.5 + 0.5) - 0.022;
wordRotation = rotationVariation;
// Matrix-Transformation mit Rotation
const cosR = Math.cos(wordRotation);
const sinR = Math.sin(wordRotation);
const transform = `matrix(${scale * cosR},${scale * sinR},${-scale * sinR},${-scale * cosR},${x},${baselineY})`;
```
**Effekt:**
- Jedes Wort bekommt eine individuelle, leichte Schräglage
- Rotation: -2.5° bis +2.5° (±0.044 Radians)
- Macht die Handschrift natürlicher und lebendiger
- Jedes Wort ist leicht unterschiedlich geneigt
---
## Variation-Pattern
### Sinuswellen-Basis
Beide Variationen verwenden Sinuswellen für natürliche, nicht-gleichförmige Muster:
```javascript
Math.sin(wordIndex * 2.5) // Wortabstand (langsame Oszillation)
Math.sin(wordIndex * 3.7) // Rotation (schnellere Oszillation)
```
**Vorteil:**
- Keine zufälligen Sprünge
- Sanfte, natürliche Übergänge
- Reproduzierbar (gleicher Text → gleiche Variation)
- Kein Seed-Management nötig
---
## Visuelle Beispiele
### Ohne Variation (alt):
```
Hallo Max Mustermann aus Berlin
```
Alle Wörter perfekt gerade, gleiche Abstände
### Mit Variation (neu):
```
Hallo Max Mustermann aus Berlin
↗ → ↘ → ↗
```
- Unterschiedliche Wortabstände (15% Variation)
- Leichte Schräglage pro Wort (±2.5°)
---
## Technische Details
### SVG Matrix-Transformation
**Original (ohne Rotation):**
```xml
<path transform="matrix(0.00846,0,0,-0.00846,75.6,120.5)" />
```
**Neu (mit Rotation):**
```xml
<path transform="matrix(0.00845,0.00004,-0.00004,-0.00845,75.6,120.5)" />
```
Die kleinen Werte in Position 2 und 3 der Matrix erzeugen die Rotation:
- Position 2 (b): `scale * sin(rotation)` ≈ 0.00004
- Position 3 (c): `-scale * sin(rotation)` ≈ -0.00004
---
## Anpassung der Variationen
### Wortabstand ändern
**Datei:** `src/lib/svg-font-engine.js` (Zeile 187-189)
```javascript
// Aktuell: 15%
const variation = baseSpace * 0.15 * (Math.sin(wordIndex * 2.5) * 0.5 + 0.5);
// Mehr Variation (z.B. 25%):
const variation = baseSpace * 0.25 * (Math.sin(wordIndex * 2.5) * 0.5 + 0.5);
// Weniger Variation (z.B. 8%):
const variation = baseSpace * 0.08 * (Math.sin(wordIndex * 2.5) * 0.5 + 0.5);
```
---
### Rotation (Schräglage) ändern
**Datei:** `src/lib/svg-font-engine.js` (Zeile 200-202)
```javascript
// Aktuell: ±2.5° (0.044 radians)
const rotationVariation = 0.044 * (Math.sin(wordIndex * 3.7) * 0.5 + 0.5) - 0.022;
// Stärker schräg (z.B. ±5°):
const rotationVariation = 0.087 * (Math.sin(wordIndex * 3.7) * 0.5 + 0.5) - 0.0435;
// Weniger schräg (z.B. ±1°):
const rotationVariation = 0.0175 * (Math.sin(wordIndex * 3.7) * 0.5 + 0.5) - 0.00875;
// Keine Rotation (deaktivieren):
const rotationVariation = 0;
```
**Umrechnung Grad → Radians:**
```
Radians = Grad × (π / 180)
±1° = ±0.0175 rad
±2.5° = ±0.044 rad
±5° = ±0.087 rad
```
---
## Test-Beispiel
```bash
curl -X POST http://localhost:4000/api/preview/batch \
-H "Content-Type: application/json" \
-d '{
"sessionId": "variation-demo",
"letters": [{
"index": 0,
"format": "a4",
"font": "tilda",
"text": "Dies ist ein Test mit natürlicher Handschrift-Variation.\n\nDie Wörter haben unterschiedliche Abstände und eine leichte Schräglage.\n\nDas macht das Schriftbild authentischer!",
"placeholders": {}
}]
}'
```
---
## Performance
**Impact:**
- Minimaler Overhead durch sin/cos Berechnungen
- Pro Zeichen: ~4 zusätzliche Operationen
- Bei 2000 Zeichen: ~0.5ms zusätzliche Verarbeitungszeit
- Vernachlässigbar im Vergleich zur Scriptalizer API (1-3 Sekunden)
---
## Kompatibilität
✅ **Alle SVG-Viewer** unterstützen Matrix-Transformationen
✅ **Alle Browser** (Chrome, Firefox, Safari, Edge)
✅ **Plotter-Software** verarbeitet transformierte Pfade korrekt
✅ **Keine Änderung der Pfad-Daten** (nur Transformation)
---
**Version:** 1.1.0
**Letzte Änderung:** 2026-01-02
**Status:** Produktionsreif ✅

View File

@@ -0,0 +1,461 @@
# Skrift Backend - Handwritten Document Generator
Docker-basiertes Backend für die Generierung von handschriftlichen Dokumenten (Briefe, Postkarten, Umschläge) mit SVG-Output.
## Features
- **Preview-System**: Batch-Generierung von Vorschauen mit Caching (30 Briefe pro Batch)
- **Scriptalizer Integration**: Nutzt externe API für natürliche Handschrift-Variationen
- **SVG-Generierung**: Eigene Font-Engine für hochqualitative SVG-Ausgabe
- **Multi-Format Support**: A4, A6 (Hoch-/Querformat), C6, DIN Lang Umschläge
- **Platzhalter-System**: Automatische Ersetzung von `[[Platzhalter]]` mit CSV-Export
- **Rate Limiting**: Schutz vor API-Spam (konfigurierbar)
- **Docker-Ready**: Vollständig containerisiert mit docker-compose
## Quick Start
### 1. Konfiguration
```bash
cp .env.example .env
# .env bearbeiten und Scriptalizer License Key eintragen
```
### 2. Lokal testen (ohne Docker)
```bash
npm install
npm start
```
Server läuft auf: `http://localhost:4000`
### 3. Mit Docker deployen
```bash
docker-compose up -d
```
## API-Endpunkte
### Health Check
```
GET /health
```
**Response:**
```json
{
"status": "healthy",
"timestamp": "2026-01-15T12:00:00Z",
"uptime": 12345,
"scriptalizer": "configured",
"storage": {
"cache": true,
"output": true
}
}
```
---
### Preview Batch Generierung
```
POST /api/preview/batch
```
**Request Body:**
```json
{
"sessionId": "uuid-abc-123",
"batchIndex": 0,
"forceRegenerate": false,
"config": {
"font": "tilda",
"letters": [
{
"index": 0,
"format": "a4",
"text": "Hallo [[Vorname]], dein Code ist [[Gutscheincode]]...",
"placeholders": {
"Vorname": "Max",
"Nachname": "Mustermann",
"Strasse": "Hauptstr. 1",
"PLZ": "10115",
"Ort": "Berlin",
"Gutscheincode": "SAVE20"
}
}
],
"envelopes": [
{
"index": 0,
"format": "c6",
"type": "recipient",
"data": {
"Vorname": "Max",
"Nachname": "Mustermann",
"Strasse": "Hauptstr. 1",
"PLZ": "10115",
"Ort": "Berlin"
}
}
]
}
}
```
**Response:**
```json
{
"sessionId": "uuid-abc-123",
"files": [
{
"type": "letter",
"index": 0,
"url": "/api/preview/uuid-abc-123/letter_000.svg"
},
{
"type": "envelope",
"index": 0,
"url": "/api/preview/uuid-abc-123/envelope_000.svg"
}
],
"csvUrl": "/api/preview/uuid-abc-123/platzhalter.csv",
"expiresAt": "2026-01-15T14:00:00Z"
}
```
**Rate Limit:** 2 Requests/Minute pro sessionId
---
### Preview-Datei abrufen
```
GET /api/preview/:sessionId/:filename
```
**Beispiel:**
```
GET /api/preview/uuid-abc-123/letter_000.svg
```
**Response:** SVG-Datei (Content-Type: image/svg+xml)
---
### Bestellung finalisieren (aus Cache)
```
POST /api/order/finalize
```
**Request Body:**
```json
{
"sessionId": "uuid-abc-123",
"orderNumber": "SK-2026-01-15-001"
}
```
**Response:**
```json
{
"orderNumber": "SK-2026-01-15-001",
"outputPath": "/app/output/SK-2026-01-15-001",
"files": {
"letters": 100,
"envelopes": 100,
"csv": "platzhalter.csv"
},
"timestamp": "2026-01-15T12:30:00Z"
}
```
---
### Bestellung neu generieren (ohne Cache)
```
POST /api/order/generate
```
**Request Body:**
```json
{
"orderNumber": "SK-2026-01-15-002",
"config": {
"font": "tilda",
"letters": [...],
"envelopes": [...]
}
}
```
**Response:** Gleich wie `/api/order/finalize`
---
## Formate
### Schriftstücke (Letters)
- `a4` - A4 Hochformat (210 × 297 mm)
- `a6p` - A6 Hochformat (105 × 148 mm)
- `a6l` - A6 Querformat (148 × 105 mm)
### Umschläge (Envelopes)
- `c6` - C6 Umschlag (162 × 114 mm)
- `din_lang` - DIN Lang Umschlag (220 × 110 mm)
### Fonts
- `tilda` - PremiumUltra79
- `alva` - PremiumUltra23
- `ellie` - PremiumUltra39
---
## Umschlag-Typen
### Empfänger-Adresse (type: "recipient")
Adresse wird **unten links** positioniert (kein Sichtfenster).
```json
{
"type": "recipient",
"data": {
"Vorname": "Max",
"Nachname": "Mustermann",
"Strasse": "Hauptstr. 1",
"PLZ": "10115",
"Ort": "Berlin"
}
}
```
### Individueller Text (type: "custom")
Text wird **mittig zentriert** positioniert. Max. 150 Zeichen.
```json
{
"type": "custom",
"data": {
"customText": "Für meine großartige Freundin Caro"
}
}
```
---
## Verzeichnisstruktur
```
/app/
├── cache/
│ └── previews/
│ └── {sessionId}/
│ ├── letter_000.svg
│ ├── envelope_000.svg
│ ├── platzhalter.csv
│ └── metadata.json
│
├── output/
│ └── {orderNumber}/
│ ├── schriftstuecke/
│ │ ├── brief_000.svg
│ │ └── ...
│ ├── umschlaege/
│ │ ├── umschlag_000.svg
│ │ └── ...
│ ├── platzhalter.csv
│ └── order-metadata.json
│
└── fonts/
├── tilda.svg
├── alva.svg
└── ellie.svg
```
---
## Umgebungsvariablen
```bash
# Node Environment
NODE_ENV=production
# Scriptalizer API
SCRIPTALIZER_LICENSE_KEY=your-key-here
SCRIPTALIZER_ERR_FREQUENCY=10
# Preview System
BATCH_SIZE=30
CACHE_LIFETIME_HOURS=2
RATE_LIMIT_PER_MINUTE=2
# Server
PORT=4000
CORS_ORIGIN=*
```
---
## Deployment
### Auf Server (mit Docker)
```bash
# .env Datei erstellen mit production values
docker-compose up -d
# Logs ansehen
docker-compose logs -f
# Stoppen
docker-compose down
```
### Nginx Proxy Manager Setup
1. Proxy Host erstellen
2. Domain: `api.skrift.de` (oder deine Domain)
3. Forward Hostname/IP: `localhost`
4. Forward Port: `4000`
5. SSL Zertifikat über NPM erstellen
---
## Entwicklung
### Lokales Testen
```bash
npm run dev # Mit nodemon
```
### Scriptalizer Separator Test
```bash
npm run test:separator
```
### Logs
```bash
# Docker logs
docker-compose logs -f skrift-backend
# Lokale logs
# Output in console
```
---
## Integration mit N8N
N8N kann direkt auf den `/app/output/{orderNumber}/` Ordner zugreifen:
```javascript
// N8N Workflow (Beispiel)
const fs = require('fs');
const orderPath = '/var/skrift-output/SK-2026-01-15-001';
// Lese alle SVGs
const letters = fs.readdirSync(`${orderPath}/schriftstuecke`);
// Sende an Plotter
for (const file of letters) {
await sendToPlotter(`${orderPath}/schriftstuecke/${file}`);
}
```
---
## Fehlerbehandlung
### HTTP Status Codes
- `200` - Success
- `400` - Bad Request (z.B. ungültige Parameter)
- `404` - Not Found (z.B. Session nicht gefunden)
- `410` - Gone (z.B. Cache abgelaufen)
- `429` - Too Many Requests (Rate Limit)
- `500` - Internal Server Error
- `503` - Service Unavailable (z.B. Scriptalizer down)
### Typische Fehler
**Rate Limit überschritten:**
```json
{
"error": "Zu viele Vorschau-Anfragen. Bitte warten Sie.",
"retryAfter": 45,
"message": "Limit: 2 Anfragen pro Minute"
}
```
**Scriptalizer Fehler:**
```json
{
"error": "Scriptalizer request failed: timeout"
}
```
**Cache abgelaufen:**
```json
{
"error": "Preview-Session abgelaufen. Bitte neu generieren."
}
```
---
## Limits
- **Scriptalizer API**: 10.000 Calls/Tag
- **Batch Size**: 30 Briefe pro Request
- **Input Size**: 48KB pro Scriptalizer Call
- **Rate Limit**: 2 Preview-Requests/Minute
- **Cache Lifetime**: 2 Stunden
---
## Troubleshooting
### Fonts nicht gefunden
```bash
# Fonts kopieren
cp /path/to/fonts/*.svg ./fonts/
```
### Scriptalizer API Fehler
```bash
# License Key prüfen
cat .env | grep SCRIPTALIZER_LICENSE_KEY
# Test-Script ausführen
npm run test:separator
```
### Permissions Fehler
```bash
# Cache/Output Ordner Permissions
chmod -R 755 cache output
```
---
## Weitere Infos
- **Scriptalizer API**: [www.scriptalizer.co.uk](https://www.scriptalizer.co.uk)
- **Support**: Siehe Issues in Repository
---
**Version**: 1.0.0
**Last Updated**: 2026-01-01

View File

@@ -0,0 +1,331 @@
# Tilda Font API-Aufruf Beispiel
## Konfiguration
- **Error Frequency:** 0 (keine durchgestrichenen Wörter)
- **Wortabstand-Variation:** ±5% für natürlicheres Schriftbild
- **Font:** Tilda (Scriptalizer: PremiumUltra79)
---
## 1. Preview-Generierung (empfohlen)
### Endpoint
```
POST http://localhost:4000/api/preview/batch
```
### Request Headers
```
Content-Type: application/json
```
### Request Body
```json
{
"sessionId": "meine-session-12345",
"letters": [
{
"index": 0,
"format": "a4",
"font": "tilda",
"text": "Sehr geehrte Damen und Herren,\n\nhiermit möchten wir Ihnen mitteilen, dass Ihre Bestellung erfolgreich bearbeitet wurde.\n\nWir bedanken uns für Ihr Vertrauen und freuen uns auf eine weitere Zusammenarbeit.\n\nMit freundlichen Grüßen\nIhr Skrift-Team",
"placeholders": {}
}
]
}
```
### cURL Beispiel
```bash
curl -X POST http://localhost:4000/api/preview/batch \
-H "Content-Type: application/json" \
-d '{
"sessionId": "meine-session-12345",
"letters": [
{
"index": 0,
"format": "a4",
"font": "tilda",
"text": "Sehr geehrte Damen und Herren,\n\nhiermit möchten wir Ihnen mitteilen, dass Ihre Bestellung erfolgreich bearbeitet wurde.\n\nWir bedanken uns für Ihr Vertrauen und freuen uns auf eine weitere Zusammenarbeit.\n\nMit freundlichen Grüßen\nIhr Skrift-Team",
"placeholders": {}
}
]
}'
```
### Response
```json
{
"sessionId": "meine-session-12345",
"files": [
{
"index": 0,
"filename": "letter_000.svg",
"url": "/api/preview/meine-session-12345/letter_000.svg"
}
],
"csvUrl": "/api/preview/meine-session-12345/placeholders.csv"
}
```
### Preview SVG abrufen
```bash
curl http://localhost:4000/api/preview/meine-session-12345/letter_000.svg -o preview.svg
```
---
## 2. Mit Platzhaltern
### Request Body
```json
{
"sessionId": "platzhalter-test",
"letters": [
{
"index": 0,
"format": "a4",
"font": "tilda",
"text": "Sehr geehrte/r [[Vorname]] [[Nachname]],\n\nhiermit bestätigen wir Ihre Bestellung [[Bestellnummer]].\n\nIhr persönlicher Gutscheincode: [[Gutscheincode]]\nGültig bis: [[Ablaufdatum]]\n\nMit freundlichen Grüßen",
"placeholders": {
"Vorname": "Max",
"Nachname": "Mustermann",
"Bestellnummer": "SK-2026-001",
"Gutscheincode": "SAVE20",
"Ablaufdatum": "31.12.2026"
}
}
]
}
```
---
## 3. Mehrere Briefe (Batch)
### Request Body
```json
{
"sessionId": "batch-test",
"letters": [
{
"index": 0,
"format": "a4",
"font": "tilda",
"text": "Sehr geehrte/r [[Vorname]] [[Nachname]],\n\nvielen Dank für Ihre Bestellung!",
"placeholders": {
"Vorname": "Max",
"Nachname": "Mustermann"
}
},
{
"index": 1,
"format": "a4",
"font": "tilda",
"text": "Sehr geehrte/r [[Vorname]] [[Nachname]],\n\nvielen Dank für Ihre Bestellung!",
"placeholders": {
"Vorname": "Anna",
"Nachname": "Schmidt"
}
},
{
"index": 2,
"format": "a4",
"font": "tilda",
"text": "Sehr geehrte/r [[Vorname]] [[Nachname]],\n\nvielen Dank für Ihre Bestellung!",
"placeholders": {
"Vorname": "Thomas",
"Nachname": "Müller"
}
}
]
}
```
**Hinweis:** Bis zu 30 Briefe pro Batch möglich (konfigurierbar)
---
## 4. Bestellung finalisieren
Nach der Vorschau kann die Bestellung finalisiert werden:
### Endpoint
```
POST http://localhost:4000/api/order/finalize
```
### Request Body
```json
{
"sessionId": "meine-session-12345",
"orderNumber": "SK-2026-01-02-001"
}
```
### cURL Beispiel
```bash
curl -X POST http://localhost:4000/api/order/finalize \
-H "Content-Type: application/json" \
-d '{
"sessionId": "meine-session-12345",
"orderNumber": "SK-2026-01-02-001"
}'
```
### Response
```json
{
"orderNumber": "SK-2026-01-02-001",
"outputPath": "/app/output/SK-2026-01-02-001",
"files": [
"letter_000.svg",
"placeholders.csv"
],
"timestamp": "2026-01-02T09:11:00.000Z"
}
```
---
## 5. Direkte Bestellungs-Generierung (ohne Preview)
### Endpoint
```
POST http://localhost:4000/api/order/generate
```
### Request Body
```json
{
"orderNumber": "SK-2026-01-02-002",
"letters": [
{
"index": 0,
"format": "a4",
"font": "tilda",
"text": "Sehr geehrte Damen und Herren,\n\nhiermit bestätigen wir den Eingang Ihrer Bestellung.\n\nMit freundlichen Grüßen",
"placeholders": {}
}
]
}
```
### cURL Beispiel
```bash
curl -X POST http://localhost:4000/api/order/generate \
-H "Content-Type: application/json" \
-d '{
"orderNumber": "SK-2026-01-02-002",
"letters": [
{
"index": 0,
"format": "a4",
"font": "tilda",
"text": "Sehr geehrte Damen und Herren,\n\nhiermit bestätigen wir den Eingang Ihrer Bestellung.\n\nMit freundlichen Grüßen",
"placeholders": {}
}
]
}'
```
---
## Verfügbare Formate
- **a4**: A4 Hochformat (210 × 297 mm)
- **a6p**: A6 Hochformat (105 × 148 mm)
- **a6l**: A6 Querformat (148 × 105 mm)
---
## Verfügbare Fonts
- **tilda**: Scriptalizer PremiumUltra79
- **alva**: Scriptalizer PremiumUltra23
- **ellie**: Scriptalizer PremiumUltra39
---
## Features
✅ **Keine Fehler:** Error Frequency = 0 (keine durchgestrichenen Wörter)
✅ **Wortabstand-Variation:** ±5% natürliche Variation für realistischeres Schriftbild
✅ **Platzhalter:** Automatische Ersetzung von [[Platzhalter]]
✅ **CSV-Export:** Automatische Generierung der Platzhalter-Tabelle
✅ **Batch-Verarbeitung:** Bis zu 30 Briefe pro Request
✅ **Preview-Caching:** 2 Stunden Cache für schnellere Finalisierung
✅ **Rate Limiting:** 2 Requests/Minute Schutz
---
## Output
Die generierten SVG-Dateien befinden sich in:
- **Preview:** `/app/cache/previews/{sessionId}/`
- **Finale Bestellung:** `/app/output/{orderNumber}/`
Struktur:
```
output/SK-2026-01-02-001/
├── letter_000.svg
├── letter_001.svg
├── placeholders.csv
└── order-metadata.json
```
---
## Beispiel Output
**letter_000.svg:**
```xml
<svg xmlns="http://www.w3.org/2000/svg"
width="210mm"
height="297mm"
viewBox="0 0 793.8 1122.66">
<rect x="0" y="0" width="793.8" height="1122.66" fill="#FFFFFF" />
<path d="M1549 1857q-55 73 -124.5 125..."
transform="matrix(0.00846,0,0,-0.00846,75.6,120.5)"
stroke="#000000"
stroke-width="0.6"
fill="none"
stroke-linecap="round"
stroke-linejoin="round" />
<!-- ~2000 weitere Pfade für handgeschriebenen Text -->
</svg>
```
Dateigröße: ~100-120 KB pro A4-Seite
---
## WordPress Integration Beispiel
```javascript
// Im WordPress Plugin
const response = await fetch('https://api.skrift.de/api/preview/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sessionId: generateUUID(),
letters: [
{
index: 0,
format: 'a4',
font: 'tilda',
text: brieftext,
placeholders: platzhalterDaten
}
]
})
});
const data = await response.json();
// Zeige Preview: data.files[0].url
```
---
**Stand:** 2026-01-02
**Version:** 1.0.0

View File

@@ -0,0 +1,37 @@
version: '3.8'
services:
skrift-backend:
build: .
container_name: skrift-backend
restart: unless-stopped
ports:
- "4000:4000"
environment:
- NODE_ENV=${NODE_ENV:-production}
- PORT=4000
- SCRIPTALIZER_LICENSE_KEY=${SCRIPTALIZER_LICENSE_KEY}
- SCRIPTALIZER_ERR_FREQUENCY=${SCRIPTALIZER_ERR_FREQUENCY:-10}
- BATCH_SIZE=${BATCH_SIZE:-30}
- CACHE_LIFETIME_HOURS=${CACHE_LIFETIME_HOURS:-2}
- RATE_LIMIT_PER_MINUTE=${RATE_LIMIT_PER_MINUTE:-2}
volumes:
- ./fonts:/app/fonts:ro # SVG Fonts (read-only)
- skrift-cache:/app/cache # Preview cache (temporary)
- /var/skrift-output:/app/output # Output files for N8N
networks:
- skrift-network
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:4000/health"]
interval: 30s
timeout: 3s
retries: 3
start_period: 5s
volumes:
skrift-cache:
driver: local
networks:
skrift-network:
driver: bridge

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 152 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 149 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 152 KiB

1624
Docker/skrift-backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
{
"name": "skrift-backend",
"version": "1.0.0",
"description": "Backend for Skrift handwritten document configurator",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"dev": "nodemon src/server.js"
},
"keywords": [
"skrift",
"handwriting",
"svg",
"scriptalizer"
],
"author": "",
"license": "ISC",
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.0",
"multer": "^2.0.2",
"uuid": "^9.0.1",
"xml2js": "^0.6.2"
},
"devDependencies": {
"nodemon": "^3.0.2"
}
}

View File

@@ -0,0 +1,541 @@
const fs = require('fs').promises;
const fsSync = require('fs');
const path = require('path');
const config = require('../../config');
const { scriptalizeBatch } = require('../../services/scriptalizer-service');
const { generateLetterSVG, generateEnvelopeSVG } = require('../../lib/svg-generator');
const { replacePlaceholders, generateAllCSVs } = require('../../services/placeholder-service');
const { TAB_MARKER } = require('../../lib/svg-font-engine');
/**
* POST /api/order/finalize
* Finalize order by copying cached previews to output directory
*/
async function finalizeOrder(req, res, next) {
try {
const { sessionId, orderNumber } = req.body;
// Validation
if (!sessionId || !orderNumber) {
return res.status(400).json({
error: 'Invalid request',
message: 'sessionId and orderNumber are required'
});
}
console.log(`[Order] Finalizing order: ${orderNumber} from session: ${sessionId}`);
// Check if session cache exists
const sessionDir = path.join(config.paths.previews, sessionId);
try {
await fs.access(sessionDir);
} catch {
return res.status(404).json({
error: 'Session not found',
message: `Preview cache not found for session: ${sessionId}. Please generate previews first.`
});
}
// Check cache expiration
const metadataPath = path.join(sessionDir, '.metadata.json');
try {
const metadataContent = await fs.readFile(metadataPath, 'utf8');
const metadata = JSON.parse(metadataContent);
const expiresAt = new Date(metadata.expiresAt);
if (new Date() > expiresAt) {
return res.status(410).json({
error: 'Cache expired',
message: 'Preview cache has expired. Please regenerate previews.',
expiredAt: metadata.expiresAt
});
}
} catch (err) {
console.warn(`[Order] Metadata not found for session: ${sessionId}`);
}
// Create output directory and subdirectories
const outputDir = path.join(config.paths.output, orderNumber);
const envelopesDir = path.join(outputDir, 'umschlaege');
await fs.mkdir(outputDir, { recursive: true });
await fs.mkdir(envelopesDir, { recursive: true });
// Copy files from cache to output, separating letters and envelopes
const files = await fs.readdir(sessionDir);
const copiedLetters = [];
const copiedEnvelopes = [];
const copiedOther = [];
for (const file of files) {
// Skip metadata file
if (file === '.metadata.json') {
continue;
}
const sourcePath = path.join(sessionDir, file);
// Determine destination based on file type
if (file.startsWith('envelope_')) {
// Umschläge in Unterordner 'umschlaege'
const destPath = path.join(envelopesDir, file);
await fs.copyFile(sourcePath, destPath);
copiedEnvelopes.push(file);
} else if (file.startsWith('letter_')) {
// Briefe im Hauptordner
const destPath = path.join(outputDir, file);
await fs.copyFile(sourcePath, destPath);
copiedLetters.push(file);
} else {
// Andere Dateien (CSV, etc.) im Hauptordner
const destPath = path.join(outputDir, file);
await fs.copyFile(sourcePath, destPath);
copiedOther.push(file);
}
}
// Create order metadata
const meta = req.body.metadata || {};
const orderMetadata = {
orderNumber,
sessionId,
finalizedAt: new Date().toISOString(),
letterCount: copiedLetters.length,
envelopeCount: copiedEnvelopes.length,
letters: copiedLetters,
envelopes: copiedEnvelopes,
other: copiedOther,
letterFormat: meta.letterFormat || null,
envelopeFormat: meta.envelopeFormat || null,
envelopeBeschriftung: meta.envelopeBeschriftung || null,
};
const orderMetadataPath = path.join(outputDir, 'order-metadata.json');
await fs.writeFile(orderMetadataPath, JSON.stringify(orderMetadata, null, 2), 'utf8');
const totalFiles = copiedLetters.length + copiedEnvelopes.length + copiedOther.length;
console.log(`[Order] Finalized order ${orderNumber}: ${copiedLetters.length} letters, ${copiedEnvelopes.length} envelopes, ${copiedOther.length} other files`);
// Response
res.status(200).json({
orderNumber,
outputPath: outputDir,
letters: copiedLetters,
envelopes: copiedEnvelopes,
other: copiedOther,
totalFiles,
envelopesGenerated: 0,
timestamp: orderMetadata.finalizedAt
});
} catch (err) {
console.error('[Order] Error finalizing order:', err);
next(err);
}
}
/**
* POST /api/order/generate
* Generate order from scratch without using cache
*/
async function generateOrder(req, res, next) {
try {
const { orderNumber, letters } = req.body;
// Validation
if (!orderNumber) {
return res.status(400).json({
error: 'Invalid request',
message: 'orderNumber is required'
});
}
if (!letters || !Array.isArray(letters) || letters.length === 0) {
return res.status(400).json({
error: 'Invalid request',
message: 'Letters array is required and must not be empty'
});
}
console.log(`[Order] Generating order: ${orderNumber}`);
console.log(`[Order] Total documents: ${letters.length}`);
// Debug: Zeige welche Dokument-Typen ankommen
const letterCount = letters.filter(l => l.type !== 'envelope').length;
const envelopeCount = letters.filter(l => l.type === 'envelope').length;
console.log(`[Order] Letters: ${letterCount}, Envelopes: ${envelopeCount}`);
console.log(`[Order] Document types:`, letters.map(l => ({ index: l.index, type: l.type, envelopeType: l.envelopeType })));
// Create output directory and subdirectories
const outputDir = path.join(config.paths.output, orderNumber);
const envelopesDir = path.join(outputDir, 'umschlaege');
await fs.mkdir(outputDir, { recursive: true });
await fs.mkdir(envelopesDir, { recursive: true });
// Step 1: Prepare texts and replace placeholders
const processedLetters = letters.map((letter, loopIndex) => {
let text = letter.text || '';
// Signatur-Platzhalter separat extrahieren – sie werden NICHT als Text ersetzt
const sigPlaceholders = {};
const nonSigPlaceholders = {};
if (letter.placeholders && typeof letter.placeholders === 'object') {
Object.entries(letter.placeholders).forEach(([k, v]) => {
if (/^signatur\d+$/i.test(k)) sigPlaceholders[k.toLowerCase()] = v;
else nonSigPlaceholders[k] = v;
});
text = replacePlaceholders(text, nonSigPlaceholders);
}
return {
...letter,
index: letter.index !== undefined ? letter.index : loopIndex,
loopIndex,
processedText: text,
signatures: sigPlaceholders,
format: letter.format || 'a4',
font: letter.font || 'tilda',
type: letter.type || 'letter',
envelopeType: letter.envelopeType || 'recipient',
realisticHandwriting: letter.realisticHandwriting ?? false,
};
});
// Step 2: Group letters by font for batch scriptalization
const lettersByFont = {};
processedLetters.forEach(letter => {
const font = letter.font;
if (!lettersByFont[font]) {
lettersByFont[font] = [];
}
lettersByFont[font].push(letter);
});
// Step 3: Scriptalize texts by font
const scriptalizedMap = new Map();
for (const [font, fontLetters] of Object.entries(lettersByFont)) {
// Tabs (\t) und Signatur-Marker ([[signaturN]]) vor Scriptalisierung extrahieren,
// danach mit TAB_MARKER bzw. [[signaturN]] restaurieren.
const scriptalizerInputs = [];
const splitMaps = [];
for (const letter of fontLetters) {
const raw = letter.processedText;
const parts = raw.split(/(\t|\[\[signatur\d+\]\]|[&|])/gi);
const segments = [], markers = [];
for (let i = 0; i < parts.length; i++) {
if (i % 2 === 0) segments.push(parts[i]);
else markers.push(parts[i]);
}
// Leere Segmente NICHT an den Scriptalizer senden
const startIdx = scriptalizerInputs.length;
const hasContent = segments.map(s => s.trim().length > 0);
segments.forEach((s, j) => { if (hasContent[j]) scriptalizerInputs.push(s); });
splitMaps.push({ loopIndex: letter.loopIndex, startIdx, segments, markers, hasContent, fallback: raw });
}
console.log(`[Order] Scriptalizing ${scriptalizerInputs.length} segments with font: ${font}`);
try {
const scriptalizedAll = await scriptalizeBatch(scriptalizerInputs, font, config.scriptalizer.errFrequency);
for (const { loopIndex, startIdx, segments, markers, hasContent, fallback } of splitMaps) {
const nonEmptyCount = hasContent.filter(Boolean).length;
if (!nonEmptyCount && !markers.length) { scriptalizedMap.set(loopIndex, fallback); continue; }
let si = startIdx;
const segs = segments.map((_s, j) => hasContent[j] ? (scriptalizedAll[si++] || '') : '');
let reconstructed = segs[0] || '';
for (let i = 0; i < markers.length; i++) {
reconstructed += (markers[i] === '\t' ? TAB_MARKER : markers[i]);
reconstructed += (segs[i + 1] || '');
}
scriptalizedMap.set(loopIndex, reconstructed);
}
} catch (err) {
console.error(`[Order] Scriptalization failed for font ${font}:`, err.message);
return res.status(500).json({
error: 'Scriptalization failed',
message: `Failed to scriptalize texts with font ${font}: ${err.message}`
});
}
}
// Step 4: Generate SVG files - separate letters and envelopes
const generatedLetters = [];
const generatedEnvelopes = [];
for (let i = 0; i < processedLetters.length; i++) {
const letter = processedLetters[i];
// Verwende loopIndex für scriptalizedMap Zuordnung
const scriptalizedText = scriptalizedMap.get(letter.loopIndex) || letter.processedText;
let svgResult;
try {
if (letter.type === 'envelope') {
svgResult = generateEnvelopeSVG(
scriptalizedText,
letter.format,
letter.envelopeType,
{ font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false }
);
} else {
svgResult = generateLetterSVG(
scriptalizedText,
letter.format,
{ font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false, signatures: letter.signatures || {} }
);
}
} catch (err) {
console.error(`[Order] SVG generation failed for letter ${i}:`, err.message);
return res.status(500).json({
error: 'SVG generation failed',
message: `Failed to generate SVG for letter ${i}: ${err.message}`
});
}
// Save SVG file - unterscheide zwischen Briefen und Umschlägen
const absoluteIndex = letter.index !== undefined ? letter.index : i;
if (letter.type === 'envelope') {
const filename = `envelope_${String(absoluteIndex).padStart(3, '0')}.svg`;
const filepath = path.join(envelopesDir, filename);
await fs.writeFile(filepath, svgResult.svg, 'utf8');
generatedEnvelopes.push(filename);
} else {
const filename = `letter_${String(absoluteIndex).padStart(3, '0')}.svg`;
const filepath = path.join(outputDir, filename);
await fs.writeFile(filepath, svgResult.svg, 'utf8');
generatedLetters.push(filename);
}
}
console.log(`[Order] Generated ${generatedLetters.length} letters, ${generatedEnvelopes.length} envelopes`);
// Step 5: Generate all CSV files if placeholders are present
const hasPlaceholders = processedLetters.some(l => l.placeholders && Object.keys(l.placeholders).length > 0);
const csvFiles = [];
if (hasPlaceholders) {
try {
const csvs = generateAllCSVs(processedLetters);
// Brief-Platzhalter CSV
if (csvs.placeholders) {
const filename = 'brief_platzhalter.csv';
await fs.writeFile(path.join(outputDir, filename), csvs.placeholders, 'utf8');
csvFiles.push(filename);
console.log(`[Order] Generated brief_platzhalter.csv`);
}
// Empfänger CSV (für recipientData-Modus)
if (csvs.recipients) {
const filename = 'empfaenger.csv';
await fs.writeFile(path.join(outputDir, filename), csvs.recipients, 'utf8');
csvFiles.push(filename);
console.log(`[Order] Generated empfaenger.csv`);
}
// Umschlag-Platzhalter CSV (für customText-Modus)
if (csvs.envelopePlaceholders) {
const filename = 'umschlag_platzhalter.csv';
await fs.writeFile(path.join(outputDir, filename), csvs.envelopePlaceholders, 'utf8');
csvFiles.push(filename);
console.log(`[Order] Generated umschlag_platzhalter.csv`);
}
} catch (err) {
console.error('[Order] CSV generation failed:', err.message);
// Don't fail the request, CSV is optional
}
}
// Step 6: Create order metadata
const meta = req.body.metadata || {};
const orderMetadata = {
orderNumber,
generatedAt: new Date().toISOString(),
letterCount: generatedLetters.length,
envelopeCount: generatedEnvelopes.length,
letters: generatedLetters,
envelopes: generatedEnvelopes,
fonts: Object.keys(lettersByFont),
hasPlaceholders,
csvFiles,
letterFormat: meta.letterFormat || null,
envelopeFormat: meta.envelopeFormat || null,
envelopeBeschriftung: meta.envelopeBeschriftung || null,
};
const orderMetadataPath = path.join(outputDir, 'order-metadata.json');
await fs.writeFile(orderMetadataPath, JSON.stringify(orderMetadata, null, 2), 'utf8');
console.log(`[Order] Order ${orderNumber} generated successfully`);
// Response
res.status(200).json({
orderNumber,
outputPath: outputDir,
letters: generatedLetters,
envelopes: generatedEnvelopes,
csvFiles,
totalFiles: generatedLetters.length + generatedEnvelopes.length + csvFiles.length,
timestamp: orderMetadata.generatedAt
});
} catch (err) {
console.error('[Order] Error generating order:', err);
next(err);
}
}
/**
* POST /api/order/motif
* Upload a motif image for an order
*/
async function uploadMotif(req, res, next) {
try {
const { orderNumber } = req.body;
// Validation
if (!orderNumber) {
return res.status(400).json({
error: 'Invalid request',
message: 'orderNumber is required'
});
}
if (!req.file) {
return res.status(400).json({
error: 'Invalid request',
message: 'No file uploaded'
});
}
console.log(`[Order] Uploading motif for order: ${orderNumber}`);
console.log(`[Order] File: ${req.file.originalname}, Size: ${req.file.size} bytes, Type: ${req.file.mimetype}`);
// Create output directory if it doesn't exist
const outputDir = path.join(config.paths.output, orderNumber);
await fs.mkdir(outputDir, { recursive: true });
// Determine file extension
const originalExt = path.extname(req.file.originalname).toLowerCase() || '.png';
const filename = `motif${originalExt}`;
const filepath = path.join(outputDir, filename);
// Write file to disk
await fs.writeFile(filepath, req.file.buffer);
console.log(`[Order] Motif saved to: ${filepath}`);
// Response
res.status(200).json({
success: true,
orderNumber,
filename,
path: filepath,
size: req.file.size,
mimetype: req.file.mimetype
});
} catch (err) {
console.error('[Order] Error uploading motif:', err);
next(err);
}
}
/**
* POST /api/order/files
* Speichert eine bereits vom WordPress-Frontend hochgeladene Datei
* (Adress-/Platzhalter-Excel, Briefdokument) im Auftragsordner ab,
* damit sie zusammen mit den generierten Umschlägen vorliegt.
*/
async function uploadOrderFile(req, res, next) {
try {
const { orderNumber, fileType } = req.body;
if (!orderNumber || !fileType) {
return res.status(400).json({
error: 'Invalid request',
message: 'orderNumber and fileType are required'
});
}
if (!req.file) {
return res.status(400).json({
error: 'Invalid request',
message: 'No file uploaded'
});
}
const outputDir = path.join(config.paths.output, orderNumber, 'dateien');
await fs.mkdir(outputDir, { recursive: true });
const ext = path.extname(req.file.originalname) || '';
const filename = `${fileType}${ext}`;
const filepath = path.join(outputDir, filename);
await fs.writeFile(filepath, req.file.buffer);
console.log(`[Order] File saved for order ${orderNumber}: ${filename}`);
res.status(200).json({
success: true,
orderNumber,
fileType,
filename,
path: filepath,
size: req.file.size,
});
} catch (err) {
console.error('[Order] Error saving order file:', err);
next(err);
}
}
/**
* POST /api/order/register
* Speichert Metadaten für einen Auftrag ohne SVG-Generierung (z.B. gedruckte Briefe)
*/
async function registerOrder(req, res, next) {
try {
const { orderNumber, metadata } = req.body;
if (!orderNumber) {
return res.status(400).json({ error: 'Invalid request', message: 'orderNumber is required' });
}
const outputDir = path.join(config.paths.output, orderNumber);
await fs.mkdir(outputDir, { recursive: true });
const orderMetadata = {
orderNumber,
registeredAt: new Date().toISOString(),
...(metadata || {}),
};
await fs.writeFile(
path.join(outputDir, 'order-metadata.json'),
JSON.stringify(orderMetadata, null, 2),
'utf8'
);
console.log(`[Order] Registered order ${orderNumber}`);
res.status(200).json({ orderNumber, registeredAt: orderMetadata.registeredAt });
} catch (err) {
console.error('[Order] Error registering order:', err);
next(err);
}
}
module.exports = {
finalizeOrder,
generateOrder,
uploadMotif,
uploadOrderFile,
registerOrder,
};

View File

@@ -0,0 +1,362 @@
const fs = require("fs").promises;
const fsSync = require("fs");
const path = require("path");
const { v4: uuidv4 } = require("uuid");
const config = require("../../config");
const { scriptalizeBatch } = require("../../services/scriptalizer-service");
const {
generateLetterSVG,
generateEnvelopeSVG,
} = require("../../lib/svg-generator");
const {
replacePlaceholders,
generateAllCSVs,
} = require("../../services/placeholder-service");
const { TAB_MARKER } = require("../../lib/svg-font-engine");
/**
* POST /api/preview/batch
* Generate preview batch with rate limiting
*/
async function generateBatch(req, res, next) {
try {
const { sessionId, letters } = req.body;
// Validation
if (!letters || !Array.isArray(letters) || letters.length === 0) {
return res.status(400).json({
error: "Invalid request",
message: "Letters array is required and must not be empty",
});
}
// KEIN Limit mehr - Frontend sendet ALLE Dokumente auf einmal
// Backend teilt sie intern in 25er Batches für Scriptalizer auf
console.log(`[Preview] Received ${letters.length} letters (no limit)`);
// Optional: Warn if extremely large
if (letters.length > 1000) {
console.warn(`[Preview] Large batch detected: ${letters.length} letters`);
}
// Generate or validate sessionId
const finalSessionId = sessionId || uuidv4();
console.log(
`[Preview] Starting batch generation for session: ${finalSessionId}`
);
console.log(`[Preview] Batch size: ${letters.length} letters`);
// Create session cache directory
const sessionDir = path.join(config.paths.previews, finalSessionId);
await fs.mkdir(sessionDir, { recursive: true });
// Step 1: Prepare texts and replace placeholders
console.log(
"[Preview] Received indices from frontend:",
letters.map((l) => l.index)
);
const processedLetters = letters.map((letter, loopIndex) => {
let text = letter.text || "";
// Signatur-Platzhalter separat extrahieren – sie werden NICHT als Text ersetzt
const sigPlaceholders = {};
const nonSigPlaceholders = {};
if (letter.placeholders && typeof letter.placeholders === "object") {
Object.entries(letter.placeholders).forEach(([k, v]) => {
if (/^signatur\d+$/i.test(k)) sigPlaceholders[k.toLowerCase()] = v;
else nonSigPlaceholders[k] = v;
});
text = replacePlaceholders(text, nonSigPlaceholders);
}
return {
...letter,
index: letter.index !== undefined ? letter.index : loopIndex,
processedText: text,
signatures: sigPlaceholders,
format: letter.format || "a4",
font: letter.font || "tilda",
type: letter.type || "letter",
envelopeType: letter.envelopeType || "recipient",
realisticHandwriting: letter.realisticHandwriting ?? false,
};
});
console.log(
"[Preview] Final indices being used for files:",
processedLetters.map((l) => l.index)
);
// Step 2: Batch scriptalize texts.
// Tabs (\t) und Signatur-Marker ([[signaturN]]) werden vor der Scriptalisierung
// extrahiert, anschliessend wieder restauriert (TAB_MARKER für Tabs, {{signaturN}} unverändert).
const font = processedLetters[0]?.font || "tilda";
const scriptalizerInputs = [];
const splitMaps = []; // pro Brief: { segments[], markers[] }
for (const letter of processedLetters) {
const raw = letter.processedText;
// Splitten an \t UND [[signaturN]] (mit capture-Gruppe → Marker bleiben erhalten)
const parts = raw.split(/(\t|\[\[signatur\d+\]\]|[&|])/gi);
const segments = [], markers = [];
for (let i = 0; i < parts.length; i++) {
if (i % 2 === 0) segments.push(parts[i]);
else markers.push(parts[i]);
}
// Leere Segmente NICHT an den Scriptalizer senden (API könnte sie ablehnen)
const startIdx = scriptalizerInputs.length;
const hasContent = segments.map(s => s.trim().length > 0);
segments.forEach((s, j) => { if (hasContent[j]) scriptalizerInputs.push(s); });
splitMaps.push({ startIdx, segments, markers, hasContent });
}
console.log(
`[Preview] Scriptalizing ${scriptalizerInputs.length} segments with font: ${font}`
);
let scriptalizedAll;
try {
scriptalizedAll = await scriptalizeBatch(
scriptalizerInputs,
font,
config.scriptalizer.errFrequency
);
} catch (err) {
console.error("[Preview] Scriptalization failed:", err.message);
return res.status(500).json({
error: "Scriptalization failed",
message: err.message,
});
}
// Segmente und Marker wieder zusammensetzen; \t → TAB_MARKER, [[signaturN]] unverändert
// Leere Segmente werden als '' wiederhergestellt (wurden nicht scriptalisiert)
const scriptalizedTexts = splitMaps.map(({ startIdx, segments, markers, hasContent }) => {
let si = startIdx;
const segs = segments.map((_s, j) => hasContent[j] ? (scriptalizedAll[si++] || '') : '');
let result = segs[0] || '';
for (let i = 0; i < markers.length; i++) {
result += (markers[i] === '\t' ? TAB_MARKER : markers[i]);
result += (segs[i + 1] || '');
}
return result;
});
if (scriptalizedTexts.length !== processedLetters.length) {
console.warn(
`[Preview] Scriptalization mismatch: expected ${processedLetters.length}, got ${scriptalizedTexts.length}`
);
}
// Step 3: Generate SVG files
const files = [];
let hasOverflow = false;
for (let i = 0; i < processedLetters.length; i++) {
const letter = processedLetters[i];
const scriptalizedText = scriptalizedTexts[i] || letter.processedText;
let svgResult;
try {
if (letter.type === "envelope") {
svgResult = generateEnvelopeSVG(
scriptalizedText,
letter.format,
letter.envelopeType,
{ font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false }
);
} else {
// Briefe: Zeileninfo mit zurückgeben
svgResult = generateLetterSVG(scriptalizedText, letter.format, {
font: letter.font,
realisticHandwriting: letter.realisticHandwriting ?? false,
signatures: letter.signatures || {},
});
}
} catch (err) {
console.error(
`[Preview] SVG generation failed for letter ${i}:`,
err.message
);
return res.status(500).json({
error: "SVG generation failed",
message: `Failed to generate SVG for letter ${i}: ${err.message}`,
});
}
// Track overflow
if (svgResult.overflow) {
hasOverflow = true;
console.log(`[Preview] Letter ${i} has overflow: ${svgResult.lineCount}/${svgResult.lineLimit} lines`);
}
// Save SVG file - verwende den absoluten Index aus letter
// Unterscheide zwischen Briefen und Umschlägen im Dateinamen
const absoluteIndex = letter.index !== undefined ? letter.index : i;
const prefix = letter.type === 'envelope' ? 'envelope' : 'letter';
const filename = `${prefix}_${String(absoluteIndex).padStart(3, "0")}.svg`;
const filepath = path.join(sessionDir, filename);
await fs.writeFile(filepath, svgResult.svg, "utf8");
// Empfänger-Info für bessere Fehlermeldung
const recipientName = letter.placeholders?.name || letter.placeholders?.vorname || `Brief ${absoluteIndex + 1}`;
files.push({
index: absoluteIndex,
filename,
url: `/api/preview/${finalSessionId}/${filename}`,
lineCount: svgResult.lineCount,
lineLimit: svgResult.lineLimit,
overflow: svgResult.overflow,
recipientName: letter.type !== 'envelope' ? recipientName : null,
});
}
console.log(`[Preview] Generated ${files.length} SVG files`);
// Step 4: Generate all CSV files (placeholders, recipients, envelope placeholders, free addresses)
const csvUrls = {};
const hasPlaceholders = processedLetters.some((l) => l.placeholders && Object.keys(l.placeholders).length > 0);
const hasFreeAddresses = processedLetters.some((l) => l.type === 'envelope' && l.envelopeType === 'free');
if (hasPlaceholders || hasFreeAddresses) {
try {
const csvs = generateAllCSVs(processedLetters);
// Brief-Platzhalter CSV
if (csvs.placeholders) {
const filename = "brief_platzhalter.csv";
await fs.writeFile(path.join(sessionDir, filename), csvs.placeholders, "utf8");
csvUrls.placeholders = `/api/preview/${finalSessionId}/${filename}`;
console.log(`[Preview] Generated brief_platzhalter.csv`);
}
// Empfänger CSV (für recipientData-Modus)
if (csvs.recipients) {
const filename = "empfaenger.csv";
await fs.writeFile(path.join(sessionDir, filename), csvs.recipients, "utf8");
csvUrls.recipients = `/api/preview/${finalSessionId}/${filename}`;
console.log(`[Preview] Generated empfaenger.csv`);
}
// Umschlag-Platzhalter CSV (für customText-Modus)
if (csvs.envelopePlaceholders) {
const filename = "umschlag_platzhalter.csv";
await fs.writeFile(path.join(sessionDir, filename), csvs.envelopePlaceholders, "utf8");
csvUrls.envelopePlaceholders = `/api/preview/${finalSessionId}/${filename}`;
console.log(`[Preview] Generated umschlag_platzhalter.csv`);
}
// Freie Adressen CSV (für free-Modus)
if (csvs.freeAddresses) {
const filename = "freie_adressen.csv";
await fs.writeFile(path.join(sessionDir, filename), csvs.freeAddresses, "utf8");
csvUrls.freeAddresses = `/api/preview/${finalSessionId}/${filename}`;
console.log(`[Preview] Generated freie_adressen.csv`);
}
} catch (err) {
console.error("[Preview] CSV generation failed:", err.message);
// Don't fail the request, CSV is optional
}
}
// Step 5: Save session metadata (no expiration)
const metadataPath = path.join(sessionDir, ".metadata.json");
const metadata = {
sessionId: finalSessionId,
createdAt: new Date().toISOString(),
letterCount: files.length,
font,
hasPlaceholders,
};
await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2), "utf8");
console.log(
`[Preview] Batch generation complete for session: ${finalSessionId}`
);
// Response
res.status(200).json({
sessionId: finalSessionId,
files,
csvUrls,
hasOverflow,
overflowFiles: files.filter(f => f.overflow),
});
} catch (err) {
console.error("[Preview] Unexpected error:", err);
next(err);
}
}
/**
* GET /api/preview/:sessionId/:filename
* Serve cached preview files
*/
async function servePreview(req, res, next) {
try {
const { sessionId, filename } = req.params;
// Validate inputs
if (!sessionId || !filename) {
return res.status(400).json({
error: "Invalid request",
message: "sessionId and filename are required",
});
}
// Prevent directory traversal
const sanitizedFilename = path.basename(filename);
const filePath = path.join(
config.paths.previews,
sessionId,
sanitizedFilename
);
// Check if file exists
try {
await fs.access(filePath);
} catch {
return res.status(404).json({
error: "File not found",
message: `Preview file not found: ${sanitizedFilename}`,
});
}
// No cache expiration check - files are served until manually deleted
// Determine content type
const ext = path.extname(sanitizedFilename).toLowerCase();
let contentType = "application/octet-stream";
if (ext === ".svg") {
contentType = "image/svg+xml";
} else if (ext === ".csv") {
contentType = "text/csv";
}
// Serve file - NO CACHING
res.setHeader("Content-Type", contentType);
res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate");
res.setHeader("Pragma", "no-cache");
res.setHeader("Expires", "0");
const fileContent = await fs.readFile(filePath);
res.send(fileContent);
} catch (err) {
console.error("[Preview] Error serving file:", err);
next(err);
}
}
// No automatic cache cleanup - files are kept until manually deleted
module.exports = {
generateBatch,
servePreview,
};

View File

@@ -0,0 +1,72 @@
/**
* Authentication Middleware
* Validiert API-Token in Request-Headers
*/
const config = require('../../config');
/**
* Middleware: API-Token validieren
*/
function authenticateApiToken(req, res, next) {
// API-Token aus Umgebungsvariablen
const validToken = config.auth.apiToken;
// Wenn kein Token konfiguriert ist, alle Requests erlauben (Development)
if (!validToken) {
console.warn('[Auth] WARNING: No API token configured - authentication disabled!');
return next();
}
// Token aus verschiedenen Quellen prüfen
const token =
req.headers['x-api-token'] || // Custom Header
req.headers['authorization']?.replace('Bearer ', '') || // Bearer Token
req.query.api_token; // Query Parameter (nur für Tests)
// Kein Token vorhanden
if (!token) {
return res.status(401).json({
error: 'Unauthorized',
message: 'API token required. Please provide X-API-Token header or Authorization: Bearer <token>',
});
}
// Token validieren
if (token !== validToken) {
return res.status(403).json({
error: 'Forbidden',
message: 'Invalid API token',
});
}
// Token ist gültig
next();
}
/**
* Optionale Authentifizierung - Warnung aber kein Fehler
*/
function optionalAuth(req, res, next) {
const validToken = config.auth.apiToken;
if (!validToken) {
return next();
}
const token =
req.headers['x-api-token'] ||
req.headers['authorization']?.replace('Bearer ', '') ||
req.query.api_token;
if (!token || token !== validToken) {
console.warn('[Auth] Unauthenticated request to', req.path);
}
next();
}
module.exports = {
authenticateApiToken,
optionalAuth,
};

View File

@@ -0,0 +1,13 @@
module.exports = (err, req, res, next) => {
console.error('Error occurred:', err);
const statusCode = err.statusCode || 500;
const message = err.message || 'Internal Server Error';
res.status(statusCode).json({
error: {
message,
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
}
});
};

View File

@@ -0,0 +1,53 @@
const config = require('../../config');
// Simple in-memory rate limiter
const rateLimitStore = new Map();
// Cleanup old entries every minute
setInterval(() => {
const now = Date.now();
const oneMinute = 60 * 1000;
for (const [key, data] of rateLimitStore.entries()) {
if (now - data.windowStart > oneMinute) {
rateLimitStore.delete(key);
}
}
}, 60 * 1000);
module.exports = (req, res, next) => {
const sessionId = req.body.sessionId || req.params.sessionId || 'anonymous';
const now = Date.now();
const oneMinute = 60 * 1000;
let rateData = rateLimitStore.get(sessionId);
if (!rateData || now - rateData.windowStart > oneMinute) {
// New window
rateData = {
windowStart: now,
requests: []
};
rateLimitStore.set(sessionId, rateData);
}
// Remove requests older than 1 minute
rateData.requests = rateData.requests.filter(timestamp => now - timestamp < oneMinute);
// Check limit
if (rateData.requests.length >= config.preview.rateLimitPerMinute) {
const oldestRequest = Math.min(...rateData.requests);
const retryAfter = Math.ceil((oneMinute - (now - oldestRequest)) / 1000);
return res.status(429).json({
error: 'Zu viele Vorschau-Anfragen. Bitte warten Sie.',
retryAfter,
message: `Limit: ${config.preview.rateLimitPerMinute} Anfragen pro Minute`
});
}
// Add current request
rateData.requests.push(now);
next();
};

View File

@@ -0,0 +1,14 @@
module.exports = (req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
const timestamp = new Date().toISOString();
console.log(
`[${timestamp}] ${req.method} ${req.path} - ${res.statusCode} (${duration}ms)`
);
});
next();
};

View File

@@ -0,0 +1,34 @@
const express = require('express');
const router = express.Router();
const fs = require('fs');
const config = require('../../config');
router.get('/', (req, res) => {
const health = {
status: 'healthy',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
scriptalizer: config.scriptalizer.licenseKey ? 'configured' : 'missing',
storage: {
cache: fs.existsSync(config.paths.cache) && isWritable(config.paths.cache),
output: fs.existsSync(config.paths.output) && isWritable(config.paths.output)
}
};
const allHealthy = health.scriptalizer === 'configured' &&
health.storage.cache &&
health.storage.output;
res.status(allHealthy ? 200 : 503).json(health);
});
function isWritable(path) {
try {
fs.accessSync(path, fs.constants.W_OK);
return true;
} catch {
return false;
}
}
module.exports = router;

View File

@@ -0,0 +1,135 @@
const express = require('express');
const router = express.Router();
const multer = require('multer');
const orderController = require('../controllers/order-controller');
const { authenticateApiToken } = require('../middleware/auth');
// Max. Uploadgröße für Auftragsdateien (Brief-PDF, Adress-/Platzhalter-Tabellen).
// 10 MB reichten für hochauflösende bzw. gescannte Brief-PDFs nicht aus – multer
// verwarf die Datei still, bevor der Controller lief.
const MAX_UPLOAD_BYTES = 120 * 1024 * 1024; // 120MB
// Multer konfigurieren für Datei-Uploads (temporär im Speicher)
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: MAX_UPLOAD_BYTES,
},
fileFilter: (req, file, cb) => {
// Erlaubte Dateitypen
const allowedTypes = ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml', 'application/pdf'];
const allowedExts = ['.png', '.jpg', '.jpeg', '.webp', '.svg', '.pdf'];
const ext = file.originalname.toLowerCase().substring(file.originalname.lastIndexOf('.'));
if (allowedTypes.includes(file.mimetype) || allowedExts.includes(ext)) {
cb(null, true);
} else {
cb(new Error('Nur PNG, JPG, WEBP, SVG und PDF Dateien sind erlaubt'), false);
}
}
});
/**
* POST /api/order/finalize
* Finalize order by copying cached previews to output directory
*
* Request body:
* {
* sessionId: string,
* orderNumber: string
* }
*
* Response:
* {
* orderNumber: string,
* outputPath: string,
* files: string[],
* timestamp: string
* }
*/
router.post('/finalize', authenticateApiToken, orderController.finalizeOrder);
/**
* POST /api/order/generate
* Generate order from scratch without using cache
*
* Request body:
* {
* orderNumber: string,
* letters: [
* {
* text: string,
* format: 'a4' | 'a6p' | 'a6l' | 'c6' | 'din_lang',
* font: 'tilda' | 'alva' | 'ellie',
* type?: 'letter' | 'envelope',
* envelopeType?: 'recipient' | 'custom',
* placeholders?: { [key: string]: string }
* }
* ]
* }
*
* Response:
* {
* orderNumber: string,
* outputPath: string,
* files: string[],
* timestamp: string
* }
*/
router.post('/generate', authenticateApiToken, orderController.generateOrder);
/**
* POST /api/order/motif
* Upload a motif image for an order
*
* Request: multipart/form-data
* - motif: file (PNG, JPG, WEBP, SVG, PDF)
* - orderNumber: string
*
* Response:
* {
* success: boolean,
* filename: string,
* path: string
* }
*/
router.post('/motif', authenticateApiToken, upload.single('motif'), orderController.uploadMotif);
// Multer für Auftragsdateien (Adress-/Platzhalter-Excel, Briefdokument)
const uploadOrderFile = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: MAX_UPLOAD_BYTES,
},
fileFilter: (req, file, cb) => {
const allowedExts = ['.xlsx', '.xls', '.csv', '.pdf', '.doc', '.docx'];
const ext = file.originalname.toLowerCase().substring(file.originalname.lastIndexOf('.'));
if (allowedExts.includes(ext)) {
cb(null, true);
} else {
cb(new Error('Nur XLSX, XLS, CSV, PDF, DOC oder DOCX Dateien sind erlaubt'), false);
}
}
});
/**
* POST /api/order/files
* Speichert eine bereits vom WordPress-Frontend hochgeladene Datei
* (Adresse, Platzhalter, Brief, Brief-Excel) im Auftragsordner
*
* Request: multipart/form-data
* - file: file (XLSX, XLS, CSV, PDF, DOC, DOCX)
* - orderNumber: string
* - fileType: 'adresse' | 'platzhalter' | 'brief' | 'brief_excel'
*/
router.post('/files', authenticateApiToken, uploadOrderFile.single('file'), orderController.uploadOrderFile);
/**
* POST /api/order/register
* Metadaten-Auftrag ohne SVG-Generierung (gedruckte Briefe)
*/
router.post('/register', authenticateApiToken, orderController.registerOrder);
module.exports = router;

View File

@@ -0,0 +1,47 @@
const express = require('express');
const router = express.Router();
const previewController = require('../controllers/preview-controller');
const { authenticateApiToken } = require('../middleware/auth');
/**
* POST /api/preview/batch
* Generate preview batch (no rate limiting, no batch size limit)
* Backend automatically splits into 25-letter batches for Scriptalizer API
*
* Request body:
* {
* sessionId: string,
* letters: [
* {
* text: string,
* format: 'a4' | 'a6p' | 'a6l' | 'c6' | 'din_lang',
* font: 'tilda' | 'alva' | 'ellie',
* type?: 'letter' | 'envelope',
* envelopeType?: 'recipient' | 'custom',
* placeholders?: { [key: string]: string }
* }
* ]
* }
*
* Response:
* {
* sessionId: string,
* files: [
* {
* index: number,
* filename: string,
* url: string
* }
* ],
* csvUrl?: string
* }
*/
router.post('/batch', authenticateApiToken, previewController.generateBatch);
/**
* GET /api/preview/:sessionId/:filename
* Serve cached preview SVG files
*/
router.get('/:sessionId/:filename', previewController.servePreview);
module.exports = router;

View File

@@ -0,0 +1,53 @@
require('dotenv').config();
const path = require('path');
const isProduction = process.env.NODE_ENV === 'production';
module.exports = {
env: process.env.NODE_ENV || 'development',
port: parseInt(process.env.PORT, 10) || 4000,
scriptalizer: {
licenseKey: process.env.SCRIPTALIZER_LICENSE_KEY,
errFrequency: parseInt(process.env.SCRIPTALIZER_ERR_FREQUENCY, 10) || 0,
endpoint: 'https://www.scriptalizer.co.uk/QuantumScriptalize.asmx/Scriptalize',
fontMap: {
tilda: 'PremiumUltra79',
alva: 'PremiumUltra23',
ellie: 'PremiumUltra39'
},
separator: '|||', // Triple pipe separator (tested and working)
maxInputSize: 48000 // 48KB limit
},
preview: {
// No batch size limit - frontend can send any number of letters
// Backend splits into 25-letter batches for Scriptalizer API internally
scriptalizerBatchSize: 25
// No cache lifetime - files are kept until manually cleaned
// No rate limiting
},
paths: {
cache: isProduction ? '/app/cache' : path.join(__dirname, '../../cache'),
previews: isProduction ? '/app/cache/previews' : path.join(__dirname, '../../cache/previews'),
output: isProduction ? '/app/output' : path.join(__dirname, '../../output'),
fonts: isProduction ? '/app/fonts' : path.join(__dirname, '../../fonts')
},
cors: {
origin: process.env.CORS_ORIGIN || '*',
credentials: true
},
auth: {
apiToken: process.env.API_TOKEN || null
},
paypal: {
clientId: process.env.PAYPAL_CLIENT_ID || '',
clientSecret: process.env.PAYPAL_CLIENT_SECRET || '',
// 'sandbox' oder 'live'
environment: process.env.PAYPAL_ENVIRONMENT || 'sandbox'
}
};

View File

@@ -0,0 +1,124 @@
const PX_PER_MM = 3.78; // 96dpi
const FONT_SIZE_PX = 26;
// Maximale Zeilenanzahl pro Format (für Textvalidierung)
const LINE_LIMITS = {
a4: 27,
a6p: 14,
a6l: 11,
};
// Layout Konfiguration pro Format + Orientation
const FORMAT_LAYOUTS = {
// A4 Hochformat
a4: {
marginTopMm: 25,
marginLeftMm: 20,
lineHeightFactor: 1.35,
},
// A6 Hochformat
a6p: {
marginTopMm: 8,
marginLeftMm: 8,
lineHeightFactor: 1.3,
},
// A6 Querformat (landscape)
a6l: {
marginTopMm: 8,
marginLeftMm: 8,
marginRightMm: 14,
lineHeightFactor: 1.2,
},
// DIN Lang Kuvert (Querformat)
din_lang: {
marginTopMm: 50,
marginLeftMm: 10, // Links unten in Ecke
lineHeightFactor: 1.2,
},
// C6 Kuvert (Querformat)
c6: {
marginTopMm: 60,
marginLeftMm: 10, // Links unten in Ecke
lineHeightFactor: 1.2,
},
};
// Seiten-Dimensionen
const PAGE_FORMATS = {
a4: { widthMm: 210, heightMm: 297 },
a6p: { widthMm: 105, heightMm: 148 },
a6l: { widthMm: 148, heightMm: 105 }, // Querformat: getauscht
din_lang: { widthMm: 220, heightMm: 110 },
c6: { widthMm: 162, heightMm: 114 },
};
// mm in Pixel umrechnen
function mmToPx(mm) {
return mm * PX_PER_MM;
}
// Layout für Format holen
function getLayoutForFormat(format) {
const normalizedFormat = String(format).toLowerCase();
const layoutConfig = FORMAT_LAYOUTS[normalizedFormat];
if (!layoutConfig) {
// Fallback auf a4
return {
marginTopPx: mmToPx(FORMAT_LAYOUTS.a4.marginTopMm),
marginLeftPx: mmToPx(FORMAT_LAYOUTS.a4.marginLeftMm),
lineHeightPx: FONT_SIZE_PX * FORMAT_LAYOUTS.a4.lineHeightFactor,
};
}
return {
marginTopPx: mmToPx(layoutConfig.marginTopMm),
marginLeftPx: mmToPx(layoutConfig.marginLeftMm),
marginRightPx: layoutConfig.marginRightMm ? mmToPx(layoutConfig.marginRightMm) : null,
lineHeightPx: FONT_SIZE_PX * layoutConfig.lineHeightFactor,
};
}
// Seiten-Dimensionen holen
function getPageDimensions(format) {
const normalizedFormat = String(format).toLowerCase();
const dimensions = PAGE_FORMATS[normalizedFormat] || PAGE_FORMATS.a4;
return {
widthMm: dimensions.widthMm,
heightMm: dimensions.heightMm,
widthPx: mmToPx(dimensions.widthMm),
heightPx: mmToPx(dimensions.heightMm),
};
}
// XML Escaping
function escapeXml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
// Zeilenlimit für Format holen
function getLineLimit(format) {
const normalizedFormat = String(format).toLowerCase();
return LINE_LIMITS[normalizedFormat] || LINE_LIMITS.a4;
}
module.exports = {
PX_PER_MM,
FONT_SIZE_PX,
FORMAT_LAYOUTS,
PAGE_FORMATS,
LINE_LIMITS,
mmToPx,
getLayoutForFormat,
getPageDimensions,
getLineLimit,
escapeXml
};

View File

@@ -0,0 +1,283 @@
const fs = require('fs');
const path = require('path');
const config = require('../config');
// Cache je Fontdatei
const FONT_CACHE = {};
// Attribut aus einem Tag lesen (mit Wortgrenze, um z.B. 'd' vs 'id' zu unterscheiden)
function getAttr(tag, name) {
const re = new RegExp(`\\b${name}="([^"]*)"`);
const m = tag.match(re);
return m ? m[1] : null;
}
// XML-Entities im unicode-Attribut dekodieren (z.B. &lt; → <, &amp; → &, &#x22; → ")
function decodeXmlEntities(str) {
return str
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCodePoint(parseInt(hex, 16)))
.replace(/&#([0-9]+);/g, (_, dec) => String.fromCodePoint(parseInt(dec, 10)))
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&');
}
// SVG-Font parsen und Metriken plus Glyphen extrahieren
function parseSvgFont(fontFileName) {
if (FONT_CACHE[fontFileName]) return FONT_CACHE[fontFileName];
const fontPath = path.join(config.paths.fonts, fontFileName);
const svg = fs.readFileSync(fontPath, 'utf8');
const fontFaceMatch = svg.match(/<font-face[\s\S]*?>/);
if (!fontFaceMatch) {
throw new Error(`Kein <font-face> in ${fontFileName} gefunden`);
}
const fontFaceTag = fontFaceMatch[0];
const unitsPerEm = parseFloat(getAttr(fontFaceTag, 'units-per-em') || '1000');
const ascent = parseFloat(getAttr(fontFaceTag, 'ascent') || '0');
const descent = parseFloat(getAttr(fontFaceTag, 'descent') || '0');
const glyphRegex = /<glyph[\s\S]*?\/>/g;
const glyphs = {};
let m;
while ((m = glyphRegex.exec(svg)) !== null) {
const tag = m[0];
const unicode = decodeXmlEntities(getAttr(tag, 'unicode') || '');
const d = getAttr(tag, 'd');
const adv = parseFloat(getAttr(tag, 'horiz-adv-x') || '0');
if (!unicode || !d) continue;
glyphs[unicode] = {
d,
adv,
};
}
const fontData = {
unitsPerEm,
ascent,
descent,
glyphs,
};
FONT_CACHE[fontFileName] = fontData;
return fontData;
}
// Zeichen, das nach der Scriptalisierung als Tabulator-Marker eingebettet wird (Unicode PUA)
const TAB_MARKER = '';
/**
* Breite einer Textzeile in Pixeln messen.
* Unterstützt TAB_MARKER: springt zur nächsten Tabstop-Position (6 × fontSizePx).
*/
function measureTextWidthPx({ text, fontFileName, fontSizePx }) {
const { unitsPerEm, glyphs } = parseSvgFont(fontFileName);
const scale = fontSizePx / unitsPerEm;
const tabStopPx = fontSizePx * 6;
let x = 0;
for (const ch of String(text || '')) {
if (ch === TAB_MARKER) {
x = Math.ceil((x + 1) / tabStopPx) * tabStopPx;
continue;
}
const glyph = glyphs[ch];
if (!glyph) {
// Fallback für Leerzeichen / unbekannte Zeichen
const spacePx = fontSizePx * 0.4;
x += spacePx;
continue;
}
const advPx = (glyph.adv || unitsPerEm * 0.5) * scale;
x += advPx;
}
return x;
}
/**
* Text nach verfügbarer Breite umbrechen.
* Garantiert: keine Zeile wird breiter als maxWidthPx.
* Unterstützt TAB_MARKER als Tabulator-Sprung innerhalb einer Zeile.
*/
function wrapTextToLinesByWidth({
text,
maxWidthPx,
fontFileName,
fontSizePx,
}) {
const paragraphs = String(text || '').split(/\r?\n/);
const allLines = [];
const measure = (t) =>
measureTextWidthPx({ text: t, fontFileName, fontSizePx });
for (const para of paragraphs) {
// Tokens: Wörter und TAB_MARKER getrennt ermitteln.
// TAB_MARKER durch einen Platzhalter ersetzen, mit \s+ splitten, dann zurücktauschen.
const TAB_PLACEHOLDER = '';
const normalizedPara = para.split(TAB_MARKER).join(` ${TAB_PLACEHOLDER} `);
const rawWords = normalizedPara.split(/\s+/).filter(Boolean);
const tokens = rawWords.map(w => (w === TAB_PLACEHOLDER ? TAB_MARKER : w));
if (tokens.length === 0) {
allLines.push('');
continue;
}
let currentLine = '';
let afterTab = false;
for (const token of tokens) {
if (token === TAB_MARKER) {
currentLine += TAB_MARKER;
afterTab = true;
continue;
}
// Wort alleine zu breit → Zeichenweise brechen
if (measure(token) > maxWidthPx) {
if (currentLine) {
allLines.push(currentLine);
currentLine = '';
afterTab = false;
}
let buf = '';
for (const ch of token) {
const candidate = buf + ch;
if (measure(candidate) > maxWidthPx && buf) {
allLines.push(buf);
buf = ch;
} else {
buf = candidate;
}
}
if (buf) { currentLine = buf; afterTab = false; }
continue;
}
if (!currentLine) {
currentLine = token;
afterTab = false;
continue;
}
// Nach einem Tab kein Leerzeichen als Trenner einfügen
const sep = afterTab ? '' : ' ';
const candidate = `${currentLine}${sep}${token}`;
if (measure(candidate) <= maxWidthPx) {
currentLine = candidate;
afterTab = false;
} else {
allLines.push(currentLine);
currentLine = token;
afterTab = false;
}
}
if (currentLine) {
allLines.push(currentLine);
}
}
return allLines;
}
/**
* Textzeilen in Pfade mit Transform-Matrix umwandeln.
*/
function layoutTextToPaths({
lines,
fontFileName,
fontSizePx,
startX,
startY,
lineHeightPx,
realisticHandwriting = false,
}) {
const { unitsPerEm, glyphs } = parseSvgFont(fontFileName);
const scale = fontSizePx / unitsPerEm;
const resultPaths = [];
let baselineY = startY;
for (const line of lines) {
let x = startX;
let drift = 0;
const lineSlope = realisticHandwriting ? (Math.random() - 0.5) * 0.01 : 0;
const tabStopPx = fontSizePx * 6;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
// Tabulator-Marker → zur nächsten Tabstop-Position springen
if (ch === TAB_MARKER) {
const relX = x - startX;
const nextTab = Math.ceil((relX + 1) / tabStopPx) * tabStopPx;
x = startX + nextTab;
continue;
}
const glyph = glyphs[ch];
if (!glyph) {
// Leerzeichen mit leicht variierender Breite
const spacePx = fontSizePx * 0.4 * (realisticHandwriting ? (1 + (Math.random() - 0.5) * 0.08) : 1);
x += spacePx;
if (realisticHandwriting) {
drift += (Math.random() - 0.5) * 0.3;
drift = Math.max(-3, Math.min(3, drift));
}
continue;
}
const d = glyph.d;
const advPx = (glyph.adv || unitsPerEm * 0.5) * scale;
const yJitter = realisticHandwriting ? (Math.random() - 0.5) * 2.5 : 0;
if (realisticHandwriting) {
drift += (Math.random() - 0.5) * 0.4;
drift = Math.max(-3, Math.min(3, drift));
}
const charY = baselineY + yJitter + drift + lineSlope * (x - startX);
const angleDeg = realisticHandwriting ? 5 + (Math.random() - 0.5) * 2 : 0;
const pivotX = x + advPx / 2;
const pivotY = charY - fontSizePx * 0.3;
const transform = `rotate(${angleDeg.toFixed(3)},${pivotX.toFixed(1)},${pivotY.toFixed(1)}) matrix(${scale},0,0,${-scale},${x},${charY})`;
resultPaths.push({ d, transform });
x += advPx;
}
baselineY += lineHeightPx;
}
return resultPaths;
}
module.exports = {
TAB_MARKER,
measureTextWidthPx,
wrapTextToLinesByWidth,
layoutTextToPaths
};

View File

@@ -0,0 +1,341 @@
const {
FONT_SIZE_PX,
getLayoutForFormat,
getPageDimensions,
getLineLimit,
escapeXml
} = require('./page-layout');
const {
layoutTextToPaths,
wrapTextToLinesByWidth,
measureTextWidthPx
} = require('./svg-font-engine');
// Höhe einer Signatur in Seiten-Pixeln (Signatur-Canvas war 600×160; wir reservieren 160px * scale)
const SIG_CANVAS_W = 600;
const SIG_CANVAS_H = 160;
/**
* Extrahiert Path- und Polyline-Elemente aus einem SVG-String und gibt sie als
* umgeformte <g>-Gruppe zurück, die an Position (x, y) mit Skalierung scale eingebettet ist.
*/
function buildSignatureElement(svgString, x, y, scale) {
if (!svgString || typeof svgString !== 'string') return '';
// Nur <path>-Elemente extrahieren (unsere Signatur-SVGs enthalten ausschließlich Paths)
const pathRegex = /<path\b([^/]*)\/>/gi;
const elements = [];
let m;
while ((m = pathRegex.exec(svgString)) !== null) {
// Stroke-Farbe auf Druckfarbe setzen, stroke-width relativ zur Skalierung anpassen
const attrs = m[1]
.replace(/stroke="[^"]*"/i, 'stroke="#000000"')
.replace(/stroke-width="[^"]*"/i, `stroke-width="${(2.5 / scale).toFixed(2)}"`);
elements.push(`<path${attrs}/>`);
}
if (!elements.length) return '';
return `<g transform="translate(${x.toFixed(1)}, ${y.toFixed(1)}) scale(${scale.toFixed(4)})">
${elements.join('\n ')}
</g>`;
}
// Mapping der Handschriften auf SVG-Font-Dateien
const SVG_FONT_CONFIG = {
tilda: { file: 'tilda.svg' },
alva: { file: 'alva.svg' },
ellie: { file: 'ellie.svg' },
};
function normalizeFontKey(font) {
if (!font) return 'tilda';
const f = String(font).toLowerCase();
if (['tilda', 'alva', 'ellie'].includes(f)) return f;
return 'tilda';
}
/**
* Generiert ein Schriftstück (Brief/Postkarte) als SVG
* @param {string} text - Der scriptalisierte Text
* @param {string} format - Format: a4, a6p, a6l
* @param {object} options - { font: 'tilda', alignment: 'left' }
*/
function generateLetterSVG(text, format, options = {}) {
const fontKey = normalizeFontKey(options.font);
const fontConfig = SVG_FONT_CONFIG[fontKey];
const signatures = options.signatures || {};
const { widthPx, heightPx, widthMm, heightMm } = getPageDimensions(format);
const { marginTopPx, marginLeftPx, marginRightPx, lineHeightPx } = getLayoutForFormat(format);
const maxWidthPx = widthPx - marginLeftPx - (marginRightPx ?? marginLeftPx);
// ── Signatur-Marker aus Text extrahieren ──────────────────────────────────
const sigMarkerRe = /\[\[(signatur\d+)\]\]/gi;
const hasSigMarkers = sigMarkerRe.test(String(text || ''));
if (!hasSigMarkers) {
// ── Standard-Pfad (ohne Signaturen) ──────────────────────────────────────
const lines = wrapTextToLinesByWidth({
text: String(text || ''),
maxWidthPx,
fontFileName: fontConfig.file,
fontSizePx: FONT_SIZE_PX,
});
const firstBaselineY = marginTopPx + FONT_SIZE_PX;
const paths = layoutTextToPaths({
lines,
fontFileName: fontConfig.file,
fontSizePx: FONT_SIZE_PX,
startX: marginLeftPx,
startY: firstBaselineY,
lineHeightPx,
realisticHandwriting: options.realisticHandwriting ?? false,
});
const pathElements = paths
.map(p => `<path d="${escapeXml(p.d)}"
transform="${p.transform}"
stroke="#000000" stroke-width="0.6" fill="none"
stroke-linecap="round" stroke-linejoin="round"
vector-effect="non-scaling-stroke"/>`)
.join('\n ');
const lineLimit = getLineLimit(format);
return {
svg: `<svg xmlns="http://www.w3.org/2000/svg"
width="${widthMm}mm" height="${heightMm}mm"
viewBox="0 0 ${widthPx} ${heightPx}">
<rect x="0" y="0" width="${widthPx}" height="${heightPx}" fill="#FFFFFF"/>
${pathElements}
</svg>`.trim(),
lineCount: lines.length,
lineLimit,
overflow: lines.length > lineLimit,
};
}
// ── Pfad mit Signaturen: Text in Segmente aufteilen ───────────────────────
// Jede Signatur-Zeile ist 2 Zeilen hoch; bis zu 3 Signaturen nebeneinander.
const sigHeightPx = 2 * lineHeightPx;
const sigSlotW = maxWidthPx / 3; // Breite pro Signatur-Slot
const sigScaleH = sigHeightPx / SIG_CANVAS_H;
const sigScaleW = sigSlotW / SIG_CANVAS_W;
const sigScale = Math.min(sigScaleH, sigScaleW); // gleichmäßig skalieren
const sigPaddingPx = lineHeightPx * 0.3; // kleiner Abstand ober/unterhalb
const rawText = String(text || '');
const segments = [];
sigMarkerRe.lastIndex = 0;
let lastEnd = 0;
let matchSig;
while ((matchSig = sigMarkerRe.exec(rawText)) !== null) {
if (matchSig.index > lastEnd) {
segments.push({ type: 'text', content: rawText.slice(lastEnd, matchSig.index) });
}
segments.push({ type: 'sig', key: matchSig[1].toLowerCase() });
lastEnd = matchSig.index + matchSig[0].length;
}
if (lastEnd < rawText.length) {
segments.push({ type: 'text', content: rawText.slice(lastEnd) });
}
const allSvgElements = [];
let currentY = marginTopPx + FONT_SIZE_PX;
let totalLineCount = 0;
const lineLimit = getLineLimit(format);
let si = 0;
while (si < segments.length) {
const seg = segments[si];
if (seg.type === 'text') {
const trimmed = seg.content.replace(/^\n+/, '').replace(/\n+$/, '');
if (!trimmed) { si++; continue; }
const lines = wrapTextToLinesByWidth({
text: seg.content,
maxWidthPx,
fontFileName: fontConfig.file,
fontSizePx: FONT_SIZE_PX,
});
const paths = layoutTextToPaths({
lines,
fontFileName: fontConfig.file,
fontSizePx: FONT_SIZE_PX,
startX: marginLeftPx,
startY: currentY,
lineHeightPx,
realisticHandwriting: options.realisticHandwriting ?? false,
});
paths.forEach(p => allSvgElements.push(
`<path d="${escapeXml(p.d)}" transform="${p.transform}"
stroke="#000000" stroke-width="0.6" fill="none"
stroke-linecap="round" stroke-linejoin="round"
vector-effect="non-scaling-stroke"/>`
));
currentY += lines.length * lineHeightPx;
totalLineCount += lines.length;
si++;
} else {
// Aufeinanderfolgende Signaturen als Zeile nebeneinander rendern
const sigRow = [];
while (si < segments.length && segments[si].type === 'sig') {
sigRow.push(segments[si]);
si++;
}
currentY += sigPaddingPx;
sigRow.forEach((sigSeg, slotIdx) => {
const svgString = signatures[sigSeg.key] || null;
if (svgString) {
const sigX = marginLeftPx + slotIdx * sigSlotW;
const sigEl = buildSignatureElement(svgString, sigX, currentY, sigScale);
if (sigEl) allSvgElements.push(sigEl);
}
});
currentY += sigHeightPx + sigPaddingPx;
totalLineCount += 2; // ganze Zeile zählt als 2 Zeilen, unabhängig von Anzahl
}
}
return {
svg: `<svg xmlns="http://www.w3.org/2000/svg"
width="${widthMm}mm" height="${heightMm}mm"
viewBox="0 0 ${widthPx} ${heightPx}">
<rect x="0" y="0" width="${widthPx}" height="${heightPx}" fill="#FFFFFF"/>
${allSvgElements.join('\n ')}
</svg>`.trim(),
lineCount: totalLineCount,
lineLimit,
overflow: totalLineCount > lineLimit,
};
}
/**
* Generiert einen Umschlag als SVG
* @param {string} text - Der scriptalisierte Text
* @param {string} format - Format: c6, din_lang
* @param {string} type - Typ: 'recipient' (links), 'free' (links, freie Adresse), 'custom' (mittig)
* @param {object} options - { font: 'tilda' }
*/
function generateEnvelopeSVG(text, format, type, options = {}) {
const fontKey = normalizeFontKey(options.font);
const fontConfig = SVG_FONT_CONFIG[fontKey];
const { widthPx, heightPx, widthMm, heightMm } = getPageDimensions(format);
const { marginTopPx, marginLeftPx, lineHeightPx } = getLayoutForFormat(format);
let lines;
let startX;
let startY;
if (type === 'custom') {
// Mittig zentriert, 70% der Umschlagsbreite
const maxWidthPx = widthPx * 0.7;
lines = wrapTextToLinesByWidth({
text: String(text || ''),
maxWidthPx,
fontFileName: fontConfig.file,
fontSizePx: FONT_SIZE_PX,
});
// Zentrierung berechnen
const totalTextHeight = lines.length * lineHeightPx;
startY = (heightPx - totalTextHeight) / 2 + FONT_SIZE_PX;
// Jede Zeile einzeln zentrieren
// startX wird später pro Zeile berechnet
startX = 0; // Dummy-Wert, wird für jede Zeile überschrieben
} else {
// type === 'recipient' / 'free': Links, max. 60% der Umschlagsbreite
const maxWidthPx = Math.min(widthPx - marginLeftPx - 20, widthPx * 0.6);
lines = wrapTextToLinesByWidth({
text: String(text || ''),
maxWidthPx,
fontFileName: fontConfig.file,
fontSizePx: FONT_SIZE_PX,
});
startX = marginLeftPx;
startY = marginTopPx + FONT_SIZE_PX;
}
let paths = [];
if (type === 'custom') {
// Bei custom type: Jede Zeile einzeln zentrieren
let currentY = startY;
for (const line of lines) {
const lineWidth = measureTextWidthPx({
text: line,
fontFileName: fontConfig.file,
fontSizePx: FONT_SIZE_PX
});
const centeredX = (widthPx - lineWidth) / 2;
const linePaths = layoutTextToPaths({
lines: [line],
fontFileName: fontConfig.file,
fontSizePx: FONT_SIZE_PX,
startX: centeredX,
startY: currentY,
lineHeightPx,
realisticHandwriting: options.realisticHandwriting ?? false,
});
paths.push(...linePaths);
currentY += lineHeightPx;
}
} else {
// Bei recipient type: Normal links ausrichten
paths = layoutTextToPaths({
lines,
fontFileName: fontConfig.file,
fontSizePx: FONT_SIZE_PX,
startX,
startY,
lineHeightPx,
realisticHandwriting: options.realisticHandwriting ?? false,
});
}
const pathElements = paths
.map(
(p) => `<path d="${escapeXml(p.d)}"
transform="${p.transform}"
stroke="#000000"
stroke-width="0.6"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
vector-effect="non-scaling-stroke"
/>`
)
.join('\n ');
const svg = `<svg xmlns="http://www.w3.org/2000/svg"
width="${widthMm}mm"
height="${heightMm}mm"
viewBox="0 0 ${widthPx} ${heightPx}">
<rect x="0" y="0" width="${widthPx}" height="${heightPx}" fill="#FFFFFF" />
${pathElements}
</svg>`;
// Overflow-Erkennung: zu viele Zeilen für die verfügbare Fläche
const overflowStartY = (type === 'custom')
? (heightPx - lines.length * lineHeightPx) / 2 + FONT_SIZE_PX
: marginTopPx + FONT_SIZE_PX;
const available = heightPx - overflowStartY - FONT_SIZE_PX * 0.5;
const lineLimit = Math.max(1, Math.floor(available / lineHeightPx) + 1);
const lineCount = lines.length;
return { svg: svg.trim(), overflow: lineCount > lineLimit, lineCount, lineLimit };
}
module.exports = {
generateLetterSVG,
generateEnvelopeSVG
};

View File

@@ -0,0 +1,53 @@
const express = require('express');
const cors = require('cors');
const config = require('./config');
const previewRoutes = require('./api/routes/preview-routes');
const orderRoutes = require('./api/routes/order-routes');
const healthRoutes = require('./api/routes/health-routes');
const errorHandler = require('./api/middleware/error-handler');
const requestLogger = require('./api/middleware/request-logger');
const app = express();
// Middleware
app.use(cors(config.cors));
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
app.use(requestLogger);
// Routes
app.use('/health', healthRoutes);
app.use('/api/preview', previewRoutes);
app.use('/api/order', orderRoutes);
// Error handling
app.use(errorHandler);
// Start server
app.listen(config.port, () => {
console.log('╔════════════════════════════════════════════════════════════╗');
console.log('║ Skrift Backend Server ║');
console.log('╚════════════════════════════════════════════════════════════╝');
console.log(`Environment: ${config.env}`);
console.log(`Port: ${config.port}`);
console.log(`Batch Size: Unlimited (auto-split to ${config.preview.scriptalizerBatchSize} for API)`);
console.log(`Cache: Disabled`);
console.log(`Rate Limit: Disabled`);
console.log(`Scriptalizer: ${config.scriptalizer.licenseKey ? 'Configured ✓' : 'Missing ✗'}`);
console.log('════════════════════════════════════════════════════════════');
console.log(`Server running at http://localhost:${config.port}`);
console.log('════════════════════════════════════════════════════════════\n');
});
// Graceful shutdown
process.on('SIGTERM', () => {
console.log('SIGTERM received, shutting down gracefully...');
process.exit(0);
});
process.on('SIGINT', () => {
console.log('\nSIGINT received, shutting down gracefully...');
process.exit(0);
});

View File

@@ -0,0 +1,283 @@
/**
* Ersetzt Platzhalter im Text
* @param {string} text - Text mit Platzhaltern wie [[Vorname]]
* @param {object} placeholders - Objekt mit Platzhalter-Werten {Vorname: 'Max', ...}
* @returns {string} - Text mit ersetzten Platzhaltern
*/
function replacePlaceholders(text, placeholders) {
if (!placeholders || typeof placeholders !== 'object') {
return text;
}
let result = text;
for (const [key, value] of Object.entries(placeholders)) {
const placeholder = `[[${key}]]`;
// Case-insensitive replacement: 'i' flag
const regex = new RegExp(placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
result = result.replace(regex, value || '');
}
return result;
}
/**
* Escaped einen Wert für CSV
*/
function escapeCSV(value) {
const str = String(value || '');
if (str.includes(',') || str.includes('\n') || str.includes('"')) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
}
/**
* Generiert CSV aus Platzhalter-Daten (für Briefe)
* @param {Array} letters - Array von Letter-Objekten mit placeholders
* @returns {string} - CSV-String
*/
function generatePlaceholderCSV(letters) {
if (!Array.isArray(letters) || letters.length === 0) {
return '';
}
// Nur Briefe filtern (keine Umschläge)
const letterItems = letters.filter(l => l.type !== 'envelope');
if (letterItems.length === 0) {
return '';
}
// Sammle alle unique Keys
const allKeys = new Set();
letterItems.forEach(letter => {
if (letter.placeholders) {
Object.keys(letter.placeholders).forEach(key => allKeys.add(key));
}
});
if (allKeys.size === 0) {
return '';
}
const keys = ['BriefNr', ...Array.from(allKeys).sort()];
// Header
const header = keys.join(',');
// Rows
const rows = letterItems.map((letter, index) => {
const briefNr = String(letter.index !== undefined ? letter.index : index).padStart(3, '0');
const values = [briefNr];
for (let i = 1; i < keys.length; i++) {
const key = keys[i];
const value = letter.placeholders?.[key] || '';
values.push(escapeCSV(value));
}
return values.join(',');
});
return [header, ...rows].join('\n');
}
/**
* Generiert CSV für Empfängerdaten (Umschläge im recipientData-Modus)
* @param {Array} letters - Array von Letter-Objekten
* @returns {string} - CSV-String
*/
function generateRecipientCSV(letters) {
if (!Array.isArray(letters) || letters.length === 0) {
return '';
}
// Nur Umschläge im recipient-Modus filtern
const envelopes = letters.filter(l => l.type === 'envelope' && l.envelopeType === 'recipient');
if (envelopes.length === 0) {
return '';
}
// Prüfe ob Empfängerdaten vorhanden sind (vorname, name, strasse, etc.)
const recipientKeys = ['vorname', 'name', 'strasse', 'hausnummer', 'plz', 'ort', 'land'];
const hasRecipientData = envelopes.some(env =>
env.placeholders && recipientKeys.some(key => env.placeholders[key])
);
if (!hasRecipientData) {
return '';
}
const keys = ['UmschlagNr', ...recipientKeys];
const header = keys.join(',');
const rows = envelopes.map((envelope, index) => {
const umschlagNr = String(envelope.index !== undefined ? envelope.index : index).padStart(3, '0');
const values = [umschlagNr];
for (let i = 1; i < keys.length; i++) {
const key = keys[i];
const value = envelope.placeholders?.[key] || '';
values.push(escapeCSV(value));
}
return values.join(',');
});
return [header, ...rows].join('\n');
}
/**
* Generiert CSV für freie Adressen (Umschläge im free-Modus)
* @param {Array} letters - Array von Letter-Objekten
* @returns {string} - CSV-String
*/
function generateFreeAddressCSV(letters) {
if (!Array.isArray(letters) || letters.length === 0) {
return '';
}
// Nur Umschläge im free-Modus filtern
const envelopes = letters.filter(l => l.type === 'envelope' && l.envelopeType === 'free');
if (envelopes.length === 0) {
return '';
}
// Freie Adressen haben bis zu 5 Zeilen
const keys = ['UmschlagNr', 'Zeile1', 'Zeile2', 'Zeile3', 'Zeile4', 'Zeile5'];
const header = keys.join(',');
const rows = envelopes.map((envelope, index) => {
const umschlagNr = String(envelope.index !== undefined ? envelope.index : index).padStart(3, '0');
// Text in Zeilen aufteilen
const textLines = (envelope.text || '').split('\n');
const values = [umschlagNr];
for (let i = 0; i < 5; i++) {
values.push(escapeCSV(textLines[i] || ''));
}
return values.join(',');
});
return [header, ...rows].join('\n');
}
/**
* Generiert CSV für Umschlag-Platzhalter (Umschläge im customText-Modus)
* @param {Array} letters - Array von Letter-Objekten
* @returns {string} - CSV-String
*/
function generateEnvelopePlaceholderCSV(letters) {
if (!Array.isArray(letters) || letters.length === 0) {
console.log('[CSV] generateEnvelopePlaceholderCSV: No letters');
return '';
}
// Nur Umschläge im custom-Modus filtern
const envelopes = letters.filter(l => l.type === 'envelope' && l.envelopeType === 'custom');
console.log('[CSV] Found custom envelopes:', envelopes.length);
console.log('[CSV] Envelope details:', envelopes.map(e => ({ type: e.type, envelopeType: e.envelopeType, placeholders: e.placeholders })));
if (envelopes.length === 0) {
return '';
}
// Sammle alle unique Keys (außer Standard-Empfängerfelder)
const recipientKeys = ['vorname', 'name', 'strasse', 'hausnummer', 'plz', 'ort', 'land'];
const allKeys = new Set();
envelopes.forEach(envelope => {
if (envelope.placeholders) {
console.log('[CSV] Envelope placeholders keys:', Object.keys(envelope.placeholders));
Object.keys(envelope.placeholders).forEach(key => {
// Nur nicht-Empfängerfelder sammeln
if (!recipientKeys.includes(key.toLowerCase())) {
allKeys.add(key);
console.log('[CSV] Added key:', key);
} else {
console.log('[CSV] Skipped recipient key:', key);
}
});
}
});
console.log('[CSV] All collected keys:', Array.from(allKeys));
if (allKeys.size === 0) {
console.log('[CSV] No custom placeholder keys found for envelopes');
return '';
}
const keys = ['UmschlagNr', ...Array.from(allKeys).sort()];
const header = keys.join(',');
const rows = envelopes.map((envelope, index) => {
const umschlagNr = String(envelope.index !== undefined ? envelope.index : index).padStart(3, '0');
const values = [umschlagNr];
for (let i = 1; i < keys.length; i++) {
const key = keys[i];
const value = envelope.placeholders?.[key] || '';
values.push(escapeCSV(value));
}
return values.join(',');
});
return [header, ...rows].join('\n');
}
/**
* Generiert alle CSV-Dateien für eine Bestellung
* @param {Array} letters - Array von Letter-Objekten (Briefe und Umschläge)
* @returns {Object} - Objekt mit CSV-Inhalten { placeholders, recipients, envelopePlaceholders, freeAddresses }
*/
function generateAllCSVs(letters) {
const result = {
placeholders: null, // Brief-Platzhalter
recipients: null, // Empfängerdaten für Umschläge (recipientData-Modus)
envelopePlaceholders: null, // Umschlag-Platzhalter (customText-Modus)
freeAddresses: null // Freie Adressen für Umschläge (free-Modus)
};
if (!Array.isArray(letters) || letters.length === 0) {
return result;
}
// Brief-Platzhalter CSV
const placeholderCSV = generatePlaceholderCSV(letters);
if (placeholderCSV) {
result.placeholders = placeholderCSV;
}
// Empfänger CSV (für recipientData-Modus)
const recipientCSV = generateRecipientCSV(letters);
if (recipientCSV) {
result.recipients = recipientCSV;
}
// Umschlag-Platzhalter CSV (für customText-Modus)
const envelopePlaceholderCSV = generateEnvelopePlaceholderCSV(letters);
if (envelopePlaceholderCSV) {
result.envelopePlaceholders = envelopePlaceholderCSV;
}
// Freie Adressen CSV (für free-Modus)
const freeAddressCSV = generateFreeAddressCSV(letters);
if (freeAddressCSV) {
result.freeAddresses = freeAddressCSV;
}
return result;
}
module.exports = {
replacePlaceholders,
generatePlaceholderCSV,
generateRecipientCSV,
generateEnvelopePlaceholderCSV,
generateFreeAddressCSV,
generateAllCSVs,
escapeCSV
};

View File

@@ -0,0 +1,230 @@
const https = require("https");
const { parseStringPromise } = require("xml2js");
const config = require("../config");
// API Request Counter (resets daily)
const stats = {
requestsToday: 0,
textsToday: 0,
lastResetDate: new Date().toDateString(),
};
/**
* Prüft ob der Zähler zurückgesetzt werden muss (neuer Tag)
*/
function checkAndResetDailyStats() {
const today = new Date().toDateString();
if (stats.lastResetDate !== today) {
console.log(`[Scriptalizer] 📅 New day detected, resetting counters`);
console.log(`[Scriptalizer] 📊 Yesterday's stats: ${stats.requestsToday} API calls, ${stats.textsToday} texts processed`);
stats.requestsToday = 0;
stats.textsToday = 0;
stats.lastResetDate = today;
}
}
/**
* Gibt die aktuellen Stats zurück
*/
function getStats() {
checkAndResetDailyStats();
return { ...stats };
}
/**
* Ruft die Scriptalizer API auf
* @param {string} inputText - Text zum Scriptalisieren
* @param {string} fontName - Font-Name (z.B. 'PremiumUltra79')
* @param {number} errFrequency - Fehlerfrequenz
* @returns {Promise<{status: string, outputText: string}>}
*/
async function callScriptalizer(inputText, fontName, errFrequency = 10) {
// Prüfe ob neuer Tag
checkAndResetDailyStats();
return new Promise((resolve, reject) => {
if (!config.scriptalizer.licenseKey) {
reject(new Error("Scriptalizer License Key fehlt in Konfiguration"));
return;
}
// Use x-www-form-urlencoded format
const params = new URLSearchParams();
params.append("LicenseKey", config.scriptalizer.licenseKey);
params.append("FontName", fontName);
params.append("ErrFrequency", String(errFrequency));
params.append("InputText", inputText);
const body = params.toString();
// Check input size (48KB limit)
if (Buffer.byteLength(body) > config.scriptalizer.maxInputSize) {
reject(
new Error(
`Input size exceeds 48KB limit (${Buffer.byteLength(body)} bytes)`
)
);
return;
}
const options = {
hostname: "www.scriptalizer.co.uk",
port: 443,
path: "/QuantumScriptalize.asmx/Scriptalize",
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
"Content-Length": Buffer.byteLength(body),
},
timeout: 30000, // 30 second timeout
};
const req = https.request(options, (res) => {
let data = "";
res.on("data", (chunk) => {
data += chunk;
});
res.on("end", async () => {
try {
const result = await parseStringPromise(data, {
explicitArray: false,
});
const response = result.ScriptalizerResponse;
if (!response || response.Status !== "OK") {
reject(new Error(response?.Status || "UNKNOWN_STATUS"));
return;
}
// Zähler erhöhen bei erfolgreichem Request
stats.requestsToday++;
console.log(`[Scriptalizer] 📊 API Request #${stats.requestsToday} today completed`);
resolve({ status: "OK", outputText: response.OutputText });
} catch (err) {
reject(
new Error(`Failed to parse Scriptalizer response: ${err.message}`)
);
}
});
});
req.on("error", (err) => {
reject(new Error(`Scriptalizer request failed: ${err.message}`));
});
req.on("timeout", () => {
req.destroy();
reject(new Error("Scriptalizer request timed out"));
});
req.write(body);
req.end();
});
}
/**
* Scriptalisiert mehrere Texte in 25er Batches
* @param {string[]} texts - Array von Texten
* @param {string} font - Font-Key (tilda, alva, ellie)
* @param {number} errFrequency - Fehlerfrequenz
* @returns {Promise<string[]>} - Array von scriptalisierten Texten
*/
async function scriptalizeBatch(texts, font = "tilda", errFrequency = 10) {
if (!Array.isArray(texts) || texts.length === 0) {
return [];
}
const fontName =
config.scriptalizer.fontMap[font.toLowerCase()] ||
config.scriptalizer.fontMap.tilda;
const separator = config.scriptalizer.separator;
const BATCH_SIZE = 18; // 18 Texte pro Scriptalizer API Call (Scriptalizer-API-Limit: 32000 Zeichen pro Call → ca. 1700 Zeichen pro Brief sicher)
console.log(
`[Scriptalizer] Processing ${texts.length} texts in batches of ${BATCH_SIZE}`
);
const allScriptalized = [];
// Teile in 25er Batches auf
for (let i = 0; i < texts.length; i += BATCH_SIZE) {
const batch = texts.slice(i, i + BATCH_SIZE);
const batchNumber = Math.floor(i / BATCH_SIZE) + 1;
const totalBatches = Math.ceil(texts.length / BATCH_SIZE);
console.log(
`[Scriptalizer] Processing batch ${batchNumber}/${totalBatches} (${
batch.length
} texts, indices ${i}-${i + batch.length - 1})`
);
// Kombiniere Texte mit Separator
const combinedText = batch.join(separator);
try {
const result = await callScriptalizer(
combinedText,
fontName,
errFrequency
);
// Trenne Ergebnis wieder
const scriptalized = result.outputText.split(separator);
if (scriptalized.length !== batch.length) {
console.warn(
`[Scriptalizer] Batch ${batchNumber}: returned ${scriptalized.length} parts, expected ${batch.length}`
);
}
allScriptalized.push(...scriptalized);
console.log(
`[Scriptalizer] Batch ${batchNumber}/${totalBatches} completed successfully`
);
} catch (err) {
console.error(`[Scriptalizer] Batch ${batchNumber} failed:`, err.message);
throw err;
}
}
// Textzähler erhöhen
stats.textsToday += allScriptalized.length;
console.log(
`[Scriptalizer] All batches completed. Total scriptalized: ${allScriptalized.length}`
);
console.log(
`[Scriptalizer] 📊 Daily stats: ${stats.requestsToday} API calls, ${stats.textsToday} texts processed`
);
return allScriptalized;
}
/**
* Scriptalisiert einen einzelnen Text
* @param {string} text - Text zum Scriptalisieren
* @param {string} font - Font-Key (tilda, alva, ellie)
* @param {number} errFrequency - Fehlerfrequenz
* @returns {Promise<string>} - Scriptalisierter Text
*/
async function scriptalizeSingle(text, font = "tilda", errFrequency = 10) {
const fontName =
config.scriptalizer.fontMap[font.toLowerCase()] ||
config.scriptalizer.fontMap.tilda;
try {
const result = await callScriptalizer(text, fontName, errFrequency);
return result.outputText;
} catch (err) {
console.error("Scriptalizer single call failed:", err.message);
throw err;
}
}
module.exports = {
scriptalizeBatch,
scriptalizeSingle,
getStats,
};

View File

@@ -0,0 +1,476 @@
# Frontend-Backend Integration - Zusammenfassung
Komplette Übersicht über die Integration des WordPress Frontends mit dem Node.js Backend.
## Änderungen in diesem Chat
### ❌ Keine Frontend-Änderungen besprochen
In diesem Chat lag der Fokus komplett auf dem **Backend**:
- Backend-Entwicklung (Node.js/Express)
- Docker-Deployment
- Scriptalizer API Integration
- SVG-Generierung mit Variationen
Das Frontend (WordPress Plugin) wurde **jetzt** für das Backend angepasst.
## Neue Dateien im WordPress Plugin
### 1. `assets/js/configurator-api.js`
**Backend API Client**
Funktionen:
- `healthCheck()` - Prüft ob Backend erreichbar ist
- `generatePreviewBatch(letters)` - Generiert Preview von Briefen
- `getPreviewUrl(sessionId, index)` - Holt Preview-URL
- `finalizeOrder(sessionId, orderNumber, metadata)` - Finalisiert Order aus Preview
- `generateOrder(orderNumber, letters, envelopes, metadata)` - Erstellt Order direkt
- `generateOrderNumber()` - Generiert Bestellnummer (SK-YYYY-MM-DD-XXX)
### 2. `assets/js/configurator-backend-integration.js`
**Integration Logic**
Funktionen:
- `handleOrderSubmitWithBackend(state)` - Erweiterte Order-Submit mit Backend
- `prepareLettersForBackend(state)` - Bereitet Letter-Daten vor
- `prepareEnvelopesForBackend(state)` - Bereitet Envelope-Daten vor
- `mapFontToBackend(font)` - Mappt Frontend-Font zu Backend
- `mapFormatToBackend(format)` - Mappt Frontend-Format zu Backend
### 3. `BACKEND_INTEGRATION.md`
**Vollständige Integrations-Dokumentation**
Inhalt:
- WordPress Admin-Einstellungen Anleitung
- Backend-API Endpunkte Dokumentation
- Workflow-Beschreibungen
- Datenmapping-Tabellen
- Troubleshooting Guide
- Testing-Anleitungen
### 4. `README.md`
**Plugin-Dokumentation**
Inhalt:
- Features-Übersicht
- Installations-Anleitung
- Konfigurations-Guide
- Datei-Struktur
- API Integration
- Workflow-Diagramme
- Troubleshooting
- Changelog
## WordPress Admin-Einstellungen
### Bereits vorhanden (keine Änderung nötig!)
Die Backend-Verbindungseinstellungen waren bereits im Plugin vorbereitet:
**Einstellungen → Skrift Konfigurator → Backend-Verbindung:**
1. **API URL / Domain**
- Beispiel: `https://backend.deine-domain.de`
- Pflichtfeld für Backend-Integration
2. **API Token / Authentifizierung**
- Optional (aktuell nicht genutzt)
- Für zukünftige Erweiterungen
3. **Order Webhook URL**
- Beispiel: `https://n8n.deine-domain.de/webhook/order`
- Wird nach Bestellung aufgerufen
4. **Redirect URL Geschäftskunden**
- Beispiel: `https://deine-domain.de/danke-business`
- Wohin nach Business-Bestellung
5. **Redirect URL Privatkunden**
- Beispiel: `https://deine-domain.de/danke-privat`
- Wohin nach Privat-Bestellung
## Integration in bestehendes Frontend
### Wie funktioniert es?
Das neue Backend-System wird **automatisch** genutzt, wenn:
1. Backend-URL in WordPress-Einstellungen gesetzt ist
2. Backend erreichbar ist (Health-Check erfolgreich)
### Fallback-Logik
Falls Backend nicht erreichbar:
- Plugin fällt zurück auf alte Webhook-Only Logik
- Bestellung wird trotzdem durchgeführt
- Aber: Keine SVG-Generierung
### Was ändert sich für den User?
**Nichts!** Der Konfigurator funktioniert genau gleich:
1. Produkt auswählen
2. Menge eingeben
3. Format wählen
4. Versand & Umschlag
5. Inhalt eingeben
6. Kundendaten
7. Bestellen → **Jetzt mit Backend-Generierung!**
## Datenmapping
### Fonts
| WordPress | Backend | Scriptalizer |
|-----------|---------|--------------|
| tilda | tilda | PremiumUltra79 |
| alva | alva | PremiumUltra23 |
| ellie | ellie | PremiumUltra39 |
### Formate
| WordPress | Backend | Papier |
|-----------|---------|--------|
| a4 | A4 | 210×297mm |
| a6p | A6_PORTRAIT | 105×148mm |
| a6l | A6_LANDSCAPE | 148×105mm |
### Envelopes
| Brief-Format | Envelope-Format | Größe |
|--------------|-----------------|-------|
| A4 | DIN_LANG | 110×220mm |
| A6 | C6 | 114×162mm |
## Workflow-Übersicht
### Business-Kunde (B2B)
```
┌─────────────────────────────────────────────────────────┐
│ 1. Kunde füllt Konfigurator aus │
│ - Businessbriefe / Business Postkarten / Follow-ups │
│ - Menge, Format, Versand, Umschlag │
│ - Text-Inhalt │
│ - Kundendaten │
└─────────────────────────┬───────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ 2. Klick auf "Jetzt kostenpflichtig bestellen" │
└─────────────────────────┬───────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ 3. WordPress Plugin │
│ - generateOrderNumber() → SK-2026-01-03-001 │
│ - prepareLettersForBackend(state) │
│ - prepareEnvelopesForBackend(state) │
└─────────────────────────┬───────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ 4. Backend API Call │
│ POST /api/order/generate │
│ { │
│ orderNumber: "SK-2026-01-03-001", │
│ letters: [...], │
│ envelopes: [...], │
│ metadata: {...} │
│ } │
└─────────────────────────┬───────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ 5. Node.js Backend │
│ - Scriptalizer API Call (Batch-Processing) │
│ - SVG-Generierung mit Variationen │
│ - Dateien speichern in /var/skrift-output/ │
│ - Placeholders.csv erstellen │
│ - order-metadata.json erstellen │
└─────────────────────────┬───────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ 6. Webhook aufrufen (optional) │
│ POST https://n8n.deine-domain.de/webhook/order │
│ { │
│ orderNumber: "SK-2026-01-03-001", │
│ customer_data: {...}, │
│ backend_result: { │
│ path: "/var/skrift-output/SK-2026-01-03-001", │
│ files: [...] │
│ } │
│ } │
└─────────────────────────┬───────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ 7. Weiterleitung │
│ → https://domain.de/danke-business?orderNumber=SK-.. │
└─────────────────────────────────────────────────────────┘
```
### Privat-Kunde (B2C) - Später mit PayPal
```
1. Kunde füllt Konfigurator aus
2. Klick auf "Jetzt kostenpflichtig bestellen"
3. → PayPal Checkout
4. PayPal Zahlung erfolgreich
5. PayPal Webhook → WordPress
6. WordPress → Backend API (generateOrder)
7. Backend generiert SVG-Dateien
8. Webhook aufrufen
9. Weiterleitung zu Danke-Seite
```
## Generierte Dateien
Für jede Bestellung erstellt das Backend:
```
/var/skrift-output/SK-2026-01-03-001/
├── letter_000.svg # Brief 1
├── letter_001.svg # Brief 2
├── ...
├── letter_099.svg # Brief 100
├── envelope_000.svg # Umschlag 1 (falls gewünscht)
├── envelope_001.svg # Umschlag 2
├── ...
├── envelope_099.svg # Umschlag 100
├── placeholders.csv # Platzhalter-Daten (CSV)
└── order-metadata.json # Bestellungs-Metadaten
```
### order-metadata.json
```json
{
"orderNumber": "SK-2026-01-03-001",
"generatedAt": "2026-01-03T12:34:56.789Z",
"summary": {
"totalLetters": 100,
"totalEnvelopes": 100,
"fonts": ["tilda"],
"formats": ["A4"]
},
"metadata": {
"customer": {
"type": "business",
"firstName": "Max",
"lastName": "Mustermann",
"company": "Beispiel GmbH",
"email": "max@example.com"
},
"product": "businessbriefe",
"quantity": 100
}
}
```
### placeholders.csv
```csv
PlaceholderName,Value
Anrede,Sehr geehrte Damen und Herren
Firma,Beispiel GmbH
Ansprechpartner,Max Mustermann
```
## Wichtige Konfigurationen
### WordPress Plugin
**Datei:** `WordPress Plugin/includes/admin-settings.php`
Bereits vorhanden:
- Backend-Verbindungseinstellungen
- Preiskonfiguration
- Produktverwaltung
- Gutschein-System
**Keine Änderungen nötig!**
### Node.js Backend
**Datei:** `Docker Backend/.env`
```bash
SCRIPTALIZER_LICENSE_KEY=f9918b40-d11c-11f0-b558-0800200c9a66
SCRIPTALIZER_ERR_FREQUENCY=0 # WICHTIG: Keine durchgestrichenen Wörter!
BATCH_SIZE=30
CACHE_LIFETIME_HOURS=2
RATE_LIMIT_PER_MINUTE=2
NODE_ENV=production
```
**Datei:** `Docker Backend/src/lib/svg-font-engine.js`
Handschrift-Variationen:
- **15% Wortabstand-Variation** (mit Sinuswelle)
- **±2.5° Wort-Rotation** (für natürliche Schräglage)
## Testing
### 1. Backend Health-Check
```bash
# Auf Server
curl http://localhost:4000/health
# Von außen
curl https://backend.deine-domain.de/health
# Erwartete Antwort:
{"status":"ok","timestamp":"2026-01-03T..."}
```
### 2. WordPress Browser-Console
```javascript
// Backend API testen
const api = window.SkriftBackendAPI;
// Health-Check
const healthy = await api.healthCheck();
console.log('Backend healthy:', healthy);
// Test-Order
const result = await api.generateOrder(
api.generateOrderNumber(),
[{
text: 'Liebe Oma, vielen Dank für das schöne Geschenk!',
font: 'tilda',
format: 'A4',
placeholders: {}
}],
[{
type: 'recipient',
recipientAddress: {
name: 'Frau Schmidt',
street: 'Hauptstraße 123',
zip: '12345',
city: 'Berlin'
},
font: 'tilda',
format: 'DIN_LANG'
}],
{
customer: {
type: 'business',
firstName: 'Test',
lastName: 'User',
email: 'test@example.com'
}
}
);
console.log('Order result:', result);
```
### 3. Vollständiger Test-Workflow
1. WordPress Konfigurator öffnen
2. Produkt wählen (z.B. Businessbriefe)
3. Menge: 5
4. Format: A4
5. Versand: Direktversand
6. Umschlag: Ja, mit Empfängeradresse
7. Text eingeben
8. Kundendaten eingeben
9. "Jetzt kostenpflichtig bestellen" klicken
10. Prüfen:
- Browser-Console: Keine Fehler
- Backend-Logs: `docker compose logs -f`
- Dateien erstellt: `ls /var/skrift-output/SK-*`
- SVGs korrekt: Keine durchgestrichenen Wörter
- Webhook aufgerufen (falls konfiguriert)
## Deployment-Reihenfolge
### 1. Backend deployen
```bash
# Siehe: Docker Backend/DEPLOYMENT_READY/START_HIER.txt
# Upload auf Server
scp -r DEPLOYMENT_READY/* root@SERVER:/opt/skrift-backend/
# Auf Server
ssh root@SERVER
cd /opt/skrift-backend
cp .env.example .env
mkdir -p /var/skrift-output
docker compose up -d --build
```
### 2. Nginx Proxy Manager konfigurieren
- Domain: `backend.deine-domain.de`
- Forward to: `skrift-backend:4000`
- SSL: Let's Encrypt
### 3. WordPress Plugin hochladen
```bash
# Per FTP/SSH
scp -r "WordPress Plugin" root@SERVER:/var/www/html/wp-content/plugins/skrift-konfigurator/
```
### 4. WordPress Plugin aktivieren
- WordPress Admin → Plugins
- "Skrift Konfigurator" aktivieren
### 5. Einstellungen konfigurieren
- Einstellungen → Skrift Konfigurator
- Backend-Verbindung:
- API URL: `https://backend.deine-domain.de`
- Webhook URL: `https://n8n.deine-domain.de/webhook/order` (optional)
- Redirect URLs setzen
- Speichern
### 6. Seite mit Konfigurator erstellen
- Neue Seite: "Konfigurator"
- Shortcode: `[skrift_konfigurator]`
- Veröffentlichen
### 7. Testen!
## Troubleshooting
Siehe:
- `WordPress Plugin/BACKEND_INTEGRATION.md`
- `Docker Backend/DEPLOYMENT_READY/SERVER_SETUP.txt`
- `Docker Backend/DEPLOYMENT_READY/CHECKLISTE.txt`
## Zusammenfassung
### ✅ Was ist fertig?
- ✅ Backend komplett entwickelt (Node.js + Docker)
- ✅ Frontend-Backend Integration implementiert
- ✅ API Client für WordPress
- ✅ Dokumentation vollständig
- ✅ Deployment-Package bereit
- ✅ Handschrift-Variationen (15% Abstand, ±2.5° Rotation)
- ✅ Keine durchgestrichenen Wörter (SCRIPTALIZER_ERR_FREQUENCY=0)
### 🚧 Was fehlt noch?
- [ ] Backend auf Server deployen
- [ ] WordPress Einstellungen konfigurieren
- [ ] Vollständigen Test-Durchlauf
- [ ] PayPal-Integration für Privatkunden
- [ ] Email-Benachrichtigungen
- [ ] N8N Workflow für Plotter-Übertragung
### 🎯 Nächste Schritte
1. **Backend deployen** (siehe `DEPLOYMENT_READY/START_HIER.txt`)
2. **WordPress-Einstellungen** konfigurieren
3. **Test-Bestellung** aufgeben
4. **Prüfen** ob SVG-Dateien korrekt generiert werden
5. **N8N Workflow** einrichten (optional)
6. **Go Live!** 🚀

View File

@@ -0,0 +1,67 @@
"""
AutoRecover - Drucker Fehler Handler
Erkennt das "Error"-Fenster (Java AWT) und klickt automatisch "Recover" (max. 3x).
"""
import win32gui
import win32con
import ctypes
import time
MAX_VERSUCHE = 3
PRUEF_INTERVAL = 2 # Sekunden zwischen den Checks
def click_recover_button(hwnd):
"""
Holt die Fensterposition und klickt auf den Recover-Button.
Der Button ist zentriert am unteren Rand des Fensters.
"""
# Fenster in den Vordergrund holen
win32gui.SetForegroundWindow(hwnd)
time.sleep(0.3)
# Fensterposition und -größe ermitteln
left, top, right, bottom = win32gui.GetWindowRect(hwnd)
width = right - left
height = bottom - top
# Recover-Button liegt ca. bei: horizontal zentriert, ~85% der Höhe
click_x = left + width // 2
click_y = top + int(height * 0.85)
# Mausklick simulieren
ctypes.windll.user32.SetCursorPos(click_x, click_y)
time.sleep(0.1)
ctypes.windll.user32.mouse_event(0x0002, 0, 0, 0, 0) # linke Taste runter
ctypes.windll.user32.mouse_event(0x0004, 0, 0, 0, 0) # linke Taste hoch
print(f" → Klick auf Position ({click_x}, {click_y})")
def main():
versuche = 0
print("AutoRecover läuft... (Strg+C zum Beenden)")
print(f"Maximal {MAX_VERSUCHE} Versuche.\n")
while versuche < MAX_VERSUCHE:
hwnd = win32gui.FindWindow(None, "Error")
if hwnd and win32gui.IsWindowVisible(hwnd):
versuche += 1
print(f"[Versuch {versuche}/{MAX_VERSUCHE}] Fehlerfenster gefunden!")
time.sleep(0.5)
click_recover_button(hwnd)
if versuche >= MAX_VERSUCHE:
print(f"\nMaximale Versuche ({MAX_VERSUCHE}) erreicht. Programm beendet.")
break
time.sleep(3)
else:
time.sleep(PRUEF_INTERVAL)
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\nProgramm manuell beendet.")

View File

@@ -0,0 +1,11 @@
@echo off
echo PyInstaller wird installiert falls noetig...
pip install pyinstaller pywin32 --quiet
echo.
echo Erstelle AutoRecover.exe ...
python -m PyInstaller --onefile --noconsole --name AutoRecover auto_recover.py
echo.
echo Fertig! Die EXE liegt in: dist\AutoRecover.exe
pause

View File

@@ -0,0 +1,27 @@
"""Debug: Zeigt alle Elemente im Error-Fenster"""
import win32gui
def dump_window(hwnd, indent=0):
try:
class_name = win32gui.GetClassName(hwnd)
text = win32gui.GetWindowText(hwnd)
print(" " * indent + f'[{class_name}] "{text}" (hwnd={hwnd})')
except:
pass
try:
def callback(child_hwnd, _):
dump_window(child_hwnd, indent + 4)
win32gui.EnumChildWindows(hwnd, callback, None)
except:
pass
hwnd = win32gui.FindWindow(None, "Error")
if hwnd:
print(f"Fenster gefunden: hwnd={hwnd}\n")
dump_window(hwnd)
else:
print("Kein Fenster mit Titel 'Error' gefunden!")
print("Bitte Fehlerfenster öffnen und nochmal ausführen.")
input("\nEnter zum Beenden...")

View File

@@ -0,0 +1,46 @@
"""
Schritt 1: Fenster-Erkennung
Lass den Druckerfehler aufpoppen, dann starte dieses Skript.
Es zeigt alle sichtbaren Fenster und deren Buttons an.
"""
import win32gui
import win32con
import win32api
def get_child_controls(hwnd):
controls = []
def callback(child_hwnd, _):
class_name = win32gui.GetClassName(child_hwnd)
text = win32gui.GetWindowText(child_hwnd)
if class_name == "Button" and text:
controls.append(f' Button: "{text}"')
elif text:
controls.append(f' {class_name}: "{text}"')
win32gui.EnumChildWindows(hwnd, callback, None)
return controls
def list_windows():
windows = []
def callback(hwnd, _):
if win32gui.IsWindowVisible(hwnd):
title = win32gui.GetWindowText(hwnd)
if title:
controls = get_child_controls(hwnd)
windows.append((title, controls))
win32gui.EnumWindows(callback, None)
return windows
print("=" * 60)
print("Gefundene Fenster mit Buttons:")
print("=" * 60)
for title, controls in list_windows():
if controls:
print(f'\nFenster: "{title}"')
for c in controls:
print(c)
print("\n" + "=" * 60)
print("Kopiere den Fenstertitel und Button-Namen für AutoRecover.")
input("\nEnter drücken zum Beenden...")

View File

@@ -0,0 +1,67 @@
@echo off
title Skrift FTP-Checker – Einrichtung
color 0A
cd /d "%~dp0"
echo.
echo ============================================
echo Skrift FTP-Checker – Einrichtung
echo ============================================
echo.
:: Python pruefen
python --version >nul 2>&1
if errorlevel 1 (
color 0C
echo FEHLER: Python wurde nicht gefunden!
echo.
echo Bitte Python von https://python.org herunterladen,
echo installieren und dabei "Add Python to PATH" aktivieren.
echo.
pause
exit /b 1
)
python --version
echo Python gefunden.
echo.
:: Virtuelle Umgebung erstellen falls nicht vorhanden
if exist ".venv\Scripts\python.exe" (
echo Virtuelle Umgebung bereits vorhanden.
) else (
echo Erstelle virtuelle Umgebung...
python -m venv .venv
if errorlevel 1 (
color 0C
echo FEHLER: Konnte .venv nicht erstellen!
pause
exit /b 1
)
echo Fertig.
)
echo.
:: Abhaengigkeiten installieren
echo Installiere / aktualisiere Abhaengigkeiten...
.venv\Scripts\pip.exe install -r requirements.txt -q --disable-pip-version-check
if errorlevel 1 (
color 0C
echo.
echo FEHLER: Installation fehlgeschlagen!
echo Bitte Internetverbindung pruefen und erneut versuchen.
echo.
pause
exit /b 1
)
echo Fertig.
echo.
color 0A
echo ============================================
echo Einrichtung abgeschlossen!
echo ============================================
echo.
echo Doppelklick auf "Starten.vbs" zum Starten.
echo.
timeout /t 3 >nul

View File

@@ -0,0 +1,23 @@
@echo off
title Skrift FTP-Checker – Debug
cd /d "%~dp0"
:: Laufende Instanz auf Port 8765 beenden
for /f "tokens=5" %%a in ('netstat -ano 2^>nul ^| findstr ":8765 " ^| findstr "LISTENING"') do (
taskkill /PID %%a /F >nul 2>&1
)
if exist ".venv\Scripts\python.exe" (
echo Starte mit .venv Python...
.venv\Scripts\python.exe main.py
) else (
echo HINWEIS: .venv nicht gefunden. Bitte zuerst Einrichten.bat ausfuehren.
echo Versuche System-Python...
python main.py
)
if errorlevel 1 (
echo.
echo Programm mit Fehler beendet. Siehe Meldung oben.
pause
)

View File

@@ -0,0 +1,40 @@
@echo off
title Skrift FTP-Checker
cd /d "%~dp0"
:: ── venv pruefen / erstellen ─────────────────────────────────────────
if exist ".venv\Scripts\python.exe" goto :deps
echo [Setup] Virtuelle Umgebung wird erstellt...
python -m venv .venv
if errorlevel 1 (
echo.
echo FEHLER: Python nicht gefunden!
echo Bitte Python von https://python.org installieren
echo und dabei "Add Python to PATH" aktivieren.
echo.
pause
exit /b 1
)
echo Erledigt.
:deps
:: ── Abhaengigkeiten installieren (immer) ─────────────────────────────
echo [Setup] Pakete werden geprueft / installiert...
.venv\Scripts\pip.exe install -r requirements.txt -q --disable-pip-version-check
if errorlevel 1 (
echo.
echo FEHLER: Pakete konnten nicht installiert werden.
pause
exit /b 1
)
:run
:: ── Alte Instanz auf Port 8765 beenden ───────────────────────────────
for /f "tokens=5" %%a in ('netstat -ano 2^>nul ^| findstr ":8765 " ^| findstr "LISTENING"') do (
taskkill /PID %%a /F >nul 2>&1
)
:: ── App in eigenem Fenster starten (laeuft weiter wenn dieses Fenster zu) ──
echo Skrift FTP-Checker wird gestartet...
start "Skrift FTP-Checker" .venv\Scripts\python.exe main.py

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
from enum import Enum
class OrderStatus(str, Enum):
NEW = "new"
QUEUED = "queued"
SENDING = "sending"
SENT = "sent"
ERROR = "error"
ARCHIVED = "archived"
class MachineStatus(str, Enum):
IDLE = "Idle"
PRINTING = "Printing"
ERROR = "Error"
PAUSED = "Paused"
UNKNOWN = "Unknown"
MACHINE_ERROR_CODES = {
0: "Erfolgreich",
-1: "Parameterfehler",
-2: "Keine Maschine verfügbar",
-3: "Service beschäftigt",
1: "Fehler beim Generieren der Schreibdatei",
2: "Bitte erneut in der Software einloggen",
}

View File

@@ -0,0 +1,176 @@
import json
import os
import copy
from typing import Any
DEFAULT_CONFIG = {
"format_mapping": {
"letter": {
"A4": "a4_single",
"A6 Hochformat": "a6_v1",
"A6 Querformat": "a6_v2",
},
"envelope": {
"DIN Lang": "kuvert_a_v1",
"C6": "kuvert_b_v1",
},
},
"connection": {
"protocol": "sftp",
"host": "",
"port": 22,
"username": "",
"password": "",
"private_key_path": "",
"remote_path": "/orders/",
"poll_interval_seconds": 30,
},
"machine": {
"base_url": "http://192.168.2.32:90",
"username": "",
"password": "",
"mid": "",
"poll_interval_seconds": 5,
},
"templates": {
"a4_single": {
"name": "A4 Brief – Einzeln",
"width": 8.27,
"height": 11.69,
"xpos": 0.0,
"ypos": -0.3,
"scale": 1.0,
"rotation": 0,
},
"a6_v1": {
"name": "A6 Brief – Variante 1",
"width": 4.1,
"height": 5.8,
"xpos": -0.3,
"ypos": -0.3,
"scale": 1.0,
"rotation": 0,
},
"a6_v2": {
"name": "A6 Brief – Variante 2",
"width": 4.1,
"height": 5.8,
"xpos": 4.0,
"ypos": -0.3,
"scale": 1.0,
"rotation": 90,
},
"kuvert_a_v1": {
"name": "Kuvert A – Variante 1",
"width": 4.49,
"height": 6.38,
"xpos": 4.2,
"ypos": -0.2,
"scale": 1.0,
"rotation": 90,
},
"kuvert_a_v2": {
"name": "Kuvert A – Variante 2",
"width": 4.33,
"height": 8.27,
"xpos": 4.0,
"ypos": -0.2,
"scale": 1.0,
"rotation": 90,
},
"kuvert_b_v1": {
"name": "Kuvert B – Variante 1",
"width": 3.9,
"height": 8.27,
"xpos": 4.49,
"ypos": -0.2,
"scale": 1.0,
"rotation": 90,
},
},
"app": {
"theme": "dark",
"local_temp_dir": "",
},
"machines": [],
}
class ConfigManager:
def __init__(self, config_path: str):
self._path = config_path
self._data: dict = {}
self._load()
def _load(self):
if os.path.exists(self._path):
with open(self._path, "r", encoding="utf-8") as f:
loaded = json.load(f)
self._data = self._deep_merge(copy.deepcopy(DEFAULT_CONFIG), loaded)
else:
self._data = copy.deepcopy(DEFAULT_CONFIG)
self._save()
def _save(self):
os.makedirs(os.path.dirname(self._path), exist_ok=True)
with open(self._path, "w", encoding="utf-8") as f:
json.dump(self._data, f, indent=2, ensure_ascii=False)
def _deep_merge(self, base: dict, override: dict) -> dict:
result = base.copy()
for key, value in override.items():
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
result[key] = self._deep_merge(result[key], value)
else:
result[key] = value
return result
def get(self, key_path: str, default: Any = None) -> Any:
"""Access nested keys with dot notation: 'connection.host'"""
parts = key_path.split(".")
current = self._data
for part in parts:
if isinstance(current, dict) and part in current:
current = current[part]
else:
return default
return current
def set(self, key_path: str, value: Any):
"""Set nested key with dot notation and persist."""
parts = key_path.split(".")
current = self._data
for part in parts[:-1]:
current = current.setdefault(part, {})
current[parts[-1]] = value
self._save()
def get_section(self, section: str) -> dict:
return copy.deepcopy(self._data.get(section, {}))
def set_section(self, section: str, data: dict):
self._data[section] = data
self._save()
def get_templates(self) -> dict:
return copy.deepcopy(self._data.get("templates", {}))
def save_template(self, key: str, template: dict):
if "templates" not in self._data:
self._data["templates"] = {}
self._data["templates"][key] = template
self._save()
def delete_template(self, key: str):
self._data.get("templates", {}).pop(key, None)
self._save()
def get_local_temp_dir(self) -> str:
path = self.get("app.local_temp_dir") or os.path.join(
os.environ.get("TEMP", os.path.expanduser("~")), "ftp_checker"
)
os.makedirs(path, exist_ok=True)
return path
def reload(self):
self._load()

View File

@@ -0,0 +1,219 @@
import errno
import ftplib
import io
import json
import os
import stat
from typing import Optional
class FTPConnectionError(Exception):
pass
class ManifestNotFound(Exception):
"""Der Ordner enthält (noch) keine order-metadata.json – kein echter Fehler."""
def _is_missing_file(exc: Exception) -> bool:
"""Erkennt 'Datei existiert nicht' über SFTP (ENOENT) und FTP (550)."""
if isinstance(exc, FileNotFoundError):
return True
if isinstance(exc, OSError) and getattr(exc, "errno", None) == errno.ENOENT:
return True
if isinstance(exc, ftplib.error_perm) and str(exc).startswith("550"):
return True
return False
class FTPClient:
"""
Protocol-agnostic FTP/SFTP client.
Uses paramiko for SFTP and ftplib for FTP.
"""
def __init__(self, config):
self._config = config
self._sftp = None
self._ssh = None
self._ftp: Optional[ftplib.FTP] = None
self._protocol: str = "sftp"
def connect(self):
self._protocol = self._config.get("connection.protocol", "sftp").lower()
host = self._config.get("connection.host", "")
port = int(self._config.get("connection.port", 22))
username = self._config.get("connection.username", "")
password = self._config.get("connection.password", "")
key_path = self._config.get("connection.private_key_path", "")
try:
if self._protocol == "sftp":
import paramiko
self._ssh = paramiko.SSHClient()
self._ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
self._ssh.connect(
hostname=host,
port=port,
username=username,
password=password or None,
key_filename=key_path or None,
timeout=10,
)
self._sftp = self._ssh.open_sftp()
else:
ftp_port = int(self._config.get("connection.port", 21))
self._ftp = ftplib.FTP()
self._ftp.connect(host, ftp_port, timeout=10)
self._ftp.login(username, password)
except Exception as e:
raise FTPConnectionError(f"Verbindung fehlgeschlagen: {e}") from e
def disconnect(self):
try:
if self._sftp:
self._sftp.close()
if self._ssh:
self._ssh.close()
if self._ftp:
self._ftp.quit()
except Exception:
pass
finally:
self._sftp = None
self._ssh = None
self._ftp = None
def is_connected(self) -> bool:
try:
if self._protocol == "sftp":
if self._sftp is None:
return False
self._sftp.listdir(".")
return True
else:
if self._ftp is None:
return False
self._ftp.voidcmd("NOOP")
return True
except Exception:
return False
def list_order_folders(self) -> list[str]:
remote_path = self._config.get("connection.remote_path", "/orders/")
try:
if self._protocol == "sftp":
entries = self._sftp.listdir_attr(remote_path)
return [
e.filename
for e in entries
if stat.S_ISDIR(e.st_mode)
]
else:
original = self._ftp.pwd()
self._ftp.cwd(remote_path)
folders = []
lines = []
self._ftp.retrlines("LIST", lines.append)
for line in lines:
parts = line.split()
if parts and parts[0].startswith("d"):
name = parts[-1]
if name not in (".", ".."):
folders.append(name)
self._ftp.cwd(original)
return folders
except Exception as e:
raise FTPConnectionError(f"Ordner auflisten fehlgeschlagen: {e}") from e
def list_folder_files(self, folder: str, recursive: bool = False) -> list[dict]:
"""
Listet Dateien im Auftragsordner auf. Mit ``recursive=True`` werden auch
Dateien in Unterordnern (z.B. 'umschlaege/') aufgenommen; ``name`` enthält
dann den relativen Pfad (z.B. 'umschlaege/kuvert_000.svg').
"""
remote_path = self._config.get("connection.remote_path", "/orders/")
folder_path = f"{remote_path.rstrip('/')}/{folder}"
try:
if self._protocol == "sftp":
return self._list_sftp(folder_path, "", recursive)
else:
return self._list_ftp(folder_path, "", recursive)
except Exception as e:
raise FTPConnectionError(f"Dateiliste fehlgeschlagen ({folder}): {e}") from e
def _list_sftp(self, base_path: str, prefix: str, recursive: bool) -> list[dict]:
out = []
for e in self._sftp.listdir_attr(base_path):
rel = f"{prefix}{e.filename}"
if stat.S_ISDIR(e.st_mode):
if recursive:
out.extend(self._list_sftp(f"{base_path}/{e.filename}", rel + "/", True))
else:
out.append({"name": rel, "size": e.st_size or 0})
return out
def _list_ftp(self, base_path: str, prefix: str, recursive: bool) -> list[dict]:
original = self._ftp.pwd()
self._ftp.cwd(base_path)
lines = []
self._ftp.retrlines("LIST", lines.append)
self._ftp.cwd(original)
out = []
for line in lines:
parts = line.split()
if not parts:
continue
name = parts[-1]
if name in (".", ".."):
continue
if parts[0].startswith("d"):
if recursive:
out.extend(self._list_ftp(f"{base_path}/{name}", f"{prefix}{name}/", True))
else:
try:
size = int(parts[4])
except (IndexError, ValueError):
size = 0
out.append({"name": f"{prefix}{name}", "size": size})
return out
def read_order_manifest(self, folder: str) -> dict:
remote_path = self._config.get("connection.remote_path", "/orders/")
manifest_path = f"{remote_path.rstrip('/')}/{folder}/order-metadata.json"
try:
if self._protocol == "sftp":
with self._sftp.open(manifest_path, "r") as f:
return json.loads(f.read())
else:
buf = io.BytesIO()
self._ftp.retrbinary(f"RETR {manifest_path}", buf.write)
return json.loads(buf.getvalue().decode("utf-8"))
except json.JSONDecodeError as e:
raise ValueError(f"Ungültiges JSON in {manifest_path}: {e}") from e
except Exception as e:
# Fehlende Datei ist der Normalfall (Ordner noch im Upload o.Ä.) –
# gesondert melden, damit der Poller nicht jeden Zyklus warnt.
if _is_missing_file(e):
raise ManifestNotFound(folder) from e
raise FTPConnectionError(f"Manifest lesen fehlgeschlagen: {e}") from e
def download_svg(self, folder: str, filename: str, local_dir: str) -> str:
"""
Lädt eine SVG-Datei herunter. filename kann einen Unterordnerpfad
enthalten (z.B. 'umschlaege/kuvert_000.svg') — lokal wird nur der
Dateiname (ohne Unterordner) gespeichert.
"""
remote_path = self._config.get("connection.remote_path", "/orders/")
remote_file = f"{remote_path.rstrip('/')}/{folder}/{filename}"
os.makedirs(local_dir, exist_ok=True)
local_path = os.path.join(local_dir, os.path.basename(filename))
try:
if self._protocol == "sftp":
self._sftp.get(remote_file, local_path)
else:
with open(local_path, "wb") as f:
self._ftp.retrbinary(f"RETR {remote_file}", f.write)
return local_path
except Exception as e:
raise FTPConnectionError(f"Download fehlgeschlagen ({filename}): {e}") from e

View File

@@ -0,0 +1,182 @@
import threading
from typing import Optional
import requests
from app.constants import MACHINE_ERROR_CODES, MachineStatus
class MachineAPIError(Exception):
def __init__(self, code: int, message: str = ""):
self.code = code
self.message = message or MACHINE_ERROR_CODES.get(code, f"Unbekannter Fehler (Code {code})")
super().__init__(self.message)
class MachineAPIClient:
def __init__(self, config, machine_override: dict = None):
self._config = config
self._override = machine_override or {}
self._session = requests.Session()
self._lock = threading.Lock()
@property
def _base_url(self) -> str:
if self._override:
return self._override.get("base_url", "").rstrip("/")
return self._config.get("machine.base_url", "").rstrip("/")
def login(self) -> bool:
if self._override:
username = self._override.get("username", "")
password = self._override.get("password", "")
else:
username = self._config.get("machine.username", "")
password = self._config.get("machine.password", "")
if not username:
return True # No login required
with self._lock:
resp = self._session.get(
f"{self._base_url}/login",
params={"uin": username, "passwd": password},
timeout=10,
)
data = resp.json()
return data.get("error", -1) == 0
def get_status(self) -> list[dict]:
"""Returns list of machine status dicts."""
if self._override:
mid = self._override.get("mid", "")
else:
mid = self._config.get("machine.mid", "")
params = {}
if mid:
params["mids"] = mid
with self._lock:
resp = self._session.get(
f"{self._base_url}/machine_status",
params=params,
timeout=10,
)
data = resp.json()
if isinstance(data, list):
return data
return []
def get_first_status(self) -> Optional[dict]:
statuses = self.get_status()
return statuses[0] if statuses else None
def is_idle(self) -> bool:
status = self.get_first_status()
if status is None:
return False
return status.get("status") == MachineStatus.IDLE
def recover_write(self, mid: str = "") -> bool:
"""Setzt einen bereits in der Maschine liegenden Schreib-Task nach einem
Fehler fort ('Feed again' / recover_write).
WICHTIG: Es wird KEINE Datei neu hochgeladen – der vorhandene Task wird
nur neu zugeführt/fortgesetzt. Gibt True zurück, wenn die Maschine den
Recovery bestätigt ({"code":0}).
"""
if self._override:
effective_mid = mid or self._override.get("mid", "")
else:
effective_mid = mid or self._config.get("machine.mid", "")
params = {}
if effective_mid:
params["mid"] = effective_mid
with self._lock:
resp = self._session.get(
f"{self._base_url}/recover_write",
params=params,
timeout=10,
)
try:
data = resp.json()
except Exception:
raise MachineAPIError(-1, f"Ungültige recover_write-Antwort (HTTP {resp.status_code}): {resp.text[:200]}")
return data.get("code", -1) == 0
def send_svgs(
self,
svg_paths: list[str],
width: float,
height: float,
xpos: float = 0,
ypos: float = 0,
rotation: float = 0,
scale: float = 1.0,
mid: str = "",
clear: bool = True,
start: bool = True,
repeat: int = 1,
) -> dict:
"""
Upload one or more SVG files in a single multipart POST.
All files share the same width/height/xpos/ypos/rotation/scale.
"""
def _fmt(v: float) -> str:
"""Format float without trailing .0 so the machine API gets '90' not '90.0'."""
return str(int(v)) if float(v) == int(v) else str(v)
data = {
"width": f"{_fmt(width)}in",
"height": f"{_fmt(height)}in",
"xpos": f"{_fmt(xpos)}in",
"ypos": f"{_fmt(ypos)}in",
"rotation": _fmt(rotation),
"scale": _fmt(scale),
"clear": "1" if clear else "0",
"start": "1" if start else "0",
"repeat": str(repeat),
}
if self._override:
effective_mid = mid or self._override.get("mid", "")
else:
effective_mid = mid or self._config.get("machine.mid", "")
if effective_mid:
data["mid"] = effective_mid
import os, logging
_log = logging.getLogger(__name__)
last = len(svg_paths) - 1
result = {}
for i, path in enumerate(svg_paths):
file_data = {
**data,
"clear": "1" if i == 0 else "0",
"start": "1" if i == last else "0",
}
_log.info(
"write_svg [%d/%d] %s | params: %s",
i + 1, last + 1, os.path.basename(path),
{k: v for k, v in file_data.items() if k != "file"},
)
with open(path, "rb") as h:
files = [("file", (os.path.basename(path), h, "image/svg+xml"))]
with self._lock:
resp = self._session.post(
f"{self._base_url}/write_svg",
data=file_data,
files=files,
timeout=60,
)
try:
result = resp.json()
except Exception:
raise MachineAPIError(-1, f"Ungültige API-Antwort (HTTP {resp.status_code}): {resp.text[:200]}")
_log.info("write_svg response [%d/%d]: %s", i + 1, last + 1, result)
if result.get("error", -1) != 0:
err_code = result["error"]
raise MachineAPIError(
err_code,
f"Maschine meldet Fehler {err_code} bei Datei '{os.path.basename(path)}' "
f"(params: width={data['width']} height={data['height']} "
f"xpos={data['xpos']} ypos={data['ypos']} "
f"rotation={data['rotation']} scale={data['scale']})"
)
return result

View File

@@ -0,0 +1,390 @@
import json
import os
import threading
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional
from app.constants import OrderStatus
@dataclass
class Order:
order_number: str
session_id: str
finalized_at: str
letter_count: int
envelope_count: int
letters: list
envelopes: list
letter_format: str
envelope_format: str
folder_name: str
local_dir: str
envelope_beschriftung: str = ""
status: OrderStatus = OrderStatus.NEW
error_message: Optional[str] = None
letter_params: Optional[dict] = None
envelope_params: Optional[dict] = None
split_size: int = 0 # 0 = no split
queue_send_letters: bool = True # in der Warteschlange: Briefe senden?
queue_send_envelopes: bool = True # in der Warteschlange: Kuverts senden?
queue_seq: float = 0.0 # Abarbeitungsreihenfolge der Warteschlange (kleiner = früher)
letter_print_log: list = field(default_factory=list) # [{"from":1,"to":10,"at":"ISO"}]
envelope_print_log: list = field(default_factory=list)
comment: str = ""
has_manifest: bool = True # False = Ordner ohne order-metadata.json (nur Dateien)
class OrderManager:
def __init__(self, processed_path: str, temp_base_dir: str, archive_path: str = None):
self._processed_path = processed_path
self._temp_base_dir = temp_base_dir
self._archive_path = archive_path or os.path.join(
os.path.dirname(processed_path), "archive.json"
)
self._order_data_path = os.path.join(
os.path.dirname(processed_path), "order_data.json"
)
self._orders: dict[str, Order] = {}
self._processed: set[str] = set()
self._archived_orders: dict[str, dict] = {}
self._order_data: dict[str, dict] = {} # persistent per-order data
self._lock = threading.Lock()
self._load_processed()
self._load_archive()
self._load_order_data()
def _load_processed(self):
if os.path.exists(self._processed_path):
try:
with open(self._processed_path, "r", encoding="utf-8") as f:
data = json.load(f)
self._processed = set(data.get("processed", []))
except Exception:
self._processed = set()
def _save_processed(self):
os.makedirs(os.path.dirname(self._processed_path), exist_ok=True)
with open(self._processed_path, "w", encoding="utf-8") as f:
json.dump(
{
"processed": sorted(self._processed),
"last_updated": datetime.utcnow().isoformat() + "Z",
},
f,
indent=2,
)
def _load_order_data(self):
if os.path.exists(self._order_data_path):
try:
with open(self._order_data_path, "r", encoding="utf-8") as f:
self._order_data = json.load(f)
except Exception:
self._order_data = {}
def _save_order_data(self):
# Merge current in-memory orders into persistent store
for num, order in self._orders.items():
self._order_data[num] = {
"comment": order.comment,
"letter_print_log": order.letter_print_log,
"envelope_print_log": order.envelope_print_log,
}
try:
os.makedirs(os.path.dirname(self._order_data_path), exist_ok=True)
with open(self._order_data_path, "w", encoding="utf-8") as f:
json.dump(self._order_data, f, indent=2, ensure_ascii=False)
except Exception:
pass
def _apply_order_data(self, order: "Order"):
"""Merge persisted per-order data (comment, print log) into an Order object."""
stored = self._order_data.get(order.order_number, {})
if stored.get("comment"):
order.comment = stored["comment"]
if stored.get("letter_print_log"):
order.letter_print_log = list(stored["letter_print_log"])
if stored.get("envelope_print_log"):
order.envelope_print_log = list(stored["envelope_print_log"])
def _load_archive(self):
if os.path.exists(self._archive_path):
try:
with open(self._archive_path, "r", encoding="utf-8") as f:
data = json.load(f)
self._archived_orders = {
entry["order_number"]: entry
for entry in data.get("archived", [])
if "order_number" in entry
}
except Exception:
self._archived_orders = {}
def _save_archive(self):
os.makedirs(os.path.dirname(self._archive_path), exist_ok=True)
with open(self._archive_path, "w", encoding="utf-8") as f:
json.dump(
{
"archived": list(self._archived_orders.values()),
"last_updated": datetime.utcnow().isoformat() + "Z",
},
f,
indent=2,
ensure_ascii=False,
)
def archive(self, order_number: str):
"""Move an order to the archive. Saves minimal metadata to archive file."""
with self._lock:
order = self._orders.get(order_number)
if not order:
return
metadata = {
"order_number": order.order_number,
"folder_name": order.folder_name,
"finalized_at": order.finalized_at,
"letter_count": order.letter_count,
"envelope_count": order.envelope_count,
"letter_format": order.letter_format,
"envelope_format": order.envelope_format,
"envelope_beschriftung": order.envelope_beschriftung,
"archived_at": datetime.utcnow().isoformat() + "Z",
}
self._archived_orders[order_number] = metadata
del self._orders[order_number]
self._save_archive()
def unarchive(self, order_number: str) -> bool:
"""Restore an archived order back to active status (NEW)."""
with self._lock:
metadata = self._archived_orders.get(order_number)
if not metadata:
return False
# Remove from archive + processed
del self._archived_orders[order_number]
self._processed.discard(order_number)
# Re-create a minimal Order object so it shows immediately
temp_dir = os.path.join(self._temp_base_dir, order_number)
order = Order(
order_number=metadata["order_number"],
session_id="",
finalized_at=metadata.get("finalized_at", ""),
letter_count=metadata.get("letter_count", 0),
envelope_count=metadata.get("envelope_count", 0),
letters=[],
envelopes=[],
letter_format=metadata.get("letter_format", ""),
envelope_format=metadata.get("envelope_format", ""),
folder_name=metadata.get("folder_name", ""),
local_dir=temp_dir,
envelope_beschriftung=metadata.get("envelope_beschriftung", ""),
status=OrderStatus.NEW,
)
self._apply_order_data(order)
self._orders[order_number] = order
self._save_archive()
self._save_processed()
return True
def get_archived(self) -> list[dict]:
"""Return archived order metadata, sorted by finalized_at descending."""
with self._lock:
items = list(self._archived_orders.values())
items.sort(key=lambda x: x.get("finalized_at", ""), reverse=True)
return items
def _apply_manifest_fields(self, order: "Order", folder: str, manifest: dict):
"""Populate/refresh an order's content fields from its manifest."""
order.session_id = manifest.get("sessionId") or ""
order.finalized_at = manifest.get("finalizedAt") or ""
order.letter_count = manifest.get("letterCount") or 0
order.envelope_count = manifest.get("envelopeCount") or 0
order.letters = manifest.get("letters") or []
order.envelopes = manifest.get("envelopes") or []
order.letter_format = manifest.get("letterFormat") or ""
order.envelope_format = manifest.get("envelopeFormat") or ""
order.envelope_beschriftung = manifest.get("envelopeBeschriftung") or ""
order.folder_name = folder
order.has_manifest = True
def update_from_remote(
self,
folders: list[str],
manifests: dict[str, dict],
no_manifest_folders: set[str] = None,
) -> list[Order]:
"""
Merge newly fetched folder data. Returns list of newly discovered orders.
Ordner mit Manifest werden wie gewohnt angelegt. Ordner in
``no_manifest_folders`` (bestätigt ohne order-metadata.json) erscheinen
mit dem Ordnernamen als Auftragsnummer und ohne SVG-Liste. Taucht später
ein Manifest auf, wird der bestehende Auftrag aufgewertet.
"""
no_manifest_folders = no_manifest_folders or set()
new_orders = []
with self._lock:
for folder in folders:
manifest = manifests.get(folder)
if manifest:
order_number = manifest.get("orderNumber") or folder
existing = self._orders.get(order_number)
if existing:
# Bereits bekannt – nur einen Manifest-losen Stub aufwerten.
if not existing.has_manifest:
self._apply_manifest_fields(existing, folder, manifest)
continue
if order_number in self._archived_orders:
continue # Already archived
local_dir = os.path.join(self._temp_base_dir, order_number)
already_sent = order_number in self._processed
order = Order(
order_number=order_number,
session_id="",
finalized_at="",
letter_count=0,
envelope_count=0,
letters=[],
envelopes=[],
letter_format="",
envelope_format="",
folder_name=folder,
local_dir=local_dir,
status=OrderStatus.SENT if already_sent else OrderStatus.NEW,
)
self._apply_manifest_fields(order, folder, manifest)
self._apply_order_data(order)
self._orders[order_number] = order
if not already_sent:
new_orders.append(order)
elif folder in no_manifest_folders:
# Ordner ohne Manifest: Ordnername = Auftragsnummer.
order_number = folder
if order_number in self._orders:
continue
if order_number in self._archived_orders:
continue
local_dir = os.path.join(self._temp_base_dir, order_number)
already_sent = order_number in self._processed
order = Order(
order_number=order_number,
session_id="",
finalized_at="",
letter_count=0,
envelope_count=0,
letters=[],
envelopes=[],
letter_format="",
envelope_format="",
folder_name=folder,
local_dir=local_dir,
status=OrderStatus.SENT if already_sent else OrderStatus.NEW,
has_manifest=False,
)
self._apply_order_data(order)
self._orders[order_number] = order
if not already_sent:
new_orders.append(order)
return new_orders
def get_all(self) -> list[Order]:
with self._lock:
return sorted(
self._orders.values(),
key=lambda o: o.finalized_at,
reverse=True,
)
def get_by_number(self, order_number: str) -> Optional[Order]:
with self._lock:
return self._orders.get(order_number)
def update_status(self, order_number: str, status: OrderStatus, error: str = None):
with self._lock:
order = self._orders.get(order_number)
if order:
order.status = status
order.error_message = error
def mark_processed(self, order_number: str):
with self._lock:
order = self._orders.get(order_number)
if order:
order.status = OrderStatus.SENT
order.error_message = None
self._processed.add(order_number)
self._save_processed()
def is_processed(self, order_number: str) -> bool:
with self._lock:
return order_number in self._processed
def get_queued(self) -> list[Order]:
with self._lock:
return sorted(
[o for o in self._orders.values() if o.status == OrderStatus.QUEUED],
key=lambda o: (o.queue_seq, o.finalized_at),
)
def add_print_log(self, order_number: str, kind: str, from_idx: int, to_idx: int):
"""Record a printed range (0-based, inclusive) for 'letter' or 'envelope'."""
with self._lock:
order = self._orders.get(order_number)
if not order:
return
entry = {"from": from_idx, "to": to_idx, "at": datetime.utcnow().isoformat() + "Z"}
if kind == "letter":
order.letter_print_log.append(entry)
elif kind == "envelope":
order.envelope_print_log.append(entry)
self._save_order_data()
def add_print_indices(self, order_number: str, kind: str, indices: list):
"""
Record printed 0-based indices (Briefnummern) for 'letter' or 'envelope'.
Aufeinanderfolgende Nummern werden zu {from,to}-Bereichen komprimiert.
"""
idx = sorted({int(i) for i in indices})
if not idx:
return
ranges = []
start = prev = idx[0]
for i in idx[1:]:
if i == prev + 1:
prev = i
else:
ranges.append((start, prev))
start = prev = i
ranges.append((start, prev))
now = datetime.utcnow().isoformat() + "Z"
with self._lock:
order = self._orders.get(order_number)
if not order:
return
log = order.letter_print_log if kind == "letter" else order.envelope_print_log
for a, b in ranges:
log.append({"from": a, "to": b, "at": now})
self._save_order_data()
def set_comment(self, order_number: str, comment: str):
"""Save a comment for an order."""
with self._lock:
order = self._orders.get(order_number)
if order:
order.comment = comment
if order_number not in self._order_data:
self._order_data[order_number] = {}
self._order_data[order_number]["comment"] = comment
self._save_order_data()
def set_order_params(self, order_number: str, **kwargs):
"""Update per-order UI parameters (template key, overrides, split size)."""
with self._lock:
order = self._orders.get(order_number)
if not order:
return
for k, v in kwargs.items():
if hasattr(order, k):
setattr(order, k, v)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,122 @@
import threading
import time
from typing import Callable, Optional
from app.constants import MachineStatus
from app.core.machine_client import MachineAPIClient
class MachinePollWorker(threading.Thread):
def __init__(
self,
config,
machine_client: MachineAPIClient,
on_status_update: Callable,
on_log: Callable,
on_idle_trigger: Callable,
):
super().__init__(daemon=True)
self._config = config
self._machine = machine_client
self._on_status_update = on_status_update # called with dict or None
self._on_log = on_log
self._on_idle_trigger = on_idle_trigger # called when machine becomes Idle
self._stop_event = threading.Event()
self._last_status: Optional[str] = None
# --- Auto-Recovery bei Fehler während des Drucks ---
# Versucht max. N-mal recover_write ("Feed again"), OHNE den Auftrag
# neu zu senden. Wird nur ausgelöst, wenn die Maschine aus dem Zustand
# "Printing" heraus auf "Error" wechselt (= Fehler während des Drucks).
self._max_retries = config.get("machine.error_max_retries", 3)
self._retry_delay = config.get("machine.error_retry_delay_seconds", 5)
self._error_active = False # läuft gerade eine Recovery-Episode?
self._error_retries = 0 # bereits unternommene recover_write-Versuche
self._last_retry_ts = 0.0 # Zeitstempel des letzten Versuchs (monotonic)
def stop(self):
self._stop_event.set()
def run(self):
interval = self._config.get("machine.poll_interval_seconds", 5)
while not self._stop_event.wait(timeout=interval):
interval = self._config.get("machine.poll_interval_seconds", 5)
self._poll()
def _poll(self):
try:
status_list = self._machine.get_status()
if status_list:
status = status_list[0]
self._on_status_update(status)
current = status.get("status", MachineStatus.UNKNOWN)
if self._last_status != MachineStatus.IDLE and current == MachineStatus.IDLE:
self._on_idle_trigger()
self._handle_error_recovery(self._last_status, current, status)
self._last_status = current
else:
self._on_status_update(None)
self._last_status = None
except Exception as e:
self._on_log(f"Maschinen-Status Fehler: {e}", "error")
self._on_status_update(None)
def _handle_error_recovery(self, prev: Optional[str], current: str, status: dict):
"""Automatischer recover_write-Retry bei Fehler während des Drucks.
Sendet NICHTS neu – ruft nur recover_write auf, um den bereits in der
Maschine liegenden Task fortzusetzen. Max. self._max_retries Versuche
mit je self._retry_delay Sekunden Abstand.
"""
if current == MachineStatus.ERROR:
# Episode nur starten, wenn der Fehler aus laufendem Druck kam
if not self._error_active and prev == MachineStatus.PRINTING:
self._error_active = True
self._error_retries = 0
self._last_retry_ts = 0.0
self._on_log(
"Maschine meldet Fehler während des Drucks – starte automatische "
f"Wiederaufnahme (max. {self._max_retries} Versuche, kein Neu-Senden).",
"warning",
)
if self._error_active and self._error_retries < self._max_retries:
now = time.monotonic()
if now - self._last_retry_ts >= self._retry_delay:
self._error_retries += 1
self._last_retry_ts = now
mid = status.get("mid", "")
self._on_log(
f"Wiederaufnahme-Versuch {self._error_retries}/{self._max_retries} "
f"(recover_write)…", "info",
)
try:
ok = self._machine.recover_write(mid)
if ok:
self._on_log(
f"recover_write bestätigt (Versuch {self._error_retries}/{self._max_retries}).",
"info",
)
else:
self._on_log(
f"recover_write nicht bestätigt (Versuch {self._error_retries}/{self._max_retries}).",
"warning",
)
except Exception as e:
self._on_log(f"recover_write Fehler: {e}", "error")
elif self._error_active and self._error_retries >= self._max_retries:
# Alle Versuche ausgeschöpft, Maschine immer noch im Fehler
self._on_log(
f"Automatische Wiederaufnahme nach {self._max_retries} Versuchen "
"fehlgeschlagen – bitte Maschine manuell prüfen.", "error",
)
self._error_active = False # nicht weiter versuchen (re-armt erst bei erneutem Druck)
else:
# Maschine hat den Fehlerzustand verlassen
if self._error_active and current in (MachineStatus.PRINTING, MachineStatus.IDLE):
if self._error_retries > 0:
self._on_log("Maschine hat sich erholt – Druck wird fortgesetzt.", "success")
self._error_active = False
self._error_retries = 0
self._last_retry_ts = 0.0

View File

@@ -0,0 +1,183 @@
import logging
import threading
from typing import Callable
from app.core.ftp_client import FTPClient, FTPConnectionError, ManifestNotFound
from app.core.order_manager import OrderManager
log = logging.getLogger(__name__)
class FTPPollWorker(threading.Thread):
def __init__(
self,
config,
order_manager: OrderManager,
on_new_orders: Callable,
on_log: Callable,
on_connection_change: Callable,
):
super().__init__(daemon=True)
self._config = config
self._order_manager = order_manager
self._on_new_orders = on_new_orders # called with list[Order]
self._on_log = on_log # called with (message, level)
self._on_connection_change = on_connection_change # called with bool
self._stop_event = threading.Event()
self._ftp = FTPClient(config)
self._connected = False
# Anti-Spam: pro Ordner die zuletzt gemeldete Hinweis-Signatur merken,
# damit dieselbe Meldung nicht bei jedem Poll erneut im Log landet.
self._folder_notices: dict[str, str] = {}
def stop(self):
self._stop_event.set()
def run(self):
self._poll() # Immediate first poll
interval = self._config.get("connection.poll_interval_seconds", 30)
while not self._stop_event.wait(timeout=interval):
interval = self._config.get("connection.poll_interval_seconds", 30)
self._poll()
def _poll(self):
try:
if not self._ftp.is_connected():
self._ftp.disconnect()
self._ftp.connect()
if not self._connected:
self._connected = True
self._on_connection_change(True)
self._on_log("FTP-Verbindung hergestellt.", "info")
folders = self._ftp.list_order_folders()
# Hinweise für verschwundene Ordner vergessen (damit sie bei
# erneutem Auftauchen wieder einmalig gemeldet werden).
present = set(folders)
for gone in [f for f in self._folder_notices if f not in present]:
self._folder_notices.pop(gone, None)
manifests = {}
no_manifest_folders: set[str] = set()
for folder in folders:
try:
manifest = self._ftp.read_order_manifest(folder)
# Kuverts liegen im Unterordner 'umschlaege/' — Pfad voraussetzen
manifest["envelopes"] = [
f"umschlaege/{f}" for f in manifest.get("envelopes", [])
]
manifests[folder] = manifest
self._folder_notices.pop(folder, None) # Ordner ok → Hinweis löschen
except ManifestNotFound:
# Ordner ohne order-metadata.json: trotzdem als Auftrag
# anzeigen (Ordnername = Auftragsnummer, alle Dateien).
no_manifest_folders.add(folder)
if self._folder_notices.get(folder) != "missing":
self._folder_notices[folder] = "missing"
self._on_log(
f"Ordner {folder}: kein order-metadata.json – "
"wird ohne SVG-Zuordnung angezeigt.", "info"
)
except (ValueError, FTPConnectionError) as e:
# Echte Fehler (kaputtes JSON, Verbindungsproblem) weiterhin
# als Warnung – aber pro Ordner nur einmal je Meldung.
# Nicht als "manifestlos" anzeigen, da das Manifest existiert.
if self._folder_notices.get(folder) != str(e):
self._folder_notices[folder] = str(e)
self._on_log(f"Manifest übersprungen ({folder}): {e}", "warning")
new_orders = self._order_manager.update_from_remote(
folders, manifests, no_manifest_folders
)
if new_orders:
self._on_log(f"{len(new_orders)} neuer Auftrag/Aufträge gefunden.", "info")
self._on_new_orders(new_orders)
except FTPConnectionError as e:
self._connected = False
self._on_connection_change(False)
self._on_log(f"FTP-Fehler: {e}", "error")
except Exception as e:
self._on_log(f"Unbekannter Poll-Fehler: {e}", "error")
def reconnect(self):
"""Force reconnect on next poll (e.g., after settings change)."""
self._ftp.disconnect()
self._connected = False
def process_queue_tick():
"""
Startet den nächsten Warteschlangen-Auftrag, sobald die Maschine frei ist.
Läuft ohne manuelle Druckbestätigung: der Auftrag wird nach der Übertragung
automatisch abgeschlossen (siehe _run_send_job), sodass der nächste Auftrag
startet, sobald die Maschine wieder Idle meldet.
Wiederverwendbar: vom QueueWorker-Loop und vom /api/queue/kick-Endpoint.
"""
# Lazy import to avoid circular dependency
from app.api.routes import _state, _run_send_job
# Skip wenn schon ein Job läuft
if _state.get("current_order"):
return
# Skip wenn Formatwechsel-Bestätigung aussteht (physischer Materialwechsel)
if _state.get("format_confirm_event"):
return
om = _state.get("om")
machine = _state.get("machine")
if not om or not machine:
return
# Prüfen ob Maschine bereit ist
if not machine.is_idle():
return
# Nächsten Auftrag aus der Warteschlange holen
queued = om.get_queued()
if not queued:
return
next_order = queued[0]
# Gespeicherte Druckparameter verwenden
zero = {"width": 0, "height": 0, "xpos": 0, "ypos": 0, "scale": 1, "rotation": 0}
letter_params = next_order.letter_params or dict(zero)
envelope_params = next_order.envelope_params or dict(zero)
# Typ-Auswahl der Warteschlange respektieren (Briefe- oder Kuvert-Sammeldruck)
send_letters = getattr(next_order, "queue_send_letters", True) and bool(next_order.letters)
send_envelopes = getattr(next_order, "queue_send_envelopes", True) and bool(next_order.envelopes)
log.info(f"Queue-Worker: Starte Auftrag {next_order.order_number}")
t = threading.Thread(
target=_run_send_job,
args=(next_order, letter_params, envelope_params),
kwargs={"send_letters": send_letters, "send_envelopes": send_envelopes},
daemon=True,
)
t.start()
class QueueWorker(threading.Thread):
"""Verarbeitet automatisch warteschlange-Aufträge wenn die Maschine bereit ist."""
INTERVAL = 6 # Sekunden zwischen Prüfungen
def __init__(self):
super().__init__(daemon=True)
self._stop_event = threading.Event()
def stop(self):
self._stop_event.set()
def run(self):
while not self._stop_event.wait(timeout=self.INTERVAL):
try:
process_queue_tick()
except Exception as e:
log.warning(f"QueueWorker Fehler: {e}")

View File

@@ -0,0 +1,117 @@
{
"connection": {
"protocol": "sftp",
"host": "192.168.1.100",
"port": 22,
"username": "skrift",
"password": "",
"private_key_path": "",
"remote_path": "/orders/",
"poll_interval_seconds": 30
},
"machine": {
"base_url": "http://192.168.2.32:90",
"username": "",
"password": "",
"mid": "",
"poll_interval_seconds": 5
},
"format_mapping": {
"letter": {
"A4": "a4_v1",
"A6 Hochformat": "a6p_v1",
"A6 Querformat": "a6l_v1"
},
"envelope": {
"DIN Lang": "dinlang_v1",
"C6": "c6_v1"
}
},
"templates": {
"a4_v1": {
"name": "A4 – Variante 1",
"width": 8.27,
"height": 11.69,
"xpos": 0.0,
"ypos": -0.3,
"scale": 1.0,
"rotation": 0
},
"a6p_v1": {
"name": "A6 Hochformat – Variante 1",
"width": 4.1,
"height": 5.8,
"xpos": -0.3,
"ypos": -0.3,
"scale": 1.0,
"rotation": 0
},
"a6p_v2": {
"name": "A6 Hochformat – Variante 2",
"width": 4.1,
"height": 5.8,
"xpos": 4.0,
"ypos": -0.3,
"scale": 1.0,
"rotation": 90
},
"a6l_v1": {
"name": "A6 Querformat – Variante 1",
"width": 5.8,
"height": 4.1,
"xpos": -0.3,
"ypos": -0.3,
"scale": 1.0,
"rotation": 0
},
"a6l_v2": {
"name": "A6 Querformat – Variante 2",
"width": 5.8,
"height": 4.1,
"xpos": 4.0,
"ypos": -0.3,
"scale": 1.0,
"rotation": 90
},
"dinlang_v1": {
"name": "DIN Lang Kuvert – Variante 1",
"width": 4.33,
"height": 8.27,
"xpos": 4.0,
"ypos": -0.2,
"scale": 1.0,
"rotation": 90
},
"dinlang_v2": {
"name": "DIN Lang Kuvert – Variante 2",
"width": 3.9,
"height": 8.27,
"xpos": 4.49,
"ypos": -0.2,
"scale": 1.0,
"rotation": 90
},
"c6_v1": {
"name": "C6 Kuvert – Variante 1",
"width": 4.49,
"height": 6.38,
"xpos": 4.2,
"ypos": -0.2,
"scale": 1.0,
"rotation": 90
},
"c6_v2": {
"name": "C6 Kuvert – Variante 2",
"width": 4.33,
"height": 6.38,
"xpos": 4.0,
"ypos": -0.2,
"scale": 1.0,
"rotation": 90
}
},
"app": {
"theme": "dark",
"local_temp_dir": ""
}
}

View File

@@ -0,0 +1,125 @@
import asyncio
import os
import sys
import threading
import webbrowser
import uvicorn
from fastapi import FastAPI
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
sys.path.insert(0, os.path.dirname(__file__))
from app.core.config_manager import ConfigManager
from app.core.machine_client import MachineAPIClient
from app.core.order_manager import OrderManager
from app.workers.poll_worker import FTPPollWorker, QueueWorker
from app.workers.machine_poll_worker import MachinePollWorker
from app.api import routes
from app.api.routes import push_log, push_machine_status, push_new_orders, push_ftp_status
CONFIG_PATH = os.path.join(os.path.dirname(__file__), "config.json")
PROCESSED_PATH = os.path.join(os.path.dirname(__file__), "processed_orders.json")
ARCHIVE_PATH = os.path.join(os.path.dirname(__file__), "archive.json")
STATIC_DIR = os.path.join(os.path.dirname(__file__), "app", "static")
PORT = 8765
def create_app():
config = ConfigManager(CONFIG_PATH)
order_manager = OrderManager(
processed_path=PROCESSED_PATH,
temp_base_dir=config.get_local_temp_dir(),
archive_path=ARCHIVE_PATH,
)
machine_client = MachineAPIClient(config)
app = FastAPI(title="Skrift FTP-Checker")
# SSE event queue (created inside the async loop)
event_queue: asyncio.Queue = None
loop_holder = {}
@app.on_event("startup")
async def startup():
nonlocal event_queue
event_queue = asyncio.Queue()
loop = asyncio.get_event_loop()
loop_holder["loop"] = loop
ftp_worker = FTPPollWorker(
config=config,
order_manager=order_manager,
on_new_orders=lambda orders: (push_new_orders(), None),
on_log=push_log,
on_connection_change=push_ftp_status,
)
machine_worker = MachinePollWorker(
config=config,
machine_client=machine_client,
on_status_update=push_machine_status,
on_log=push_log,
on_idle_trigger=_on_machine_idle,
)
routes.init(
config=config,
order_manager=order_manager,
machine_client=machine_client,
ftp_worker=ftp_worker,
machine_worker=machine_worker,
event_queue=event_queue,
)
routes._state["loop"] = loop
routes._state["auto_queue"] = False
routes._state["cancel_event"] = None
routes._state["current_order"] = None
queue_worker = QueueWorker()
ftp_worker.start()
machine_worker.start()
queue_worker.start()
# Login attempt
threading.Thread(target=_do_login, args=(machine_client,), daemon=True).start()
# Open browser after short delay
threading.Timer(1.2, lambda: webbrowser.open(f"http://localhost:{PORT}")).start()
def _on_machine_idle():
pass # Queue-Verarbeitung übernimmt QueueWorker
def _do_login(machine):
try:
ok = machine.login()
push_log("Maschinen-Login erfolgreich." if ok else "Maschinen-Login fehlgeschlagen.", "success" if ok else "warning")
except Exception as e:
push_log(f"Maschinen-Login Fehler: {e}", "error")
# Routes
app.include_router(routes.router)
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
@app.get("/")
def index():
# no-store, damit der Browser nach einem Update immer die neue
# index.html (inkl. aktualisiertem JS) lädt und nicht die alte cacht.
return FileResponse(
os.path.join(STATIC_DIR, "index.html"),
headers={"Cache-Control": "no-store, must-revalidate"},
)
return app
if __name__ == "__main__":
import logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
handlers=[logging.StreamHandler()],
)
app = create_app()
uvicorn.run(app, host="127.0.0.1", port=PORT, log_level="warning")

View File

@@ -0,0 +1,5 @@
fastapi>=0.111.0
uvicorn>=0.29.0
paramiko>=3.4.0
requests>=2.31.0
python-multipart>=0.0.9

View File

@@ -0,0 +1,388 @@
# Backend Integration - WordPress Plugin
Anleitung zur Integration des Node.js Backends mit dem WordPress Konfigurator-Plugin.
## Überblick
Das WordPress Plugin kommuniziert mit dem Node.js Backend über eine REST API. Das Backend generiert die handgeschriebenen SVG-Dateien und speichert die finalen Bestellungen.
## WordPress Admin-Einstellungen
Nach der Installation des Plugins in WordPress:
1. Gehe zu **Einstellungen → Skrift Konfigurator**
2. Scrolle nach unten zum Abschnitt **"Backend-Verbindung"**
### Erforderliche Einstellungen
#### 1. API URL / Domain
**Beispiel:** `https://backend.deine-domain.de`
Die vollständige URL zu deinem Backend-Server (ohne trailing slash).
- ✅ `https://backend.example.com`
- ✅ `http://localhost:4000` (nur für lokale Tests)
- ❌ `https://backend.example.com/` (kein Slash am Ende!)
#### 2. API Token / Authentifizierung
**Optional** - Aktuell nicht implementiert, für zukünftige Erweiterungen vorbereitet.
Lasse dieses Feld erstmal leer.
#### 3. Order Webhook URL
**Beispiel:** `https://n8n.deine-domain.de/webhook/order`
URL die aufgerufen wird, nachdem eine Bestellung abgeschickt wurde.
**Wird aufgerufen:**
- **Business-Kunden:** Sofort nach Klick auf "Jetzt kostenpflichtig bestellen"
- **Privat-Kunden:** Nach erfolgreicher PayPal-Zahlung (später implementiert)
**Webhook-Payload:**
```json
{
"orderNumber": "SK-2026-01-03-001",
"customer_type": "business",
"product": "businessbriefe",
"quantity": 100,
"format": "A4",
"shipping_mode": "direct",
"envelope": "yes",
"customer_data": {
"firstName": "Max",
"lastName": "Mustermann",
"company": "Beispiel GmbH",
"email": "max@example.com",
...
},
"quote": {
"total": 250.00,
...
},
"backend_result": {
"path": "/var/skrift-output/SK-2026-01-03-001",
"files": [...],
"summary": {...}
},
"timestamp": "2026-01-03T..."
}
```
**Verwendung:**
- N8N Workflow triggern
- CRM-System benachrichtigen
- Interne Benachrichtigungen versenden
#### 4. Redirect URL Geschäftskunden
**Beispiel:** `https://deine-domain.de/danke-business`
Wohin Business-Kunden nach dem Klick auf "Jetzt kostenpflichtig bestellen" weitergeleitet werden.
**Query-Parameter:**
- `orderNumber` - Die generierte Bestellnummer
**Beispiel-Redirect:**
```
https://deine-domain.de/danke-business?orderNumber=SK-2026-01-03-001
```
#### 5. Redirect URL Privatkunden
**Beispiel:** `https://deine-domain.de/danke-privat`
Wohin Privat-Kunden nach erfolgreicher PayPal-Zahlung weitergeleitet werden.
**Query-Parameter:**
- `orderNumber` - Die generierte Bestellnummer
## Backend-API Endpunkte
Das Plugin nutzt folgende Backend-Endpunkte:
### 1. Health Check
```
GET /health
```
**Response:**
```json
{
"status": "ok",
"timestamp": "2026-01-03T..."
}
```
### 2. Preview Batch
```
POST /api/preview/batch
```
**Request:**
```json
{
"sessionId": "session-1234567890-abc",
"letters": [
{
"text": "Liebe Oma, ...",
"font": "tilda",
"format": "A4",
"placeholders": {},
"envelope": null
}
]
}
```
**Response:**
```json
{
"sessionId": "session-1234567890-abc",
"previews": [
{
"index": 0,
"url": "/api/preview/session-1234567890-abc/0"
}
],
"batchInfo": {
"totalLetters": 1,
"batchSize": 30
}
}
```
### 3. Generate Order
```
POST /api/order/generate
```
**Request:**
```json
{
"orderNumber": "SK-2026-01-03-001",
"letters": [...],
"envelopes": [...],
"metadata": {
"customer": {...},
"orderDate": "2026-01-03T..."
}
}
```
**Response:**
```json
{
"orderNumber": "SK-2026-01-03-001",
"path": "/var/skrift-output/SK-2026-01-03-001",
"files": [
"letter_000.svg",
"envelope_000.svg",
"order-metadata.json",
"placeholders.csv"
],
"summary": {
"totalLetters": 100,
"totalEnvelopes": 100,
"fonts": ["tilda"],
"formats": ["A4"]
}
}
```
## Workflow
### Für Business-Kunden
```
1. Kunde füllt Konfigurator aus
2. Kunde klickt "Jetzt kostenpflichtig bestellen"
3. WordPress Plugin → Backend API (generateOrder)
4. Backend generiert SVG-Dateien
5. Backend speichert in /var/skrift-output/SK-...
6. WordPress Plugin → Webhook aufrufen
7. WordPress Plugin → Redirect zu Business-Danke-Seite
```
### Für Privat-Kunden (später)
```
1. Kunde füllt Konfigurator aus
2. Kunde klickt "Jetzt kostenpflichtig bestellen"
3. WordPress Plugin → PayPal Checkout
4. PayPal → Zahlung erfolgreich
5. PayPal Webhook → WordPress
6. WordPress Plugin → Backend API (generateOrder)
7. Backend generiert SVG-Dateien
8. WordPress Plugin → Webhook aufrufen
9. WordPress Plugin → Redirect zu Privat-Danke-Seite
```
## Datenmapping
### Fonts
| Frontend | Backend |
|----------|---------|
| tilda | tilda (PremiumUltra79) |
| alva | alva (PremiumUltra23) |
| ellie | ellie (PremiumUltra39) |
### Formate
| Frontend | Backend |
|----------|---------|
| a4 | A4 |
| a6p | A6_PORTRAIT |
| a6l | A6_LANDSCAPE |
### Envelope-Formate
| Brief-Format | Envelope-Format |
|--------------|-----------------|
| A4 | DIN_LANG |
| A6 | C6 |
## JavaScript Integration
### API Client laden
Das Plugin lädt automatisch:
- `configurator-api.js` - Backend API Client
- `configurator-backend-integration.js` - Integration Logic
### Globale Instanz
```javascript
// Verfügbar in allen Konfigurator-Scripts
const api = window.SkriftBackendAPI;
// Health-Check
const isHealthy = await api.healthCheck();
// Preview generieren
const result = await api.generatePreviewBatch(letters);
// Order generieren
const order = await api.generateOrder(orderNumber, letters, envelopes, metadata);
```
## Testing
### 1. Backend Health-Check
```javascript
// In Browser-Console auf Konfigurator-Seite:
const api = window.SkriftBackendAPI;
const healthy = await api.healthCheck();
console.log('Backend healthy:', healthy);
```
### 2. Test-Order generieren
```javascript
const api = window.SkriftBackendAPI;
const result = await api.generateOrder(
api.generateOrderNumber(),
[
{
text: 'Test Brief',
font: 'tilda',
format: 'A4',
placeholders: {}
}
],
[],
{
customer: {
type: 'business',
firstName: 'Test',
lastName: 'User',
email: 'test@example.com'
}
}
);
console.log('Order result:', result);
```
## Troubleshooting
### Fehler: "Backend ist nicht erreichbar"
**Lösung:**
1. Prüfe Backend-URL in WordPress-Einstellungen
2. Teste Health-Check: `curl https://backend.deine-domain.de/health`
3. Prüfe Nginx Proxy Manager Konfiguration
4. Prüfe Backend-Container: `docker compose logs -f`
### Fehler: "CORS Error"
**Lösung:**
Nginx Proxy Manager muss CORS-Header setzen:
```nginx
# In Custom Nginx Configuration (Advanced Tab)
add_header Access-Control-Allow-Origin "https://deine-wordpress-domain.de" always;
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
if ($request_method = 'OPTIONS') {
return 204;
}
```
### Fehler: "Order generation failed"
**Lösung:**
1. Prüfe Backend-Logs: `docker compose logs -f`
2. Prüfe `/var/skrift-output` Verzeichnis existiert
3. Prüfe Fonts sind vorhanden in `/app/fonts`
4. Prüfe `.env` hat `SCRIPTALIZER_ERR_FREQUENCY=0`
### Webhook wird nicht aufgerufen
**Lösung:**
1. Prüfe Webhook-URL in WordPress-Einstellungen
2. Teste Webhook manuell mit curl
3. Prüfe N8N/Webhook-Service Logs
4. Webhook-Fehler werden ignoriert (soft fail) - Order wird trotzdem erstellt
## Weitere Entwicklung
### Preview-System (TODO)
Aktuell wird direkt die finale Order generiert. Zukünftig:
1. Kunde füllt Schritt 1-4 aus
2. Preview generieren mit `/api/preview/batch`
3. Kunde sieht Vorschau der Briefe
4. Kunde bestätigt → Order finalisieren mit `/api/order/finalize`
### PayPal-Integration (TODO)
Für Privatkunden:
1. PayPal SDK laden
2. Order-ID an PayPal übergeben
3. Nach Zahlung Webhook empfangen
4. Backend-Order generieren
5. Weiterleitung
## Support
Bei Problemen:
1. Browser-Console öffnen (F12)
2. Logs prüfen (nach `[API]` oder `[Backend Integration]` filtern)
3. Backend-Logs prüfen: `docker compose logs -f`
4. WordPress Debug-Log prüfen: `wp-content/debug.log`
## Checkliste nach Installation
- [ ] Backend deployed und erreichbar
- [ ] Health-Check erfolgreich: `https://backend.domain.de/health`
- [ ] WordPress Admin-Einstellungen konfiguriert
- [ ] API URL gesetzt
- [ ] Order Webhook URL gesetzt (optional)
- [ ] Redirect URLs gesetzt
- [ ] Test-Bestellung generiert
- [ ] Dateien in `/var/skrift-output` erstellt
- [ ] Webhook wurde aufgerufen (falls konfiguriert)
- [ ] Keine durchgestrichenen Wörter in SVGs
- [ ] Handschrift-Variationen sichtbar (Wortabstände, Rotation)

View File

@@ -0,0 +1,318 @@
# Skrift Konfigurator - WordPress Plugin
Interaktiver Konfigurator für handgeschriebene Briefe, Postkarten und Einladungen.
## Features
- ✅ Multi-Step Konfigurator mit 6 Schritten
- ✅ B2B und B2C Workflows
- ✅ Dynamische Preisberechnung
- ✅ Gutschein-System
- ✅ Backend-Integration für SVG-Generierung
- ✅ Preview-System (in Entwicklung)
- ✅ Responsive Design
- ✅ Vollständig anpassbare Preise und Produkte
## Installation
1. Plugin-Ordner nach `wp-content/plugins/skrift-konfigurator/` kopieren
2. In WordPress: **Plugins → Installierte Plugins**
3. "Skrift Konfigurator" aktivieren
4. Zu **Einstellungen → Skrift Konfigurator** gehen
5. Einstellungen konfigurieren
## Konfiguration
### 1. Produkte
Verwalte Namen, Beschreibungen und Basispreise für alle 5 Produkte:
- Businessbriefe
- Business Postkarten
- Follow-ups
- Einladungen
- Private Briefe
### 2. Preise
Konfiguriere Aufpreise für:
- Formate (A4 Upgrade)
- Versand (Direkt vs. Bulk)
- Umschläge (mit Adresse vs. Custom Text)
- Zusatzleistungen (Motiv-Upload, Design-Service, etc.)
- Schriftarten (Tilda, Alva, Ellie)
### 3. Dynamische Preisformeln
Erstelle Mengenrabatt-Formeln für Business und Privatkunden:
**Beispiel Business:**
```
if (q < 50) return 2.50;
if (q < 100) return 2.30;
if (q < 200) return 2.10;
return 1.90;
```
**Beispiel Privat:**
```
if (q < 10) return 3.00;
if (q < 25) return 2.80;
return 2.50;
```
### 4. Backend-Verbindung
**WICHTIG:** Für die SVG-Generierung!
- **API URL:** `https://backend.deine-domain.de`
- **Order Webhook URL:** `https://n8n.deine-domain.de/webhook/order` (optional)
- **Redirect URLs:** Wohin nach Bestellung weitergeleitet wird
Siehe [BACKEND_INTEGRATION.md](./BACKEND_INTEGRATION.md) für Details.
### 5. Gutscheine
Erstelle Gutschein-Codes für Rabatte oder Testbestellungen.
**Arten:**
- **Prozentual:** 10%, 20%, 50%
- **Festbetrag:** 5€, 10€, 20€
- **Gratis:** 100% Rabatt
**Einstellungen:**
- Einmalverwendung oder Mehrfachnutzung
- Aktiv/Inaktiv Toggle
## Verwendung
### Shortcode einfügen
```
[skrift_konfigurator]
```
Füge diesen Shortcode auf jeder Seite oder jedem Beitrag ein.
### Mit URL-Parametern
Direktlink zu einem Produkt:
```
https://deine-domain.de/konfigurator/?businessbriefe
https://deine-domain.de/konfigurator/?private-briefe
https://deine-domain.de/konfigurator/?einladungen
```
## Datei-Struktur
```
skrift-konfigurator/
├── assets/
│ ├── css/
│ │ └── configurator.css # Styling
│ └── js/
│ ├── configurator-app.js # Main App
│ ├── configurator-state.js # State Management
│ ├── configurator-ui.js # UI Rendering
│ ├── configurator-pricing.js # Price Calculation
│ ├── configurator-api.js # Backend API Client
│ └── configurator-backend-integration.js # Backend Integration
├── includes/
│ ├── admin-settings.php # Admin Settings Page
│ └── admin-vouchers.php # Voucher Management
├── skrift-konfigurator.php # Main Plugin File
├── BACKEND_INTEGRATION.md # Backend Integration Guide
└── README.md # This file
```
## API Integration
Das Plugin kommuniziert mit dem Node.js Backend über REST API.
### Endpoints
| Endpoint | Method | Beschreibung |
|----------|--------|--------------|
| `/health` | GET | Health-Check |
| `/api/preview/batch` | POST | Preview generieren |
| `/api/order/generate` | POST | Order erstellen |
| `/api/order/finalize` | POST | Order aus Preview finalisieren |
Siehe [BACKEND_INTEGRATION.md](./BACKEND_INTEGRATION.md) für vollständige API-Dokumentation.
## Workflow
### Business-Kunde
```
1. Produkt auswählen (Businessbriefe, Business Postkarten, Follow-ups)
2. Menge eingeben
3. Format wählen
4. Versand & Umschlag konfigurieren
5. Inhalt eingeben (Text)
6. Kundendaten eingeben
7. Prüfen & Bestellen
→ Backend generiert SVG-Dateien
→ Webhook wird aufgerufen
→ Weiterleitung zu Danke-Seite
```
### Privat-Kunde
```
1. Produkt auswählen (Private Briefe, Einladungen)
2. Menge eingeben
3. Format wählen
4. Versand & Umschlag konfigurieren
5. Inhalt eingeben (Text + optional Motiv)
6. Kundendaten eingeben
7. Prüfen & Bestellen
→ PayPal-Checkout (später)
→ Backend generiert SVG-Dateien
→ Webhook wird aufgerufen
→ Weiterleitung zu Danke-Seite
```
## Entwicklung
### Requirements
- PHP 7.4+
- WordPress 5.8+
- Modern Browser mit ES6+ Support
### JavaScript
Das Plugin nutzt ES6 Modules und läuft ohne Build-Step.
**State Management:**
- Reducer-Pattern (ähnlich Redux)
- Immutable State Updates
- Uni-directional Data Flow
**UI Rendering:**
- Virtual DOM mit `h()` Helper
- Deklaratives Rendering
- Event-Delegation
### Debugging
Browser-Console öffnen (F12):
```javascript
// State prüfen
console.log(window.currentState);
// Backend API testen
const api = window.SkriftBackendAPI;
await api.healthCheck();
// Test-Order erstellen
await api.generateOrder(
api.generateOrderNumber(),
[{ text: 'Test', font: 'tilda', format: 'A4', placeholders: {} }],
[],
{ customer: { type: 'business', firstName: 'Test' } }
);
```
## Deployment
### 1. Plugin auf Server hochladen
Via FTP, SSH oder WordPress Dashboard:
```
wp-content/plugins/skrift-konfigurator/
```
### 2. Backend deployen
Siehe `Docker Backend/DEPLOYMENT.md`
### 3. WordPress konfigurieren
- Einstellungen → Skrift Konfigurator
- Backend-URL setzen
- Preise anpassen
- Gutscheine erstellen (optional)
### 4. Seite erstellen
- Neue Seite: "Konfigurator"
- Shortcode einfügen: `[skrift_konfigurator]`
- Veröffentlichen
### 5. Testen
- Produkt durchklicken
- Test-Bestellung aufgeben
- Prüfen ob Backend-Order erstellt wurde
- Prüfen ob Dateien in `/var/skrift-output/` erstellt wurden
## Troubleshooting
### Konfigurator wird nicht angezeigt
**Lösung:**
- Shortcode korrekt? `[skrift_konfigurator]`
- JavaScript-Fehler in Console? (F12)
- Plugin aktiviert?
### Backend-Verbindung fehlgeschlagen
**Lösung:**
- Backend URL korrekt in Einstellungen?
- Backend erreichbar? `curl https://backend.domain.de/health`
- CORS konfiguriert? (Nginx Proxy Manager)
### Preise werden falsch berechnet
**Lösung:**
- Preise in Einstellungen prüfen
- Dynamische Formeln prüfen (Syntax)
- Console-Logs prüfen: `window.currentState.quote`
### Gutschein funktioniert nicht
**Lösung:**
- Gutschein ist aktiv?
- Gutschein noch nicht verwendet? (bei Einmalverwendung)
- Code korrekt geschrieben? (Case-sensitive!)
## Changelog
### Version 0.3.0
- ✅ Backend-Integration implementiert
- ✅ API Client für Preview & Order
- ✅ Webhook-Support
- ✅ Redirect-URLs konfigurierbar
### Version 0.2.0
- ✅ Gutschein-System
- ✅ Dynamische Preisformeln
- ✅ Admin-Einstellungen erweitert
### Version 0.1.0
- ✅ Basis-Konfigurator
- ✅ 6 Schritte
- ✅ B2B und B2C Workflows
## TODO
- [ ] Preview-System vollständig integrieren
- [ ] PayPal-Integration für Privatkunden
- [ ] Email-Benachrichtigungen
- [ ] PDF-Export der Bestellung
- [ ] Admin-Dashboard für Bestellungen
## Support
Bei Fragen oder Problemen:
1. [BACKEND_INTEGRATION.md](./BACKEND_INTEGRATION.md) lesen
2. Browser-Console prüfen (F12)
3. Backend-Logs prüfen: `docker compose logs -f`
4. WordPress Debug-Log: `wp-content/debug.log`
## Lizenz
Proprietär - Alle Rechte vorbehalten

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,394 @@
/**
* Skrift Backend API Client
* Kommunikation mit dem Node.js Backend über WordPress Proxy
* Der API-Token wird serverseitig gehandhabt und ist nicht im Frontend exponiert
*/
class SkriftBackendAPI {
constructor() {
// WordPress REST API URL für den Proxy
this.restUrl = window.SkriftConfigurator?.restUrl || '/wp-json/';
// API Key für WordPress REST API Authentifizierung
this.apiKey = window.SkriftConfigurator?.apiKey || '';
// WordPress Nonce für CSRF-Schutz
this.nonce = window.SkriftConfigurator?.nonce || '';
// Direkte Backend-URL nur für Preview-Bilder (read-only)
this.backendUrl = window.SkriftConfigurator?.settings?.backend_connection?.api_url || '';
// Alias für Kompatibilität mit PreviewManager
this.baseURL = this.backendUrl;
this.sessionId = null;
this.previewCache = new Map();
}
/**
* Gibt Standard-Headers für WordPress REST API zurück
*/
getHeaders() {
const headers = {
'Content-Type': 'application/json',
'X-WP-Nonce': this.nonce,
};
if (this.apiKey) {
headers['X-Skrift-API-Key'] = this.apiKey;
}
return headers;
}
/**
* Generiert eine eindeutige Session-ID für Preview-Caching
*/
generateSessionId() {
return `session-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
}
/**
* Health-Check: Prüft ob Backend erreichbar ist (über WordPress Proxy)
*/
async healthCheck() {
try {
const response = await fetch(`${this.restUrl}skrift/v1/proxy/health`, {
method: 'GET',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Health check failed: ${response.statusText}`);
}
const data = await response.json();
return data.status === 'ok';
} catch (error) {
console.error('[API] Health check failed:', error);
return false;
}
}
/**
* Preview Batch: Generiert eine Vorschau von Briefen (über WordPress Proxy)
* Briefe und Umschläge werden in derselben Session gespeichert
*/
async generatePreviewBatch(letters, options = {}) {
try {
// SessionId nur generieren wenn noch keine existiert oder explizit angefordert
// So bleiben Briefe und Umschläge in derselben Session
if (!this.sessionId || options.newSession) {
this.sessionId = this.generateSessionId();
console.log('[API] New session created:', this.sessionId);
} else {
console.log('[API] Reusing existing session:', this.sessionId);
}
const requestBody = {
sessionId: this.sessionId,
letters: letters.map(letter => ({
index: letter.index,
text: letter.text,
font: letter.font || 'tilda',
format: letter.format || 'A4',
placeholders: letter.placeholders || {},
type: letter.type || 'letter',
envelopeType: letter.envelopeType || 'recipient',
envelope: letter.envelope || null,
realisticHandwriting: letter.realisticHandwriting ?? false,
})),
};
const response = await fetch(`${this.restUrl}skrift/v1/proxy/preview/batch`, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const error = await response.json();
if (response.status === 429 && error.retryAfter) {
const err = new Error(error.error || 'Rate limit exceeded');
err.retryAfter = error.retryAfter;
err.statusCode = 429;
throw err;
}
throw new Error(error.error || error.message || `Preview generation failed: ${response.statusText}`);
}
const data = await response.json();
// Session-ID vom Backend übernehmen (falls anders als gesendet)
if (data.sessionId) {
this.sessionId = data.sessionId;
}
const previews = data.files ? data.files.map((file, index) => ({
index: file.index !== undefined ? file.index : index,
url: file.url || file.path,
format: file.format,
pages: file.pages || 1,
lineCount: file.lineCount,
lineLimit: file.lineLimit,
overflow: file.overflow,
recipientName: file.recipientName,
})) : [];
previews.forEach((preview, index) => {
this.previewCache.set(`${this.sessionId}-${index}`, preview);
});
return {
success: true,
sessionId: this.sessionId,
previews: previews,
batchInfo: data.batchInfo,
hasOverflow: data.hasOverflow || false,
overflowFiles: data.overflowFiles || [],
};
} catch (error) {
console.error('[API] Preview batch error:', error);
return {
success: false,
error: error.message,
};
}
}
/**
* Get Preview: Ruft eine einzelne Preview-URL ab
* Hinweis: Diese Methode wird aktuell nicht verwendet, da Preview-URLs direkt vom Backend kommen
*/
async getPreviewUrl(sessionId, index) {
try {
const cacheKey = `${sessionId}-${index}`;
if (this.previewCache.has(cacheKey)) {
return this.previewCache.get(cacheKey).url;
}
// Über WordPress Proxy abrufen
const response = await fetch(`${this.restUrl}skrift/v1/proxy/preview/${sessionId}/${index}`, {
method: 'GET',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Preview not found: ${response.statusText}`);
}
const svgText = await response.text();
// Sicheres Base64-Encoding für Unicode
const dataUrl = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svgText)))}`;
return dataUrl;
} catch (error) {
console.error('[API] Get preview URL error:', error);
throw error;
}
}
/**
* Finalize Order: Finalisiert eine Bestellung aus dem Preview-Cache (über WordPress Proxy)
*/
async finalizeOrder(sessionId, orderNumber, metadata = {}) {
try {
const requestBody = {
sessionId: sessionId,
orderNumber: orderNumber,
metadata: {
customer: metadata.customer || {},
orderDate: metadata.orderDate || new Date().toISOString(),
...metadata,
},
};
const response = await fetch(`${this.restUrl}skrift/v1/proxy/order/finalize`, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || `Order finalization failed: ${response.statusText}`);
}
const data = await response.json();
return {
success: true,
orderNumber: data.orderNumber,
path: data.path,
files: data.files,
envelopesGenerated: data.envelopesGenerated || 0,
};
} catch (error) {
console.error('[API] Finalize order error:', error);
return {
success: false,
error: error.message,
};
}
}
/**
* Generate Order: Generiert eine Bestellung ohne Preview (direkt, über WordPress Proxy)
* Backend erwartet alle Dokumente (Briefe + Umschläge) im letters-Array mit type-Property
*/
async generateOrder(orderNumber, letters, envelopes = [], metadata = {}) {
try {
// Letters vorbereiten
const preparedLetters = letters.map((letter, index) => ({
index: letter.index !== undefined ? letter.index : index,
text: letter.text,
font: letter.font || 'tilda',
format: letter.format || 'A4',
placeholders: letter.placeholders || {},
type: 'letter',
realisticHandwriting: letter.realisticHandwriting ?? false,
}));
// Envelopes vorbereiten und anhängen
const preparedEnvelopes = envelopes.map((envelope, index) => ({
index: envelope.index !== undefined ? envelope.index : index,
text: envelope.text || '',
font: envelope.font || 'tilda',
format: envelope.format || 'DIN_LANG',
placeholders: envelope.placeholders || {},
type: 'envelope',
envelopeType: envelope.envelopeType || 'recipient',
}));
// Alle Dokumente in einem Array für Backend
const allDocuments = [...preparedLetters, ...preparedEnvelopes];
const requestBody = {
orderNumber: orderNumber,
letters: allDocuments,
metadata: {
customer: metadata.customer || {},
orderDate: metadata.orderDate || new Date().toISOString(),
...metadata,
},
};
const response = await fetch(`${this.restUrl}skrift/v1/proxy/order/generate`, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || `Order generation failed: ${response.statusText}`);
}
const data = await response.json();
return {
success: true,
orderNumber: data.orderNumber,
path: data.path,
files: data.files,
summary: data.summary,
};
} catch (error) {
console.error('[API] Generate order error:', error);
return {
success: false,
error: error.message,
};
}
}
/**
* Generate Order Number: Holt fortlaufende Bestellnummer vom WordPress-Backend
* Schema: S-JAHR-MONAT-TAG-fortlaufendeNummer (z.B. S-2026-01-12-001)
*/
async generateOrderNumber() {
try {
const response = await fetch(`${this.restUrl}skrift/v1/order/generate-number`, {
method: 'POST',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Failed to generate order number: ${response.statusText}`);
}
const data = await response.json();
return data.orderNumber;
} catch (error) {
console.error('[API] Failed to generate order number from WP:', error);
// Fallback: Lokale Generierung (sollte nicht passieren)
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const random = String(Math.floor(Math.random() * 1000)).padStart(3, '0');
return `S-${year}-${month}-${day}-${random}`;
}
}
/**
* Upload Motif: Lädt ein Motiv-Bild hoch (über WordPress Proxy)
* @param {File} file - Die hochzuladende Datei
* @param {string} orderNumber - Die Bestellnummer für die Dateinamenszuordnung
* @returns {Promise<{success: boolean, filename?: string, url?: string, error?: string}>}
*/
async uploadMotif(file, orderNumber) {
try {
const formData = new FormData();
formData.append('motif', file);
formData.append('orderNumber', orderNumber || '');
const response = await fetch(`${this.restUrl}skrift/v1/proxy/motif/upload`, {
method: 'POST',
headers: {
'X-WP-Nonce': this.nonce,
...(this.apiKey ? { 'X-Skrift-API-Key': this.apiKey } : {}),
},
body: formData,
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || `Motif upload failed: ${response.statusText}`);
}
const data = await response.json();
return {
success: true,
filename: data.filename,
url: data.url,
path: data.path,
};
} catch (error) {
console.error('[API] Motif upload error:', error);
return {
success: false,
error: error.message,
};
}
}
/**
* Clear Preview Cache: Löscht den Preview-Cache und setzt Session zurück
*/
clearPreviewCache() {
this.previewCache.clear();
this.sessionId = null; // Wird beim nächsten Preview-Aufruf neu generiert
}
/**
* Start New Session: Erzwingt eine neue Session für den nächsten Preview-Aufruf
*/
startNewSession() {
this.sessionId = null;
this.previewCache.clear();
}
}
// Globale Instanz exportieren
window.SkriftBackendAPI = new SkriftBackendAPI();
export default SkriftBackendAPI;

View File

@@ -0,0 +1,220 @@
/* global SkriftConfigurator */
import {
createInitialState,
deriveContextFromUrl,
reducer,
STEPS,
} from "./configurator-state.js?ver=0.3.1";
import {
render,
showValidationOverlay,
hideValidationOverlay,
showOverflowWarning,
showValidationError,
flushAllTables,
} from "./configurator-ui.js?ver=0.3.1";
import "./configurator-api.js"; // Backend API initialisieren
import PreviewManager from "./configurator-preview-manager.js"; // Preview Management
(function boot() {
const root = document.querySelector('[data-skrift-konfigurator="1"]');
if (!root) return;
// Guard: Verhindere doppelte Initialisierung
if (root.dataset.skriftInitialized === "1") {
console.warn("[Konfigurator] Bereits initialisiert, überspringe.");
return;
}
root.dataset.skriftInitialized = "1";
// Cleanup bei Navigation (SPA) oder Seiten-Unload
const cleanupHandlers = [];
const cleanup = () => {
flushAllTables(); // Tabellen-Daten speichern vor Cleanup/Seiten-Unload
cleanupHandlers.forEach((handler) => handler());
cleanupHandlers.length = 0;
if (window.envelopePreviewManager) {
window.envelopePreviewManager.destroy();
}
if (window.contentPreviewManager) {
window.contentPreviewManager.destroy();
}
};
// Cleanup bei Seiten-Unload
window.addEventListener("beforeunload", cleanup);
cleanupHandlers.push(() =>
window.removeEventListener("beforeunload", cleanup),
);
const ctx = deriveContextFromUrl(window.location.search);
let state = createInitialState(ctx);
const dom = {
topbar: document.getElementById("sk-topbar"),
stepper: document.getElementById("sk-stepper"),
form: document.getElementById("sk-form"),
prev: document.getElementById("sk-prev"),
next: document.getElementById("sk-next"),
preview: document.getElementById("sk-preview"),
previewMobile: document.getElementById("sk-preview-mobile"),
contactMobile: document.getElementById("sk-contact-mobile"),
};
// Preview Manager initialisieren
const api = window.SkriftBackendAPI;
if (api) {
window.envelopePreviewManager = new PreviewManager(api);
window.contentPreviewManager = new PreviewManager(api);
}
const dispatch = (action) => {
// Scroll-Position VOR dem State-Update speichern
const scrollY = window.scrollY;
const scrollX = window.scrollX;
state = reducer(state, action);
render({ state, dom, dispatch });
// Scroll-Position NACH dem Render wiederherstellen
// Nur bei Actions die NICHT den Step wechseln (Navigation)
const navigationActions = ["NAV_NEXT", "NAV_PREV", "SET_STEP"];
if (!navigationActions.includes(action.type)) {
// requestAnimationFrame stellt sicher, dass das DOM komplett gerendert ist
requestAnimationFrame(() => {
window.scrollTo(scrollX, scrollY);
});
}
};
// Event-Handler mit Cleanup-Tracking
const prevClickHandler = () => {
flushAllTables(); // Tabellen-Daten speichern vor Navigation
dispatch({ type: "NAV_PREV" });
};
// Next-Handler mit Validierung bei Content-Step
const nextClickHandler = async () => {
flushAllTables(); // Tabellen-Daten speichern vor Navigation
// WICHTIG: Globalen State in den Store synchronisieren
// Nötig weil Paste direkt in window.currentGlobalState schreibt (Performance)
const globalState = window.currentGlobalState;
if (globalState) {
// Alle Tabellen-Daten in einem einzigen Dispatch synchronisieren
dispatch({
type: "SYNC_GLOBAL_STATE",
payload: {
recipientRows: globalState.recipientRows,
freeAddressRows: globalState.freeAddressRows,
placeholderValues: globalState.placeholderValues,
},
});
}
// WICHTIG: Aktuellen State verwenden, nicht den gecachten aus dem Closure
const currentState = window.currentGlobalState || state;
// Bei Content-Step: Textlänge validieren und alle Previews generieren
if (currentState.step === STEPS.CONTENT) {
const previewManager = window.contentPreviewManager;
if (previewManager) {
showValidationOverlay();
try {
const validation =
await previewManager.validateTextLength(currentState);
if (!validation.valid) {
hideValidationOverlay();
// Bei Overflow: Warnung anzeigen
if (
validation.overflowFiles &&
validation.overflowFiles.length > 0
) {
showOverflowWarning(validation.overflowFiles, dom.form);
} else if (validation.error) {
// Bei Fehler (z.B. keine Anfragen mehr): Fehlermeldung anzeigen
showValidationError(validation.error, dom.form);
}
return; // Nicht weiter navigieren
}
// Nach erfolgreicher Validierung: Umschlag-Previews generieren (gleiche Session)
// So sind alle Dokumente im Cache wenn die Bestellung finalisiert wird
const envelopeManager = window.envelopePreviewManager;
if (envelopeManager && currentState.answers?.envelope === true) {
try {
envelopeManager.previewCount =
parseInt(currentState.answers?.quantity) || 1;
await envelopeManager.loadAllPreviews(currentState, true, true);
console.log("[App] Envelope previews generated for cache");
} catch (envError) {
console.error(
"[App] Envelope preview generation failed:",
envError,
);
// Nicht blockieren - Umschläge sind nicht kritisch für Navigation
}
}
hideValidationOverlay();
} catch (error) {
hideValidationOverlay();
console.error("[App] Validation error:", error);
showValidationError(
error.message || "Validierung fehlgeschlagen",
dom.form,
);
return; // Nicht weiter navigieren
}
}
}
dispatch({ type: "NAV_NEXT" });
};
dom.prev.addEventListener("click", prevClickHandler);
dom.next.addEventListener("click", nextClickHandler);
cleanupHandlers.push(() => {
dom.prev.removeEventListener("click", prevClickHandler);
dom.next.removeEventListener("click", nextClickHandler);
});
// Keyboard Navigation für Previews (nur innerhalb des aktuellen Batches)
const keydownHandler = (e) => {
if (
window.envelopePreviewManager &&
window.envelopePreviewManager.currentBatchPreviews.length > 0
) {
window.envelopePreviewManager.handleKeyboardNavigation(
e,
state,
dom.preview,
true,
);
}
if (
window.contentPreviewManager &&
window.contentPreviewManager.currentBatchPreviews.length > 0
) {
window.contentPreviewManager.handleKeyboardNavigation(
e,
state,
dom.preview,
false,
);
}
};
document.addEventListener("keydown", keydownHandler);
cleanupHandlers.push(() =>
document.removeEventListener("keydown", keydownHandler),
);
render({ state, dom, dispatch });
// Konfigurator sichtbar machen nachdem erster Render abgeschlossen ist
requestAnimationFrame(() => {
root.classList.add("sk-ready");
});
})();

View File

@@ -0,0 +1,585 @@
/**
* Backend Integration für Skrift Konfigurator
* Erweitert handleOrderSubmit um Backend-API Calls
*/
import SkriftBackendAPI from './configurator-api.js';
import { preparePlaceholdersForIndex } from './configurator-utils.js';
/**
* Bereitet Letter-Daten für Backend vor
*/
function prepareLettersForBackend(state) {
const letters = [];
const quantity = parseInt(state.answers?.quantity) || 1;
// Haupttext
const mainText = state.answers?.letterText || state.answers?.text || state.answers?.briefText || '';
const font = state.answers?.font || 'tilda';
const format = state.answers?.format || 'A4';
// Für jede Kopie einen Letter-Eintrag erstellen mit individuellen Platzhaltern
for (let i = 0; i < quantity; i++) {
const placeholders = preparePlaceholdersForIndex(state, i);
letters.push({
index: i,
text: mainText,
font: mapFontToBackend(font),
format: mapFormatToBackend(format),
placeholders: placeholders,
type: 'letter',
realisticHandwriting: state.answers?.realisticHandwriting ?? false,
});
}
return letters;
}
/**
* Bereitet Envelope-Daten für Backend vor
*/
function prepareEnvelopesForBackend(state) {
const envelopes = [];
// envelope ist ein boolean (true/false), nicht 'yes'/'no'
const hasEnvelope = state.answers?.envelope === true;
console.log('[Backend Integration] prepareEnvelopesForBackend:', {
envelope: state.answers?.envelope,
hasEnvelope,
envelopeMode: state.answers?.envelopeMode,
});
if (!hasEnvelope) {
return envelopes;
}
const quantity = parseInt(state.answers?.quantity) || 1;
const envelopeMode = state.answers?.envelopeMode || 'recipientData';
const format = state.answers?.format || 'A4';
const font = state.answers?.envelopeFont || state.answers?.font || 'tilda';
// Envelope Format bestimmen
const envelopeFormat = format === 'a4' ? 'DIN_LANG' : 'C6';
if (envelopeMode === 'recipientData') {
// Empfängeradresse-Modus: Ein Envelope pro Brief mit individuellen Empfängerdaten
for (let i = 0; i < quantity; i++) {
const placeholders = preparePlaceholdersForIndex(state, i);
const recipient = state.recipientRows?.[i] || {};
// Umschlagtext aus Empfängerdaten zusammenbauen
const lines = [];
const fullName = `${recipient.firstName || ''} ${recipient.lastName || ''}`.trim();
if (fullName) lines.push(fullName);
const streetLine = `${recipient.street || ''} ${recipient.houseNumber || ''}`.trim();
if (streetLine) lines.push(streetLine);
const location = `${recipient.zip || ''} ${recipient.city || ''}`.trim();
if (location) lines.push(location);
if (recipient.country && recipient.country !== 'Deutschland') {
lines.push(recipient.country);
}
envelopes.push({
index: i,
text: lines.join('\n'),
font: mapFontToBackend(font),
format: envelopeFormat,
placeholders: placeholders,
type: 'envelope',
envelopeType: 'recipient',
});
}
} else if (envelopeMode === 'customText') {
// Custom Text Modus mit Platzhaltern
const customText = state.answers?.envelopeCustomText || '';
for (let i = 0; i < quantity; i++) {
const placeholders = preparePlaceholdersForIndex(state, i);
envelopes.push({
index: i,
text: customText,
font: mapFontToBackend(font),
format: envelopeFormat,
placeholders: placeholders,
type: 'envelope',
envelopeType: 'custom',
});
}
}
return envelopes;
}
// Hinweis: preparePlaceholdersForIndex ist jetzt in configurator-utils.js
/**
* Mapped Frontend-Font zu Backend-Font
*/
function mapFontToBackend(frontendFont) {
const fontMap = {
'tilda': 'tilda',
'alva': 'alva',
'ellie': 'ellie',
// Füge weitere Mappings hinzu falls nötig
};
return fontMap[frontendFont] || 'tilda';
}
/**
* Mapped Frontend-Format zu Backend-Format
*/
function mapFormatToBackend(frontendFormat) {
const formatMap = {
'a4': 'A4',
'a6p': 'A6_PORTRAIT',
'a6l': 'A6_LANDSCAPE',
'A4': 'A4',
'A6_PORTRAIT': 'A6_PORTRAIT',
'A6_LANDSCAPE': 'A6_LANDSCAPE',
};
return formatMap[frontendFormat] || 'A4';
}
/**
* Ermittelt das Umschlag-Format basierend auf Brief-Format
*/
function getEnvelopeFormat(letterFormat) {
const format = String(letterFormat).toLowerCase();
if (format === 'a4') return 'DIN_LANG';
if (format === 'a6p' || format === 'a6l') return 'C6';
return 'DIN_LANG';
}
/**
* Formatiert Format für lesbare Ausgabe
*/
function formatFormatLabel(format) {
const labels = {
'a4': 'A4 Hochformat',
'a6p': 'A6 Hochformat',
'a6l': 'A6 Querformat',
};
return labels[format] || format;
}
/**
* Formatiert Font für lesbare Ausgabe
*/
function formatFontLabel(font) {
const labels = {
'tilda': 'Tilda',
'alva': 'Alva',
'ellie': 'Ellie',
};
return labels[font] || font;
}
/**
* Baut das komplette Webhook-Datenobjekt zusammen
* Enthält ALLE relevanten Felder für Bestellbestätigung und n8n Workflow
*/
function buildWebhookData(state, backendResult) {
const answers = state.answers || {};
const order = state.order || {};
const quote = state.quote || {};
const ctx = state.ctx || {};
// Gutschein-Informationen
const voucherCode = order.voucherStatus?.valid ? order.voucherCode : null;
const voucherDiscount = order.voucherStatus?.valid ? (order.voucherStatus.discount || 0) : 0;
// Umschlag-Format ermitteln
const envelopeFormat = answers.envelope ? getEnvelopeFormat(answers.format) : null;
// Inland/Ausland zählen
let domesticCount = 0;
let internationalCount = 0;
const addressMode = state.addressMode || 'classic';
const rows = addressMode === 'free' ? (state.freeAddressRows || []) : (state.recipientRows || []);
for (const row of rows) {
if (!row) continue;
const country = addressMode === 'free' ? (row.line5 || '') : (row.country || '');
const countryLower = country.toLowerCase().trim();
const isDomestic = !countryLower ||
countryLower === 'deutschland' ||
countryLower === 'germany' ||
countryLower === 'de';
if (isDomestic) {
domesticCount++;
} else {
internationalCount++;
}
}
return {
// === BESTELLNUMMER & ZEITSTEMPEL ===
orderNumber: backendResult?.orderNumber || null,
timestamp: new Date().toISOString(),
// === KUNDE ===
customerType: answers.customerType || 'private',
customerTypeLabel: answers.customerType === 'business' ? 'Geschäftskunde' : 'Privatkunde',
// === PRODUKT ===
product: ctx.product?.key || null,
productLabel: ctx.product?.label || null,
productCategory: ctx.product?.category || null,
// === MENGE ===
quantity: parseInt(answers.quantity) || 0,
domesticCount: domesticCount,
internationalCount: internationalCount,
// === FORMAT & SCHRIFT ===
format: answers.format || null,
formatLabel: formatFormatLabel(answers.format),
font: answers.font || 'tilda',
fontLabel: formatFontLabel(answers.font || 'tilda'),
// === VERSAND ===
shippingMode: answers.shippingMode || null,
shippingModeLabel: answers.shippingMode === 'direct' ? 'Einzelversand durch Skrift' : 'Sammellieferung',
// === UMSCHLAG ===
envelopeIncluded: answers.envelope === true,
envelopeFormat: envelopeFormat,
envelopeFormatLabel: envelopeFormat === 'DIN_LANG' ? 'DIN Lang' : (envelopeFormat === 'C6' ? 'C6' : null),
envelopeMode: answers.envelopeMode || null,
envelopeModeLabel: answers.envelopeMode === 'recipientData' ? 'Empfängeradresse' :
(answers.envelopeMode === 'customText' ? 'Individueller Text' : null),
envelopeFont: answers.envelopeFont || answers.font || 'tilda',
envelopeFontLabel: formatFontLabel(answers.envelopeFont || answers.font || 'tilda'),
envelopeCustomText: answers.envelopeCustomText || null,
// === INHALT ===
contentCreateMode: answers.contentCreateMode || null,
contentCreateModeLabel: answers.contentCreateMode === 'self' ? 'Selbst erstellt' :
(answers.contentCreateMode === 'textservice' ? 'Textservice' : null),
letterText: answers.letterText || null,
// === MOTIV ===
motifNeeded: answers.motifNeed === true,
motifSource: answers.motifSource || null,
motifSourceLabel: answers.motifSource === 'upload' ? 'Eigenes Motiv hochgeladen' :
(answers.motifSource === 'printed' ? 'Bedruckte Karten verwenden' :
(answers.motifSource === 'design' ? 'Designservice' : null)),
motifFileName: answers.motifFileName || null,
motifFileMeta: answers.motifFileMeta || null,
// === SERVICES ===
serviceText: answers.serviceText === true,
serviceDesign: answers.serviceDesign === true,
serviceApi: answers.serviceApi === true,
// === FOLLOW-UP DETAILS (nur bei Follow-ups) ===
followupYearlyVolume: ctx.product?.isFollowUp ? (answers.followupYearlyVolume || null) : null,
followupCreateMode: ctx.product?.isFollowUp ? (answers.followupCreateMode || null) : null,
followupCreateModeLabel: ctx.product?.isFollowUp ? (
answers.followupCreateMode === 'auto' ? 'Automatisch (API)' :
(answers.followupCreateMode === 'manual' ? 'Manuell' : null)
) : null,
followupSourceSystem: ctx.product?.isFollowUp ? (answers.followupSourceSystem || null) : null,
followupTriggerDescription: ctx.product?.isFollowUp ? (answers.followupTriggerDescription || null) : null,
followupCheckCycle: ctx.product?.isFollowUp ? (answers.followupCheckCycle || null) : null,
followupCheckCycleLabel: ctx.product?.isFollowUp ? (
answers.followupCheckCycle === 'weekly' ? 'Wöchentlich' :
(answers.followupCheckCycle === 'monthly' ? 'Monatlich' :
(answers.followupCheckCycle === 'quarterly' ? 'Quartalsweise' : null))
) : null,
// === GUTSCHEIN ===
voucherCode: voucherCode,
voucherDiscount: voucherDiscount,
// === PREISE ===
currency: quote.currency || 'EUR',
subtotalNet: quote.subtotalNet || 0,
vatRate: quote.vatRate || 0.19,
vatAmount: quote.vatAmount || 0,
totalGross: quote.totalGross || 0,
priceLines: quote.lines || [],
// === KUNDENDATEN (Rechnungsadresse) ===
billingFirstName: order.billing?.firstName || '',
billingLastName: order.billing?.lastName || '',
billingCompany: order.billing?.company || '',
billingEmail: order.billing?.email || '',
billingPhone: order.billing?.phone || '',
billingStreet: order.billing?.street || '',
billingHouseNumber: order.billing?.houseNumber || '',
billingZip: order.billing?.zip || '',
billingCity: order.billing?.city || '',
billingCountry: order.billing?.country || 'Deutschland',
// === LIEFERADRESSE (falls abweichend) ===
shippingDifferent: order.shippingDifferent || false,
shippingFirstName: order.shippingDifferent ? (order.shipping?.firstName || '') : null,
shippingLastName: order.shippingDifferent ? (order.shipping?.lastName || '') : null,
shippingCompany: order.shippingDifferent ? (order.shipping?.company || '') : null,
shippingStreet: order.shippingDifferent ? (order.shipping?.street || '') : null,
shippingHouseNumber: order.shippingDifferent ? (order.shipping?.houseNumber || '') : null,
shippingZip: order.shippingDifferent ? (order.shipping?.zip || '') : null,
shippingCity: order.shippingDifferent ? (order.shipping?.city || '') : null,
shippingCountry: order.shippingDifferent ? (order.shipping?.country || 'Deutschland') : null,
// === EMPFÄNGERLISTE ===
addressMode: addressMode,
addressModeLabel: addressMode === 'free' ? 'Freie Adresszeilen' : 'Klassische Adresse',
recipients: addressMode === 'classic' ? (state.recipientRows || []).map((r, i) => ({
index: i,
firstName: r?.firstName || '',
lastName: r?.lastName || '',
street: r?.street || '',
houseNumber: r?.houseNumber || '',
zip: r?.zip || '',
city: r?.city || '',
country: r?.country || 'Deutschland',
})) : null,
recipientsFree: addressMode === 'free' ? (state.freeAddressRows || []).map((r, i) => ({
index: i,
line1: r?.line1 || '',
line2: r?.line2 || '',
line3: r?.line3 || '',
line4: r?.line4 || '',
line5: r?.line5 || '',
})) : null,
// === PLATZHALTER ===
placeholdersEnvelope: state.placeholders?.envelope || [],
placeholdersLetter: state.placeholders?.letter || [],
placeholderValues: state.placeholderValues || {},
// === BACKEND RESULT (falls vorhanden) ===
backendPath: backendResult?.path || null,
backendFiles: backendResult?.files || [],
backendSummary: backendResult?.summary || null,
};
}
/**
* Bereitet Metadaten für Backend vor
*/
function prepareOrderMetadata(state) {
return {
customer: {
type: state.answers?.customerType || 'private',
firstName: state.order?.firstName || '',
lastName: state.order?.lastName || '',
company: state.order?.company || '',
email: state.order?.email || '',
phone: state.order?.phone || '',
street: state.order?.street || '',
zip: state.order?.zip || '',
city: state.order?.city || '',
},
orderDate: new Date().toISOString(),
product: state.ctx?.product?.key || '',
quantity: state.answers?.quantity || 1,
format: state.answers?.format || 'A4',
shippingMode: state.answers?.shippingMode || 'direct',
quote: state.quote || {},
voucherCode: state.order?.voucherCode || null,
};
}
/**
* Erweiterte Order-Submit Funktion mit Backend-Integration
*/
export async function handleOrderSubmitWithBackend(state) {
const isB2B = state.answers?.customerType === "business";
const backend = window.SkriftConfigurator?.settings?.backend_connection || {};
const webhookUrl = backend.order_webhook_url;
const redirectUrlBusiness = backend.redirect_url_business;
const redirectUrlPrivate = backend.redirect_url_private;
const api = window.SkriftBackendAPI;
// Prüfe ob Backend konfiguriert ist
if (!backend.api_url) {
console.warn('[Backend Integration] Backend API URL nicht konfiguriert');
// Fallback zur alten Logik
return handleOrderSubmitLegacy(state);
}
try {
// 1. Backend Health Check
const isHealthy = await api.healthCheck();
if (!isHealthy) {
throw new Error('Backend ist nicht erreichbar');
}
// 2. Bestellnummer generieren (fortlaufend vom WP-Backend)
const orderNumber = await api.generateOrderNumber();
// 3. Daten vorbereiten
const letters = prepareLettersForBackend(state);
const envelopes = prepareEnvelopesForBackend(state);
const metadata = prepareOrderMetadata(state);
console.log('[Backend Integration] Generating order:', {
orderNumber,
letters,
envelopes,
metadata,
});
// 4. Order im Backend generieren
const result = await api.generateOrder(
orderNumber,
letters,
envelopes,
metadata
);
if (!result.success) {
throw new Error(result.error || 'Order generation failed');
}
console.log('[Backend Integration] Order generated successfully:', result);
// 5. Gutschein als verwendet markieren (falls vorhanden)
const voucherCode = state.order?.voucherStatus?.valid
? state.order.voucherCode
: null;
if (voucherCode) {
try {
const restUrl = window.SkriftConfigurator?.restUrl || "/wp-json/";
await fetch(restUrl + "skrift/v1/voucher/use", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ code: voucherCode }),
});
} catch (error) {
console.warn('[Backend Integration] Fehler beim Markieren des Gutscheins:', error);
}
}
// 6. Webhook aufrufen (wenn konfiguriert)
if (webhookUrl) {
try {
const webhookData = buildWebhookData(state, result);
const response = await fetch(webhookUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(webhookData),
});
if (!response.ok) {
console.warn('[Backend Integration] Webhook call failed:', response.statusText);
}
} catch (error) {
console.warn('[Backend Integration] Webhook error:', error);
}
}
// 7. Weiterleitung
if (isB2B) {
if (redirectUrlBusiness) {
// Bestellnummer als Query-Parameter anhängen
const redirectUrl = new URL(redirectUrlBusiness);
redirectUrl.searchParams.set('orderNumber', result.orderNumber);
window.location.href = redirectUrl.toString();
} else {
alert(
`Vielen Dank für Ihre Bestellung!\n\nBestellnummer: ${result.orderNumber}\n\nSie erhalten in Kürze eine Bestätigungs-E-Mail mit allen Details.`
);
}
} else {
// Privatkunde: Zu PayPal weiterleiten
if (redirectUrlPrivate) {
const redirectUrl = new URL(redirectUrlPrivate);
redirectUrl.searchParams.set('orderNumber', result.orderNumber);
window.location.href = redirectUrl.toString();
} else {
alert(
`Bestellung erfolgreich erstellt!\n\nBestellnummer: ${result.orderNumber}\n\nWeiterleitung zu PayPal folgt...`
);
}
}
} catch (error) {
console.error('[Backend Integration] Order submission failed:', error);
alert(
`Fehler bei der Bestellverarbeitung:\n\n${error.message}\n\nBitte versuchen Sie es erneut oder kontaktieren Sie uns.`
);
}
}
/**
* Legacy Order-Submit (Fallback ohne Backend)
*/
async function handleOrderSubmitLegacy(state) {
const isB2B = state.answers?.customerType === "business";
const backend = window.SkriftConfigurator?.settings?.backend_connection || {};
const webhookUrl = backend.order_webhook_url;
const redirectUrlBusiness = backend.redirect_url_business;
const redirectUrlPrivate = backend.redirect_url_private;
// Gutschein als verwendet markieren
const voucherCode = state.order?.voucherStatus?.valid
? state.order.voucherCode
: null;
if (voucherCode) {
try {
const restUrl = window.SkriftConfigurator?.restUrl || "/wp-json/";
await fetch(restUrl + "skrift/v1/voucher/use", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ code: voucherCode }),
});
} catch (error) {
console.warn('Fehler beim Markieren des Gutscheins:', error);
}
}
// Webhook aufrufen
if (isB2B && webhookUrl) {
try {
const webhookData = buildWebhookData(state, null);
await fetch(webhookUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(webhookData),
});
} catch (error) {
console.warn('Webhook error:', error);
}
}
// Weiterleitung
if (isB2B && redirectUrlBusiness) {
window.location.href = redirectUrlBusiness;
} else if (!isB2B && redirectUrlPrivate) {
window.location.href = redirectUrlPrivate;
} else {
alert("Vielen Dank für Ihre Bestellung!");
}
}
export default {
handleOrderSubmitWithBackend,
prepareLettersForBackend,
prepareEnvelopesForBackend,
mapFontToBackend,
mapFormatToBackend,
buildWebhookData,
};

View File

@@ -0,0 +1,623 @@
/**
* Preview Manager - Verwaltet Preview-Generation mit Batch-Loading, Navigation und Rate-Limiting
*/
import { preparePlaceholdersForIndex } from './configurator-utils.js';
class PreviewManager {
constructor(api) {
this.api = api;
this.currentBatchIndex = 0;
this.currentDocIndex = 0;
this.previewCount = 0;
this.batchSize = 25;
this.currentBatchPreviews = [];
this.requestsRemaining = 10;
this.maxRequests = 10;
// Für Änderungserkennung und Validierungs-Caching
this.lastValidatedTextHash = null;
this.lastValidationResult = null;
this.lastOverflowFiles = null; // null = keine Validierung durchgeführt
}
/**
* Erzeugt einen einfachen Hash für Text-Vergleich
*/
hashText(text, quantity, format, font) {
const str = `${text || ''}|${quantity || 1}|${format || 'a4'}|${font || 'tilda'}`;
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return hash.toString(36);
}
/**
* Prüft ob sich der Text seit der letzten Validierung geändert hat
*/
hasTextChanged(state) {
const currentHash = this.hashText(
state.answers?.letterText,
state.answers?.quantity,
state.answers?.format,
state.answers?.font
);
return currentHash !== this.lastValidatedTextHash;
}
/**
* Speichert den aktuellen Text-Hash nach Validierung
*/
saveTextHash(state) {
this.lastValidatedTextHash = this.hashText(
state.answers?.letterText,
state.answers?.quantity,
state.answers?.format,
state.answers?.font
);
}
/**
* Bereitet Platzhalter für ein bestimmtes Dokument vor
* Verwendet jetzt die gemeinsame Utility-Funktion
*/
preparePlaceholders(state, index) {
return preparePlaceholdersForIndex(state, index);
}
/**
* Generiert ein Letter-Objekt für ein bestimmtes Dokument
*/
prepareLetter(state, index, isEnvelope = false) {
const placeholders = this.preparePlaceholders(state, index);
if (isEnvelope) {
const isRecipientMode = state.answers?.envelopeMode === 'recipientData';
const isCustomMode = state.answers?.envelopeMode === 'customText';
const addressMode = state.addressMode || 'classic';
let envelopeText = '';
let envelopeType = 'recipient';
if (isRecipientMode) {
if (addressMode === 'free' && Array.isArray(state.freeAddressRows) && state.freeAddressRows.length > index) {
// Freie Adresse: Bis zu 5 Zeilen
const freeAddr = state.freeAddressRows[index];
const lines = [];
if (freeAddr.line1) lines.push(freeAddr.line1);
if (freeAddr.line2) lines.push(freeAddr.line2);
if (freeAddr.line3) lines.push(freeAddr.line3);
if (freeAddr.line4) lines.push(freeAddr.line4);
if (freeAddr.line5) lines.push(freeAddr.line5);
envelopeText = lines.join('\n');
envelopeType = 'free';
} else if (addressMode === 'classic' && Array.isArray(state.recipientRows) && state.recipientRows.length > index) {
// Klassische Adresse
const recipient = state.recipientRows[index];
const lines = [];
const fullName = `${recipient.firstName || ''} ${recipient.lastName || ''}`.trim();
if (fullName) lines.push(fullName);
const streetLine = `${recipient.street || ''} ${recipient.houseNumber || ''}`.trim();
if (streetLine) lines.push(streetLine);
const location = `${recipient.zip || ''} ${recipient.city || ''}`.trim();
if (location) lines.push(location);
if (recipient.country && recipient.country !== 'Deutschland') {
lines.push(recipient.country);
}
envelopeText = lines.join('\n');
envelopeType = 'recipient';
}
} else if (isCustomMode) {
envelopeText = state.answers?.envelopeCustomText || '';
envelopeType = 'custom';
}
return {
index: index,
text: envelopeText,
font: state.answers?.envelopeFont || 'tilda',
format: state.answers?.format === 'a4' ? 'DIN_LANG' : 'C6',
placeholders: placeholders,
type: 'envelope',
envelopeType: envelopeType,
envelope: {
type: envelopeType
}
};
} else {
return {
index: index,
text: state.answers?.letterText || state.answers?.text || state.answers?.briefText || '',
font: state.answers?.font || 'tilda',
format: state.answers?.format || 'a4',
placeholders: placeholders,
type: 'letter',
realisticHandwriting: state.answers?.realisticHandwriting ?? false,
};
}
}
/**
* Lädt ALLE Previews auf einmal
* @param {boolean} skipLimitCheck - Wenn true, wird das Request-Limit ignoriert (für Validierung)
*/
async loadAllPreviews(state, isEnvelope = false, skipLimitCheck = false) {
// Request-Limit nur prüfen wenn nicht übersprungen (normale Preview-Generierung)
if (!skipLimitCheck) {
if (this.requestsRemaining <= 0) {
throw new Error(`Maximale Anzahl von ${this.maxRequests} Vorschau-Anfragen erreicht.`);
}
this.requestsRemaining--;
}
const letters = [];
for (let i = 0; i < this.previewCount; i++) {
letters.push(this.prepareLetter(state, i, isEnvelope));
}
try {
const result = await this.api.generatePreviewBatch(letters);
if (!result.success) {
throw new Error(result.error || 'Preview generation failed');
}
this.currentBatchPreviews = result.previews;
this.lastValidationResult = result; // Speichere für Overflow-Check
return true;
} catch (error) {
console.error('[PreviewManager] Load error:', error);
throw error;
}
}
/**
* Validiert Textlänge durch Preview-Generierung
* Wenn Text nicht geändert wurde und Preview bereits existiert, wird gecachtes Ergebnis verwendet
* @returns {Object} { valid: boolean, overflowFiles: Array, fromCache: boolean }
*/
async validateTextLength(state, forceRevalidate = false) {
// Prüfe ob wir gecachtes Ergebnis verwenden können
if (!forceRevalidate && !this.hasTextChanged(state) && this.lastOverflowFiles !== null) {
console.log('[PreviewManager] Using cached validation result');
return {
valid: this.lastOverflowFiles.length === 0,
overflowFiles: this.lastOverflowFiles,
fromCache: true
};
}
this.previewCount = parseInt(state.answers?.quantity) || 1;
try {
// skipLimitCheck = true: Validierung soll immer möglich sein, auch ohne verbleibende Anfragen
await this.loadAllPreviews(state, false, true);
const result = this.lastValidationResult;
if (!result) {
this.lastOverflowFiles = [];
this.saveTextHash(state);
return { valid: true, overflowFiles: [], fromCache: false };
}
const hasOverflow = result.hasOverflow || false;
const overflowFiles = (result.overflowFiles || []).map(f => ({
index: f.index,
lineCount: f.lineCount,
lineLimit: f.lineLimit
}));
// Cache das Ergebnis
this.lastOverflowFiles = overflowFiles;
this.saveTextHash(state);
return {
valid: !hasOverflow,
overflowFiles: overflowFiles,
fromCache: false
};
} catch (error) {
console.error('[PreviewManager] Validation error:', error);
// Bei Fehlern: Nicht durchlassen - Nutzer muss es erneut versuchen
return {
valid: false,
overflowFiles: [],
error: error.message,
fromCache: false
};
}
}
/**
* Gibt gecachte Overflow-Infos zurück (ohne neue Anfrage)
*/
getCachedOverflowFiles() {
return this.lastOverflowFiles || [];
}
/**
* Prüft ob Validierung bereits erfolgt ist (für UI-Anzeige)
*/
hasValidationResult() {
return this.lastOverflowFiles !== null;
}
/**
* Generiert Previews und zeigt Overflow-Warnung wenn nötig
* @returns {Object} { success: boolean, hasOverflow: boolean, overflowFiles: Array }
*/
async generatePreviews(state, dom, isEnvelope = false) {
const btn = dom.querySelector('.sk-preview-generate-btn');
const statusEl = dom.querySelector('.sk-preview-status');
const requestCounterEl = dom.querySelector('.sk-preview-request-counter');
if (!btn) return { success: false, hasOverflow: false, overflowFiles: [] };
try {
btn.disabled = true;
btn.textContent = 'Generiere Vorschau...';
this.previewCount = parseInt(state.answers?.quantity) || 1;
if (statusEl) {
statusEl.textContent = `Lade alle ${this.previewCount} Dokumente...`;
}
await this.loadAllPreviews(state, isEnvelope);
// Text-Hash speichern für Änderungserkennung
if (!isEnvelope) {
this.saveTextHash(state);
}
this.currentDocIndex = 0;
this.showPreview(0, dom);
this.showNavigationControls(dom, state, isEnvelope);
if (statusEl) {
statusEl.textContent = `Dokument 1 von ${this.previewCount}`;
}
if (requestCounterEl) {
requestCounterEl.textContent = `Verbleibende Vorschau-Anfragen: ${this.requestsRemaining}/${this.maxRequests}`;
}
btn.disabled = false;
btn.textContent = btn.textContent.includes('Umschlag') ? 'Umschlag Vorschau generieren' : 'Vorschau Schriftstück generieren';
// Overflow-Prüfung für Briefe (nicht Umschläge)
if (!isEnvelope && this.lastValidationResult) {
const hasOverflow = this.lastValidationResult.hasOverflow || false;
const overflowFiles = (this.lastValidationResult.overflowFiles || []).map(f => ({
index: f.index,
lineCount: f.lineCount,
lineLimit: f.lineLimit
}));
// Cache speichern
this.lastOverflowFiles = overflowFiles;
return { success: true, hasOverflow, overflowFiles };
}
return { success: true, hasOverflow: false, overflowFiles: [] };
} catch (error) {
console.error('[PreviewManager] Error:', error);
btn.disabled = false;
btn.textContent = isEnvelope ? 'Umschlag Vorschau generieren' : 'Vorschau generieren';
// Fehlermeldung im Preview-Bereich anzeigen
const previewBox = dom.querySelector('.sk-preview-box');
if (previewBox) {
previewBox.innerHTML = '';
const notice = document.createElement('div');
notice.style.cssText = 'padding: 20px; text-align: center; color: #666;';
notice.innerHTML = `
<p style="margin-bottom: 15px;">Die Vorschau konnte nicht generiert werden.</p>
<p style="color: #999; font-size: 13px;">Bitte versuchen Sie es erneut oder kontaktieren Sie uns bei anhaltenden Problemen.</p>
`;
previewBox.appendChild(notice);
}
return { success: false, hasOverflow: false, overflowFiles: [], error: error.message };
}
}
/**
* Zeigt eine Preview an einem bestimmten Index
*/
showPreview(index, dom) {
const preview = this.currentBatchPreviews[index];
if (!preview) {
console.warn('[PreviewManager] Preview not loaded:', index);
return;
}
const previewBox = dom.querySelector('.sk-preview-box');
const statusEl = dom.querySelector('.sk-preview-status');
if (!previewBox) {
console.warn('[PreviewManager] Preview box not found');
return;
}
const imgContainer = document.createElement('div');
imgContainer.style.cssText = 'position: relative; overflow: hidden; margin-top: 15px; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;';
const img = document.createElement('img');
img.src = `${this.api.baseURL}${preview.url}?t=${Date.now()}`;
img.style.cssText = 'width: 100%; display: block;';
imgContainer.addEventListener('click', () => {
this.showFullscreenPreview(img.src);
});
imgContainer.addEventListener('mouseenter', () => {
imgContainer.style.opacity = '0.9';
});
imgContainer.addEventListener('mouseleave', () => {
imgContainer.style.opacity = '1';
});
imgContainer.appendChild(img);
previewBox.innerHTML = '';
previewBox.appendChild(imgContainer);
if (statusEl) {
statusEl.textContent = `Dokument ${index + 1} von ${this.previewCount}`;
}
this.currentDocIndex = index;
}
/**
* Navigiert zur nächsten/vorherigen Preview
*/
navigateWithinBatch(direction, dom) {
const newIndex = this.currentDocIndex + direction;
if (newIndex < 0 || newIndex >= this.currentBatchPreviews.length) {
return;
}
this.showPreview(newIndex, dom);
this.updateNavigationButtons();
}
// navigateToBatch wurde entfernt - Batch-Loading wird nicht mehr verwendet
// Alle Previews werden jetzt auf einmal geladen (loadAllPreviews)
/**
* Ermittelt den aktuellen lokalen Index
*/
getCurrentLocalIndex(dom) {
const statusEl = dom.querySelector('.sk-preview-status');
if (!statusEl) return 0;
const match = statusEl.textContent.match(/Dokument (\d+) von/);
if (match) {
return parseInt(match[1]) - 1;
}
return 0;
}
/**
* Aktualisiert den Request Counter im DOM
*/
updateRequestCounter(dom) {
const requestCounterEl = dom.querySelector('.sk-preview-request-counter');
if (requestCounterEl) {
requestCounterEl.textContent = `Verbleibende Vorschau-Anfragen: ${this.requestsRemaining}/${this.maxRequests}`;
}
}
/**
* Aktualisiert Button-States der Navigation
*/
updateNavigationButtons() {
const navWrapper = document.querySelector('.sk-preview-navigation-container');
if (!navWrapper) return;
const navContainer = navWrapper.querySelector('.sk-preview-navigation');
if (!navContainer) return;
const prevDocBtn = navContainer.querySelector('.sk-preview-prev-doc');
const nextDocBtn = navContainer.querySelector('.sk-preview-next-doc');
if (prevDocBtn) {
const canPrev = this.currentDocIndex > 0;
prevDocBtn.disabled = !canPrev;
prevDocBtn.style.opacity = canPrev ? '1' : '0.5';
prevDocBtn.style.cursor = canPrev ? 'pointer' : 'not-allowed';
}
if (nextDocBtn) {
const canNext = this.currentDocIndex < this.currentBatchPreviews.length - 1;
nextDocBtn.disabled = !canNext;
nextDocBtn.style.opacity = canNext ? '1' : '0.5';
nextDocBtn.style.cursor = canNext ? 'pointer' : 'not-allowed';
}
}
/**
* Zeigt Navigation Controls
*/
showNavigationControls(dom, state, isEnvelope) {
const navWrapper = dom.querySelector('.sk-preview-navigation-container');
if (!navWrapper) return;
let navContainer = navWrapper.querySelector('.sk-preview-navigation');
if (navContainer) {
navContainer.remove();
}
navContainer = document.createElement('div');
navContainer.className = 'sk-preview-navigation';
navContainer.style.display = 'flex';
navContainer.style.gap = '10px';
navContainer.style.alignItems = 'center';
const buttonStyle = {
padding: '8px 12px',
fontSize: '20px',
lineHeight: '1',
minWidth: '40px',
backgroundColor: '#fff',
border: '1px solid #ddd',
borderRadius: '4px',
cursor: 'pointer'
};
const prevDocBtn = document.createElement('button');
prevDocBtn.type = 'button';
prevDocBtn.className = 'sk-btn sk-btn-secondary sk-preview-prev-doc';
prevDocBtn.textContent = '‹';
prevDocBtn.title = 'Vorheriges Dokument';
Object.assign(prevDocBtn.style, buttonStyle);
prevDocBtn.onclick = () => {
this.navigateWithinBatch(-1, dom);
};
const nextDocBtn = document.createElement('button');
nextDocBtn.type = 'button';
nextDocBtn.className = 'sk-btn sk-btn-secondary sk-preview-next-doc';
nextDocBtn.textContent = '›';
nextDocBtn.title = 'Nächstes Dokument';
Object.assign(nextDocBtn.style, buttonStyle);
nextDocBtn.onclick = () => {
this.navigateWithinBatch(1, dom);
};
navContainer.appendChild(prevDocBtn);
navContainer.appendChild(nextDocBtn);
navWrapper.appendChild(navContainer);
this.updateNavigationButtons();
}
/**
* Keyboard Event Handler
*/
handleKeyboardNavigation(event, state, dom, isEnvelope = false) {
if (this.currentBatchPreviews.length === 0) return;
const currentLocalIndex = this.getCurrentLocalIndex(dom);
if (event.key === 'ArrowLeft' && currentLocalIndex > 0) {
event.preventDefault();
this.navigateWithinBatch(-1, dom);
} else if (event.key === 'ArrowRight' && currentLocalIndex < this.currentBatchPreviews.length - 1) {
event.preventDefault();
this.navigateWithinBatch(1, dom);
}
}
/**
* Cleanup
*/
destroy() {
this.currentBatchPreviews = [];
this.currentBatchIndex = 0;
this.previewCount = 0;
}
/**
* Reset für neue Session
*/
reset() {
this.currentBatchPreviews = [];
this.currentBatchIndex = 0;
this.previewCount = 0;
this.requestsRemaining = this.maxRequests;
}
/**
* Zeigt Fullscreen-Vorschau als Modal
*/
showFullscreenPreview(imgSrc) {
const existingModal = document.getElementById('sk-preview-fullscreen-modal');
if (existingModal) {
existingModal.remove();
}
const modal = document.createElement('div');
modal.id = 'sk-preview-fullscreen-modal';
modal.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.9);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
cursor: pointer;
`;
const modalImg = document.createElement('img');
modalImg.src = imgSrc;
modalImg.style.cssText = `
max-width: 90vw;
max-height: 90vh;
object-fit: contain;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
`;
const closeBtn = document.createElement('button');
closeBtn.innerHTML = '×';
closeBtn.style.cssText = `
position: absolute;
top: 20px;
right: 30px;
font-size: 40px;
color: white;
background: none;
border: none;
cursor: pointer;
line-height: 1;
`;
const hint = document.createElement('div');
hint.textContent = 'Klicken zum Schließen';
hint.style.cssText = `
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
color: rgba(255, 255, 255, 0.7);
font-size: 14px;
`;
modal.appendChild(modalImg);
modal.appendChild(closeBtn);
modal.appendChild(hint);
const closeModal = () => modal.remove();
modal.addEventListener('click', closeModal);
closeBtn.addEventListener('click', closeModal);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeModal();
}, { once: true });
document.body.appendChild(modal);
}
}
// Globale Instanzen
window.envelopePreviewManager = null;
window.contentPreviewManager = null;
export default PreviewManager;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,96 @@
/**
* Gemeinsame Utility-Funktionen für Skrift Konfigurator
*/
/**
* Bereitet Platzhalter für einen bestimmten Index vor
* Wird von PreviewManager und Backend-Integration verwendet
*/
export function preparePlaceholdersForIndex(state, index) {
const placeholders = {};
// Prüfen ob strukturierte Empfängerdaten (klassische Adresse) im aktuellen Flow verwendet werden:
// - Adressmodus muss 'classic' sein (bei 'free' gibt es keine Felder wie vorname, name etc.)
// - UND: Direktversand ODER Kuvert mit Empfängeradresse
const isClassicAddress = (state.addressMode || 'classic') === 'classic';
const needsRecipientData = isClassicAddress && (
state.answers?.shippingMode === 'direct' ||
(state.answers?.envelope === true && state.answers?.envelopeMode === 'recipientData')
);
// Empfänger-Feldnamen, die aus recipientRows kommen können
const recipientFields = ['vorname', 'name', 'ort', 'strasse', 'hausnummer', 'plz', 'land'];
// Platzhalter aus placeholderValues extrahieren
if (state.placeholderValues) {
for (const [name, values] of Object.entries(state.placeholderValues)) {
// Empfängerfelder nur überspringen wenn sie aus recipientRows kommen
if (needsRecipientData && recipientFields.includes(name)) {
continue;
}
if (Array.isArray(values) && values.length > index) {
placeholders[name] = values[index];
}
}
}
// Empfängerdaten aus recipientRows hinzufügen (nur wenn benötigt)
if (needsRecipientData && Array.isArray(state.recipientRows) && state.recipientRows.length > index) {
const recipient = state.recipientRows[index];
placeholders['vorname'] = recipient.firstName || '';
placeholders['name'] = recipient.lastName || '';
placeholders['ort'] = recipient.city || '';
placeholders['strasse'] = recipient.street || '';
placeholders['hausnummer'] = recipient.houseNumber || '';
placeholders['plz'] = recipient.zip || '';
placeholders['land'] = recipient.country || '';
}
return placeholders;
}
/**
* Validiert Empfängerzeilen (gemeinsame Logik für klassische und freie Adressen)
*/
export function validateRecipientRows(state, requiredCount) {
const addressMode = state.addressMode || 'classic';
if (addressMode === 'free') {
// Freie Adresse: mindestens Zeile 1 muss ausgefüllt sein
const rows = state.freeAddressRows || [];
if (rows.length !== requiredCount) return false;
for (const r of rows) {
if (!r) return false;
if (!String(r.line1 || "").trim()) return false;
}
} else {
// Klassische Adresse
const rows = state.recipientRows || [];
if (rows.length !== requiredCount) return false;
const required = [
"firstName",
"lastName",
"street",
"houseNumber",
"zip",
"city",
"country",
];
for (const r of rows) {
if (!r) return false;
for (const k of required) {
if (!String(r[k] || "").trim()) return false;
}
}
}
return true;
}
export default {
preparePlaceholdersForIndex,
validateRecipientRows,
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,110 @@
<?php
/**
* Schnelle Datenbank-Prüfung
* Aufruf: /wp-content/plugins/skrift-konfigurator/check-db.php
*/
// WordPress laden
require_once('../../../wp-load.php');
// Sicherheit
if (!current_user_can('manage_options')) {
die('Keine Berechtigung');
}
header('Content-Type: text/html; charset=utf-8');
?>
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Gutschein DB Check</title>
<style>
body { font-family: monospace; padding: 20px; background: #f5f5f5; }
.box { background: white; padding: 20px; margin: 10px 0; border: 2px solid #333; }
.error { background: #ffebee; border-color: #c62828; }
.success { background: #e8f5e9; border-color: #2e7d32; }
pre { background: #f5f5f5; padding: 10px; overflow-x: auto; }
h2 { margin-top: 0; }
</style>
</head>
<body>
<h1>🔍 Gutschein Datenbank Check</h1>
<?php
// 1. Direkt aus DB
$vouchers = get_option('skrift_konfigurator_vouchers', []);
$count = count($vouchers);
?>
<div class="box <?php echo $count > 0 ? 'success' : 'error'; ?>">
<h2>1. Datenbank Status</h2>
<p><strong>Anzahl Gutscheine:</strong> <?php echo $count; ?></p>
<?php if ($count === 0): ?>
<p style="color: #c62828;">⚠️ <strong>KEINE GUTSCHEINE IN DER DATENBANK!</strong></p>
<?php else: ?>
<p style="color: #2e7d32;">✅ Gutscheine gefunden!</p>
<?php endif; ?>
</div>
<div class="box">
<h2>2. Rohe Daten (PHP)</h2>
<pre><?php var_dump($vouchers); ?></pre>
</div>
<div class="box">
<h2>3. JSON Encoding (wie im Frontend)</h2>
<pre><?php echo wp_json_encode($vouchers, JSON_PRETTY_PRINT); ?></pre>
</div>
<div class="box">
<h2>4. JavaScript Test</h2>
<script>
const vouchers = <?php echo wp_json_encode($vouchers); ?>;
console.log('Vouchers:', vouchers);
console.log('Type:', Array.isArray(vouchers) ? 'ARRAY ❌' : 'OBJECT ✅');
console.log('Keys:', Object.keys(vouchers));
console.log('Count:', Object.keys(vouchers).length);
</script>
<p>Öffnen Sie die Browser Console (F12) für JavaScript-Output</p>
</div>
<?php if ($count === 0): ?>
<div class="box error">
<h2>⚠️ Lösung: Gutscheine erstellen</h2>
<p>Es sind keine Gutscheine in der Datenbank. Bitte:</p>
<ol>
<li>Gehen Sie zu: <a href="<?php echo admin_url('options-general.php?page=skrift-vouchers'); ?>">Gutschein-Verwaltung</a></li>
<li>Erstellen Sie einen Test-Gutschein (z.B. Code: TEST10, Typ: Prozent, Wert: 10)</li>
<li>Oder führen Sie <code>create-test-voucher.php</code> aus</li>
</ol>
</div>
<?php endif; ?>
<div class="box">
<h2>5. Schnell-Fix: Test-Gutschein erstellen</h2>
<form method="post">
<button type="submit" name="create_test" style="padding: 10px 20px; font-size: 16px; cursor: pointer;">
🚀 Test-Gutschein "TEST10" jetzt erstellen
</button>
</form>
<?php
if (isset($_POST['create_test'])) {
$vouchers['TEST10'] = [
'code' => 'TEST10',
'type' => 'percent',
'value' => 10,
'expiry_date' => '',
'usage_limit' => 0,
'usage_count' => 0,
];
update_option('skrift_konfigurator_vouchers', $vouchers);
echo '<p style="color: #2e7d32; font-weight: bold;">✅ Gutschein TEST10 wurde erstellt! Seite neu laden...</p>';
echo '<script>setTimeout(() => location.reload(), 1500);</script>';
}
?>
</div>
</body>
</html>

View File

@@ -0,0 +1,61 @@
<?php
/**
* EINMALIG AUSFÜHREN: Test-Gutschein erstellen
*
* Anleitung:
* 1. Diese Datei in den Plugin-Ordner legen
* 2. Im Browser aufrufen: https://ihre-domain.de/wp-content/plugins/skrift-konfigurator/create-test-voucher.php
* 3. Nach erfolgreicher Ausführung diese Datei LÖSCHEN
*/
// WordPress laden
require_once('../../../wp-load.php');
// Sicherheit: Nur für Admins
if (!current_user_can('manage_options')) {
die('Keine Berechtigung');
}
// Test-Gutscheine erstellen
$vouchers = get_option('skrift_konfigurator_vouchers', []);
// Test-Gutschein 1: 10% Rabatt
$vouchers['TEST10'] = [
'code' => 'TEST10',
'type' => 'percent',
'value' => 10,
'expiry_date' => '', // Unbegrenzt
'usage_limit' => 0, // Unbegrenzt
'usage_count' => 0,
];
// Test-Gutschein 2: 5€ Rabatt
$vouchers['SAVE5'] = [
'code' => 'SAVE5',
'type' => 'fixed',
'value' => 5.00,
'expiry_date' => '', // Unbegrenzt
'usage_limit' => 0, // Unbegrenzt
'usage_count' => 0,
];
// Test-Gutschein 3: 20% Rabatt mit Limit
$vouchers['WELCOME20'] = [
'code' => 'WELCOME20',
'type' => 'percent',
'value' => 20,
'expiry_date' => date('Y-m-d', strtotime('+30 days')), // 30 Tage gültig
'usage_limit' => 10, // Max 10x einlösbar
'usage_count' => 0,
];
update_option('skrift_konfigurator_vouchers', $vouchers);
echo '<h1>✓ Test-Gutscheine erfolgreich erstellt!</h1>';
echo '<ul>';
echo '<li><strong>TEST10</strong> - 10% Rabatt (unbegrenzt)</li>';
echo '<li><strong>SAVE5</strong> - 5,00€ Rabatt (unbegrenzt)</li>';
echo '<li><strong>WELCOME20</strong> - 20% Rabatt (30 Tage gültig, max. 10x)</li>';
echo '</ul>';
echo '<p><a href="' . admin_url('options-general.php?page=skrift-vouchers') . '">→ Gutscheine im Backend anzeigen</a></p>';
echo '<p style="color: red;"><strong>WICHTIG: Bitte löschen Sie diese Datei jetzt aus Sicherheitsgründen!</strong></p>';

View File

@@ -0,0 +1,72 @@
<?php
/**
* DEBUG: Gutscheine prüfen
* Aufruf: /wp-content/plugins/skrift-konfigurator/debug-vouchers.php
*/
// WordPress laden
require_once('../../../wp-load.php');
// Sicherheit: Nur für Admins
if (!current_user_can('manage_options')) {
die('Keine Berechtigung');
}
echo '<h1>Gutschein Debug</h1>';
echo '<style>pre { background: #f5f5f5; padding: 15px; border: 1px solid #ddd; }</style>';
// 1. Direkt aus Datenbank lesen
$vouchers_db = get_option('skrift_konfigurator_vouchers', []);
echo '<h2>1. Direkt aus Datenbank (get_option)</h2>';
echo '<pre>';
print_r($vouchers_db);
echo '</pre>';
// 2. Über die Klasse
require_once plugin_dir_path(__FILE__) . 'includes/admin-vouchers.php';
$vouchers_class = Skrift_Konfigurator_Vouchers::get_vouchers();
echo '<h2>2. Über Klassen-Methode get_vouchers()</h2>';
echo '<pre>';
print_r($vouchers_class);
echo '</pre>';
// 3. JSON-Encoding prüfen (wie wp_localize_script es macht)
$vouchers_json = json_encode($vouchers_class);
echo '<h2>3. JSON-Encoded (wie wp_localize_script)</h2>';
echo '<pre>';
echo htmlspecialchars($vouchers_json);
echo '</pre>';
// 4. Zurück decodiert
$vouchers_decoded = json_decode($vouchers_json, true);
echo '<h2>4. JSON wieder decodiert</h2>';
echo '<pre>';
print_r($vouchers_decoded);
echo '</pre>';
// 5. Test: Ist es ein assoziatives Array oder Objekt?
echo '<h2>5. Datentyp-Analyse</h2>';
echo '<pre>';
echo 'is_array: ' . (is_array($vouchers_class) ? 'JA' : 'NEIN') . "\n";
echo 'count: ' . count($vouchers_class) . "\n";
echo 'empty: ' . (empty($vouchers_class) ? 'JA' : 'NEIN') . "\n";
echo 'Keys: ' . print_r(array_keys($vouchers_class), true) . "\n";
echo '</pre>';
// 6. Simuliere wp_localize_script
echo '<h2>6. Simuliertes wp_localize_script Output</h2>';
echo '<script>';
echo "\n";
echo 'var SkriftConfigurator = {';
echo "\n";
echo ' "version": "0.3.0",';
echo "\n";
echo ' "vouchers": ' . json_encode($vouchers_class);
echo "\n";
echo '};';
echo "\n";
echo 'console.log("Vouchers from simulated wp_localize_script:", SkriftConfigurator.vouchers);';
echo "\n";
echo '</script>';
echo '<p><strong>Öffnen Sie die Browser-Console, um das simulierte Output zu sehen!</strong></p>';

View File

@@ -0,0 +1,186 @@
<?php
/**
* Bestellnummer-Verwaltung für Skrift Konfigurator
* Generiert fortlaufende Bestellnummern im Format: S-JAHR-MONAT-TAG-XXX
*/
if (!defined('ABSPATH')) { exit; }
final class Skrift_Konfigurator_Orders {
const COUNTER_OPTION_KEY = 'skrift_konfigurator_order_counter';
const ORDERS_OPTION_KEY = 'skrift_konfigurator_orders';
public function __construct() {
add_action('rest_api_init', [$this, 'register_rest_routes']);
}
/**
* REST API Routen registrieren
*/
public function register_rest_routes() {
// Neue Bestellnummer generieren
register_rest_route('skrift/v1', '/order/generate-number', [
'methods' => 'POST',
'callback' => [$this, 'rest_generate_order_number'],
'permission_callback' => ['Skrift_Konfigurator_Admin_Settings', 'rest_api_key_permission'],
]);
// Bestellung registrieren (nach erfolgreicher Zahlung)
register_rest_route('skrift/v1', '/order/register', [
'methods' => 'POST',
'callback' => [$this, 'rest_register_order'],
'permission_callback' => ['Skrift_Konfigurator_Admin_Settings', 'rest_api_key_permission'],
]);
}
/**
* Generiert die nächste fortlaufende Bestellnummer
* Format: S-YYYY-MM-DD-XXX (z.B. S-2026-01-12-001)
* Verwendet Transient-Lock um Race Conditions zu vermeiden
*/
public static function generate_order_number() {
$lock_key = 'skrift_order_number_lock';
$max_attempts = 10;
$attempt = 0;
// Versuche Lock zu bekommen (einfaches Locking mit Transients)
while ($attempt < $max_attempts) {
// Prüfe ob Lock existiert
if (get_transient($lock_key) === false) {
// Setze Lock (5 Sekunden Timeout)
set_transient($lock_key, time(), 5);
// Aktuellen Counter holen
$counter_data = get_option(self::COUNTER_OPTION_KEY, [
'date' => '',
'counter' => 0
]);
$today = date('Y-m-d');
// Counter zurücksetzen wenn neuer Tag
if ($counter_data['date'] !== $today) {
$counter_data = [
'date' => $today,
'counter' => 0
];
}
// Counter erhöhen
$counter_data['counter']++;
// Speichern
update_option(self::COUNTER_OPTION_KEY, $counter_data);
// Lock freigeben
delete_transient($lock_key);
// Format: S-YYYY-MM-DD-XXX
$year = date('Y');
$month = date('m');
$day = date('d');
$number = str_pad($counter_data['counter'], 3, '0', STR_PAD_LEFT);
return "S-{$year}-{$month}-{$day}-{$number}";
}
// Warte kurz und versuche erneut
usleep(100000); // 100ms
$attempt++;
}
// Fallback wenn Lock nicht bekommen werden konnte
// Verwende Microtime für Eindeutigkeit
$year = date('Y');
$month = date('m');
$day = date('d');
$micro = substr(str_replace('.', '', microtime(true)), -6);
return "S-{$year}-{$month}-{$day}-{$micro}";
}
/**
* REST API Endpoint: Neue Bestellnummer generieren
*/
public function rest_generate_order_number($request) {
$order_number = self::generate_order_number();
return [
'success' => true,
'orderNumber' => $order_number
];
}
/**
* REST API Endpoint: Bestellung registrieren
*/
public function rest_register_order($request) {
$order_number = $request->get_param('orderNumber');
$customer = $request->get_param('customer');
$quote = $request->get_param('quote');
$payment_method = $request->get_param('paymentMethod');
$payment_id = $request->get_param('paymentId');
if (empty($order_number)) {
return new WP_Error('missing_order_number', 'Bestellnummer fehlt', ['status' => 400]);
}
// Bestellung speichern
$orders = get_option(self::ORDERS_OPTION_KEY, []);
$orders[$order_number] = [
'orderNumber' => $order_number,
'customer' => $customer,
'quote' => $quote,
'paymentMethod' => $payment_method,
'paymentId' => $payment_id,
'createdAt' => current_time('mysql'),
'status' => 'pending'
];
update_option(self::ORDERS_OPTION_KEY, $orders);
return [
'success' => true,
'orderNumber' => $order_number,
'message' => 'Bestellung erfolgreich registriert'
];
}
/**
* Bestellung als bezahlt markieren
*/
public static function mark_order_paid($order_number, $payment_id = null) {
$orders = get_option(self::ORDERS_OPTION_KEY, []);
if (isset($orders[$order_number])) {
$orders[$order_number]['status'] = 'paid';
$orders[$order_number]['paidAt'] = current_time('mysql');
if ($payment_id) {
$orders[$order_number]['paymentId'] = $payment_id;
}
update_option(self::ORDERS_OPTION_KEY, $orders);
return true;
}
return false;
}
/**
* Alle Bestellungen abrufen
*/
public static function get_orders() {
return get_option(self::ORDERS_OPTION_KEY, []);
}
/**
* Einzelne Bestellung abrufen
*/
public static function get_order($order_number) {
$orders = self::get_orders();
return $orders[$order_number] ?? null;
}
}
new Skrift_Konfigurator_Orders();

View File

@@ -0,0 +1,944 @@
<?php
/**
* Admin Settings für Skrift Konfigurator
* Verwaltung von Produkten, Preisen und Beschreibungen
*/
if (!defined('ABSPATH')) { exit; }
class Skrift_Konfigurator_Admin_Settings {
const OPTION_KEY = 'skrift_konfigurator_settings';
public function __construct() {
add_action('admin_menu', [$this, 'add_admin_menu']);
add_action('admin_init', [$this, 'register_settings']);
}
public function add_admin_menu(): void {
add_options_page(
'Skrift Konfigurator Einstellungen',
'Skrift Konfigurator',
'manage_options',
'skrift-konfigurator',
[$this, 'render_settings_page']
);
}
public function register_settings(): void {
register_setting('skrift_konfigurator', self::OPTION_KEY, [
'type' => 'array',
'sanitize_callback' => [$this, 'sanitize_settings'],
]);
}
public function sanitize_settings($input) {
$sanitized = [];
// Produkte sanitieren
if (isset($input['products']) && is_array($input['products'])) {
$sanitized['products'] = [];
foreach ($input['products'] as $key => $product) {
$sanitized['products'][sanitize_key($key)] = [
'label' => sanitize_text_field($product['label'] ?? ''),
'description' => sanitize_textarea_field($product['description'] ?? ''),
'base_price' => floatval($product['base_price'] ?? 0),
];
}
}
// Preise sanitieren
if (isset($input['prices']) && is_array($input['prices'])) {
$sanitized['prices'] = [];
foreach ($input['prices'] as $key => $value) {
$sanitized['prices'][sanitize_key($key)] = floatval($value);
}
}
// Dynamische Preisformeln sanitieren
if (isset($input['dynamic_pricing'])) {
$sanitized['dynamic_pricing'] = [
'business_formula' => sanitize_textarea_field($input['dynamic_pricing']['business_formula'] ?? ''),
'private_formula' => sanitize_textarea_field($input['dynamic_pricing']['private_formula'] ?? ''),
'business_min_quantity' => intval($input['dynamic_pricing']['business_min_quantity'] ?? 0),
'private_min_quantity' => intval($input['dynamic_pricing']['private_min_quantity'] ?? 0),
'business_normal_quantity' => intval($input['dynamic_pricing']['business_normal_quantity'] ?? 0),
'private_normal_quantity' => intval($input['dynamic_pricing']['private_normal_quantity'] ?? 0),
];
}
// Backend-Verbindung sanitieren
if (isset($input['backend_connection'])) {
$sanitized['backend_connection'] = [
'api_url' => esc_url_raw($input['backend_connection']['api_url'] ?? ''),
'api_token' => sanitize_text_field($input['backend_connection']['api_token'] ?? ''),
'webhook_url_business' => esc_url_raw($input['backend_connection']['webhook_url_business'] ?? ''),
'webhook_url_private' => esc_url_raw($input['backend_connection']['webhook_url_private'] ?? ''),
'redirect_url_business' => esc_url_raw($input['backend_connection']['redirect_url_business'] ?? ''),
'redirect_url_private' => esc_url_raw($input['backend_connection']['redirect_url_private'] ?? ''),
];
}
// REST API Key sanitieren
if (isset($input['api_security'])) {
$sanitized['api_security'] = [
'api_key' => sanitize_text_field($input['api_security']['api_key'] ?? ''),
];
}
// PayPal-Verbindung sanitieren
if (isset($input['paypal'])) {
$sanitized['paypal'] = [
'enabled' => !empty($input['paypal']['enabled']),
'mode' => sanitize_text_field($input['paypal']['mode'] ?? 'sandbox'),
'client_id_sandbox' => sanitize_text_field($input['paypal']['client_id_sandbox'] ?? ''),
'client_secret_sandbox' => sanitize_text_field($input['paypal']['client_secret_sandbox'] ?? ''),
'client_id_live' => sanitize_text_field($input['paypal']['client_id_live'] ?? ''),
'client_secret_live' => sanitize_text_field($input['paypal']['client_secret_live'] ?? ''),
];
}
// Schriftmuster und Platzhalter-Hilfe sanitieren
if (isset($input['font_sample'])) {
$sanitized['font_sample'] = [
'url' => esc_url_raw($input['font_sample']['url'] ?? ''),
'placeholder_help_url' => esc_url_raw($input['font_sample']['placeholder_help_url'] ?? ''),
];
}
return $sanitized;
}
public function render_settings_page(): void {
if (!current_user_can('manage_options')) {
return;
}
$settings = $this->get_settings();
?>
<style>
.sk-admin-wrap { max-width: 1200px; }
.sk-admin-section { background: #fff; padding: 20px; margin: 20px 0; border: 1px solid #ccd0d4; box-shadow: 0 1px 1px rgba(0,0,0,.04); }
.sk-admin-section h2 { margin-top: 0; padding-bottom: 10px; border-bottom: 1px solid #eee; }
.sk-product-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); gap: 20px; margin-top: 20px; }
.sk-product-card { background: #f9f9f9; padding: 15px; border: 1px solid #ddd; border-radius: 4px; }
.sk-product-card h3 { margin-top: 0; color: #0073aa; font-size: 16px; }
.sk-field-row { margin-bottom: 15px; }
.sk-field-row label { display: block; font-weight: 600; margin-bottom: 5px; }
.sk-field-row input[type="text"], .sk-field-row textarea { width: 100%; }
.sk-field-row input[type="number"] { width: 120px; }
.sk-price-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 15px; margin-top: 15px; }
.sk-price-item { background: #f9f9f9; padding: 12px; border-left: 3px solid #0073aa; }
.sk-price-item label { display: flex; justify-content: space-between; align-items: center; }
.sk-price-item strong { color: #23282d; }
.sk-multiplier-table { width: 100%; border-collapse: collapse; margin-top: 10px; }
.sk-multiplier-table th, .sk-multiplier-table td { padding: 10px; text-align: left; border-bottom: 1px solid #ddd; }
.sk-multiplier-table th { background: #f0f0f1; font-weight: 600; }
.sk-multiplier-table input[type="number"] { width: 100px; }
</style>
<div class="wrap sk-admin-wrap">
<h1><?php echo esc_html(get_admin_page_title()); ?></h1>
<form method="post" action="options.php">
<?php
settings_fields('skrift_konfigurator');
do_settings_sections('skrift_konfigurator');
?>
<!-- Produkte -->
<div class="sk-admin-section">
<h2>📦 Produkte</h2>
<p>Verwalten Sie Namen, Beschreibungen und Startpreise für alle Produkte.</p>
<div class="sk-product-grid">
<?php $this->render_product_card('businessbriefe', 'Businessbriefe', $settings); ?>
<?php $this->render_product_card('business-postkarten', 'Business Postkarten', $settings); ?>
<?php $this->render_product_card('follow-ups', 'Follow-ups', $settings); ?>
<?php $this->render_product_card('einladungen', 'Einladungen', $settings); ?>
<?php $this->render_product_card('private-briefe', 'Private Briefe', $settings); ?>
</div>
</div>
<!-- Format Aufpreise -->
<div class="sk-admin-section">
<h2>📐 Format Aufpreise</h2>
<p>Aufpreise wenn bei bestimmten Produkten das Format gewechselt wird.</p>
<div class="sk-price-grid">
<div class="sk-price-item">
<label>
<strong>A4 Aufpreis (Follow-ups/Einladungen)</strong>
<span>
<input type="number" step="0.01" min="0"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[prices][a4_upgrade_surcharge]"
value="<?php echo esc_attr($settings['prices']['a4_upgrade_surcharge'] ?? '0.50'); ?>"> €
</span>
</label>
<small style="display: block; margin-top: 5px; color: #666;">Aufpreis pro Stück wenn bei Postkarten oder Einladungen auf A4 gewechselt wird</small>
</div>
</div>
</div>
<!-- Versand & Umschlag -->
<div class="sk-admin-section">
<h2>🚚 Versand & Umschlag</h2>
<div class="sk-price-grid">
<div class="sk-price-item">
<label>
<strong>Porto Inland / Deutschland (pro Stück)</strong>
<span>
<input type="number" step="0.01" min="0"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[prices][shipping_domestic]"
value="<?php echo esc_attr($settings['prices']['shipping_domestic'] ?? '0.95'); ?>"> €
</span>
</label>
<small style="display: block; margin-top: 5px; color: #666;">Portokosten für Versand innerhalb Deutschlands (0% MwSt.)</small>
</div>
<div class="sk-price-item">
<label>
<strong>Porto Ausland (pro Stück)</strong>
<span>
<input type="number" step="0.01" min="0"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[prices][shipping_international]"
value="<?php echo esc_attr($settings['prices']['shipping_international'] ?? '1.25'); ?>"> €
</span>
</label>
<small style="display: block; margin-top: 5px; color: #666;">Portokosten für Auslandsversand (0% MwSt.)</small>
</div>
<div class="sk-price-item">
<label>
<strong>Serviceaufschlag Versand (pro Stück)</strong>
<span>
<input type="number" step="0.01" min="0"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[prices][shipping_service]"
value="<?php echo esc_attr($settings['prices']['shipping_service'] ?? '0.95'); ?>"> €
</span>
</label>
<small style="display: block; margin-top: 5px; color: #666;">Service-Aufschlag für Direktversand (19% MwSt.)</small>
</div>
<div class="sk-price-item">
<label>
<strong>Bulkversand (einmalig)</strong>
<span>
<input type="number" step="0.01" min="0"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[prices][shipping_bulk]"
value="<?php echo esc_attr($settings['prices']['shipping_bulk'] ?? '4.95'); ?>"> €
</span>
</label>
</div>
<div class="sk-price-item">
<label>
<strong>Kuvert (Grundpreis pro Stück)</strong>
<span>
<input type="number" step="0.01" min="0"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[prices][envelope_base]"
value="<?php echo esc_attr($settings['prices']['envelope_base'] ?? '0.50'); ?>"> €
</span>
</label>
<small style="display: block; margin-top: 5px; color: #666;">Grundpreis für Kuvert</small>
</div>
<div class="sk-price-item">
<label>
<strong>Aufschlag Beschriftung (pro Stück)</strong>
<span>
<input type="number" step="0.01" min="0"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[prices][envelope_labeling]"
value="<?php echo esc_attr($settings['prices']['envelope_labeling'] ?? '0.50'); ?>"> €
</span>
</label>
<small style="display: block; margin-top: 5px; color: #666;">Aufschlag für Beschriftung des Umschlags (Empfängeradresse oder individueller Text)</small>
</div>
</div>
</div>
<!-- Zusatzleistungen -->
<div class="sk-admin-section">
<h2>✨ Zusatzleistungen</h2>
<div class="sk-price-grid">
<div class="sk-price-item">
<label>
<strong>Motiv Upload <span style="color: #0073aa; font-size: 12px;">(einmalig)</span></strong>
<span>
<input type="number" step="0.01" min="0"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[prices][motif_upload]"
value="<?php echo esc_attr($settings['prices']['motif_upload'] ?? '0.30'); ?>"> €
</span>
</label>
</div>
<div class="sk-price-item">
<label>
<strong>Bedruckte Karten zusenden (pro Stück)</strong>
<span>
<input type="number" step="0.01" min="0"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[prices][motif_printed]"
value="<?php echo esc_attr($settings['prices']['motif_printed'] ?? '0.00'); ?>"> €
</span>
</label>
</div>
<div class="sk-price-item">
<label>
<strong>Designservice <span style="color: #0073aa; font-size: 12px;">(einmalig)</span></strong>
<span>
<input type="number" step="0.01" min="0"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[prices][motif_design]"
value="<?php echo esc_attr($settings['prices']['motif_design'] ?? '0.00'); ?>"> €
</span>
</label>
</div>
<div class="sk-price-item">
<label>
<strong>Textservice <span style="color: #0073aa; font-size: 12px;">(einmalig)</span></strong>
<span>
<input type="number" step="0.01" min="0"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[prices][textservice]"
value="<?php echo esc_attr($settings['prices']['textservice'] ?? '0.00'); ?>"> €
</span>
</label>
</div>
<div class="sk-price-item">
<label>
<strong>API-Anbindung <span style="color: #0073aa; font-size: 12px;">(einmalig)</span></strong>
<span>
<input type="number" step="0.01" min="0"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[prices][api_connection]"
value="<?php echo esc_attr($settings['prices']['api_connection'] ?? '250.00'); ?>"> €
</span>
</label>
<small style="display: block; margin-top: 5px; color: #666;">Einmalige Einrichtungsgebühr für die API-Anbindung</small>
</div>
</div>
</div>
<!-- Follow-ups Mengenstaffel -->
<div class="sk-admin-section">
<h2>📊 Follow-ups Preis-Multiplikatoren</h2>
<p>Die Gesamtkosten pro Schriftstück werden mit diesen Multiplikatoren je nach Menge multipliziert.</p>
<table class="sk-multiplier-table">
<thead>
<tr>
<th>Menge</th>
<th>Multiplikator</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>5 - 49 Stück</strong></td>
<td>
<input type="number" step="0.1" min="0"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[prices][followup_mult_5_49]"
value="<?php echo esc_attr($settings['prices']['followup_mult_5_49'] ?? '2.0'); ?>">
<small style="margin-left: 10px; color: #666;">×</small>
</td>
</tr>
<tr>
<td><strong>50 - 199 Stück</strong></td>
<td>
<input type="number" step="0.1" min="0"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[prices][followup_mult_50_199]"
value="<?php echo esc_attr($settings['prices']['followup_mult_50_199'] ?? '1.7'); ?>">
<small style="margin-left: 10px; color: #666;">×</small>
</td>
</tr>
<tr>
<td><strong>200 - 499 Stück</strong></td>
<td>
<input type="number" step="0.1" min="0"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[prices][followup_mult_200_499]"
value="<?php echo esc_attr($settings['prices']['followup_mult_200_499'] ?? '1.4'); ?>">
<small style="margin-left: 10px; color: #666;">×</small>
</td>
</tr>
<tr>
<td><strong>500 - 999 Stück</strong></td>
<td>
<input type="number" step="0.1" min="0"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[prices][followup_mult_500_999]"
value="<?php echo esc_attr($settings['prices']['followup_mult_500_999'] ?? '1.2'); ?>">
<small style="margin-left: 10px; color: #666;">×</small>
</td>
</tr>
<tr>
<td><strong>1000+ Stück</strong></td>
<td>
<input type="number" step="0.1" min="0"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[prices][followup_mult_1000_plus]"
value="<?php echo esc_attr($settings['prices']['followup_mult_1000_plus'] ?? '1.0'); ?>">
<small style="margin-left: 10px; color: #666;">×</small>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Steuern -->
<div class="sk-admin-section">
<h2>📋 Steuern</h2>
<div class="sk-price-grid">
<div class="sk-price-item">
<label>
<strong>Mehrwertsteuersatz (%)</strong>
<span>
<input type="number" step="0.01" min="0" max="100"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[prices][tax_rate]"
value="<?php echo esc_attr($settings['prices']['tax_rate'] ?? '19'); ?>"> %
</span>
</label>
<small style="display: block; margin-top: 5px; color: #666;">Gilt für alle Positionen inkl. Versand</small>
</div>
</div>
</div>
<!-- Dynamische Preisberechnung -->
<div class="sk-admin-section">
<h2>🧮 Dynamische Preisberechnung</h2>
<p>Konfigurieren Sie die dynamische Preisberechnung basierend auf Mengen. Die Formeln unterstützen Platzhalter wie <code>%qty%</code> (aktuelle Menge), <code>%norm_b%</code> (Normalpreis Menge Business), <code>%mind_b%</code> (Mind. Menge Business), <code>%norm_p%</code> (Normalpreis Menge Privat), <code>%mind_p%</code> (Mind. Menge Privat).</p>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 30px; margin-top: 20px;">
<!-- Business -->
<div style="background: #f9f9f9; padding: 20px; border: 1px solid #ddd; border-radius: 4px;">
<h3 style="margin-top: 0; color: #0073aa;">Business</h3>
<div class="sk-field-row">
<label><strong>Mindestmenge Business</strong></label>
<input type="number" step="1" min="0"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[dynamic_pricing][business_min_quantity]"
value="<?php echo esc_attr($settings['dynamic_pricing']['business_min_quantity'] ?? '50'); ?>"
style="width: 150px;">
<small style="display: block; margin-top: 5px; color: #666;">Minimale Menge für Business-Bestellungen (außer Follow-ups)</small>
</div>
<div class="sk-field-row" style="margin-top: 15px;">
<label><strong>Normalpreis Menge Business</strong></label>
<input type="number" step="1" min="0"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[dynamic_pricing][business_normal_quantity]"
value="<?php echo esc_attr($settings['dynamic_pricing']['business_normal_quantity'] ?? '200'); ?>"
style="width: 150px;">
<small style="display: block; margin-top: 5px; color: #666;">Ab dieser Menge gilt der Normalpreis (Multiplikator = 1)</small>
</div>
<div class="sk-field-row" style="margin-top: 15px;">
<label><strong>Dynamische Formel Business</strong></label>
<textarea
name="<?php echo esc_attr(self::OPTION_KEY); ?>[dynamic_pricing][business_formula]"
rows="8"
style="width: 100%; font-family: monospace; font-size: 12px;"
placeholder="(%qty% >= %norm_b%) ? 1 : (2 - Math.sqrt(%qty% / %norm_b%))"><?php echo esc_textarea($settings['dynamic_pricing']['business_formula'] ?? ''); ?></textarea>
<small style="display: block; margin-top: 5px; color: #666;">
<strong>JavaScript-Formel zur Berechnung des Preis-Multiplikators.</strong><br>
Platzhalter verwenden: <code>%qty%</code> für aktuelle Menge, <code>%norm_b%</code> für Normalpreis-Menge Business, <code>%mind_b%</code> für Mindestmenge Business.<br>
Beispiel: <code>(%qty% >= %norm_b%) ? 1 : (2 - Math.sqrt(%qty% / %norm_b%))</code><br>
Dies bedeutet: Wenn die Menge >= Normalpreis-Menge ist, dann Multiplikator = 1, sonst dynamische Berechnung.
</small>
</div>
</div>
<!-- Privat -->
<div style="background: #f9f9f9; padding: 20px; border: 1px solid #ddd; border-radius: 4px;">
<h3 style="margin-top: 0; color: #0073aa;">Privat</h3>
<div class="sk-field-row">
<label><strong>Mindestmenge Privat</strong></label>
<input type="number" step="1" min="0"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[dynamic_pricing][private_min_quantity]"
value="<?php echo esc_attr($settings['dynamic_pricing']['private_min_quantity'] ?? '10'); ?>"
style="width: 150px;">
<small style="display: block; margin-top: 5px; color: #666;">Minimale Menge für Private Bestellungen</small>
</div>
<div class="sk-field-row" style="margin-top: 15px;">
<label><strong>Normalpreis Menge Privat</strong></label>
<input type="number" step="1" min="0"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[dynamic_pricing][private_normal_quantity]"
value="<?php echo esc_attr($settings['dynamic_pricing']['private_normal_quantity'] ?? '50'); ?>"
style="width: 150px;">
<small style="display: block; margin-top: 5px; color: #666;">Ab dieser Menge gilt der Normalpreis (Multiplikator = 1)</small>
</div>
<div class="sk-field-row" style="margin-top: 15px;">
<label><strong>Dynamische Formel Privat</strong></label>
<textarea
name="<?php echo esc_attr(self::OPTION_KEY); ?>[dynamic_pricing][private_formula]"
rows="8"
style="width: 100%; font-family: monospace; font-size: 12px;"
placeholder="(%qty% >= %norm_p%) ? 1 : (2 - Math.sqrt(%qty% / %norm_p%))"><?php echo esc_textarea($settings['dynamic_pricing']['private_formula'] ?? ''); ?></textarea>
<small style="display: block; margin-top: 5px; color: #666;">
<strong>JavaScript-Formel zur Berechnung des Preis-Multiplikators.</strong><br>
Platzhalter verwenden: <code>%qty%</code> für aktuelle Menge, <code>%norm_p%</code> für Normalpreis-Menge Privat, <code>%mind_p%</code> für Mindestmenge Privat.<br>
Beispiel: <code>(%qty% >= %norm_p%) ? 1 : (2 - Math.sqrt(%qty% / %norm_p%))</code><br>
Dies bedeutet: Wenn die Menge >= Normalpreis-Menge ist, dann Multiplikator = 1, sonst dynamische Berechnung.
</small>
</div>
</div>
</div>
<div style="margin-top: 20px; padding: 15px; background: #fff3cd; border-left: 4px solid #ffc107;">
<strong>Verfügbare Platzhalter:</strong>
<ul style="margin: 10px 0 0 20px;">
<li><code>%qty%</code> - Aktuelle Menge (eingegebene Stückzahl)</li>
<li><code>%norm_b%</code> - Normalpreis Menge Business</li>
<li><code>%mind_b%</code> - Mindestmenge Business</li>
<li><code>%norm_p%</code> - Normalpreis Menge Privat</li>
<li><code>%mind_p%</code> - Mindestmenge Privat</li>
</ul>
</div>
</div>
<!-- Backend-Verbindung -->
<div class="sk-admin-section">
<h2>🔌 Backend-Verbindung</h2>
<p>Konfigurieren Sie die Verbindung zum Backend-System für erweiterte Funktionen.</p>
<div class="sk-price-grid">
<div class="sk-price-item">
<label>
<strong>API URL / Domain</strong>
</label>
<input type="url"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[backend_connection][api_url]"
value="<?php echo esc_attr($settings['backend_connection']['api_url'] ?? ''); ?>"
placeholder="https://api.example.com"
style="width: 100%; margin-top: 8px;">
<small style="display: block; margin-top: 5px; color: #666;">Basis-URL des Backend-Systems (z.B. https://api.example.com)</small>
</div>
<div class="sk-price-item">
<label>
<strong>API Token / Authentifizierung</strong>
</label>
<input type="text"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[backend_connection][api_token]"
value="<?php echo esc_attr($settings['backend_connection']['api_token'] ?? ''); ?>"
placeholder="sk_live_..."
style="width: 100%; margin-top: 8px; font-family: monospace;">
<small style="display: block; margin-top: 5px; color: #666;">Authentifizierungs-Token für API-Zugriff</small>
</div>
<div class="sk-price-item">
<label>
<strong>Webhook URL Geschäftskunden (B2B)</strong>
</label>
<input type="url"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[backend_connection][webhook_url_business]"
value="<?php echo esc_attr($settings['backend_connection']['webhook_url_business'] ?? ''); ?>"
placeholder="https://api.example.com/webhooks/order-business"
style="width: 100%; margin-top: 8px; font-family: monospace;">
<small style="display: block; margin-top: 5px; color: #666;">Webhook wird nach Klick auf "Jetzt kostenpflichtig bestellen" für Geschäftskunden aufgerufen</small>
</div>
<div class="sk-price-item">
<label>
<strong>Webhook URL Privatkunden (B2C)</strong>
</label>
<input type="url"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[backend_connection][webhook_url_private]"
value="<?php echo esc_attr($settings['backend_connection']['webhook_url_private'] ?? ''); ?>"
placeholder="https://api.example.com/webhooks/order-private"
style="width: 100%; margin-top: 8px; font-family: monospace;">
<small style="display: block; margin-top: 5px; color: #666;">Webhook wird nach erfolgreicher PayPal-Zahlung für Privatkunden aufgerufen</small>
</div>
<div class="sk-price-item">
<label>
<strong>Redirect URL Geschäftskunden</strong>
</label>
<input type="url"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[backend_connection][redirect_url_business]"
value="<?php echo esc_attr($settings['backend_connection']['redirect_url_business'] ?? ''); ?>"
placeholder="https://example.com/danke-business"
style="width: 100%; margin-top: 8px;">
<small style="display: block; margin-top: 5px; color: #666;">Weiterleitung nach Bestellung für Geschäftskunden (nach Klick auf "Jetzt kostenpflichtig bestellen")</small>
</div>
<div class="sk-price-item">
<label>
<strong>Redirect URL Privatkunden</strong>
</label>
<input type="url"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[backend_connection][redirect_url_private]"
value="<?php echo esc_attr($settings['backend_connection']['redirect_url_private'] ?? ''); ?>"
placeholder="https://example.com/danke-privat"
style="width: 100%; margin-top: 8px;">
<small style="display: block; margin-top: 5px; color: #666;">Weiterleitung nach erfolgreicher PayPal-Zahlung für Privatkunden</small>
</div>
</div>
</div>
<!-- REST API Security -->
<div class="sk-admin-section">
<h2>🔐 REST API Sicherheit</h2>
<p>Konfigurieren Sie einen API-Key für die REST-API-Endpunkte. Dieser Key muss im Header <code>X-Skrift-API-Key</code> mitgesendet werden.</p>
<div class="sk-price-grid">
<div class="sk-price-item" style="grid-column: 1 / -1;">
<label>
<strong>API Key</strong>
</label>
<div style="display: flex; gap: 10px; margin-top: 8px;">
<input type="text" id="sk-api-key-input"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[api_security][api_key]"
value="<?php echo esc_attr($settings['api_security']['api_key'] ?? ''); ?>"
placeholder="sk_api_..."
style="flex: 1; font-family: monospace;">
<button type="button" class="button" onclick="document.getElementById('sk-api-key-input').value = 'sk_api_' + Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);">
Generieren
</button>
</div>
<small style="display: block; margin-top: 5px; color: #666;">Leer lassen um API-Key-Prüfung zu deaktivieren (nicht empfohlen für Produktion)</small>
</div>
</div>
</div>
<!-- Schriftmuster Fallback -->
<div class="sk-admin-section">
<h2>✍️ Schriftmuster (Vorschau-Fallback)</h2>
<p>Wenn die Vorschau-Generierung fehlschlägt oder nicht verfügbar ist, wird ein "Schriftmuster ansehen"-Link angezeigt. Der Link öffnet sich in einem neuen Tab.</p>
<div class="sk-price-grid">
<div class="sk-price-item" style="grid-column: 1 / -1;">
<label>
<strong>Schriftmuster-URL</strong>
</label>
<input type="url"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[font_sample][url]"
value="<?php echo esc_attr($settings['font_sample']['url'] ?? ''); ?>"
placeholder="https://example.com/schriftmuster"
style="width: 100%; margin-top: 8px;">
<small style="display: block; margin-top: 5px; color: #666;">URL zur Schriftmuster-Seite (wird in neuem Tab geöffnet)</small>
</div>
<div class="sk-price-item" style="grid-column: 1 / -1;">
<label>
<strong>Platzhalter-Hilfe URL</strong>
</label>
<input type="url"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[font_sample][placeholder_help_url]"
value="<?php echo esc_attr($settings['font_sample']['placeholder_help_url'] ?? ''); ?>"
placeholder="https://example.com/platzhalter-hilfe"
style="width: 100%; margin-top: 8px;">
<small style="display: block; margin-top: 5px; color: #666;">URL zur Platzhalter-Hilfeseite (wird bei Platzhalter-Infotexten als Link angezeigt)</small>
</div>
</div>
</div>
<!-- PayPal-Verbindung -->
<div class="sk-admin-section">
<h2>💳 PayPal-Verbindung (nur Privatkunden)</h2>
<p>Konfigurieren Sie die PayPal-Zahlungsintegration. PayPal ist nur für Privatkunden aktiviert.</p>
<div class="sk-price-grid">
<div class="sk-price-item" style="grid-column: 1 / -1;">
<label style="display: flex; align-items: center; gap: 10px;">
<input type="checkbox"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[paypal][enabled]"
value="1"
<?php checked($settings['paypal']['enabled'] ?? false); ?>>
<strong>PayPal-Zahlung aktivieren</strong>
</label>
<small style="display: block; margin-top: 5px; color: #666;">Aktiviert PayPal als Zahlungsoption für Privatkunden</small>
</div>
<div class="sk-price-item" style="grid-column: 1 / -1;">
<label>
<strong>Modus</strong>
</label>
<select
name="<?php echo esc_attr(self::OPTION_KEY); ?>[paypal][mode]"
style="width: 200px; margin-top: 8px;">
<option value="sandbox" <?php selected(($settings['paypal']['mode'] ?? 'sandbox'), 'sandbox'); ?>>Sandbox (Test)</option>
<option value="live" <?php selected(($settings['paypal']['mode'] ?? 'sandbox'), 'live'); ?>>Live (Produktion)</option>
</select>
<small style="display: block; margin-top: 5px; color: #666;">Sandbox für Tests, Live für echte Zahlungen</small>
</div>
</div>
<h3 style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #ddd;">Sandbox-Zugangsdaten (Test)</h3>
<div class="sk-price-grid">
<div class="sk-price-item">
<label>
<strong>Client ID (Sandbox)</strong>
</label>
<input type="text"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[paypal][client_id_sandbox]"
value="<?php echo esc_attr($settings['paypal']['client_id_sandbox'] ?? ''); ?>"
placeholder="AZn4..."
style="width: 100%; margin-top: 8px; font-family: monospace;">
</div>
<div class="sk-price-item">
<label>
<strong>Client Secret (Sandbox)</strong>
</label>
<input type="password"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[paypal][client_secret_sandbox]"
value="<?php echo esc_attr($settings['paypal']['client_secret_sandbox'] ?? ''); ?>"
placeholder="EL3..."
style="width: 100%; margin-top: 8px; font-family: monospace;">
</div>
</div>
<h3 style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #ddd;">Live-Zugangsdaten (Produktion)</h3>
<div class="sk-price-grid">
<div class="sk-price-item">
<label>
<strong>Client ID (Live)</strong>
</label>
<input type="text"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[paypal][client_id_live]"
value="<?php echo esc_attr($settings['paypal']['client_id_live'] ?? ''); ?>"
placeholder="AZn4..."
style="width: 100%; margin-top: 8px; font-family: monospace;">
</div>
<div class="sk-price-item">
<label>
<strong>Client Secret (Live)</strong>
</label>
<input type="password"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[paypal][client_secret_live]"
value="<?php echo esc_attr($settings['paypal']['client_secret_live'] ?? ''); ?>"
placeholder="EL3..."
style="width: 100%; margin-top: 8px; font-family: monospace;">
</div>
</div>
<div style="margin-top: 20px; padding: 15px; background: #e7f3ff; border-left: 4px solid #2196f3;">
<strong>Hinweis:</strong> Um PayPal zu aktivieren, benötigen Sie ein PayPal Business-Konto.
Sie erhalten die API-Zugangsdaten im <a href="https://developer.paypal.com/dashboard/applications/" target="_blank">PayPal Developer Dashboard</a>.
</div>
</div>
<?php submit_button('Einstellungen speichern'); ?>
</form>
<!-- URL Parameter Dokumentation -->
<div class="sk-section" style="margin-top: 40px; padding: 20px; background: #f9f9f9; border: 1px solid #ddd; border-radius: 8px;">
<h2 style="margin-top: 0;">URL-Parameter</h2>
<p>Der Konfigurator unterstützt folgende URL-Parameter:</p>
<table class="widefat" style="margin-top: 15px;">
<thead>
<tr>
<th style="width: 200px;">Parameter</th>
<th style="width: 200px;">Werte</th>
<th>Beschreibung</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>?businessbriefe</code><br>
<code>?business-postkarten</code><br>
<code>?follow-ups</code><br>
<code>?einladungen</code><br>
<code>?private-briefe</code></td>
<td>–</td>
<td>Produkt direkt vorauswählen. Der Produktauswahlschritt wird übersprungen.</td>
</tr>
<tr>
<td><code>quantity</code></td>
<td>Zahl (z.B. <code>100</code>)</td>
<td>Menge vorausfüllen.</td>
</tr>
<tr>
<td><code>format</code></td>
<td><code>a4</code>, <code>a6h</code>, <code>a6q</code></td>
<td>Format vorauswählen. <code>a6h</code> = A6 Hochformat, <code>a6q</code> = A6 Querformat.</td>
</tr>
<tr>
<td><code>noPrice</code></td>
<td>–</td>
<td>Preise im Konfigurator ausblenden.</td>
</tr>
<tr>
<td><code>noLimits</code></td>
<td>–</td>
<td>Keine Mindestmengen. Erlaubt Bestellungen ab 1 Stück.</td>
</tr>
</tbody>
</table>
<h3 style="margin-top: 25px;">Beispiele</h3>
<ul style="margin-left: 20px;">
<li><code>/konfigurator/?businessbriefe</code> – Direkt zu Business Briefe</li>
<li><code>/konfigurator/?einladungen&quantity=25&format=a6h</code> – Einladungen mit 25 Stück im A6 Hochformat</li>
<li><code>/konfigurator/?businessbriefe&noPrice</code> – Business Briefe ohne Preisanzeige</li>
<li><code>/konfigurator/?private-briefe&noLimits</code> – Private Briefe ohne Mindestmenge</li>
<li><code>/konfigurator/?business-postkarten&noLimits&noPrice</code> – Postkarten ohne Mindestmenge und ohne Preise</li>
</ul>
</div>
</div>
<?php
}
private function render_product_card(string $key, string $default_label, array $settings): void {
$label = $settings['products'][$key]['label'] ?? $default_label;
$description = $settings['products'][$key]['description'] ?? 'Professionelle handgeschriebene Korrespondenz';
$base_price = $settings['products'][$key]['base_price'] ?? '2.50';
?>
<div class="sk-product-card">
<h3><?php echo esc_html($default_label); ?></h3>
<div class="sk-field-row">
<label>Produktname</label>
<input type="text"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[products][<?php echo esc_attr($key); ?>][label]"
value="<?php echo esc_attr($label); ?>"
placeholder="<?php echo esc_attr($default_label); ?>">
</div>
<div class="sk-field-row">
<label>Beschreibung</label>
<textarea
name="<?php echo esc_attr(self::OPTION_KEY); ?>[products][<?php echo esc_attr($key); ?>][description]"
rows="3"
placeholder="Professionelle handgeschriebene Korrespondenz"><?php echo esc_textarea($description); ?></textarea>
</div>
<div class="sk-field-row">
<label>Startpreis (ab)</label>
<div>
<input type="number" step="0.01" min="0"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[products][<?php echo esc_attr($key); ?>][base_price]"
value="<?php echo esc_attr($base_price); ?>"> €
</div>
</div>
</div>
<?php
}
public static function get_settings(): array {
$defaults = [
'products' => [
'businessbriefe' => [
'label' => 'Businessbriefe',
'description' => 'Professionelle handgeschriebene Korrespondenz',
'base_price' => 2.50,
],
'business-postkarten' => [
'label' => 'Business Postkarten',
'description' => 'Professionelle handgeschriebene Korrespondenz',
'base_price' => 1.80,
],
'follow-ups' => [
'label' => 'Follow-ups',
'description' => 'Professionelle handgeschriebene Korrespondenz',
'base_price' => 2.50,
],
'einladungen' => [
'label' => 'Einladungen',
'description' => 'Professionelle handgeschriebene Korrespondenz',
'base_price' => 1.80,
],
'private-briefe' => [
'label' => 'Private Briefe',
'description' => 'Professionelle handgeschriebene Korrespondenz',
'base_price' => 2.50,
],
],
'prices' => [
// Versand & Umschlag
'shipping_domestic' => 0.95, // Porto Inland
'shipping_international' => 1.25, // Porto Ausland
'shipping_service' => 0.95, // Serviceaufschlag Versand
'shipping_bulk' => 4.95, // Bulkversand einmalig
'envelope_base' => 0.50, // Kuvert Grundpreis
'envelope_labeling' => 0.50, // Aufschlag Beschriftung
// Legacy fields for backwards compatibility
'shipping_direct' => 2.40,
'envelope_recipient_address' => 0.50,
'envelope_custom_text' => 0.30,
// Format Aufpreise
'a4_upgrade_surcharge' => 0.50,
// Zusatzleistungen
'motif_upload' => 0.30,
'motif_printed' => 0.00,
'motif_design' => 0.00,
'textservice' => 0.00,
'api_connection' => 250.00,
// Follow-ups Multiplikatoren
'followup_mult_5_49' => 2.0,
'followup_mult_50_199' => 1.7,
'followup_mult_200_499' => 1.4,
'followup_mult_500_999' => 1.2,
'followup_mult_1000_plus' => 1.0,
// Steuern
'tax_rate' => 19,
'shipping_tax_rate' => 0,
],
'dynamic_pricing' => [
'business_formula' => "(%qty% >= %norm_b%) ? 1 : (2 - Math.sqrt(%qty% / %norm_b%))",
'private_formula' => "(%qty% >= %norm_p%) ? 1 : (2 - Math.sqrt(%qty% / %norm_p%))",
'business_min_quantity' => 50,
'private_min_quantity' => 10,
'business_normal_quantity' => 200,
'private_normal_quantity' => 50,
],
'backend_connection' => [
'api_url' => '',
'api_token' => '',
'webhook_url_business' => '',
'webhook_url_private' => '',
'redirect_url_business' => '',
'redirect_url_private' => '',
],
'paypal' => [
'enabled' => false,
'mode' => 'sandbox',
'client_id_sandbox' => '',
'client_secret_sandbox' => '',
'client_id_live' => '',
'client_secret_live' => '',
],
'api_security' => [
'api_key' => '',
],
'font_sample' => [
'url' => '',
'placeholder_help_url' => '',
],
];
$saved = get_option(self::OPTION_KEY, []);
// Merge nested arrays properly
$merged = $defaults;
foreach (['products', 'prices', 'dynamic_pricing', 'backend_connection', 'paypal', 'api_security', 'font_sample'] as $section) {
if (isset($saved[$section]) && is_array($saved[$section])) {
$merged[$section] = array_merge($defaults[$section], $saved[$section]);
}
}
return $merged;
}
/**
* Prüft ob ein API-Key gültig ist
*/
public static function validate_api_key($provided_key): bool {
$settings = self::get_settings();
$stored_key = $settings['api_security']['api_key'] ?? '';
// Wenn kein Key konfiguriert ist, ist alles erlaubt (für Entwicklung)
if (empty($stored_key)) {
return true;
}
// Key vergleichen (timing-safe)
return hash_equals($stored_key, $provided_key);
}
/**
* Permission Callback für REST API mit API-Key
*/
public static function rest_api_key_permission($request): bool {
$api_key = $request->get_header('X-Skrift-API-Key');
return self::validate_api_key($api_key ?? '');
}
}
new Skrift_Konfigurator_Admin_Settings();

View File

@@ -0,0 +1,403 @@
<?php
/**
* Gutschein-Verwaltung für Skrift Konfigurator
*/
if (!defined('ABSPATH')) { exit; }
final class Skrift_Konfigurator_Vouchers {
const OPTION_KEY = 'skrift_konfigurator_vouchers';
public function __construct() {
add_action('admin_menu', [$this, 'add_menu_page']);
add_action('admin_init', [$this, 'register_settings']);
add_action('admin_post_sk_add_voucher', [$this, 'handle_add_voucher']);
add_action('admin_post_sk_delete_voucher', [$this, 'handle_delete_voucher']);
add_action('rest_api_init', [$this, 'register_rest_routes']);
}
public function add_menu_page(): void {
add_submenu_page(
'options-general.php',
'Skrift Gutscheine',
'Skrift Gutscheine',
'manage_options',
'skrift-vouchers',
[$this, 'render_vouchers_page']
);
}
public function register_settings(): void {
register_setting('skrift_vouchers', self::OPTION_KEY, [
'type' => 'array',
'sanitize_callback' => [$this, 'sanitize_vouchers'],
]);
}
public function sanitize_vouchers($input) {
if (!is_array($input)) {
return [];
}
$sanitized = [];
foreach ($input as $code => $voucher) {
// Verwende den originalen Code (in Großbuchstaben) als Key
$voucherCode = strtoupper(sanitize_text_field($voucher['code'] ?? ''));
$sanitized[$voucherCode] = [
'code' => $voucherCode,
'type' => in_array($voucher['type'] ?? '', ['percent', 'fixed']) ? $voucher['type'] : 'percent',
'value' => floatval($voucher['value'] ?? 0),
'expiry_date' => sanitize_text_field($voucher['expiry_date'] ?? ''),
'usage_limit' => intval($voucher['usage_limit'] ?? 0),
'usage_count' => intval($voucher['usage_count'] ?? 0),
];
}
return $sanitized;
}
public function handle_add_voucher(): void {
if (!current_user_can('manage_options')) {
wp_die('Keine Berechtigung');
}
check_admin_referer('sk_add_voucher');
$vouchers = get_option(self::OPTION_KEY, []);
$code = strtoupper(sanitize_text_field($_POST['voucher_code'] ?? ''));
if (empty($code)) {
wp_redirect(add_query_arg(['page' => 'skrift-vouchers', 'error' => 'empty_code'], admin_url('options-general.php')));
exit;
}
if (isset($vouchers[$code])) {
wp_redirect(add_query_arg(['page' => 'skrift-vouchers', 'error' => 'duplicate'], admin_url('options-general.php')));
exit;
}
$vouchers[$code] = [
'code' => $code,
'type' => sanitize_text_field($_POST['voucher_type'] ?? 'percent'),
'value' => floatval($_POST['voucher_value'] ?? 0),
'expiry_date' => sanitize_text_field($_POST['voucher_expiry'] ?? ''),
'usage_limit' => intval($_POST['voucher_limit'] ?? 0),
'usage_count' => 0,
];
update_option(self::OPTION_KEY, $vouchers);
wp_redirect(add_query_arg(['page' => 'skrift-vouchers', 'success' => 'added'], admin_url('options-general.php')));
exit;
}
public function handle_delete_voucher(): void {
if (!current_user_can('manage_options')) {
wp_die('Keine Berechtigung');
}
check_admin_referer('sk_delete_voucher');
$code = sanitize_text_field($_GET['code'] ?? '');
$vouchers = get_option(self::OPTION_KEY, []);
if (isset($vouchers[$code])) {
unset($vouchers[$code]);
update_option(self::OPTION_KEY, $vouchers);
}
wp_redirect(add_query_arg(['page' => 'skrift-vouchers', 'success' => 'deleted'], admin_url('options-general.php')));
exit;
}
public function render_vouchers_page(): void {
if (!current_user_can('manage_options')) {
return;
}
$vouchers = get_option(self::OPTION_KEY, []);
?>
<div class="wrap">
<h1>Gutschein-Verwaltung</h1>
<?php if (isset($_GET['success'])): ?>
<div class="notice notice-success is-dismissible">
<p>
<?php
if ($_GET['success'] === 'added') {
echo 'Gutschein erfolgreich hinzugefügt!';
} elseif ($_GET['success'] === 'deleted') {
echo 'Gutschein erfolgreich gelöscht!';
}
?>
</p>
</div>
<?php endif; ?>
<?php if (isset($_GET['error'])): ?>
<div class="notice notice-error is-dismissible">
<p>
<?php
if ($_GET['error'] === 'empty_code') {
echo 'Gutscheincode darf nicht leer sein!';
} elseif ($_GET['error'] === 'duplicate') {
echo 'Dieser Gutscheincode existiert bereits!';
}
?>
</p>
</div>
<?php endif; ?>
<div style="background: white; padding: 20px; margin-top: 20px; border: 1px solid #ccc;">
<h2>Neuen Gutschein erstellen</h2>
<form method="post" action="<?php echo admin_url('admin-post.php'); ?>">
<input type="hidden" name="action" value="sk_add_voucher">
<?php wp_nonce_field('sk_add_voucher'); ?>
<table class="form-table">
<tr>
<th scope="row"><label for="voucher_code">Gutscheincode *</label></th>
<td>
<input type="text" id="voucher_code" name="voucher_code" class="regular-text" required
style="text-transform: uppercase;" placeholder="z.B. SOMMER2025">
<p class="description">Code wird automatisch in Großbuchstaben umgewandelt</p>
</td>
</tr>
<tr>
<th scope="row"><label for="voucher_type">Rabatt-Art *</label></th>
<td>
<select id="voucher_type" name="voucher_type" required>
<option value="percent">Prozent (%)</option>
<option value="fixed">Festbetrag (€)</option>
</select>
</td>
</tr>
<tr>
<th scope="row"><label for="voucher_value">Rabatt-Wert *</label></th>
<td>
<input type="number" id="voucher_value" name="voucher_value" step="0.01" min="0" required
class="small-text">
<p class="description">Bei Prozent: z.B. 10 für 10% | Bei Festbetrag: z.B. 5.00 für 5€</p>
</td>
</tr>
<tr>
<th scope="row"><label for="voucher_expiry">Ablaufdatum</label></th>
<td>
<input type="date" id="voucher_expiry" name="voucher_expiry" class="regular-text">
<p class="description">Leer lassen für unbegrenzte Gültigkeit</p>
</td>
</tr>
<tr>
<th scope="row"><label for="voucher_limit">Einlöse-Limit</label></th>
<td>
<input type="number" id="voucher_limit" name="voucher_limit" min="0" class="small-text" value="0">
<p class="description">0 = Unbegrenzt oft einlösbar</p>
</td>
</tr>
</table>
<?php submit_button('Gutschein erstellen'); ?>
</form>
</div>
<div style="background: white; padding: 20px; margin-top: 20px; border: 1px solid #ccc;">
<h2>Vorhandene Gutscheine</h2>
<?php if (empty($vouchers)): ?>
<p>Noch keine Gutscheine vorhanden.</p>
<?php else: ?>
<table class="wp-list-table widefat fixed striped">
<thead>
<tr>
<th>Code</th>
<th>Typ</th>
<th>Wert</th>
<th>Ablaufdatum</th>
<th>Limit</th>
<th>Eingelöst</th>
<th>Status</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
<?php foreach ($vouchers as $code => $voucher):
$is_expired = !empty($voucher['expiry_date']) && strtotime($voucher['expiry_date']) < time();
$is_used_up = $voucher['usage_limit'] > 0 && $voucher['usage_count'] >= $voucher['usage_limit'];
$is_active = !$is_expired && !$is_used_up;
?>
<tr>
<td><strong><?php echo esc_html($voucher['code']); ?></strong></td>
<td><?php echo $voucher['type'] === 'percent' ? 'Prozent' : 'Festbetrag'; ?></td>
<td>
<?php
if ($voucher['type'] === 'percent') {
echo number_format($voucher['value'], 1) . ' %';
} else {
echo number_format($voucher['value'], 2, ',', '.') . ' €';
}
?>
</td>
<td>
<?php
if (!empty($voucher['expiry_date'])) {
echo date('d.m.Y', strtotime($voucher['expiry_date']));
} else {
echo '—';
}
?>
</td>
<td><?php echo $voucher['usage_limit'] > 0 ? $voucher['usage_limit'] : 'Unbegrenzt'; ?></td>
<td><?php echo $voucher['usage_count']; ?></td>
<td>
<?php if ($is_active): ?>
<span style="color: green; font-weight: bold;">✓ Aktiv</span>
<?php elseif ($is_expired): ?>
<span style="color: red;">✗ Abgelaufen</span>
<?php elseif ($is_used_up): ?>
<span style="color: orange;">✗ Limit erreicht</span>
<?php endif; ?>
</td>
<td>
<a href="<?php echo wp_nonce_url(
admin_url('admin-post.php?action=sk_delete_voucher&code=' . urlencode($code)),
'sk_delete_voucher'
); ?>"
class="button button-small"
onclick="return confirm('Gutschein <?php echo esc_js($code); ?> wirklich löschen?');">
Löschen
</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
</div>
<?php
}
/**
* Öffentliche Funktion um Gutscheine abzurufen
*/
public static function get_vouchers() {
return get_option(self::OPTION_KEY, []);
}
/**
* Validiert einen Gutschein
*/
public static function validate_voucher($code) {
$vouchers = self::get_vouchers();
$code = strtoupper(trim($code));
if (!isset($vouchers[$code])) {
return ['valid' => false, 'error' => 'Gutschein nicht gefunden'];
}
$voucher = $vouchers[$code];
// Ablaufdatum prüfen
if (!empty($voucher['expiry_date']) && strtotime($voucher['expiry_date']) < time()) {
return ['valid' => false, 'error' => 'Gutschein ist abgelaufen'];
}
// Nutzungslimit prüfen
if ($voucher['usage_limit'] > 0 && $voucher['usage_count'] >= $voucher['usage_limit']) {
return ['valid' => false, 'error' => 'Gutschein wurde bereits zu oft eingelöst'];
}
return [
'valid' => true,
'voucher' => $voucher
];
}
/**
* Markiert einen Gutschein als verwendet
*/
public static function use_voucher($code) {
$vouchers = self::get_vouchers();
$code = strtoupper(trim($code));
if (isset($vouchers[$code])) {
$vouchers[$code]['usage_count']++;
update_option(self::OPTION_KEY, $vouchers);
return true;
}
return false;
}
/**
* REST API Routen registrieren
*/
public function register_rest_routes() {
register_rest_route('skrift/v1', '/voucher/use', [
'methods' => 'POST',
'callback' => [$this, 'rest_use_voucher'],
'permission_callback' => ['Skrift_Konfigurator_Admin_Settings', 'rest_api_key_permission'],
]);
register_rest_route('skrift/v1', '/voucher/validate', [
'methods' => 'POST',
'callback' => [$this, 'rest_validate_voucher'],
'permission_callback' => ['Skrift_Konfigurator_Admin_Settings', 'rest_api_key_permission'],
]);
}
/**
* REST API Endpoint: Gutschein validieren
*/
public function rest_validate_voucher($request) {
$code = $request->get_param('code');
if (empty($code)) {
return new WP_Error('missing_code', 'Gutschein-Code fehlt', ['status' => 400]);
}
$result = self::validate_voucher($code);
if ($result['valid']) {
return [
'valid' => true,
'voucher' => [
'code' => $result['voucher']['code'],
'type' => $result['voucher']['type'],
'value' => $result['voucher']['value'],
]
];
} else {
return [
'valid' => false,
'error' => $result['error']
];
}
}
/**
* REST API Endpoint: Gutschein als verwendet markieren
*/
public function rest_use_voucher($request) {
$code = $request->get_param('code');
if (empty($code)) {
return new WP_Error('missing_code', 'Gutschein-Code fehlt', ['status' => 400]);
}
$result = self::use_voucher($code);
if ($result) {
return ['success' => true, 'message' => 'Gutschein wurde als verwendet markiert'];
} else {
return new WP_Error('invalid_code', 'Ungültiger Gutschein-Code', ['status' => 404]);
}
}
}
new Skrift_Konfigurator_Vouchers();

View File

@@ -0,0 +1,382 @@
<?php
/**
* API Proxy für Skrift Konfigurator
* Leitet Anfragen an das Backend weiter, ohne den API-Token im Frontend zu exponieren
*/
if (!defined('ABSPATH')) { exit; }
final class Skrift_Konfigurator_API_Proxy {
public function __construct() {
add_action('rest_api_init', [$this, 'register_rest_routes']);
}
/**
* REST API Routen registrieren
*/
public function register_rest_routes() {
// Health Check
register_rest_route('skrift/v1', '/proxy/health', [
'methods' => 'GET',
'callback' => [$this, 'proxy_health_check'],
'permission_callback' => ['Skrift_Konfigurator_Admin_Settings', 'rest_api_key_permission'],
]);
// Preview Batch generieren
register_rest_route('skrift/v1', '/proxy/preview/batch', [
'methods' => 'POST',
'callback' => [$this, 'proxy_preview_batch'],
'permission_callback' => ['Skrift_Konfigurator_Admin_Settings', 'rest_api_key_permission'],
]);
// Einzelne Preview abrufen
register_rest_route('skrift/v1', '/proxy/preview/(?P<sessionId>[a-zA-Z0-9_-]+)/(?P<index>\d+)', [
'methods' => 'GET',
'callback' => [$this, 'proxy_preview_get'],
'permission_callback' => ['Skrift_Konfigurator_Admin_Settings', 'rest_api_key_permission'],
]);
// Order generieren
register_rest_route('skrift/v1', '/proxy/order/generate', [
'methods' => 'POST',
'callback' => [$this, 'proxy_order_generate'],
'permission_callback' => ['Skrift_Konfigurator_Admin_Settings', 'rest_api_key_permission'],
]);
// Order finalisieren
register_rest_route('skrift/v1', '/proxy/order/finalize', [
'methods' => 'POST',
'callback' => [$this, 'proxy_order_finalize'],
'permission_callback' => ['Skrift_Konfigurator_Admin_Settings', 'rest_api_key_permission'],
]);
register_rest_route('skrift/v1', '/proxy/order/register', [
'methods' => 'POST',
'callback' => [$this, 'proxy_order_register'],
'permission_callback' => ['Skrift_Konfigurator_Admin_Settings', 'rest_api_key_permission'],
]);
// Motiv hochladen (ans Docker-Backend weiterleiten)
register_rest_route('skrift/v1', '/proxy/motif/upload', [
'methods' => 'POST',
'callback' => [$this, 'proxy_motif_upload'],
'permission_callback' => ['Skrift_Konfigurator_Admin_Settings', 'rest_api_key_permission'],
]);
}
/**
* Holt Backend-Konfiguration
*/
private function get_backend_config(): array {
$settings = Skrift_Konfigurator_Admin_Settings::get_settings();
return [
'api_url' => $settings['backend_connection']['api_url'] ?? '',
'api_token' => $settings['backend_connection']['api_token'] ?? '',
];
}
/**
* Führt einen HTTP-Request ans Backend aus
*/
private function make_backend_request(string $method, string $endpoint, array $body = null): array {
$config = $this->get_backend_config();
if (empty($config['api_url'])) {
return [
'success' => false,
'error' => 'Backend API URL nicht konfiguriert',
'status' => 500,
];
}
$url = rtrim($config['api_url'], '/') . $endpoint;
$args = [
'method' => $method,
'timeout' => 60,
'headers' => [
'Content-Type' => 'application/json',
],
];
// API Token hinzufügen wenn vorhanden
if (!empty($config['api_token'])) {
$args['headers']['X-API-Token'] = $config['api_token'];
}
// Body hinzufügen bei POST/PUT
if ($body !== null && in_array($method, ['POST', 'PUT', 'PATCH'])) {
$args['body'] = wp_json_encode($body);
}
$response = wp_remote_request($url, $args);
if (is_wp_error($response)) {
return [
'success' => false,
'error' => $response->get_error_message(),
'status' => 500,
];
}
$status_code = wp_remote_retrieve_response_code($response);
$response_body = wp_remote_retrieve_body($response);
// Versuche JSON zu parsen
$data = json_decode($response_body, true);
if ($status_code >= 200 && $status_code < 300) {
return [
'success' => true,
'data' => $data ?? $response_body,
'status' => $status_code,
];
}
return [
'success' => false,
'error' => $data['error'] ?? $data['message'] ?? 'Backend-Fehler',
'status' => $status_code,
'data' => $data,
];
}
/**
* Health Check Proxy
*/
public function proxy_health_check($request) {
$result = $this->make_backend_request('GET', '/health');
if (!$result['success']) {
return new WP_Error('backend_error', $result['error'], ['status' => $result['status']]);
}
return $result['data'];
}
/**
* Preview Batch Proxy
*/
public function proxy_preview_batch($request) {
$body = $request->get_json_params();
if (empty($body)) {
return new WP_Error('invalid_request', 'Keine Daten empfangen', ['status' => 400]);
}
$result = $this->make_backend_request('POST', '/api/preview/batch', $body);
if (!$result['success']) {
// Rate Limiting Info weitergeben
if ($result['status'] === 429 && isset($result['data']['retryAfter'])) {
return new WP_REST_Response([
'error' => $result['error'],
'retryAfter' => $result['data']['retryAfter'],
], 429);
}
// Overflow-Daten durchreichen: Backend liefert hasOverflow/overflowFiles auch bei Fehler-Status
if (!empty($result['data']['hasOverflow'])) {
return new WP_REST_Response([
'hasOverflow' => true,
'overflowFiles' => $result['data']['overflowFiles'] ?? [],
'files' => $result['data']['files'] ?? [],
'error' => $result['error'],
], 200);
}
return new WP_Error('backend_error', $result['error'], ['status' => $result['status']]);
}
return $result['data'];
}
/**
* Einzelne Preview abrufen Proxy
*/
public function proxy_preview_get($request) {
$session_id = $request->get_param('sessionId');
$index = $request->get_param('index');
// Sicherheits-Validierung: Session-ID darf nur alphanumerische Zeichen, Unterstriche und Bindestriche enthalten
if (!preg_match('/^[a-zA-Z0-9_-]+$/', $session_id)) {
return new WP_Error('invalid_session_id', 'Ungültige Session-ID', ['status' => 400]);
}
// Index muss eine positive Ganzzahl sein
$index = absint($index);
$result = $this->make_backend_request('GET', "/api/preview/{$session_id}/{$index}");
if (!$result['success']) {
return new WP_Error('backend_error', $result['error'], ['status' => $result['status']]);
}
// Bei SVG-Daten: Content-Type setzen
if (is_string($result['data']) && strpos($result['data'], '<svg') !== false) {
$response = new WP_REST_Response($result['data']);
$response->header('Content-Type', 'image/svg+xml');
return $response;
}
return $result['data'];
}
/**
* Order generieren Proxy
*/
public function proxy_order_generate($request) {
$body = $request->get_json_params();
if (empty($body)) {
return new WP_Error('invalid_request', 'Keine Daten empfangen', ['status' => 400]);
}
$result = $this->make_backend_request('POST', '/api/order/generate', $body);
if (!$result['success']) {
return new WP_Error('backend_error', $result['error'], ['status' => $result['status']]);
}
return $result['data'];
}
/**
* Order finalisieren Proxy
*/
public function proxy_order_finalize($request) {
$body = $request->get_json_params();
if (empty($body)) {
return new WP_Error('invalid_request', 'Keine Daten empfangen', ['status' => 400]);
}
$result = $this->make_backend_request('POST', '/api/order/finalize', $body);
if (!$result['success']) {
return new WP_Error('backend_error', $result['error'], ['status' => $result['status']]);
}
return $result['data'];
}
public function proxy_order_register($request) {
$body = $request->get_json_params();
if (empty($body)) {
return new WP_Error('invalid_request', 'Keine Daten empfangen', ['status' => 400]);
}
$result = $this->make_backend_request('POST', '/api/order/register', $body);
if (!$result['success']) {
return new WP_Error('backend_error', $result['error'], ['status' => $result['status']]);
}
return $result['data'];
}
/**
* Motiv hochladen Proxy
* Leitet Datei ans Docker-Backend weiter, speichert dort im Auftragsordner
*/
public function proxy_motif_upload($request) {
// Datei aus Request holen
$files = $request->get_file_params();
if (empty($files['motif'])) {
return new WP_Error('no_file', 'Keine Datei empfangen', ['status' => 400]);
}
$file = $files['motif'];
$order_number = $request->get_param('orderNumber');
if (empty($order_number)) {
return new WP_Error('no_order_number', 'Bestellnummer fehlt', ['status' => 400]);
}
// Überprüfe auf Upload-Fehler
if ($file['error'] !== UPLOAD_ERR_OK) {
return new WP_Error('upload_error', 'Upload-Fehler: ' . $file['error'], ['status' => 400]);
}
// Erlaubte Dateitypen prüfen
$allowed_types = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp', 'image/svg+xml', 'application/pdf'];
$file_type = wp_check_filetype($file['name']);
// MIME-Type Prüfung
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$real_type = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
// SVG wird oft als text/xml erkannt
if ($real_type === 'text/xml' || $real_type === 'text/plain') {
$content = file_get_contents($file['tmp_name']);
if (strpos($content, '<svg') !== false) {
$real_type = 'image/svg+xml';
}
}
if (!in_array($real_type, $allowed_types) && !in_array($file['type'], $allowed_types)) {
return new WP_Error('invalid_type', 'Ungültiger Dateityp: ' . $real_type, ['status' => 400]);
}
// Datei ans Docker-Backend senden (multipart/form-data)
$config = $this->get_backend_config();
if (empty($config['api_url'])) {
return new WP_Error('no_backend', 'Backend API URL nicht konfiguriert', ['status' => 500]);
}
$url = rtrim($config['api_url'], '/') . '/api/order/motif';
// Boundary für multipart
$boundary = wp_generate_password(24, false);
// Multipart Body bauen
$body = '';
// orderNumber Feld
$body .= "--{$boundary}\r\n";
$body .= "Content-Disposition: form-data; name=\"orderNumber\"\r\n\r\n";
$body .= $order_number . "\r\n";
// Datei Feld
$body .= "--{$boundary}\r\n";
$body .= "Content-Disposition: form-data; name=\"motif\"; filename=\"" . basename($file['name']) . "\"\r\n";
$body .= "Content-Type: " . ($real_type ?: 'application/octet-stream') . "\r\n\r\n";
$body .= file_get_contents($file['tmp_name']) . "\r\n";
$body .= "--{$boundary}--\r\n";
$args = [
'method' => 'POST',
'timeout' => 60,
'headers' => [
'Content-Type' => 'multipart/form-data; boundary=' . $boundary,
],
'body' => $body,
];
// API Token hinzufügen wenn vorhanden
if (!empty($config['api_token'])) {
$args['headers']['X-API-Token'] = $config['api_token'];
}
$response = wp_remote_post($url, $args);
if (is_wp_error($response)) {
return new WP_Error('backend_error', $response->get_error_message(), ['status' => 500]);
}
$status_code = wp_remote_retrieve_response_code($response);
$response_body = wp_remote_retrieve_body($response);
$data = json_decode($response_body, true);
if ($status_code >= 200 && $status_code < 300) {
return $data;
}
return new WP_Error('backend_error', $data['error'] ?? $data['message'] ?? 'Upload fehlgeschlagen', ['status' => $status_code]);
}
}
new Skrift_Konfigurator_API_Proxy();

View File

View File

@@ -0,0 +1,202 @@
<?php
/**
* Plugin Name: Skrift Konfigurator
* Description: Interaktiver Konfigurator für handgeschriebene Briefe
* Version: 0.3.0
* Author: Skrift
*/
if (!defined('ABSPATH')) { exit; }
// Admin Settings IMMER laden (für REST API Permission Callbacks)
require_once plugin_dir_path(__FILE__) . 'includes/admin-settings.php';
// Gutscheine IMMER laden (für REST API)
require_once plugin_dir_path(__FILE__) . 'includes/admin-vouchers.php';
// Bestellnummern-Verwaltung laden (für REST API)
require_once plugin_dir_path(__FILE__) . 'includes/admin-orders.php';
// Backend API Proxy laden (für REST API)
require_once plugin_dir_path(__FILE__) . 'includes/api-proxy.php';
final class Skrift_Konfigurator_Plugin {
const VERSION = '0.3.0';
const SLUG = 'skrift-konfigurator';
public function __construct() {
add_action('wp_enqueue_scripts', [$this, 'register_assets']);
add_filter('script_loader_tag', [$this, 'add_module_attribute'], 10, 3);
add_shortcode('skrift_konfigurator', [$this, 'render_shortcode']);
add_shortcode('skrift_preisrechner', [$this, 'render_preisrechner_shortcode']);
}
public function register_assets(): void {
$base = plugin_dir_url(__FILE__);
wp_register_style(
self::SLUG,
$base . 'assets/css/configurator.css',
[],
self::VERSION
);
wp_register_script(
self::SLUG,
$base . 'assets/js/configurator-app.js',
[],
self::VERSION,
true
);
// Preisrechner Script
wp_register_script(
'skrift-preisrechner',
$base . 'assets/js/price-calculator.js',
[],
self::VERSION,
true
);
}
public function add_module_attribute(string $tag, string $handle, string $src): string {
$module_handles = [self::SLUG, 'skrift-preisrechner'];
if (!in_array($handle, $module_handles, true)) return $tag;
return '<script type="module" src="' . esc_url($src) . '"></script>';
}
public function render_shortcode($atts = []): string {
wp_enqueue_style(self::SLUG);
wp_enqueue_script(self::SLUG);
// Einstellungen aus DB holen
$settings = Skrift_Konfigurator_Admin_Settings::get_settings();
$vouchers = Skrift_Konfigurator_Vouchers::get_vouchers();
// WICHTIG: Sicherstellen dass leere Arrays als Objekt {} encodiert werden, nicht als Array []
if (empty($vouchers)) {
$vouchers = new stdClass();
}
// PayPal-Einstellungen für Frontend vorbereiten (ohne sensible Daten)
$paypal_frontend = [];
if (!empty($settings['paypal']['enabled'])) {
$mode = $settings['paypal']['mode'] ?? 'sandbox';
$client_id = ($mode === 'live')
? ($settings['paypal']['client_id_live'] ?? '')
: ($settings['paypal']['client_id_sandbox'] ?? '');
$paypal_frontend = [
'enabled' => true,
'mode' => $mode,
'client_id' => $client_id,
];
}
ob_start();
?>
<script>
<?php
// Settings kopieren aber sensible Daten entfernen
$frontend_settings = $settings;
// API Token NICHT ans Frontend senden - wird über WordPress Proxy gehandhabt
unset($frontend_settings['backend_connection']['api_token']);
// API Security Key NICHT ans Frontend senden
unset($frontend_settings['api_security']);
// PayPal Secrets NICHT ans Frontend senden
unset($frontend_settings['paypal']['client_secret_sandbox']);
unset($frontend_settings['paypal']['client_secret_live']);
?>
window.SkriftConfigurator = <?php echo wp_json_encode([
'version' => self::VERSION,
'restUrl' => esc_url_raw(rest_url()),
'nonce' => wp_create_nonce('wp_rest'),
'apiKey' => $settings['api_security']['api_key'] ?? '', // API Key für REST-Aufrufe
'settings' => $frontend_settings,
'vouchers' => $vouchers,
'paypal' => $paypal_frontend,
]); ?>;
</script>
<style>.sk-configurator{opacity:0;transition:opacity .2s ease}.sk-configurator.sk-ready{opacity:1}</style>
<div class="sk-configurator" data-skrift-konfigurator="1">
<div class="sk-configurator__layout">
<!-- Main Content Area -->
<div class="sk-main">
<!-- Top Bar mit Preis und Stepper -->
<div id="sk-topbar" class="sk-topbar"></div>
<!-- Stepper -->
<div id="sk-stepper"></div>
<!-- Form Content -->
<div id="sk-form"></div>
<!-- Mobile Preview (nur auf mobilen Geräten sichtbar, vor dem Button) -->
<div id="sk-preview-mobile" class="sk-preview-mobile"></div>
<!-- Navigation Buttons -->
<div class="sk-nav">
<button type="button" id="sk-prev" class="sk-btn sk-btn-secondary">
← Zurück
</button>
<button type="button" id="sk-next" class="sk-btn sk-btn-primary">
Weiter →
</button>
</div>
<!-- Mobile Contact Card (nach dem Button, nur mobile) -->
<div id="sk-contact-mobile" class="sk-contact-card-mobile"></div>
</div>
<!-- Preview Sidebar -->
<aside class="sk-side">
<div id="sk-preview"></div>
</aside>
</div>
</div>
<?php
return ob_get_clean();
}
/**
* Rendert den Preisrechner Shortcode
*/
public function render_preisrechner_shortcode($atts = []): string {
wp_enqueue_style(self::SLUG);
wp_enqueue_script('skrift-preisrechner');
// Einstellungen aus DB holen (gleiche wie Konfigurator)
$settings = Skrift_Konfigurator_Admin_Settings::get_settings();
ob_start();
?>
<script>
<?php
// Settings kopieren aber sensible Daten entfernen
$frontend_settings = $settings;
unset($frontend_settings['backend_connection']['api_token']);
unset($frontend_settings['api_security']);
unset($frontend_settings['paypal']);
?>
window.SkriftPreisrechner = <?php echo wp_json_encode([
'version' => self::VERSION,
'settings' => $frontend_settings,
]); ?>;
</script>
<div class="sk-configurator" data-skrift-preisrechner="1">
<!-- Preisrechner wird per JavaScript gerendert -->
</div>
<?php
return ob_get_clean();
}
}
new Skrift_Konfigurator_Plugin();

View File

@@ -0,0 +1,615 @@
/* ──────────────────────────────────────────────
Skrift Auftragsmanager – job-spezifische Styles
Baut auf configurator.css auf (--sk-* Variablen)
────────────────────────────────────────────── */
/* Breite eingrenzen */
.sk-job-app {
max-width: 1200px;
margin: 0 auto;
}
/* ── Mehr Luft in Cards ──────────────────────────── */
.sk-job-app .sk-card-head {
padding: 20px 24px;
}
.sk-job-app .sk-card-body {
padding: 24px;
}
.sk-job-app .sk-card {
margin-bottom: 20px;
}
/* ── Stepper: nie umbrechen, horizontal scrollen ─── */
.sk-job-app .sk-stepper {
flex-wrap: nowrap;
overflow-x: auto;
padding: 4px 0 8px;
scrollbar-width: thin;
scrollbar-color: var(--sk-border-strong) transparent;
margin-bottom: 16px;
}
.sk-job-app .sk-stepper::-webkit-scrollbar {
height: 4px;
}
.sk-job-app .sk-stepper::-webkit-scrollbar-thumb {
background: var(--sk-border-strong);
border-radius: 2px;
}
.sk-job-app .sk-chip {
flex-shrink: 0;
white-space: nowrap;
}
.sk-job-app .sk-stepper-arrow {
flex-shrink: 0;
}
/* ── Gesperrte Felder (ausgegraut) ───────────────── */
.sk-job-locked {
padding: 10px 12px;
background: rgba(15, 23, 42, 0.04);
border: 1px solid var(--sk-border);
border-radius: var(--sk-radius);
font-size: 14px;
color: var(--sk-muted);
font-style: italic;
}
/* ── Übersichts-Grid ─────────────────────────────── */
.sk-job-overview-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
@media (max-width: 560px) {
.sk-job-overview-grid { grid-template-columns: 1fr; }
}
/* ── Upload-Zone ─────────────────────────────────── */
.sk-job-upload-zone {
border: 2px dashed var(--sk-border-strong);
border-radius: var(--sk-radius);
padding: 32px 24px;
text-align: center;
cursor: pointer;
transition: border-color .15s, background .15s;
margin-top: 16px;
}
.sk-job-upload-zone:hover,
.sk-job-upload-zone.is-dragover {
border-color: var(--sk-accent);
background: rgba(30, 58, 106, 0.03);
}
.sk-job-upload-zone.is-success {
border-color: var(--sk-success);
background: rgba(5, 150, 105, 0.04);
border-style: solid;
}
.sk-job-upload-zone input[type="file"] { display: none; }
.sk-job-upload-icon {
font-size: 28px;
margin-bottom: 8px;
opacity: .6;
}
.sk-job-upload-title {
font-size: 14px;
font-weight: 600;
color: var(--sk-text);
}
.sk-job-upload-sub {
font-size: 12px;
color: var(--sk-muted);
margin-top: 4px;
}
/* ── Download-Buttons ────────────────────────────── */
.sk-job-dl-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 8px;
}
.sk-job-dl-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border: 1px solid var(--sk-border-strong);
border-radius: var(--sk-radius);
background: white;
font-size: 13px;
font-weight: 600;
color: var(--sk-accent);
cursor: pointer;
transition: all .15s;
font-family: inherit;
}
.sk-job-dl-btn:hover {
border-color: var(--sk-accent);
background: rgba(30, 58, 106, 0.04);
transform: translateY(-1px);
}
/* ── Platzhalter-Chips ───────────────────────────── */
.sk-job-ph-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
}
.sk-job-ph-chip {
display: inline-block;
padding: 3px 10px;
background: rgba(30, 58, 106, 0.08);
border: 1px solid rgba(30, 58, 106, 0.2);
border-radius: 999px;
font-size: 12px;
font-family: monospace;
color: var(--sk-accent);
cursor: pointer;
transition: background .12s, border-color .12s;
user-select: none;
}
.sk-job-ph-chip:hover {
background: rgba(30, 58, 106, 0.14);
border-color: var(--sk-accent);
}
/* ── Preview-Navigation ──────────────────────────── */
.sk-job-preview-nav {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-top: 12px;
}
.sk-job-preview-counter {
font-size: 13px;
color: var(--sk-muted);
min-width: 70px;
text-align: center;
}
.sk-job-preview-box {
margin-top: 16px;
min-height: 260px;
border: 1px solid var(--sk-border);
border-radius: var(--sk-radius);
background: rgba(15, 23, 42, 0.02);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
padding: 12px;
}
.sk-job-preview-box img {
max-width: 100%;
max-height: 520px;
border-radius: 4px;
box-shadow: 0 2px 12px rgba(0, 0, 0, .12);
}
/* ── Spinner ─────────────────────────────────────── */
@keyframes sk-job-spin { to { transform: rotate(360deg); } }
.sk-job-spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, .35);
border-top-color: currentColor;
border-radius: 50%;
animation: sk-job-spin .7s linear infinite;
vertical-align: middle;
}
.sk-job-spinner--dark {
border-color: var(--sk-border);
border-top-color: var(--sk-accent);
}
/* ── Verify-Eingabe ──────────────────────────────── */
.sk-job-verify-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.sk-job-verify-row .sk-input {
flex: 1;
min-width: 180px;
font-size: 16px;
letter-spacing: .03em;
}
/* ── Mengen-Popup (Modal) ────────────────────────── */
.sk-job-modal-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.55);
z-index: 99999;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
animation: sk-modal-fade-in .15s ease;
/* Das Overlay hängt an <body> – also außerhalb von .sk-configurator, wo die
--sk-*-Variablen definiert sind. Ohne diese Re-Definition blieben
var(--sk-accent) & Co. leer und die .sk-btn-Buttons erschienen unsichtbar
(weißer Primärbutton, kein Rahmen). Werte identisch zu configurator.css. */
--sk-accent: #1e3a6a;
--sk-text: #0f172a;
--sk-muted: rgba(15, 23, 42, 0.68);
--sk-card: #ffffff;
--sk-border: rgba(15, 23, 42, 0.1);
--sk-border-strong: rgba(15, 23, 42, 0.16);
--sk-radius: 8px;
--sk-font-text: system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
color: var(--sk-text);
}
@keyframes sk-modal-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.sk-job-modal {
background: #fff;
border-radius: var(--sk-radius);
padding: 32px;
max-width: 480px;
width: 100%;
box-shadow: 0 20px 60px rgba(0, 0, 0, .2);
animation: sk-modal-slide-in .15s ease;
}
@keyframes sk-modal-slide-in {
from { transform: translateY(-12px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.sk-job-modal-title {
margin: 0 0 16px;
font-size: 18px;
font-weight: 700;
color: var(--sk-text);
}
.sk-job-modal-text {
color: var(--sk-muted);
font-size: 14px;
margin: 0 0 10px;
line-height: 1.6;
}
.sk-job-modal-text strong {
color: var(--sk-text);
}
.sk-job-modal-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 24px;
flex-wrap: wrap;
}
/* ── Vollbreite Checkbox-Option ──────────────────── */
.sk-option-full {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px;
border: 1px solid var(--sk-border);
border-radius: var(--sk-radius);
cursor: pointer;
transition: all 0.2s ease;
background: white;
width: 100%;
box-sizing: border-box;
scroll-margin: 0;
}
.sk-option-full input[type="checkbox"] {
margin-top: 2px;
flex-shrink: 0;
scroll-margin: 0;
}
.sk-option-full:hover {
border-color: var(--sk-border-strong);
background: rgba(15, 23, 42, 0.02);
}
.sk-option-full.is-selected {
border-color: var(--sk-accent);
background: rgba(30, 58, 106, 0.04);
}
.sk-option-full.is-selected .sk-option-label {
font-weight: 600;
}
/* Warn-Variante (finale Bestätigung) */
.sk-option-full--warn.is-selected {
border-color: #d97706;
background: rgba(217, 119, 6, 0.06);
}
.sk-option-full--warn.is-selected .sk-option-label {
color: #92400e;
}
/* ── Tabellen-Nummerierungs-Spalte (#) ───────────── */
.sk-table .sk-col-nr {
width: 36px;
min-width: 36px;
max-width: 36px;
padding: 8px 6px !important;
text-align: center;
color: var(--sk-muted);
font-variant-numeric: tabular-nums;
font-size: 12px;
white-space: nowrap;
}
.sk-job-app .sk-table {
min-width: 0; /* Konfigurator-Default ist 600px → bei wenig Spalten ungenutzte Breite */
}
/* Vertikale Scroll-Höhe für lange Tabellen begrenzen */
.sk-job-app .sk-table-wrapper {
max-height: 420px;
overflow: auto;
}
/* ── Editierbare Tabelle (Direkteingabe Adresse/Platzhalter) ─────── */
.sk-table-input {
width: 100%;
min-width: 90px;
padding: 6px 8px;
border: 1px solid transparent;
border-radius: 4px;
font-size: 13px;
font-family: inherit;
background: transparent;
box-sizing: border-box;
}
.sk-table-input:hover {
border-color: var(--sk-border);
}
.sk-table-input:focus {
border-color: var(--sk-accent);
background: white;
outline: none;
}
.sk-table-readonly {
color: var(--sk-muted);
font-style: italic;
}
/* ── Preview-Sektionen (Brief + Umschlag gestapelt) ─ */
.sk-job-preview-section {
margin-top: 20px;
}
.sk-job-preview-section-title {
font-size: 13px;
font-weight: 600;
color: var(--sk-muted);
text-transform: uppercase;
letter-spacing: .05em;
margin-bottom: 8px;
}
.sk-job-preview-box--envelope {
min-height: 160px;
background: rgba(15, 23, 42, 0.04);
}
.sk-job-preview-box--envelope img {
max-height: 280px;
}
/* ── Overflow-Warnung (Text zu lang) ─────────────── */
.sk-overflow-warning .sk-overflow-title {
font-weight: 700;
font-size: 15px;
margin-bottom: 10px;
}
.sk-overflow-list {
list-style: none;
margin: 0 0 10px;
padding: 0;
max-height: 220px;
overflow-y: auto;
}
.sk-overflow-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 10px;
border-radius: 4px;
font-size: 13px;
gap: 12px;
}
.sk-overflow-item + .sk-overflow-item {
border-top: 1px solid rgba(220, 38, 38, 0.15);
}
.sk-overflow-lines {
font-variant-numeric: tabular-nums;
font-weight: 600;
white-space: nowrap;
}
.sk-overflow-more {
font-style: italic;
opacity: .8;
justify-content: flex-start;
}
.sk-overflow-hint {
font-size: 13px;
margin: 8px 0 0;
line-height: 1.5;
opacity: .9;
}
/* ── Lightbox / Bild-Vergrößerung ────────────────── */
.sk-job-lightbox {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.92);
z-index: 100000;
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
cursor: zoom-out;
animation: sk-modal-fade-in .15s ease;
}
.sk-job-lightbox img {
max-width: 95vw;
max-height: 90vh;
object-fit: contain;
background: white;
border-radius: 6px;
box-shadow: 0 12px 60px rgba(0, 0, 0, 0.5);
cursor: default;
}
.sk-job-lightbox-close {
position: absolute;
top: 16px;
right: 16px;
width: 44px;
height: 44px;
border-radius: 50%;
border: none;
background: rgba(255, 255, 255, 0.9);
color: var(--sk-text);
font-size: 28px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background .15s, transform .15s;
}
.sk-job-lightbox-close:hover {
background: white;
transform: scale(1.05);
}
/* ── Platzhalter-Import Tabelle ──────────────────── */
.sk-job-ph-table-wrap {
overflow: auto;
margin-top: 12px;
border: 1px solid var(--sk-border);
border-radius: var(--sk-radius);
}
.sk-job-ph-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.sk-job-ph-table th,
.sk-job-ph-table td {
padding: 8px 12px;
text-align: left;
border-bottom: 1px solid var(--sk-border);
white-space: nowrap;
}
.sk-job-ph-table th {
background: rgba(15, 23, 42, 0.03);
font-weight: 600;
color: var(--sk-text);
position: sticky;
top: 0;
}
.sk-job-ph-table td {
color: var(--sk-muted);
}
.sk-job-ph-table tr:last-child td {
border-bottom: none;
}
/* ── Mobile Optimierungen ────────────────────────── */
@media (max-width: 600px) {
.sk-job-app .sk-card-head {
padding: 16px;
}
.sk-job-app .sk-card-body {
padding: 16px;
}
.sk-job-overview-grid {
grid-template-columns: 1fr;
}
.sk-job-dl-row {
flex-direction: column;
}
.sk-job-dl-btn {
width: 100%;
justify-content: center;
}
.sk-job-verify-row {
flex-direction: column;
}
.sk-job-verify-row .sk-input {
min-width: 0;
width: 100%;
}
.sk-job-modal {
padding: 24px 16px;
}
.sk-job-modal-actions {
flex-direction: column-reverse;
}
.sk-job-modal-actions .sk-btn {
width: 100%;
justify-content: center;
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,104 @@
<?php
/**
* Plugin Name: Skrift Auftragsmanager
* Description: Kundenauftrag-Verwaltung für den Skrift Konfigurator. Benötigt das Plugin "Skrift Konfigurator" für CSS, Proxy-Endpunkte und Backend-Verbindung.
* Version: 1.0.0
* Author: Skrift
*/
if (!defined('ABSPATH')) { exit; }
require_once plugin_dir_path(__FILE__) . 'includes/admin-job-manager.php';
// Cron beim Deaktivieren des Plugins entfernen
register_deactivation_hook(__FILE__, function () {
wp_clear_scheduled_hook('skrift_cleanup_old_jobs');
});
final class Skrift_Job_Manager_Plugin {
const VERSION = '1.0.0';
const SLUG = 'skrift-job-manager';
public function __construct() {
add_action('wp_enqueue_scripts', [$this, 'register_assets']);
add_filter('script_loader_tag', [$this, 'add_module_attribute'], 10, 3);
add_shortcode('skrift_kundenauftrag', [$this, 'render_shortcode']);
}
public function register_assets(): void {
$base = plugin_dir_url(__FILE__);
// Basis-CSS vom Konfigurator als Abhängigkeit (liefert --sk-* Variablen und alle .sk-* Klassen)
wp_register_style(
self::SLUG,
$base . 'assets/css/job-app.css',
['skrift-konfigurator'],
self::VERSION
);
// SheetJS UMD – im <head> damit window.XLSX vor ES6-Modulen verfügbar ist
wp_register_script(
'skrift-xlsx-js',
'https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js',
[],
'0.18.5',
false
);
wp_register_script(
self::SLUG,
$base . 'assets/js/job-app.js',
['skrift-xlsx-js'],
self::VERSION,
true
);
}
public function add_module_attribute(string $tag, string $handle, string $src): string {
if ($handle !== self::SLUG) return $tag;
return '<script type="module" src="' . esc_url($src) . '"></script>';
}
public function render_shortcode($atts = []): string {
wp_enqueue_style('skrift-konfigurator');
wp_enqueue_style(self::SLUG);
wp_enqueue_script('skrift-xlsx-js');
wp_enqueue_script(self::SLUG);
// Konfigurator-Einstellungen lesen (Backend-URL, API-Key, etc.)
if (class_exists('Skrift_Konfigurator_Admin_Settings')) {
$settings = Skrift_Konfigurator_Admin_Settings::get_settings();
} else {
$settings = get_option('skrift_konfigurator_settings', []);
}
$frontend_settings = $settings;
unset($frontend_settings['backend_connection']['api_token']);
unset($frontend_settings['api_security']);
unset($frontend_settings['paypal']['client_secret_sandbox']);
unset($frontend_settings['paypal']['client_secret_live']);
$job_settings = Skrift_Job_Manager::get_plugin_settings();
ob_start();
?>
<script>
window.SkriftJobApp = <?php echo wp_json_encode([
'version' => self::VERSION,
'restUrl' => esc_url_raw(rest_url()),
'nonce' => wp_create_nonce('wp_rest'),
'apiKey' => $settings['api_security']['api_key'] ?? '',
'settings' => $frontend_settings,
'redirectUrl' => $job_settings['redirect_url'] ?? '',
]); ?>;
</script>
<div class="sk-configurator sk-job-app" data-skrift-kundenauftrag="1">
<!-- Wird per JavaScript befüllt -->
</div>
<?php
return ob_get_clean();
}
}
new Skrift_Job_Manager_Plugin();