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. Ausnahme: Kuvert mit Absender – das wird frisch // gerendert, damit der Absender (oben links) sicher drauf ist. if (previewSession && !(isEnv && metadata.envelopeSender)) { 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, sender: metadata.envelopeSender || '' } ); } 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, };