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, generatePostcardSVG, 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\]\]|\[\[pk_abs\]\]|\[\[pk_adr\]\]|[&|])/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, sender: letter.sender || '', senderScale: letter.senderScale || 1 } ); 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.type === "postcard") { const svgResult = generatePostcardSVG(scriptalizedText, letter.format, { font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false, fontScale: letter.fontScale, senderScale: letter.senderScale || 1, recipientScale: letter.recipientScale || 1, }); if (svgResult.overflow) hasOverflow = true; 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, }); } 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, };