From 0ddaab27d004f423ab70ef26c12e0f215ce4c014 Mon Sep 17 00:00:00 2001 From: Lucas Orth Date: Mon, 24 Aug 2026 12:13:50 +0200 Subject: [PATCH] Import aus Monorepo (frischer Start) --- .dockerignore | 31 + .env.example | 26 + .gitea/workflows/deploy.yml | 39 + .gitignore | 8 + Dockerfile | 31 + HANDSCHRIFT_VARIATIONEN.md | 198 ++ README.md | 461 ++++ TILDA_API_BEISPIEL.md | 331 +++ docker-compose.yml | 41 + fonts/alva.svg | 2224 +++++++++++++++++++ fonts/ellie.svg | 2033 +++++++++++++++++ fonts/tilda.svg | 2319 ++++++++++++++++++++ package-lock.json | 1624 ++++++++++++++ package.json | 30 + src/api/controllers/charge-controller.js | 69 + src/api/controllers/directus-controller.js | 225 ++ src/api/controllers/order-controller.js | 601 +++++ src/api/controllers/preview-controller.js | 363 +++ src/api/middleware/auth.js | 72 + src/api/middleware/error-handler.js | 13 + src/api/middleware/rate-limiter.js | 53 + src/api/middleware/request-logger.js | 14 + src/api/routes/charge-routes.js | 13 + src/api/routes/health-routes.js | 34 + src/api/routes/order-routes.js | 154 ++ src/api/routes/preview-routes.js | 47 + src/config/index.js | 59 + src/lib/page-layout.js | 146 ++ src/lib/svg-font-engine.js | 318 +++ src/lib/svg-generator.js | 461 ++++ src/server.js | 93 + src/services/directus-service.js | 113 + src/services/placeholder-service.js | 294 +++ src/services/scriptalizer-service.js | 230 ++ 34 files changed, 12768 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitea/workflows/deploy.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 HANDSCHRIFT_VARIATIONEN.md create mode 100644 README.md create mode 100644 TILDA_API_BEISPIEL.md create mode 100644 docker-compose.yml create mode 100644 fonts/alva.svg create mode 100644 fonts/ellie.svg create mode 100644 fonts/tilda.svg create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/api/controllers/charge-controller.js create mode 100644 src/api/controllers/directus-controller.js create mode 100644 src/api/controllers/order-controller.js create mode 100644 src/api/controllers/preview-controller.js create mode 100644 src/api/middleware/auth.js create mode 100644 src/api/middleware/error-handler.js create mode 100644 src/api/middleware/rate-limiter.js create mode 100644 src/api/middleware/request-logger.js create mode 100644 src/api/routes/charge-routes.js create mode 100644 src/api/routes/health-routes.js create mode 100644 src/api/routes/order-routes.js create mode 100644 src/api/routes/preview-routes.js create mode 100644 src/config/index.js create mode 100644 src/lib/page-layout.js create mode 100644 src/lib/svg-font-engine.js create mode 100644 src/lib/svg-generator.js create mode 100644 src/server.js create mode 100644 src/services/directus-service.js create mode 100644 src/services/placeholder-service.js create mode 100644 src/services/scriptalizer-service.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b5065ae --- /dev/null +++ b/.dockerignore @@ -0,0 +1,31 @@ +# 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 / CI +.git +.gitignore +.gitea + +# 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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..eb94d34 --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +# 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 + +# Directus (Datenquelle für Aufträge) +# Token = statischer Token eines Nutzers in der Rolle "Service" +DIRECTUS_URL=https://admin.skrift.de +DIRECTUS_TOKEN=your-directus-service-token diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..80dacfe --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,39 @@ +name: Build & Deploy + +on: + push: + tags: + - 'v*' + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + # Intern klonen (NAT-Hairpin: die öffentliche Domain ist aus dem + # Job-Container heraus nicht erreichbar). + - uses: actions/checkout@v4 + with: + github-server-url: http://gitea:3000 + + # Kein separater Build/Test-Schritt: der Docker-Build (npm install) ist + # der Build. Falls später Tests dazukommen: hier VOR dem Deploy einfügen. + + - name: Tag ermitteln + run: echo "IMAGE_TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV + + - name: .env aus Secret erzeugen + env: + DOTENV: ${{ secrets.DOTENV }} + run: | + if [ -z "$DOTENV" ]; then + echo "Secret DOTENV ist leer oder nicht gesetzt." + exit 1 + fi + umask 077 + echo "$DOTENV" > .env + + - name: Deployen + run: docker compose up -d --build --remove-orphans --wait --wait-timeout 180 + + - name: Alte Layer aufräumen + run: docker image prune -f diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..73ef00d --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +.env +.env.* +!.env.example +cache/ +output/ +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..39d0737 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/HANDSCHRIFT_VARIATIONEN.md b/HANDSCHRIFT_VARIATIONEN.md new file mode 100644 index 0000000..79af986 --- /dev/null +++ b/HANDSCHRIFT_VARIATIONEN.md @@ -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 + +``` + +**Neu (mit Rotation):** +```xml + +``` + +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 ✅ diff --git a/README.md b/README.md new file mode 100644 index 0000000..38ac946 --- /dev/null +++ b/README.md @@ -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 diff --git a/TILDA_API_BEISPIEL.md b/TILDA_API_BEISPIEL.md new file mode 100644 index 0000000..5fd0239 --- /dev/null +++ b/TILDA_API_BEISPIEL.md @@ -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 + + + + + +``` + +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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4f76232 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,41 @@ +# Auto-Deploy per Tag-Push – siehe DEPLOY.md. Fester Projekt-/Container-Name, +# Image mit Versionstag (Rollback ohne Rebuild), kein Port-Mapping (NPM proxyt +# intern über den Container-Namen). +name: skrift-backend + +services: + app: + image: skrift-backend:${IMAGE_TAG:-latest} + build: . + container_name: skrift-backend + restart: unless-stopped + # Betriebs-Defaults inline; Secrets/Config kommen aus .env (Secret DOTENV). + environment: + - NODE_ENV=production + - PORT=4000 + env_file: .env + volumes: + # Fonts sind ins Image gebacken (COPY im Dockerfile) – kein Bind nötig. + - skrift-backend-cache:/app/cache + # Gemeinsamer Ausgabe-/SFTP-Ordner: ABSOLUTER Host-Pfad, bewusst KEIN named + # volume – Directus liest ihn (ro) und er ist das SFTP-Ziel der Produktion. + - /var/skrift-output:/app/output + networks: + - nginx-proxy-manager_default + # Gate für `--wait`. /health liefert 503, wenn Scriptalizer-Key fehlt oder + # der Output-Ordner nicht schreibbar ist – dann bricht der Deploy bewusst ab. + healthcheck: + test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:4000/health',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"] + interval: 60s + timeout: 5s + retries: 3 + +networks: + nginx-proxy-manager_default: + external: true + +volumes: + # Vorschau-Cache (regenerierbar) mit festem Namen, damit er nicht am + # Projektnamen hängt. + skrift-backend-cache: + name: skrift-backend-cache diff --git a/fonts/alva.svg b/fonts/alva.svg new file mode 100644 index 0000000..1f2d1c7 --- /dev/null +++ b/fonts/alva.svg @@ -0,0 +1,2224 @@ + + + + Created by FontForge 20200511 at Fri Mar 22 09:00:32 2024 By convertio Copyright: Quantum Enterprises + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fonts/ellie.svg b/fonts/ellie.svg new file mode 100644 index 0000000..aa1584e --- /dev/null +++ b/fonts/ellie.svg @@ -0,0 +1,2033 @@ + + + + +Created by FontForge 20200306 at Sun Aug 14 09:09:30 2022 + By convertio +Copyright: Quantum Enterprises + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fonts/tilda.svg b/fonts/tilda.svg new file mode 100644 index 0000000..fc517bd --- /dev/null +++ b/fonts/tilda.svg @@ -0,0 +1,2319 @@ + + + + +Created by FontForge 20200511 at Wed Nov 19 10:12:13 2025 + By convertio +Copyright: Quantum Enterprises + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..5d64b96 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1624 @@ +{ + "name": "skrift-backend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "skrift-backend", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@paypal/paypal-server-sdk": "^1.0.0", + "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" + } + }, + "node_modules/@apimatic/authentication-adapters": { + "version": "0.5.14", + "resolved": "https://registry.npmjs.org/@apimatic/authentication-adapters/-/authentication-adapters-0.5.14.tgz", + "integrity": "sha512-V7nhHShPrU8LfjKKHoVJNS50SveSL77CexVuS4aeQyXx99HwdQVJwl2MK0KAYM6/b2ufQbJ7Eee2fzQT0TVXSQ==", + "license": "MIT", + "dependencies": { + "@apimatic/core-interfaces": "^0.2.14", + "@apimatic/http-headers": "^0.3.8", + "@apimatic/http-query": "^0.3.9", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.15.0 || >=16.0.0" + } + }, + "node_modules/@apimatic/axios-client-adapter": { + "version": "0.3.21", + "resolved": "https://registry.npmjs.org/@apimatic/axios-client-adapter/-/axios-client-adapter-0.3.21.tgz", + "integrity": "sha512-pr/XvAvH9FjbpwM+B7vHQxM7alocOX1kLNtSpXKW3yxTYxksF3ydnUuQ85rRbCoNpyfMOIjnRBCNUBzX5p2Hnw==", + "license": "MIT", + "dependencies": { + "@apimatic/convert-to-stream": "^0.1.9", + "@apimatic/core-interfaces": "^0.2.14", + "@apimatic/file-wrapper": "^0.3.9", + "@apimatic/http-headers": "^0.3.8", + "@apimatic/http-query": "^0.3.9", + "@apimatic/json-bigint": "^1.2.0", + "@apimatic/proxy": "^0.1.4", + "axios": "^1.8.4", + "detect-browser": "^5.3.0", + "detect-node": "^2.1.0", + "form-data": "^4.0.1", + "lodash.flatmap": "^4.5.0", + "tiny-warning": "^1.0.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.15.0 || >=16.0.0" + } + }, + "node_modules/@apimatic/convert-to-stream": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/@apimatic/convert-to-stream/-/convert-to-stream-0.1.9.tgz", + "integrity": "sha512-C9NEKnDZoTRBRVeUGXVyAEmy6P5o+8oLwEckTKj0iBlExJLEXNt14nf4wxfzRO1KR8j5Bw8S6yStKCrQzcVERA==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=14.15.0 || >=16.0.0" + } + }, + "node_modules/@apimatic/core": { + "version": "0.10.29", + "resolved": "https://registry.npmjs.org/@apimatic/core/-/core-0.10.29.tgz", + "integrity": "sha512-QhORiq0QbjlDMrw8ZZsAeG2DzE6QgGz5ukD5w2MOWE/3iIWnUDEROjmc2SfhyiGsE3GoEJ8cyhMMdjlOioP6ww==", + "license": "MIT", + "dependencies": { + "@apimatic/convert-to-stream": "^0.1.9", + "@apimatic/core-interfaces": "^0.2.14", + "@apimatic/file-wrapper": "^0.3.9", + "@apimatic/http-headers": "^0.3.8", + "@apimatic/http-query": "^0.3.9", + "@apimatic/json-bigint": "^1.2.0", + "@apimatic/schema": "^0.7.21", + "detect-browser": "^5.3.0", + "detect-node": "^2.1.0", + "form-data": "^4.0.1", + "lodash.defaultsdeep": "^4.6.1", + "lodash.flatmap": "^4.5.0", + "tiny-warning": "^1.0.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.15.0 || >=16.0.0" + } + }, + "node_modules/@apimatic/core-interfaces": { + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/@apimatic/core-interfaces/-/core-interfaces-0.2.14.tgz", + "integrity": "sha512-PQmSU32ndxtDddMCjbkNY/sVvDwQAsHUGKrdG5aGVE7iw/qvB2Tm2zyCarOB5TlDr4OB+/tuLCVhji0icx6MHg==", + "license": "MIT", + "dependencies": { + "@apimatic/file-wrapper": "^0.3.9", + "@apimatic/json-bigint": "^1.2.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.15.0 || >=16.0.0" + } + }, + "node_modules/@apimatic/file-wrapper": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@apimatic/file-wrapper/-/file-wrapper-0.3.9.tgz", + "integrity": "sha512-Fh3UE7UPs2v4wkJdsD+uJFF147+7X0qkQfKBdeLZx6mZ5RmBJOBbS6ApvstQTV279YsHiiedKUZGJ6XLoVU+pQ==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=14.15.0 || >=16.0.0" + } + }, + "node_modules/@apimatic/http-headers": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@apimatic/http-headers/-/http-headers-0.3.8.tgz", + "integrity": "sha512-ShvCuT39hYfBTI+H1I16m5i6XZCyUy2kQJ6Jhfj78TwsW5r6AyCbzW7DEro8GN2nNYRU1+E/hrgH6J85YmriOA==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=14.15.0 || >=16.0.0" + } + }, + "node_modules/@apimatic/http-query": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@apimatic/http-query/-/http-query-0.3.9.tgz", + "integrity": "sha512-D6nqXcCR3P6iWbJ9uFXyyF2z1PEhTbGFbHNNuwF1NQ4tnThQk67DW9ou7/XcWi21zLh9MUchDWw9I0iE+5F2xA==", + "license": "MIT", + "dependencies": { + "@apimatic/core-interfaces": "^0.2.14", + "@apimatic/file-wrapper": "^0.3.9", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.15.0 || >=16.0.0" + } + }, + "node_modules/@apimatic/json-bigint": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@apimatic/json-bigint/-/json-bigint-1.2.0.tgz", + "integrity": "sha512-+bmVzYMdZu0Ya5L+my4FXFUih54OvQA/qlZsFOYdOoostyUuB27UDrVWQs/WVCmS0ADdo5vTU0eeTrrBkHoySw==", + "license": "MIT" + }, + "node_modules/@apimatic/oauth-adapters": { + "version": "0.4.18", + "resolved": "https://registry.npmjs.org/@apimatic/oauth-adapters/-/oauth-adapters-0.4.18.tgz", + "integrity": "sha512-MpRyBgKWg3yINQR85tBPmtU/596P0gdj4RmQ3s4D1LRuwDzqRO8GpRq74J8hxQ7vLQvH0OfGh6zzhr1KI4dQRQ==", + "license": "MIT", + "dependencies": { + "@apimatic/core-interfaces": "^0.2.14", + "@apimatic/file-wrapper": "^0.3.9", + "@apimatic/http-headers": "^0.3.8", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.15.0 || >=16.0.0" + } + }, + "node_modules/@apimatic/proxy": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@apimatic/proxy/-/proxy-0.1.4.tgz", + "integrity": "sha512-Vzgfu7wcA5aEJyj2SjQ00Tb06fhBof8gDo1kSsF6sZBm4QjdFywN5AMbQwhfFOKjHqcsNmJspdeqcdymUQ77jA==", + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6" + }, + "engines": { + "node": ">=14.15.0 || >=16.0.0" + } + }, + "node_modules/@apimatic/schema": { + "version": "0.7.21", + "resolved": "https://registry.npmjs.org/@apimatic/schema/-/schema-0.7.21.tgz", + "integrity": "sha512-RCke4toXjA7fBRxQVa1GR+Lj9utVOEJ3voDI26dhk+bZuAac4UXPzkTEaIO3AIe/o8pcKCOkpNIzhzm57Cv2Qg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.15.0 || >=16.0.0" + } + }, + "node_modules/@paypal/paypal-server-sdk": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@paypal/paypal-server-sdk/-/paypal-server-sdk-1.1.0.tgz", + "integrity": "sha512-MaJQmSVpQb5nEeZWZDTtGRxeaHj6O51G9Olmm3zQ5E4XNgR4emAI08CKC2ZPhBldVoxAoBVjLyvKjGV5lC1n2Q==", + "license": "MIT", + "dependencies": { + "@apimatic/authentication-adapters": "^0.5.4", + "@apimatic/axios-client-adapter": "^0.3.7", + "@apimatic/core": "^0.10.16", + "@apimatic/oauth-adapters": "^0.4.8", + "@apimatic/schema": "^0.7.14" + }, + "engines": { + "node": ">=14.17.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-browser": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/detect-browser/-/detect-browser-5.3.0.tgz", + "integrity": "sha512-53rsFbGdwMwlF7qvCt0ypLM5V5/Mbl0szB7GPN8y9NCcbknYOeVVXdrXEq+90IwAfrrzt6Hd+u2E2ntakICU8w==", + "license": "MIT" + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "license": "MIT" + }, + "node_modules/dotenv": { + "version": "16.6.1", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/http-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "dev": true, + "license": "ISC" + }, + "node_modules/inherits": { + "version": "2.0.4", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/lodash.defaultsdeep": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/lodash.defaultsdeep/-/lodash.defaultsdeep-4.6.1.tgz", + "integrity": "sha512-3j8wdDzYuWO3lM3Reg03MuQR957t287Rpcxp1njpEa8oDrikb+FwGdW3n+FELh/A6qib6yPit0j/pv9G/yeAqA==", + "license": "MIT" + }, + "node_modules/lodash.flatmap": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.flatmap/-/lodash.flatmap-4.5.0.tgz", + "integrity": "sha512-/OcpcAGWlrZyoHGeHh3cAoa6nGdX6QYtmzNP84Jqol6UEQQ2gIaU3H+0eICcjcKGl0/XF8LWOujNn9lffsnaOg==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", + "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "mkdirp": "^0.5.6", + "object-assign": "^4.1.1", + "type-is": "^1.6.18", + "xtend": "^4.0.2" + }, + "engines": { + "node": ">= 10.16.0" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nodemon": { + "version": "3.1.11", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/debug": { + "version": "4.4.3", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/nodemon/node_modules/ms": { + "version": "2.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "license": "MIT" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "dev": true, + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.14.1", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.4.3", + "license": "BlueOak-1.0.0" + }, + "node_modules/semver": { + "version": "7.7.3", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/tiny-warning": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-is": { + "version": "1.6.18", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "9.0.1", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/xml2js": { + "version": "0.6.2", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..d64a944 --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "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", + "pdf-lib": "^1.17.1", + "uuid": "^9.0.1", + "xml2js": "^0.6.2" + }, + "devDependencies": { + "nodemon": "^3.0.2" + } +} diff --git a/src/api/controllers/charge-controller.js b/src/api/controllers/charge-controller.js new file mode 100644 index 0000000..166851b --- /dev/null +++ b/src/api/controllers/charge-controller.js @@ -0,0 +1,69 @@ +/** + * Charge-Controller – Sammel-PDF für API-Chargen. + * + * Führt die hochgeladenen Brief-PDFs mehrerer Aufträge (Datei-Produkte, z. B. + * Unterschriften-Service) zu EINER PDF zusammen, die am Bürodrucker in einem + * Rutsch gedruckt werden kann. Reihenfolge = Auftragsnummer (aufsteigend), damit + * sie zur Kuvert-Reihenfolge im Plotter passt. + */ +const { PDFDocument } = require('pdf-lib'); +const directus = require('../../services/directus-service'); + +/** + * POST /api/charge/merge-pdf + * Body: { orderIds: (number|string)[] } → application/pdf (zusammengeführt) + */ +async function mergePdf(req, res, next) { + try { + const ids = Array.isArray(req.body?.orderIds) ? req.body.orderIds.filter((v) => v != null) : []; + if (!ids.length) { + return res.status(400).json({ error: 'Invalid request', message: 'orderIds (array) is required.' }); + } + + const orders = await directus.fetchOrdersMinimal(ids); + if (!orders || !orders.length) { + return res.status(404).json({ error: 'Not found', message: 'Keine Aufträge gefunden.' }); + } + + // Datei-IDs in Auftragsnummern-Reihenfolge (orders ist bereits sortiert). + const fileIds = []; + for (const o of orders) { + const sf = Array.isArray(o.source_files) ? o.source_files.filter(Boolean) : []; + if (sf.length) fileIds.push(...sf); + else if (o.source_file) fileIds.push(o.source_file); + } + if (!fileIds.length) { + return res.status(400).json({ error: 'No files', message: 'Keine Druckdateien an den Aufträgen.' }); + } + + const merged = await PDFDocument.create(); + let ok = 0; + const skipped = []; + for (const fid of fileIds) { + try { + const buf = await directus.fetchAssetBuffer(fid); + const src = await PDFDocument.load(buf, { ignoreEncryption: true }); + const pages = await merged.copyPages(src, src.getPageIndices()); + pages.forEach((p) => merged.addPage(p)); + ok += 1; + } catch (e) { + skipped.push(fid); + console.warn(`[Charge] PDF ${fid} übersprungen: ${e.message}`); + } + } + if (!ok) { + return res.status(400).json({ error: 'Merge failed', message: 'Keine gültige PDF-Datei gefunden.' }); + } + + const bytes = await merged.save(); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', 'attachment; filename="charge.pdf"'); + res.setHeader('X-Skrift-Merged', String(ok)); + if (skipped.length) res.setHeader('X-Skrift-Skipped', String(skipped.length)); + return res.status(200).send(Buffer.from(bytes)); + } catch (err) { + return next(err); + } +} + +module.exports = { mergePdf }; diff --git a/src/api/controllers/directus-controller.js b/src/api/controllers/directus-controller.js new file mode 100644 index 0000000..e5d7609 --- /dev/null +++ b/src/api/controllers/directus-controller.js @@ -0,0 +1,225 @@ +/** + * Generierung auf Basis eines Directus-Auftrags. + * + * Ablauf: Directus meldet "Auftrag X ist bezahlt" → dieser Endpunkt holt den + * Auftrag samt Empfängerzeilen aus Directus, baut daraus die Dokumentliste für + * die bestehende Pipeline (runGeneration) und schreibt Ergebnis + Status zurück. + * + * Antwortet sofort mit 202 – die Generierung läuft im Hintergrund weiter, weil + * sie je nach Menge deutlich länger dauern kann als ein HTTP-Timeout erlaubt. + */ + +const { runGeneration } = require('./order-controller'); +const directus = require('../../services/directus-service'); +const { countLetterPages } = require('../../lib/svg-generator'); +const { replacePlaceholders } = require('../../services/placeholder-service'); + +// Directus-Formatschlüssel → Backend-Formate +const LETTER_FORMAT = { a4: 'a4', a6_hoch: 'a6p', a6_quer: 'a6l' }; +const ENVELOPE_FORMAT = { c6: 'c6', dinlang: 'din_lang' }; + +/** Baut den Adressblock für einen Umschlag aus einer Empfängerzeile. */ +function addressText(entry) { + if (entry.free_text) return String(entry.free_text).trim(); + const name = [entry.first_name, entry.last_name].filter(Boolean).join(' '); + const street = [entry.street, entry.house_no].filter(Boolean).join(' '); + const city = [entry.zip, entry.city].filter(Boolean).join(' '); + const isForeign = entry.country && !/^(de|deutschland|germany)$/i.test(String(entry.country).trim()); + return [name, street, city, isForeign ? entry.country : null] + .filter(Boolean).join('\n'); +} + +/** Platzhalterwerte einer Zeile (strukturierte Felder + freie Platzhalter). */ +function placeholdersOf(entry) { + return { + Anrede: entry.salutation || '', + Vorname: entry.first_name || '', + Nachname: entry.last_name || '', + Strasse: [entry.street, entry.house_no].filter(Boolean).join(' '), + PLZ: entry.zip || '', + Ort: entry.city || '', + Land: entry.country || '', + Briefnummer: entry.letter_number ?? '', + ...(entry.placeholders && typeof entry.placeholders === 'object' ? entry.placeholders : {}), + }; +} + +/** Übersetzt einen Directus-Auftrag in die Dokumentliste für runGeneration. */ +function buildDocuments(order, entries) { + const font = order.font || 'tilda'; + // Eigene Format-Schlüssel (freie Formate) unverändert durchreichen. + const letterFormat = LETTER_FORMAT[order.format?.key] || order.format?.key || 'a4'; + const envFormat = ENVELOPE_FORMAT[order.envelope_format] || 'c6'; + const wantsEnvelope = !!order.needs_envelope && order.envelope_labeling !== 'keine'; + const envelopeType = order.envelope_labeling === 'freitext' ? 'custom' : 'recipient'; + // Datei-Produkte (z. B. Unterschriftenservice): das „Schriftstück" ist das vom + // Kunden hochgeladene Dokument – es wird NICHT generiert. Kuverts hingegen schon. + const istDatei = order.product?.input_mode === 'datei'; + // Kuvert-only-Produkt: es wird KEIN Schriftstück generiert, nur die (adressierten) + // Kuverts. Etwaige hochgeladene PDF-Briefe liegen als source_files am Auftrag. + const istKuvertOnly = order.product?.type === 'envelope'; + // Natürliche Handschrift-Variation (Default an), am Auftrag gespeichert. + const realistisch = order.realistic !== false; + // Relative Schriftgröße (getrennt Brief/Kuvert), aus dem Auftrag. 1 = Standard. + const fontScaleLetter = Number(order.font_scale_letter) || 1; + const fontScaleEnv = Number(order.font_scale_envelope) || 1; + // Signatur(en) auf dem Brief (manuelle Aufträge): SVG je [[signaturN]] + Feinjustierung. + const sigManual = order.signatures_manual && typeof order.signatures_manual === 'object' ? order.signatures_manual : null; + const sigMap = sigManual?.signatures || {}; + const sigOff = sigManual?.offsets || {}; + // Mehrseitiges Schriftstück (Auto-Fluss über N Seiten) – nur wenn am Auftrag gesetzt. + const istMehrseitig = !!order.multi_page; + + const documents = []; + + entries.forEach((entry, i) => { + const placeholders = placeholdersOf(entry); + // Dateinummer = Briefnummer (1-basiert), damit letter_001.svg zu + // Briefnummer 1 gehört. Vorschau nummeriert identisch (siehe skrift-orders). + const nr = Number(entry.letter_number) || (i + 1); + + if (!istDatei && !istKuvertOnly) { + // Abweichender Text je Schriftstück (#5) hat Vorrang; sonst Standardtext. + const briefText = (entry.text && String(entry.text).trim()) ? entry.text : (order.text_template || ''); + documents.push({ + index: nr, + type: 'letter', + text: briefText, + format: letterFormat, + font, + placeholders, + fontScale: fontScaleLetter, + signatures: sigMap, + signatureOffsets: sigOff, + multiPage: istMehrseitig, + realisticHandwriting: realistisch, + }); + } + + if (wantsEnvelope) { + documents.push({ + index: nr, + type: 'envelope', + envelopeType, + text: envelopeType === 'recipient' ? addressText(entry) : (order.envelope_text || order.text_template || ''), + format: envFormat, + font, + placeholders, + fontScale: fontScaleEnv, + realisticHandwriting: realistisch, + }); + } + }); + + return { documents, letterFormat, envFormat, wantsEnvelope }; +} + +/** + * POST /api/order/from-directus + * Body: { orderId } + */ +async function generateFromDirectus(req, res, next) { + const { orderId } = req.body || {}; + if (!orderId) { + return res.status(400).json({ error: 'Invalid request', message: 'orderId is required' }); + } + + let order; + try { + const loaded = await directus.fetchOrder(orderId); + order = loaded.order; + + if (!loaded.entries.length) { + return res.status(400).json({ + error: 'Invalid order', + message: `Auftrag ${order.order_number} hat keine Empfängerzeilen.` + }); + } + + // Sofort quittieren – der Rest läuft im Hintergrund. + res.status(202).json({ + accepted: true, + orderId, + orderNumber: order.order_number, + documents: loaded.entries.length * (order.needs_envelope ? 2 : 1), + }); + + const { documents, letterFormat, envFormat, wantsEnvelope } = buildDocuments(order, loaded.entries); + + // Nichts zu generieren (z. B. Datei-Produkt ohne Kuvert) → sauber überspringen. + if (!documents.length) { + console.log(`[Directus] ${order.order_number}: nichts zu generieren (Datei-Produkt ohne Kuvert).`); + return; + } + + console.log(`[Directus] Generiere ${order.order_number}: ${documents.length} Dokumente`); + + const result = await runGeneration({ + orderNumber: order.order_number, + letters: documents, + metadata: { + letterFormat, + envelopeFormat: wantsEnvelope ? envFormat : null, + envelopeBeschriftung: wantsEnvelope ? order.envelope_labeling : null, + directusOrderId: orderId, + customerEmail: order.customer_email, + // Vorschau-Session → gezeigte Dokumente 1:1 übernehmen. + previewSession: order.preview_session || null, + // Eingegebener Brieftext → als brieftext.txt im Ausgabeordner ablegen. + rawText: order.text_template || null, + }, + }); + + await directus.updateOrder(orderId, { + status: 'in_queue', + artifact_path: result.outputPath, + }); + await directus.addStatusHistory(orderId, 'in_queue', + `${result.letters.length} Schriftstücke, ${result.envelopes.length} Umschläge generiert.`); + + console.log(`[Directus] ${order.order_number} fertig → in_queue`); + + } catch (err) { + console.error('[Directus] Generierung fehlgeschlagen:', err.message); + + // Fehler in Directus sichtbar machen, statt still zu scheitern. + if (order?.id || orderId) { + try { + await directus.addStatusHistory(orderId, 'fehler', `Generierung fehlgeschlagen: ${err.message}`); + } catch (e) { + console.error('[Directus] Konnte Fehler nicht zurückschreiben:', e.message); + } + } + + // Antwort wurde ggf. schon gesendet (202) – dann nur noch loggen. + if (!res.headersSent) { + if (err.status) return res.status(err.status).json({ error: 'Generation failed', message: err.message }); + return next(err); + } + } +} + +/** + * POST /api/order/pagecount + * Body: { text, format, fontScale, entries: [{ placeholders }] } + * Liefert je Dokument die Seitenzahl (nur Umbruch-Zählung, kein Scriptalizer/SVG). + */ +function pageCounts(req, res) { + const { text, format, fontScale, entries } = req.body || {}; + if (!text || !String(text).trim()) { + return res.status(400).json({ error: 'Invalid request', message: 'text is required' }); + } + const fmt = format || 'a4'; + const scale = Number(fontScale) || 1; + const list = Array.isArray(entries) && entries.length ? entries : [{}]; + const counts = list.map((e, i) => { + const ph = { ...(e && typeof e.placeholders === 'object' ? e.placeholders : {}), Briefnummer: String(i + 1) }; + let pages = 1; + try { pages = countLetterPages(replacePlaceholders(String(text), ph), fmt, { fontScale: scale }); } + catch { pages = 1; } + return { index: i + 1, pages }; + }); + return res.json({ counts }); +} + +module.exports = { generateFromDirectus, pageCounts }; diff --git a/src/api/controllers/order-controller.js b/src/api/controllers/order-controller.js new file mode 100644 index 0000000..b007da1 --- /dev/null +++ b/src/api/controllers/order-controller.js @@ -0,0 +1,601 @@ +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, generateLetterPages } = 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 runGeneration({ orderNumber, letters, metadata }) { + { + // Validation + if (!orderNumber) { + const e = new Error('orderNumber is required'); + e.status = 400; + throw e; + } + + if (!letters || !Array.isArray(letters) || letters.length === 0) { + const e = new Error('Letters array is required and must not be empty'); + e.status = 400; + throw e; + } + + 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, + // Signaturen aus Platzhaltern UND direkt übergebene (manuelle Aufträge). + signatures: { ...(letter.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+\]\]|\[\[seitenumbruch\]\]|[&|])/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); + const e = new Error(`Failed to scriptalize texts with font ${font}: ${err.message}`); + e.status = 500; + e.label = 'Scriptalization failed'; + throw e; + } + } + + // Step 4: Generate SVG files - separate letters and envelopes + const generatedLetters = []; + const generatedEnvelopes = []; + // Vorschau-Session (falls vorhanden): bereits gezeigte Dokumente werden 1:1 + // aus dem Vorschau-Cache übernommen, damit der Kunde exakt das gedruckte + // Ergebnis erhält, das er gesehen hat (gleiche Zufalls-Handschrift). + const previewSession = (metadata || {}).previewSession; + const previewsRoot = config.paths.previews; + + for (let i = 0; i < processedLetters.length; i++) { + const letter = processedLetters[i]; + const absoluteIndex = letter.index !== undefined ? letter.index : i; + const isEnv = letter.type === 'envelope'; + const pad = String(absoluteIndex).padStart(3, '0'); + + // Mehrseitiger Brief → N Seiten-Dateien letter_NNN_pM.svg (Seite 1..N). + if (!isEnv && letter.multiPage) { + const scriptalizedText = scriptalizedMap.get(letter.loopIndex) || letter.processedText; + let pages; + try { + ({ pages } = generateLetterPages(scriptalizedText, letter.format, { + font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false, fontScale: letter.fontScale, + })); + } catch (err) { + const e = new Error(`Failed to generate SVG for letter ${i}: ${err.message}`); + e.status = 500; e.label = 'SVG generation failed'; throw e; + } + // Entweder ALLE Seiten aus der Vorschau übernehmen oder ALLE neu rendern – + // nicht mischen, sonst unterschiedliche Handschrift-Variation je Seite. + let alleInVorschau = false; + if (previewSession) { + alleInVorschau = true; + for (let p = 0; p < pages.length; p++) { + try { await fs.access(path.join(previewsRoot, previewSession, `letter_${pad}_p${p + 1}.svg`)); } + catch { alleInVorschau = false; break; } + } + } + for (let p = 0; p < pages.length; p++) { + const fn = `letter_${pad}_p${p + 1}.svg`; + const fp = path.join(outputDir, fn); + if (alleInVorschau) await fs.copyFile(path.join(previewsRoot, previewSession, fn), fp); + else await fs.writeFile(fp, pages[p], 'utf8'); + generatedLetters.push(fn); + } + console.log(`[Order] letter_${pad}: ${pages.length} Seiten${alleInVorschau ? ' (aus Vorschau)' : ''}.`); + continue; + } + + const filename = `${isEnv ? 'envelope' : 'letter'}_${pad}.svg`; + const filepath = isEnv ? path.join(envelopesDir, filename) : path.join(outputDir, filename); + + // 1:1 aus der Vorschau übernehmen, wenn für dieses Dokument eine + // Vorschau-Datei existiert. + if (previewSession) { + const cached = path.join(previewsRoot, previewSession, filename); + try { + await fs.access(cached); + await fs.copyFile(cached, filepath); + (isEnv ? generatedEnvelopes : generatedLetters).push(filename); + console.log(`[Order] ${filename} 1:1 aus der Vorschau übernommen.`); + continue; + } catch { /* keine Vorschau-Datei → normal rendern */ } + } + + const scriptalizedText = scriptalizedMap.get(letter.loopIndex) || letter.processedText; + let svgResult; + try { + if (isEnv) { + svgResult = generateEnvelopeSVG( + scriptalizedText, letter.format, letter.envelopeType, + { font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false, fontScale: letter.fontScale } + ); + } else { + svgResult = generateLetterSVG( + scriptalizedText, letter.format, + { font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false, signatures: letter.signatures || {}, signatureOffsets: letter.signatureOffsets || {}, fontScale: letter.fontScale } + ); + } + } catch (err) { + console.error(`[Order] SVG generation failed for letter ${i}:`, err.message); + const e = new Error(`Failed to generate SVG for letter ${i}: ${err.message}`); + e.status = 500; + e.label = 'SVG generation failed'; + throw e; + } + + await fs.writeFile(filepath, svgResult.svg, 'utf8'); + (isEnv ? generatedEnvelopes : 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 = 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'); + + // Eingegebenen Text als reine Textdatei mitablegen (falls übergeben). + if (meta.rawText) { + try { await fs.writeFile(path.join(outputDir, 'brieftext.txt'), String(meta.rawText), 'utf8'); } + catch (e) { console.warn('[Order] brieftext.txt nicht geschrieben:', e.message); } + } + + console.log(`[Order] Order ${orderNumber} generated successfully`); + + return { + orderNumber, + outputPath: outputDir, + letters: generatedLetters, + envelopes: generatedEnvelopes, + csvFiles, + totalFiles: generatedLetters.length + generatedEnvelopes.length + csvFiles.length, + timestamp: orderMetadata.generatedAt + }; + } +} + +/** + * POST /api/order/generate + * HTTP-Wrapper um runGeneration – Verhalten unverändert. + */ +async function generateOrder(req, res, next) { + try { + const { orderNumber, letters, metadata } = req.body; + const result = await runGeneration({ orderNumber, letters, metadata }); + res.status(200).json(result); + } catch (err) { + if (err.status) { + return res.status(err.status).json({ + error: err.label || 'Invalid request', + message: err.message + }); + } + 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, + runGeneration, + uploadMotif, + uploadOrderFile, + registerOrder, +}; diff --git a/src/api/controllers/preview-controller.js b/src/api/controllers/preview-controller.js new file mode 100644 index 0000000..40ec39e --- /dev/null +++ b/src/api/controllers/preview-controller.js @@ -0,0 +1,363 @@ +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, + generateLetterPages, +} = 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, + // Signaturen aus Platzhaltern UND direkt mitgeschickte (manuelle Aufträge). + signatures: { ...(letter.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+\]\]|\[\[seitenumbruch\]\]|[&|])/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; + const absoluteIndex = letter.index !== undefined ? letter.index : i; + const pad = String(absoluteIndex).padStart(3, "0"); + const recipientName = letter.placeholders?.name || letter.placeholders?.vorname || `Brief ${absoluteIndex + 1}`; + + try { + if (letter.type === "envelope") { + const svgResult = generateEnvelopeSVG( + scriptalizedText, letter.format, letter.envelopeType, + { font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false, fontScale: letter.fontScale } + ); + if (svgResult.overflow) hasOverflow = true; + const filename = `envelope_${pad}.svg`; + await fs.writeFile(path.join(sessionDir, filename), svgResult.svg, "utf8"); + files.push({ + index: absoluteIndex, filename, url: `/api/preview/${finalSessionId}/${filename}`, + lineCount: svgResult.lineCount, lineLimit: svgResult.lineLimit, overflow: svgResult.overflow, + recipientName: null, + }); + } else if (letter.multiPage) { + // Mehrseitiger Brief: Text fließt über N Seiten – kein Überlauf-Fehler. + const { pages } = generateLetterPages(scriptalizedText, letter.format, { + font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false, fontScale: letter.fontScale, + }); + for (let p = 0; p < pages.length; p++) { + const filename = `letter_${pad}_p${p + 1}.svg`; + await fs.writeFile(path.join(sessionDir, filename), pages[p], "utf8"); + files.push({ + index: absoluteIndex, page: p + 1, pageCount: pages.length, filename, + url: `/api/preview/${finalSessionId}/${filename}`, recipientName, + }); + } + } else { + const svgResult = generateLetterSVG(scriptalizedText, letter.format, { + font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false, + signatures: letter.signatures || {}, signatureOffsets: letter.signatureOffsets || {}, + fontScale: letter.fontScale, + }); + if (svgResult.overflow) { + hasOverflow = true; + console.log(`[Preview] Letter ${i} has overflow: ${svgResult.lineCount}/${svgResult.lineLimit} lines`); + } + const filename = `letter_${pad}.svg`; + await fs.writeFile(path.join(sessionDir, filename), svgResult.svg, "utf8"); + files.push({ + index: absoluteIndex, filename, url: `/api/preview/${finalSessionId}/${filename}`, + lineCount: svgResult.lineCount, lineLimit: svgResult.lineLimit, overflow: svgResult.overflow, + recipientName, + }); + } + } 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}`, + }); + } + } + + 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, +}; diff --git a/src/api/middleware/auth.js b/src/api/middleware/auth.js new file mode 100644 index 0000000..0c9f8fd --- /dev/null +++ b/src/api/middleware/auth.js @@ -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 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, +}; diff --git a/src/api/middleware/error-handler.js b/src/api/middleware/error-handler.js new file mode 100644 index 0000000..20bb20e --- /dev/null +++ b/src/api/middleware/error-handler.js @@ -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 }) + } + }); +}; diff --git a/src/api/middleware/rate-limiter.js b/src/api/middleware/rate-limiter.js new file mode 100644 index 0000000..f91db85 --- /dev/null +++ b/src/api/middleware/rate-limiter.js @@ -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(); +}; diff --git a/src/api/middleware/request-logger.js b/src/api/middleware/request-logger.js new file mode 100644 index 0000000..1fbf736 --- /dev/null +++ b/src/api/middleware/request-logger.js @@ -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(); +}; diff --git a/src/api/routes/charge-routes.js b/src/api/routes/charge-routes.js new file mode 100644 index 0000000..9e6d829 --- /dev/null +++ b/src/api/routes/charge-routes.js @@ -0,0 +1,13 @@ +const express = require('express'); +const router = express.Router(); +const chargeController = require('../controllers/charge-controller'); +const { authenticateApiToken } = require('../middleware/auth'); + +/** + * POST /api/charge/merge-pdf + * Führt die Brief-PDFs mehrerer Aufträge zu einer PDF zusammen (Sammeldruck). + * Body: { orderIds: (number|string)[] } + */ +router.post('/merge-pdf', authenticateApiToken, chargeController.mergePdf); + +module.exports = router; diff --git a/src/api/routes/health-routes.js b/src/api/routes/health-routes.js new file mode 100644 index 0000000..6192eda --- /dev/null +++ b/src/api/routes/health-routes.js @@ -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; diff --git a/src/api/routes/order-routes.js b/src/api/routes/order-routes.js new file mode 100644 index 0000000..8e1bd53 --- /dev/null +++ b/src/api/routes/order-routes.js @@ -0,0 +1,154 @@ +const express = require('express'); +const router = express.Router(); +const multer = require('multer'); +const orderController = require('../controllers/order-controller'); +const directusController = require('../controllers/directus-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); + +/** + * POST /api/order/from-directus + * Generiert einen Auftrag, dessen Daten in Directus liegen. + * + * Request body: { orderId: string|number } (ID des Directus-Auftrags) + * + * Antwortet mit 202 (accepted); die Generierung läuft im Hintergrund und + * schreibt Status + artifact_path selbstständig nach Directus zurück. + */ +router.post('/from-directus', authenticateApiToken, directusController.generateFromDirectus); + +/** + * POST /api/order/pagecount + * Seitenzahl je Dokument (mehrseitig) – ohne Scriptalizer/SVG, nur Umbruch-Zählung. + * Body: { text, format, fontScale, entries: [{ placeholders }] } + */ +router.post('/pagecount', authenticateApiToken, directusController.pageCounts); + +module.exports = router; diff --git a/src/api/routes/preview-routes.js b/src/api/routes/preview-routes.js new file mode 100644 index 0000000..0b98dc4 --- /dev/null +++ b/src/api/routes/preview-routes.js @@ -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; diff --git a/src/config/index.js b/src/config/index.js new file mode 100644 index 0000000..1d67b96 --- /dev/null +++ b/src/config/index.js @@ -0,0 +1,59 @@ +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 + }, + + // Directus ist die Datenquelle für Aufträge (Rolle "Service"-Token). + directus: { + url: process.env.DIRECTUS_URL || '', + token: process.env.DIRECTUS_TOKEN || '' + }, + + paypal: { + clientId: process.env.PAYPAL_CLIENT_ID || '', + clientSecret: process.env.PAYPAL_CLIENT_SECRET || '', + // 'sandbox' oder 'live' + environment: process.env.PAYPAL_ENVIRONMENT || 'sandbox' + } +}; diff --git a/src/lib/page-layout.js b/src/lib/page-layout.js new file mode 100644 index 0000000..328ee60 --- /dev/null +++ b/src/lib/page-layout.js @@ -0,0 +1,146 @@ +const PX_PER_MM = 3.78; // 96dpi +const FONT_SIZE_PX = 26; + +// ── Formate: Defaults (Fallback) + datengetriebener Cache ─────────────────── +// Die Werte kommen zentral aus Directus (Collection render_formats) und werden +// im Speicher gehalten (stündlich / manuell aufgefrischt via applyRenderFormats). +// Ist Directus nicht erreichbar oder ein Format nicht hinterlegt, greifen die +// eingebauten Defaults – die Generierung läuft also immer. +const DEFAULT_LINE_LIMITS = { a4: 27, a6p: 14, a6l: 11 }; +const DEFAULT_FORMAT_LAYOUTS = { + a4: { marginTopMm: 25, marginLeftMm: 20, lineHeightFactor: 1.35 }, + a6p: { marginTopMm: 8, marginLeftMm: 8, lineHeightFactor: 1.3 }, + a6l: { marginTopMm: 8, marginLeftMm: 8, marginRightMm: 14, lineHeightFactor: 1.2 }, + din_lang: { marginTopMm: 50, marginLeftMm: 10, lineHeightFactor: 1.2 }, + c6: { marginTopMm: 60, marginLeftMm: 10, lineHeightFactor: 1.2 }, +}; +const DEFAULT_PAGE_FORMATS = { + a4: { widthMm: 210, heightMm: 297 }, + a6p: { widthMm: 105, heightMm: 148 }, + a6l: { widthMm: 148, heightMm: 105 }, + din_lang: { widthMm: 220, heightMm: 110 }, + c6: { widthMm: 162, heightMm: 114 }, +}; + +// Live-Cache (wird von applyRenderFormats ersetzt). Start = Defaults. +let LINE_LIMITS = { ...DEFAULT_LINE_LIMITS }; +let FORMAT_LAYOUTS = { ...DEFAULT_FORMAT_LAYOUTS }; +let PAGE_FORMATS = { ...DEFAULT_PAGE_FORMATS }; + +/** + * Übernimmt Format-Definitionen aus Directus (render_formats) in den Cache. + * Baut immer auf den Defaults auf, damit Teilangaben nichts kaputt machen. + * rows: [{ key, page_width_mm, page_height_mm, margin_top_mm, margin_left_mm, + * margin_right_mm, line_height_factor, line_limit }] + */ +function applyRenderFormats(rows) { + const pf = { ...DEFAULT_PAGE_FORMATS }; + const fl = { ...DEFAULT_FORMAT_LAYOUTS }; + const ll = { ...DEFAULT_LINE_LIMITS }; + for (const r of Array.isArray(rows) ? rows : []) { + const k = String(r.key || '').toLowerCase(); + if (!k) continue; + if (r.page_width_mm && r.page_height_mm) { + pf[k] = { widthMm: Number(r.page_width_mm), heightMm: Number(r.page_height_mm) }; + } + const alt = fl[k] || {}; + const layout = { + marginTopMm: Number(r.margin_top_mm ?? alt.marginTopMm ?? 25), + marginLeftMm: Number(r.margin_left_mm ?? alt.marginLeftMm ?? 20), + lineHeightFactor: Number(r.line_height_factor ?? alt.lineHeightFactor ?? 1.35), + }; + const mr = r.margin_right_mm ?? alt.marginRightMm; + if (mr != null && mr !== '') layout.marginRightMm = Number(mr); + fl[k] = layout; + if (r.line_limit != null && r.line_limit !== '') ll[k] = Number(r.line_limit); + } + PAGE_FORMATS = pf; + FORMAT_LAYOUTS = fl; + LINE_LIMITS = ll; + signaledMisses.clear(); +} + +// mm in Pixel umrechnen +function mmToPx(mm) { + return mm * PX_PER_MM; +} + +// ── Refresh bei unbekanntem Format ────────────────────────────────────────── +// Wird ein Format angefragt, das (noch) nicht im Cache liegt, meldet page-layout +// das einmalig an einen Handler (den der Server auf refreshRenderFormats setzt). +// So werden neu in Directus angelegte Formate sofort nachgeladen, ohne die +// stündliche Aktualisierung abzuwarten. Für DIESEN Aufruf greift noch der +// Default – der nächste Aufruf hat das Format. +let missHandler = null; +const signaledMisses = new Set(); +function setFormatMissHandler(fn) { missHandler = fn; } +function signalMiss(key) { + const k = String(key || '').toLowerCase(); + if (!missHandler || !k || signaledMisses.has(k)) return; + signaledMisses.add(k); + try { missHandler(k); } catch { /* egal */ } +} + +// Layout für Format holen +function getLayoutForFormat(format) { + const normalizedFormat = String(format).toLowerCase(); + const layoutConfig = FORMAT_LAYOUTS[normalizedFormat]; + if (!layoutConfig) signalMiss(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(); + if (!PAGE_FORMATS[normalizedFormat]) signalMiss(normalizedFormat); + 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, '>'); +} + +// Zeilenlimit für Format holen +function getLineLimit(format) { + const normalizedFormat = String(format).toLowerCase(); + if (!LINE_LIMITS[normalizedFormat]) signalMiss(normalizedFormat); + return LINE_LIMITS[normalizedFormat] || LINE_LIMITS.a4; +} + +module.exports = { + PX_PER_MM, + FONT_SIZE_PX, + mmToPx, + getLayoutForFormat, + getPageDimensions, + getLineLimit, + escapeXml, + applyRenderFormats, + setFormatMissHandler, +}; diff --git a/src/lib/svg-font-engine.js b/src/lib/svg-font-engine.js new file mode 100644 index 0000000..432365c --- /dev/null +++ b/src/lib/svg-font-engine.js @@ -0,0 +1,318 @@ +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(//); + if (!fontFaceMatch) { + throw new Error(`Kein 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 = //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). + * maxWidthPx (optional) begrenzt den Tab-Sprung auf den rechten Rand, damit viele + * Tabs nicht über die Seite hinauslaufen. Ohne Angabe (Infinity) unverändert. + */ +function measureTextWidthPx({ text, fontFileName, fontSizePx, maxWidthPx = Infinity }) { + 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.min(Math.ceil((x + 1) / tabStopPx) * tabStopPx, maxWidthPx); + 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) { + // Führende Tabs = bewusste Positionierung der Zeile (z.B. Datum nach rechts). + // Solche Zeilen NICHT nach Wortbreite umbrechen, sonst würde der Text als + // "zu breit" gewertet und die Wörter fielen nach links auf eine neue Zeile. + // Der eigentliche Versatz (bzw. Rechtsbündigkeit bei Überlauf) passiert im Layout. + const lead = para.match(new RegExp(`^${TAB_MARKER}+`)); + if (lead) { + const rest = para.slice(lead[0].length); + if (!rest.includes(TAB_MARKER) && measure(rest) <= maxWidthPx) { + allLines.push(para); + continue; + } + } + + // 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, + maxWidthPx = Infinity, +}) { + 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; + + // Führende Tabs vorab auflösen: normalerweise an die Tab-Position rücken; + // würde der restliche Zeileninhalt dort über den rechten Rand laufen, die + // Zeile stattdessen rechtsbündig an den Rand setzen (z.B. Datum ganz rechts, + // ohne dass etwas daneben steht). Nur aktiv, wenn maxWidthPx bekannt ist. + let startIdx = 0; + if (line[0] === TAB_MARKER) { + let leadCount = 0; + while (line[leadCount] === TAB_MARKER) leadCount++; + let tx = 0; + for (let t = 0; t < leadCount; t++) { + tx = Math.min(Math.ceil((tx + 1) / tabStopPx) * tabStopPx, maxWidthPx); + } + const rest = line.slice(leadCount); + const restW = measureTextWidthPx({ text: rest, fontFileName, fontSizePx, maxWidthPx }); + if (tx + restW > maxWidthPx) tx = Math.max(0, maxWidthPx - restW); + x = startX + tx; + startIdx = leadCount; + } + + for (let i = startIdx; i < line.length; i++) { + const ch = line[i]; + + // Tabulator-Marker → zur nächsten Tabstop-Position springen (an den Rand geclampt) + if (ch === TAB_MARKER) { + const relX = x - startX; + const nextTab = Math.min(Math.ceil((relX + 1) / tabStopPx) * tabStopPx, maxWidthPx); + 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 +}; diff --git a/src/lib/svg-generator.js b/src/lib/svg-generator.js new file mode 100644 index 0000000..224e094 --- /dev/null +++ b/src/lib/svg-generator.js @@ -0,0 +1,461 @@ +const { + FONT_SIZE_PX, + getLayoutForFormat, + getPageDimensions, + getLineLimit, + escapeXml, + mmToPx +} = require('./page-layout'); + +const { + layoutTextToPaths, + wrapTextToLinesByWidth, + measureTextWidthPx, + TAB_MARKER +} = 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 -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 -Elemente extrahieren (unsere Signatur-SVGs enthalten ausschließlich Paths) + const pathRegex = //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(``); + } + if (!elements.length) return ''; + + return ` + ${elements.join('\n ')} +`; +} + +// 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 || {}; + // Feinjustierung je Signatur: { signatur1: { x: mm, y: mm, scale: Faktor } }. + const signatureOffsets = options.signatureOffsets || {}; + // Einseitig: Seitenumbruch-Marker gibt es hier nicht – als Zeilenumbruch behandeln, + // damit er nie wörtlich gedruckt wird (falls „Mehrseitig" nicht aktiv ist). + text = String(text || '').replace(/\[\[seitenumbruch\]\]/gi, '\n'); + + const { widthPx, heightPx, widthMm, heightMm } = getPageDimensions(format); + const { marginTopPx, marginLeftPx, marginRightPx, lineHeightPx: baseLineHeightPx } = getLayoutForFormat(format); + const maxWidthPx = widthPx - marginLeftPx - (marginRightPx ?? marginLeftPx); + + // Relative Schriftgröße (nur manuelle Aufträge setzen ≠ 1). Schrift UND Zeilenhöhe + // skalieren mit, damit sich Zeilen nicht überlappen. Default 1 = unverändert. + const fontScale = Math.max(0.8, Math.min(1.5, Number(options.fontScale) || 1)); + const fontSizePx = FONT_SIZE_PX * fontScale; + const lineHeightPx = baseLineHeightPx * fontScale; + + // ── 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, + }); + + const firstBaselineY = marginTopPx + fontSizePx; + const paths = layoutTextToPaths({ + lines, + fontFileName: fontConfig.file, + fontSizePx, + startX: marginLeftPx, + startY: firstBaselineY, + lineHeightPx, + realisticHandwriting: options.realisticHandwriting ?? false, + maxWidthPx, + }); + + const pathElements = paths + .map(p => ``) + .join('\n '); + + // Größere Schrift → weniger Zeilen passen aufs Blatt (Limit gegenläufig skalieren). + const lineLimit = Math.max(1, Math.round(getLineLimit(format) / fontScale)); + return { + svg: ` + + ${pathElements} +`.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 + fontSizePx; + let totalLineCount = 0; + const lineLimit = Math.max(1, Math.round(getLineLimit(format) / fontScale)); + + 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, + }); + const paths = layoutTextToPaths({ + lines, + fontFileName: fontConfig.file, + fontSizePx, + startX: marginLeftPx, + startY: currentY, + lineHeightPx, + realisticHandwriting: options.realisticHandwriting ?? false, + maxWidthPx, + }); + paths.forEach(p => allSvgElements.push( + `` + )); + 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) { + // Feinjustierung: Versatz in mm + Größen-Faktor relativ zur Auto-Position. + const off = signatureOffsets[sigSeg.key] || {}; + const dx = mmToPx(Number(off.x) || 0); + const dy = mmToPx(Number(off.y) || 0); + const sc = Number(off.scale) > 0 ? Number(off.scale) : 1; + const sigX = marginLeftPx + slotIdx * sigSlotW + dx; + const sigEl = buildSignatureElement(svgString, sigX, currentY + dy, sigScale * sc); + if (sigEl) allSvgElements.push(sigEl); + } + }); + currentY += sigHeightPx + sigPaddingPx; + totalLineCount += 2; // ganze Zeile zählt als 2 Zeilen, unabhängig von Anzahl + } + } + + return { + svg: ` + + ${allSvgElements.join('\n ')} +`.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: baseLineHeightPx } = getLayoutForFormat(format); + + // Relative Schriftgröße (getrennt vom Brief einstellbar). Default 1 = unverändert. + const fontScale = Math.max(0.8, Math.min(1.5, Number(options.fontScale) || 1)); + const fontSizePx = FONT_SIZE_PX * fontScale; + const lineHeightPx = baseLineHeightPx * fontScale; + + 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, + }); + + // Zentrierung berechnen + const totalTextHeight = lines.length * lineHeightPx; + startY = (heightPx - totalTextHeight) / 2 + fontSizePx; + + // 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, + }); + + startX = marginLeftPx; + startY = marginTopPx + fontSizePx; + } + + 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 + }); + const centeredX = (widthPx - lineWidth) / 2; + + const linePaths = layoutTextToPaths({ + lines: [line], + fontFileName: fontConfig.file, + fontSizePx, + 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, + startX, + startY, + lineHeightPx, + realisticHandwriting: options.realisticHandwriting ?? false, + }); + } + + const pathElements = paths + .map( + (p) => `` + ) + .join('\n '); + + const svg = ` + + ${pathElements} +`; + + // Overflow-Erkennung: zu viele Zeilen für die verfügbare Fläche + const overflowStartY = (type === 'custom') + ? (heightPx - lines.length * lineHeightPx) / 2 + fontSizePx + : marginTopPx + fontSizePx; + const available = heightPx - overflowStartY - fontSizePx * 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 }; +} + +/** + * Mehrseitiges Schriftstück: Der Text fließt automatisch über so viele Seiten, + * wie nötig (linesPerPage = Zeilenlimit des Formats). Ein erzwungener Umbruch ist + * über den Marker [[seitenumbruch]] möglich – jeder so getrennte Block beginnt auf + * einer neuen Seite. Gibt die Seiten in natürlicher Reihenfolge (Seite 1..N) zurück. + * + * Bewusst OHNE Signatur-Unterstützung (kommt als eigener Schritt). Tab-/Rechts- + * bündig-Logik und relative Schriftgröße greifen pro Seite wie beim Einzelbrief. + */ +function generateLetterPages(text, format, options = {}) { + const fontKey = normalizeFontKey(options.font); + const fontConfig = SVG_FONT_CONFIG[fontKey]; + + const { widthPx, heightPx, widthMm, heightMm } = getPageDimensions(format); + const { marginTopPx, marginLeftPx, marginRightPx, lineHeightPx: baseLineHeightPx } = getLayoutForFormat(format); + const maxWidthPx = widthPx - marginLeftPx - (marginRightPx ?? marginLeftPx); + + const fontScale = Math.max(0.8, Math.min(1.5, Number(options.fontScale) || 1)); + const fontSizePx = FONT_SIZE_PX * fontScale; + const lineHeightPx = baseLineHeightPx * fontScale; + const linesPerPage = Math.max(1, Math.round(getLineLimit(format) / fontScale)); + + // Erzwungene Seitenumbrüche zuerst; danach jeden Block nach Zeilen auto-fließen. + const bloecke = String(text || '').split(/\[\[seitenumbruch\]\]/gi); + const seitenZeilen = []; + for (const block of bloecke) { + const lines = wrapTextToLinesByWidth({ text: block, maxWidthPx, fontFileName: fontConfig.file, fontSizePx }); + for (let i = 0; i < lines.length; i += linesPerPage) { + seitenZeilen.push(lines.slice(i, i + linesPerPage)); + } + } + if (seitenZeilen.length === 0) seitenZeilen.push(['']); + + // Leere Seiten verwerfen: enthält eine Seite kein sichtbares Zeichen (nur + // Leerzeichen, Tabs, Umbrüche), wird sie weder generiert noch gedruckt. + // Bleibt nichts übrig (komplett leerer Text), eine einzige Seite behalten. + const istLeer = (lines) => lines.join('').split(TAB_MARKER).join('').replace(/\s/g, '') === ''; + let seiten = seitenZeilen.filter((lines) => !istLeer(lines)); + if (seiten.length === 0) seiten = [['']]; + + const pages = seiten.map((lines) => { + const paths = layoutTextToPaths({ + lines, + fontFileName: fontConfig.file, + fontSizePx, + startX: marginLeftPx, + startY: marginTopPx + fontSizePx, + lineHeightPx, + realisticHandwriting: options.realisticHandwriting ?? false, + maxWidthPx, + }); + const pathElements = paths + .map(p => ``) + .join('\n '); + return ` + + ${pathElements} +`.trim(); + }); + + return { pages, pageCount: pages.length, lineCount: seiten.reduce((a, l) => a + l.length, 0) }; +} + +/** + * Zählt, wie viele Seiten ein Text ergäbe (gleiche Logik wie generateLetterPages, + * aber ohne SVG/Scriptalizer) – für die Seiten-Übersicht je Schriftstück. + */ +function countLetterPages(text, format, options = {}) { + const fontConfig = SVG_FONT_CONFIG[normalizeFontKey(options.font)]; + const { widthPx } = getPageDimensions(format); + const { marginLeftPx, marginRightPx } = getLayoutForFormat(format); + const maxWidthPx = widthPx - marginLeftPx - (marginRightPx ?? marginLeftPx); + const fontScale = Math.max(0.8, Math.min(1.5, Number(options.fontScale) || 1)); + const fontSizePx = FONT_SIZE_PX * fontScale; + const linesPerPage = Math.max(1, Math.round(getLineLimit(format) / fontScale)); + + const seitenZeilen = []; + for (const block of String(text || '').split(/\[\[seitenumbruch\]\]/gi)) { + const lines = wrapTextToLinesByWidth({ text: block, maxWidthPx, fontFileName: fontConfig.file, fontSizePx }); + for (let i = 0; i < lines.length; i += linesPerPage) seitenZeilen.push(lines.slice(i, i + linesPerPage)); + } + const istLeer = (lines) => lines.join('').split(TAB_MARKER).join('').replace(/\s/g, '') === ''; + const seiten = seitenZeilen.filter((lines) => !istLeer(lines)); + return Math.max(1, seiten.length); +} + +module.exports = { + generateLetterSVG, + generateEnvelopeSVG, + generateLetterPages, + countLetterPages +}; diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..50a87cb --- /dev/null +++ b/src/server.js @@ -0,0 +1,93 @@ +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 chargeRoutes = require('./api/routes/charge-routes'); +const healthRoutes = require('./api/routes/health-routes'); + +const errorHandler = require('./api/middleware/error-handler'); +const requestLogger = require('./api/middleware/request-logger'); + +const { applyRenderFormats, setFormatMissHandler } = require('./lib/page-layout'); +const { fetchRenderFormats } = require('./services/directus-service'); + +// Zentrale Format-Definitionen aus Directus in den Cache holen. Läuft beim Start +// und stündlich; Fehler sind unkritisch (die Defaults im page-layout greifen). +let lastFormatRefresh = 0; +async function refreshRenderFormats() { + lastFormatRefresh = Date.now(); + try { + const rows = await fetchRenderFormats(); + applyRenderFormats(rows); + console.log(`[Formate] ${Array.isArray(rows) ? rows.length : 0} Format(e) aus Directus geladen.`); + } catch (e) { + console.warn(`[Formate] Konnte Directus-Formate nicht laden (nutze Defaults): ${e.message}`); + } +} + +// Unbekanntes Format angefragt → sofort nachladen (max. alle 30 s, damit ein +// dauerhaft unbekanntes Format kein Dauerfeuer auslöst). +setFormatMissHandler((key) => { + if (Date.now() - lastFormatRefresh < 30 * 1000) return; + console.log(`[Formate] Unbekanntes Format "${key}" angefragt – lade Formate nach.`); + refreshRenderFormats(); +}); + +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); +app.use('/api/charge', chargeRoutes); + +// Format-Cache manuell auffrischen (Token-geschützt) – für „sofort übernehmen". +app.post('/api/formats/refresh', async (req, res) => { + const token = config.auth && config.auth.apiToken; + if (token && req.headers['x-api-token'] !== token) { + return res.status(401).json({ error: 'API token required' }); + } + await refreshRenderFormats(); + res.json({ ok: true }); +}); + +// 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'); + // Formate initial laden und stündlich auffrischen (zentral aus Directus). + refreshRenderFormats(); + setInterval(refreshRenderFormats, 60 * 60 * 1000); +}); + +// 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); +}); diff --git a/src/services/directus-service.js b/src/services/directus-service.js new file mode 100644 index 0000000..5749930 --- /dev/null +++ b/src/services/directus-service.js @@ -0,0 +1,113 @@ +/** + * Directus-Client für das Backend. + * + * Directus ist die Datenquelle für Aufträge; dieses Backend bleibt die reine + * Generierungs-Engine. Zugriff über einen Service-Token (Rolle "Service"). + */ + +const config = require('../config'); + +function requireConfig() { + if (!config.directus.url || !config.directus.token) { + const e = new Error('DIRECTUS_URL und DIRECTUS_TOKEN müssen gesetzt sein.'); + e.status = 500; + throw e; + } +} + +async function directusFetch(path, options = {}) { + requireConfig(); + const res = await fetch(config.directus.url.replace(/\/$/, '') + path, { + ...options, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${config.directus.token}`, + ...(options.headers || {}), + }, + }); + const text = await res.text(); + let data = null; + try { data = text ? JSON.parse(text) : null; } catch { /* ignore */ } + if (!res.ok) { + const msg = data?.errors?.[0]?.message || text || res.statusText; + const e = new Error(`Directus ${options.method || 'GET'} ${path}: ${msg}`); + e.status = res.status === 404 ? 404 : 502; + throw e; + } + return data?.data ?? data; +} + +/** Lädt einen Auftrag inkl. Empfängerzeilen, Produkt und Format. */ +async function fetchOrder(orderId) { + const fields = [ + 'id', 'order_number', 'customer_email', 'font', 'status', + 'needs_envelope', 'envelope_labeling', 'envelope_text', 'envelope_format', + 'text_template', 'motif_mode', 'own_cards', 'entries_count', 'preview_session', + 'source_file', 'product.key', 'product.type', 'product.input_mode', 'format.key', + 'font_scale_letter', 'font_scale_envelope', 'signatures_manual', 'multi_page', 'realistic', + ].join(','); + + const order = await directusFetch(`/items/orders/${encodeURIComponent(orderId)}?fields=${fields}`); + if (!order) { + const e = new Error(`Auftrag ${orderId} nicht gefunden.`); + e.status = 404; + throw e; + } + + const entries = await directusFetch( + `/items/order_entries?filter[order][_eq]=${encodeURIComponent(orderId)}` + + `&sort=letter_number&limit=-1&fields=*` + ); + + return { order, entries: entries || [] }; +} + +/** Schreibt Status und Artefakt-Pfad zurück. */ +async function updateOrder(orderId, patch) { + return directusFetch(`/items/orders/${encodeURIComponent(orderId)}`, { + method: 'PATCH', + body: JSON.stringify(patch), + }); +} + +/** Protokolliert einen Statuswechsel in der Timeline. */ +async function addStatusHistory(orderId, status, note) { + return directusFetch('/items/status_history', { + method: 'POST', + body: JSON.stringify({ order: orderId, status, note }), + }); +} + +/** Lädt mehrere Aufträge (nur die für den Charge-Sammeldruck nötigen Felder), + * sortiert nach Auftragsnummer. */ +async function fetchOrdersMinimal(ids) { + const idList = ids.map((v) => encodeURIComponent(v)).join(','); + return directusFetch( + `/items/orders?filter[id][_in]=${idList}&limit=-1&sort=order_number` + + '&fields=id,order_number,source_files,source_file', + ); +} + +/** Lädt eine Directus-Datei (z. B. hochgeladenes Brief-PDF) als Buffer. */ +async function fetchAssetBuffer(fileId) { + requireConfig(); + const res = await fetch(config.directus.url.replace(/\/$/, '') + '/assets/' + encodeURIComponent(fileId), { + headers: { Authorization: `Bearer ${config.directus.token}` }, + }); + if (!res.ok) { + const e = new Error(`Directus asset ${fileId}: ${res.status}`); + e.status = res.status === 404 ? 404 : 502; + throw e; + } + return Buffer.from(await res.arrayBuffer()); +} + +/** Zentrale Format-Definitionen (Seitengröße, Ränder, Zeilenlimit) aus Directus. */ +async function fetchRenderFormats() { + return directusFetch( + '/items/render_formats?filter[active][_eq]=true&limit=-1' + + '&fields=key,page_width_mm,page_height_mm,margin_top_mm,margin_left_mm,margin_right_mm,line_height_factor,line_limit', + ); +} + +module.exports = { fetchOrder, updateOrder, addStatusHistory, fetchRenderFormats, fetchOrdersMinimal, fetchAssetBuffer }; diff --git a/src/services/placeholder-service.js b/src/services/placeholder-service.js new file mode 100644 index 0000000..0ddd604 --- /dev/null +++ b/src/services/placeholder-service.js @@ -0,0 +1,294 @@ +/** + * 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 ''; + } + + // Keys in Auftrittsreihenfolge sammeln (NICHT alphabetisch sortieren – sonst + // stimmt die Spaltenreihenfolge nicht mit der Tabelle auf der Website überein). + const auftritt = []; + letterItems.forEach(letter => { + if (letter.placeholders) { + Object.keys(letter.placeholders).forEach(key => { + if (!auftritt.includes(key)) auftritt.push(key); + }); + } + }); + + if (auftritt.length === 0) { + return ''; + } + + // Strukturierte Adressfelder zuerst (wie im Konfigurator), eigene Platzhalter + // danach in Auftrittsreihenfolge. Briefnummer steckt bereits in „BriefNr". + const BEVORZUGT = ['Anrede', 'Vorname', 'Nachname', 'Strasse', 'PLZ', 'Ort', 'Land']; + const vorhanden = new Set(auftritt); + const geordnet = [ + ...BEVORZUGT.filter(k => vorhanden.has(k)), + ...auftritt.filter(k => !BEVORZUGT.includes(k) && k !== 'Briefnummer'), + ]; + const keys = ['BriefNr', ...geordnet]; + + // 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 +}; diff --git a/src/services/scriptalizer-service.js b/src/services/scriptalizer-service.js new file mode 100644 index 0000000..a1a1340 --- /dev/null +++ b/src/services/scriptalizer-service.js @@ -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} - 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} - 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, +};