Import aus Monorepo (frischer Start)
All checks were successful
Build & Deploy / deploy (push) Successful in 10s
All checks were successful
Build & Deploy / deploy (push) Successful in 10s
This commit is contained in:
363
src/api/controllers/preview-controller.js
Normal file
363
src/api/controllers/preview-controller.js
Normal file
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user