All checks were successful
Build & Deploy / deploy (push) Successful in 22s
generateEnvelopeSVG rendert bei gesetztem Absender den Absender klein oben links und den Empfaenger unten rechts (sonst unveraendert). Absender wird ueber die Auftrags-/ Vorschau-Pipeline durchgereicht (metadata.envelopeSender, envelope_sender), fetchOrder laedt das Feld, Kuvert wird bei Absender frisch gerendert statt 1:1 aus der Vorschau. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
364 lines
13 KiB
JavaScript
364 lines
13 KiB
JavaScript
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, sender: letter.sender || '' }
|
||
);
|
||
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,
|
||
};
|