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:
38
.gitignore
vendored
Normal file
38
.gitignore
vendored
Normal 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
127
CLAUDE.md
Normal 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.
|
||||||
6
Docker/signature-service/.dockerignore
Normal file
6
Docker/signature-service/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
tests/
|
||||||
|
conftest.py
|
||||||
|
.pytest_cache/
|
||||||
|
README.md
|
||||||
15
Docker/signature-service/Dockerfile
Normal file
15
Docker/signature-service/Dockerfile
Normal 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"]
|
||||||
197
Docker/signature-service/README.md
Normal file
197
Docker/signature-service/README.md
Normal 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.
|
||||||
139
Docker/signature-service/app.py
Normal file
139
Docker/signature-service/app.py
Normal 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"'},
|
||||||
|
)
|
||||||
2
Docker/signature-service/conftest.py
Normal file
2
Docker/signature-service/conftest.py
Normal 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.
|
||||||
315
Docker/signature-service/core.py
Normal file
315
Docker/signature-service/core.py
Normal 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("&", "&").replace('"', """)
|
||||||
|
.replace("<", "<").replace(">", ">"))
|
||||||
|
|
||||||
|
|
||||||
|
# ── 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
|
||||||
30
Docker/signature-service/docker-compose.yml
Normal file
30
Docker/signature-service/docker-compose.yml
Normal 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
|
||||||
29
Docker/signature-service/models.py
Normal file
29
Docker/signature-service/models.py
Normal 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
|
||||||
5
Docker/signature-service/requirements.txt
Normal file
5
Docker/signature-service/requirements.txt
Normal 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
|
||||||
286
Docker/signature-service/tests/test_core.py
Normal file
286
Docker/signature-service/tests/test_core.py
Normal 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)
|
||||||
30
Docker/skrift-backend/.dockerignore
Normal file
30
Docker/skrift-backend/.dockerignore
Normal 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
|
||||||
21
Docker/skrift-backend/.env.example
Normal file
21
Docker/skrift-backend/.env.example
Normal 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
7
Docker/skrift-backend/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
cache/
|
||||||
|
output/
|
||||||
|
npm-debug.log
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
31
Docker/skrift-backend/Dockerfile
Normal file
31
Docker/skrift-backend/Dockerfile
Normal 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"]
|
||||||
198
Docker/skrift-backend/HANDSCHRIFT_VARIATIONEN.md
Normal file
198
Docker/skrift-backend/HANDSCHRIFT_VARIATIONEN.md
Normal 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 ✅
|
||||||
461
Docker/skrift-backend/README.md
Normal file
461
Docker/skrift-backend/README.md
Normal 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
|
||||||
331
Docker/skrift-backend/TILDA_API_BEISPIEL.md
Normal file
331
Docker/skrift-backend/TILDA_API_BEISPIEL.md
Normal 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
|
||||||
37
Docker/skrift-backend/docker-compose.yml
Normal file
37
Docker/skrift-backend/docker-compose.yml
Normal 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
|
||||||
2224
Docker/skrift-backend/fonts/alva.svg
Normal file
2224
Docker/skrift-backend/fonts/alva.svg
Normal file
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 152 KiB |
2033
Docker/skrift-backend/fonts/ellie.svg
Normal file
2033
Docker/skrift-backend/fonts/ellie.svg
Normal file
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 149 KiB |
2319
Docker/skrift-backend/fonts/tilda.svg
Normal file
2319
Docker/skrift-backend/fonts/tilda.svg
Normal file
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
1624
Docker/skrift-backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
Docker/skrift-backend/package.json
Normal file
29
Docker/skrift-backend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
541
Docker/skrift-backend/src/api/controllers/order-controller.js
Normal file
541
Docker/skrift-backend/src/api/controllers/order-controller.js
Normal 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,
|
||||||
|
};
|
||||||
362
Docker/skrift-backend/src/api/controllers/preview-controller.js
Normal file
362
Docker/skrift-backend/src/api/controllers/preview-controller.js
Normal 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,
|
||||||
|
};
|
||||||
72
Docker/skrift-backend/src/api/middleware/auth.js
Normal file
72
Docker/skrift-backend/src/api/middleware/auth.js
Normal 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,
|
||||||
|
};
|
||||||
13
Docker/skrift-backend/src/api/middleware/error-handler.js
Normal file
13
Docker/skrift-backend/src/api/middleware/error-handler.js
Normal 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 })
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
53
Docker/skrift-backend/src/api/middleware/rate-limiter.js
Normal file
53
Docker/skrift-backend/src/api/middleware/rate-limiter.js
Normal 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();
|
||||||
|
};
|
||||||
14
Docker/skrift-backend/src/api/middleware/request-logger.js
Normal file
14
Docker/skrift-backend/src/api/middleware/request-logger.js
Normal 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();
|
||||||
|
};
|
||||||
34
Docker/skrift-backend/src/api/routes/health-routes.js
Normal file
34
Docker/skrift-backend/src/api/routes/health-routes.js
Normal 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;
|
||||||
135
Docker/skrift-backend/src/api/routes/order-routes.js
Normal file
135
Docker/skrift-backend/src/api/routes/order-routes.js
Normal 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;
|
||||||
47
Docker/skrift-backend/src/api/routes/preview-routes.js
Normal file
47
Docker/skrift-backend/src/api/routes/preview-routes.js
Normal 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;
|
||||||
53
Docker/skrift-backend/src/config/index.js
Normal file
53
Docker/skrift-backend/src/config/index.js
Normal 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'
|
||||||
|
}
|
||||||
|
};
|
||||||
124
Docker/skrift-backend/src/lib/page-layout.js
Normal file
124
Docker/skrift-backend/src/lib/page-layout.js
Normal 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
};
|
||||||
283
Docker/skrift-backend/src/lib/svg-font-engine.js
Normal file
283
Docker/skrift-backend/src/lib/svg-font-engine.js
Normal 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. < → <, & → &, " → ")
|
||||||
|
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(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/&/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
|
||||||
|
};
|
||||||
341
Docker/skrift-backend/src/lib/svg-generator.js
Normal file
341
Docker/skrift-backend/src/lib/svg-generator.js
Normal 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
|
||||||
|
};
|
||||||
53
Docker/skrift-backend/src/server.js
Normal file
53
Docker/skrift-backend/src/server.js
Normal 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);
|
||||||
|
});
|
||||||
283
Docker/skrift-backend/src/services/placeholder-service.js
Normal file
283
Docker/skrift-backend/src/services/placeholder-service.js
Normal 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
|
||||||
|
};
|
||||||
230
Docker/skrift-backend/src/services/scriptalizer-service.js
Normal file
230
Docker/skrift-backend/src/services/scriptalizer-service.js
Normal 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,
|
||||||
|
};
|
||||||
476
FRONTEND_BACKEND_ZUSAMMENFASSUNG.md
Normal file
476
FRONTEND_BACKEND_ZUSAMMENFASSUNG.md
Normal 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!** 🚀
|
||||||
67
Produktion/AutoRecover/auto_recover.py
Normal file
67
Produktion/AutoRecover/auto_recover.py
Normal 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.")
|
||||||
11
Produktion/AutoRecover/build_exe.bat
Normal file
11
Produktion/AutoRecover/build_exe.bat
Normal 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
|
||||||
27
Produktion/AutoRecover/debug_window.py
Normal file
27
Produktion/AutoRecover/debug_window.py
Normal 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...")
|
||||||
46
Produktion/AutoRecover/find_window_info.py
Normal file
46
Produktion/AutoRecover/find_window_info.py
Normal 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...")
|
||||||
67
Produktion/skrift-produktion/Einrichten.bat
Normal file
67
Produktion/skrift-produktion/Einrichten.bat
Normal 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
|
||||||
23
Produktion/skrift-produktion/Starten (Debug).bat
Normal file
23
Produktion/skrift-produktion/Starten (Debug).bat
Normal 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
|
||||||
|
)
|
||||||
40
Produktion/skrift-produktion/Starten.bat
Normal file
40
Produktion/skrift-produktion/Starten.bat
Normal 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
|
||||||
0
Produktion/skrift-produktion/app/__init__.py
Normal file
0
Produktion/skrift-produktion/app/__init__.py
Normal file
0
Produktion/skrift-produktion/app/api/__init__.py
Normal file
0
Produktion/skrift-produktion/app/api/__init__.py
Normal file
1238
Produktion/skrift-produktion/app/api/routes.py
Normal file
1238
Produktion/skrift-produktion/app/api/routes.py
Normal file
File diff suppressed because it is too large
Load Diff
28
Produktion/skrift-produktion/app/constants.py
Normal file
28
Produktion/skrift-produktion/app/constants.py
Normal 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",
|
||||||
|
}
|
||||||
0
Produktion/skrift-produktion/app/core/__init__.py
Normal file
0
Produktion/skrift-produktion/app/core/__init__.py
Normal file
176
Produktion/skrift-produktion/app/core/config_manager.py
Normal file
176
Produktion/skrift-produktion/app/core/config_manager.py
Normal 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()
|
||||||
219
Produktion/skrift-produktion/app/core/ftp_client.py
Normal file
219
Produktion/skrift-produktion/app/core/ftp_client.py
Normal 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
|
||||||
182
Produktion/skrift-produktion/app/core/machine_client.py
Normal file
182
Produktion/skrift-produktion/app/core/machine_client.py
Normal 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
|
||||||
390
Produktion/skrift-produktion/app/core/order_manager.py
Normal file
390
Produktion/skrift-produktion/app/core/order_manager.py
Normal 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)
|
||||||
2870
Produktion/skrift-produktion/app/static/index.html
Normal file
2870
Produktion/skrift-produktion/app/static/index.html
Normal file
File diff suppressed because it is too large
Load Diff
122
Produktion/skrift-produktion/app/workers/machine_poll_worker.py
Normal file
122
Produktion/skrift-produktion/app/workers/machine_poll_worker.py
Normal 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
|
||||||
183
Produktion/skrift-produktion/app/workers/poll_worker.py
Normal file
183
Produktion/skrift-produktion/app/workers/poll_worker.py
Normal 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}")
|
||||||
117
Produktion/skrift-produktion/config.default.json
Normal file
117
Produktion/skrift-produktion/config.default.json
Normal 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": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
125
Produktion/skrift-produktion/main.py
Normal file
125
Produktion/skrift-produktion/main.py
Normal 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")
|
||||||
5
Produktion/skrift-produktion/requirements.txt
Normal file
5
Produktion/skrift-produktion/requirements.txt
Normal 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
|
||||||
388
Webseite/skrift-configurator/BACKEND_INTEGRATION.md
Normal file
388
Webseite/skrift-configurator/BACKEND_INTEGRATION.md
Normal 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)
|
||||||
318
Webseite/skrift-configurator/README.md
Normal file
318
Webseite/skrift-configurator/README.md
Normal 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
|
||||||
1463
Webseite/skrift-configurator/assets/css/configurator.css
Normal file
1463
Webseite/skrift-configurator/assets/css/configurator.css
Normal file
File diff suppressed because it is too large
Load Diff
394
Webseite/skrift-configurator/assets/js/configurator-api.js
Normal file
394
Webseite/skrift-configurator/assets/js/configurator-api.js
Normal 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;
|
||||||
220
Webseite/skrift-configurator/assets/js/configurator-app.js
Normal file
220
Webseite/skrift-configurator/assets/js/configurator-app.js
Normal 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");
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
1035
Webseite/skrift-configurator/assets/js/configurator-pricing.js
Normal file
1035
Webseite/skrift-configurator/assets/js/configurator-pricing.js
Normal file
File diff suppressed because it is too large
Load Diff
1007
Webseite/skrift-configurator/assets/js/configurator-state.js
Normal file
1007
Webseite/skrift-configurator/assets/js/configurator-state.js
Normal file
File diff suppressed because it is too large
Load Diff
5666
Webseite/skrift-configurator/assets/js/configurator-ui.js
Normal file
5666
Webseite/skrift-configurator/assets/js/configurator-ui.js
Normal file
File diff suppressed because it is too large
Load Diff
96
Webseite/skrift-configurator/assets/js/configurator-utils.js
Normal file
96
Webseite/skrift-configurator/assets/js/configurator-utils.js
Normal 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,
|
||||||
|
};
|
||||||
1151
Webseite/skrift-configurator/assets/js/price-calculator.js
Normal file
1151
Webseite/skrift-configurator/assets/js/price-calculator.js
Normal file
File diff suppressed because it is too large
Load Diff
110
Webseite/skrift-configurator/check-db.php
Normal file
110
Webseite/skrift-configurator/check-db.php
Normal 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>
|
||||||
61
Webseite/skrift-configurator/create-test-voucher.php
Normal file
61
Webseite/skrift-configurator/create-test-voucher.php
Normal 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>';
|
||||||
72
Webseite/skrift-configurator/debug-vouchers.php
Normal file
72
Webseite/skrift-configurator/debug-vouchers.php
Normal 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>';
|
||||||
186
Webseite/skrift-configurator/includes/admin-orders.php
Normal file
186
Webseite/skrift-configurator/includes/admin-orders.php
Normal 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();
|
||||||
944
Webseite/skrift-configurator/includes/admin-settings.php
Normal file
944
Webseite/skrift-configurator/includes/admin-settings.php
Normal 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();
|
||||||
403
Webseite/skrift-configurator/includes/admin-vouchers.php
Normal file
403
Webseite/skrift-configurator/includes/admin-vouchers.php
Normal 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();
|
||||||
382
Webseite/skrift-configurator/includes/api-proxy.php
Normal file
382
Webseite/skrift-configurator/includes/api-proxy.php
Normal 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();
|
||||||
0
Webseite/skrift-configurator/readme.txt
Normal file
0
Webseite/skrift-configurator/readme.txt
Normal file
202
Webseite/skrift-configurator/skrift-konfigurator.php
Normal file
202
Webseite/skrift-configurator/skrift-konfigurator.php
Normal 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();
|
||||||
615
Webseite/skrift-job-manager/assets/css/job-app.css
Normal file
615
Webseite/skrift-job-manager/assets/css/job-app.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
2630
Webseite/skrift-job-manager/assets/js/job-app.js
Normal file
2630
Webseite/skrift-job-manager/assets/js/job-app.js
Normal file
File diff suppressed because it is too large
Load Diff
2162
Webseite/skrift-job-manager/includes/admin-job-manager.php
Normal file
2162
Webseite/skrift-job-manager/includes/admin-job-manager.php
Normal file
File diff suppressed because it is too large
Load Diff
104
Webseite/skrift-job-manager/skrift-job-manager.php
Normal file
104
Webseite/skrift-job-manager/skrift-job-manager.php
Normal 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();
|
||||||
Reference in New Issue
Block a user