From 5d3e55e7ea13978644bda09003416425609a5090 Mon Sep 17 00:00:00 2001 From: Matthias Vach Date: Sun, 15 Feb 2026 15:30:47 +0100 Subject: [PATCH] feat: add InDesign serial letter automation script --- .gitignore | 3 +- Makefile | 5 +- docs/manual-de.md.tmp | 36 ++- docs/manual-en.md.tmp | 39 +++- jsx/create_serial_letters.jsx | 405 ++++++++++++++++++++++++++++++++++ scripts/build_binaries | 7 +- 6 files changed, 488 insertions(+), 7 deletions(-) create mode 100644 jsx/create_serial_letters.jsx diff --git a/.gitignore b/.gitignore index f7f909b..8fcfb10 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ go.work.sum # local mkdocs.yml copy in the root directory /mkdocs.yml +/.venv .timetracker local/ @@ -42,4 +43,4 @@ ctRestClient* coverage.* *.kdbx -.vscode \ No newline at end of file +.vscode diff --git a/Makefile b/Makefile index eb353e6..2514aad 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: test alltest coverage coverage-html coverage-func generate lint build clean help +.PHONY: test alltest coverage coverage-html coverage-func generate lint build mkdocs clean help test: go test -coverprofile=coverage.out $$(go list ./... | grep -v fakes | grep -v ./tests/end2end) @@ -26,6 +26,9 @@ build: generate: ./scripts/generate_fakes +mkdocs: + ./scripts/run_mkdocs.sh + clean: rm -f coverage.out coverage.html rm -rf dist/ diff --git a/docs/manual-de.md.tmp b/docs/manual-de.md.tmp index 88b447b..29cbd42 100644 --- a/docs/manual-de.md.tmp +++ b/docs/manual-de.md.tmp @@ -24,7 +24,7 @@ **Wichtig**: Ohne `keepassxc-cli` im PATH kann ctRestClient nicht auf die Token-Datenbank zugreifen! -2. **ChurchTools-Zugang**: +2. **ChurchTools-Zugang**: - Gültiger API-Token für jede ChurchTools-Instanz - Berechtigung zum Lesen der entsprechenden Gruppen @@ -116,7 +116,7 @@ Erstellen Sie die Datei `data/persons/sexId.yml`: **Konfiguration:** ```yaml -fields: +fields: - id - firstName - lastName @@ -340,6 +340,38 @@ Detaillierte Informationen über die Ausführung finden Sie in der `ctRestClient - Fehlermeldungen mit Details - Performance-Informationen +## Serienbrief-Automation + +Um die exportierten CSV-Dateien komfortabel in Adobe InDesign für Serienbriefe zu verarbeiten, enthält das `ctRestClient_.tar.gz`-Archiv das InDesign-Skript `create_serial_letters.jsx` im Verzeichnis `jsx`. +Vor der ersten Verwendung muss dieses Skript zunächst in das entsprechende InDesign-Skriptverzeichnis kopiert werden. + +Bei der Ausführung des Skripts in InDesign werden Sie aufgefordert, ein Verzeichnis mit CSV-Dateien auszuwählen. +Das Skript verarbeitet die exportierten CSV-Dateien mithilfe der benötigten InDesign-Vorlagen zu Serienbriefen. +Die erforderlichen InDesign-Vorlagen werden im Verzeichnis `templates` erwartet. Dieses `templates`-Verzeichnis muss auf derselben Ebene wie das `exports`-Verzeichnis verfügbar sein. +Die erzeugten Serienbriefdokumente werden im `serial_letters`-Verzeichnis abgelegt. Innerhalb des `serial_letters`-Verzeichnisses wird ein datumsbasiertes Unterverzeichnis angelegt, das dem Datumsverzeichnis der verwendeten CSV-Dateien entspricht. + +Das Script geht davon aus, dass `exports`, `templates` und `serial_letters` im Benutzerverzeichnis liegen. Falls diese Verzeichnisse an einem anderen Ort abgelegt sind, müssen diese Pfade im Skript verändert werden. + +Nachfolgend ist die gesamte Verzeichnisstruktur dargestellt. +``` +exports/ +└── 2025.08.06_14-30-15/ // Dieses Verzeichnis kann als Quelle für CSV-Dateien ausgewählt werden + ├── ctRestClient.log + └── ihre-kirche.krz.tools/ // Alternativ kann auch dieses Verzeichnis als Quelle gewählt werden + ├── Konfirmanden.csv + └── Eltern_von_Konfirmanden.csv + +templates/ +└── ihre-kirche.krz.tools/ // Die Vorlagen müssen vor der Skriptausführung bereitgestellt werden + ├── Konfirmanden.indd // Die Namen der Vorlagen müssen mit den CSV-Dateien übereinstimmen + └── Eltern_von_Konfirmanden.indd + +serial_letters/ // Alle Daten in diesem Verzeichnis werden vom Skript generiert +└── 2025.08.06_14-30-15/ + ├── ihre-kirche_Konfirmanden_merged.indd + └── ihre-kirche_Eltern_von_Konfirmanden_merged.indd +``` + ## Best Practices ### Sicherheit diff --git a/docs/manual-en.md.tmp b/docs/manual-en.md.tmp index e74230c..20ed7dc 100644 --- a/docs/manual-en.md.tmp +++ b/docs/manual-en.md.tmp @@ -24,7 +24,7 @@ **Important**: Without `keepassxc-cli` in PATH, ctRestClient cannot access the token database! -2. **ChurchTools Access**: +2. **ChurchTools Access**: - Valid API token for each ChurchTools instance - Permission to read the relevant groups @@ -114,7 +114,7 @@ Create the file `data/persons/sexId.yml`: **Configuration:** ```yaml -fields: +fields: - id - firstName - lastName @@ -338,6 +338,41 @@ Detailed information about execution can be found in the `ctRestClient.log` file - Error messages with details - Performance information +## Mail Merge Automation + +To conveniently process exported CSV files for mail merges in Adobe InDesign, the `ctRestClient_.tar.gz` archive includes the InDesign script `create_serial_letters.jsx` in the `jsx` directory. +Before first use, this script must be copied to the appropriate InDesign scripts directory. + +When executing the script in InDesign, you will be prompted to select a directory containing CSV files. +The script processes the exported CSV files using the required InDesign templates to generate mail merge documents. +The necessary InDesign templates are expected in the `templates` directory. This `templates` directory must be available at the same level as the `exports` directory. +The generated mail merge documents are saved in the `serial_letters` directory. Within the `serial_letters` directory, a date-based subdirectory is created, corresponding to the date directory of the CSV files used. + +**Note**: +The script assumes that the `exports`, `templates`, and `serial_letters` directories are located in the user directory. If these directories are stored elsewhere, the paths must be adjusted in the script. + +Directory Structure + +``` +exports/ +└── 2025.08.06_14-30-15/ // This directory can be selected as the source for CSV files + ├── ctRestClient.log + └── your-church.krz.tools/ // Alternatively, this directory can also be selected as the source + ├── Confirmation_Class.csv + └── Parents_of_Confirmation_Class.csv + +templates/ +└── your-church.krz.tools/ // Templates must be provided before running the script + ├── Confirmation_Class.indd // Template names must match the CSV files (without extension) + └── Parents_of_Confirmation_Class.indd + +serial_letters/ // All data in this directory is generated by the script +└── 2025.08.06_14-30-15/ + ├── your-church_Confirmation_Class_merged.indd + └── your-church_Parents_of_Confirmation_Class_merged.indd +``` + + ## Best Practices ### Security diff --git a/jsx/create_serial_letters.jsx b/jsx/create_serial_letters.jsx new file mode 100644 index 0000000..fa9ddf5 --- /dev/null +++ b/jsx/create_serial_letters.jsx @@ -0,0 +1,405 @@ +var userHome = getUserHome(); + +if (typeof EXPORTS_DIR === 'undefined') { + var EXPORTS_DIR = userHome + "/exports"; +} +if (typeof TEMPLATES_DIR === 'undefined') { + var TEMPLATES_DIR = userHome + "/templates"; +} +if (typeof OUTPUT_DIR === 'undefined') { + var OUTPUT_DIR = userHome + "/serial_letters"; +} + +var LOG_FILE; + +/** + * Opens an InDesign template file without user interaction + * @param {File} templateFile - The template file to open + * @returns {Document} The opened document + */ +function loadTemplate(templateFile) { + app.scriptPreferences.userInteractionLevel = UserInteractionLevels.NEVER_INTERACT; + var doc = app.open(templateFile); + app.scriptPreferences.userInteractionLevel = UserInteractionLevels.INTERACT_WITH_ALL; + return doc; +} + +/** + * Checks if a folder exists and raises an exception if not + * @param {Folder} folder - The folder to check + */ +function assertFolder(folder) { + if (!folder.exists) { + throw new Error("The folder \"" + folder.fsName + "\" does not exist."); + } +} + +/** + * Ensures a folder exists by creating it if it doesn't exist + * @param {Folder} folder - The folder to ensure exists + */ +function ensureFolder(folder) { + if (!folder.exists) { + folder.create(); + } +} + +/** + * Append a line to the persistent log file. + * @param {string} message + */ +function writeLogFile(message) { + try { + LOG_FILE.encoding = 'UTF-8'; + if (LOG_FILE.open('a')) { + var ts = new Date(); + // ExtendScript Date may not implement toISOString(), so format manually + var pad = function (n) { return (n < 10 ? '0' : '') + n; }; + var iso = ts.getFullYear() + '-' + pad(ts.getMonth() + 1) + '-' + pad(ts.getDate()) + 'T' + pad(ts.getHours()) + ':' + pad(ts.getMinutes()) + ':' + pad(ts.getSeconds()); + LOG_FILE.writeln('[' + iso + '] ' + String(message)); + LOG_FILE.close(); + } + } catch (e) { + $.writeln("[ERROR] Failed to write log file: " + e.message); + } +} + +/** + * Logs a message and optionally shows an alert + * @param {string} type - The type of log message (e.g., "ERROR", "WARN", "INFO") + * @param {string} message - The message + * @param {boolean} showAlert - Whether to show an alert dialog + * @param {boolean} addNewline - Whether to add a newline after logging + */ +function log(type, message, showAlert, addNewline) { + var logMessage; + if (type === 'WARN' || type === 'warn') { + logMessage = "[WARN] " + message; + } else if (type === 'ERROR' || type === 'error') { + logMessage = "[ERROR] " + message; + if (showAlert) { + alert("❌ " + message); + } + } else { + logMessage = "[INFO] " + message; + } + $.writeln(logMessage); + try { writeLogFile(logMessage); } catch (e) { } + + if (addNewline) { + $.writeln(""); + try { writeLogFile(''); } catch (e) { } + } +} + +/** + * Initializes the log file + * @param {string} filename - Path to the log file + */ +function initLogFile(filename) { + LOG_FILE = File(filename); + try { + if (LOG_FILE.exists) { + LOG_FILE.remove(); + } + LOG_FILE.encoding = 'UTF-8'; + if (LOG_FILE.open('w')) { + var ts = new Date(); + var pad = function (n) { return (n < 10 ? '0' : '') + n; }; + var iso = ts.getFullYear() + '-' + pad(ts.getMonth() + 1) + '-' + pad(ts.getDate()) + 'T' + pad(ts.getHours()) + ':' + pad(ts.getMinutes()) + ':' + pad(ts.getSeconds()); + LOG_FILE.close(); + } + } catch (e) { + $.writeln("[ERROR] Could not initialize log file: " + e.message); + alert("❌ Could not initialize log file: " + e.message); + } +} + +/** + * Generates file paths for a community's CSV, INDD template, and output INDD + * @param {File} csvFile - The CSV file + * @param {Folder} templateFolder - The folder containing the INDD template + * @param {Folder} outputFolder - The folder for output INDD files + * @returns {Object} Object with csv, indd, and outputIndd File objects + */ +function getCommunityFile(csvFile, templateFolder, outputFolder) { + var cleanName = csvFile.displayName.replace(/\.(csv|indd|pdf)$/i, ""); + var communityName = csvFile.parent.name.split('.')[0]; + + return { + csv: File(csvFile.fsName), + indd: File(templateFolder + "/" + cleanName + ".indd"), + outputIndd: File(outputFolder + "/" + communityName + "_" + cleanName + "_merged.indd") + }; +} + +/** + * Checks whether a document has overset text + * @param {Document} doc - The document to check + * @returns {Object} Object with details (array) and firstPage (number) + */ +function getOverflows(doc) { + var details = []; + var firstPage = -1; + + if (!doc || !doc.isValid) { + return { details: [], firstPage: -1 }; + } + + // Check all pages for overset text + for (var p = 0; p < doc.pages.length; p++) { + var page = doc.pages[p]; + for (var f = 0; f < page.textFrames.length; f++) { + if (page.textFrames[f].overflows) { + if (firstPage === -1) { + firstPage = p; + } + details.push("- page " + (p + 1) + ", textbox " + (f + 1) + ": " + page.textFrames[f].contents.length + " chars"); + } + } + } + if (details.length > 0) { + log("WARN", "Overset text found: " + details.join("\n"), false, false); + } + + return { + details: details, + firstPage: firstPage + }; +} + +/** + * Get user home directory (cross-platform) + * @returns {string} The user's home directory path + */ +function getUserHome() { + var home = $.getenv('USERPROFILE') || $.getenv('HOME'); + + if (!home) { + throw new Error('Could not determine user home directory. Neither USERPROFILE nor HOME environment variable is set.'); + } + + // Normalize path separators to forward slashes (works everywhere in ExtendScript) + return home.replace(/\\/g, '/'); +} + +/** + * Returns community folders that contain CSV files + * @param {Folder} dataRoot + * @returns {Array} + */ +function getCommunityFolders(dataRoot) { + return dataRoot.getFiles(function (f) { + if (!(f instanceof Folder)) return false; + var csvs = f.getFiles("*.csv"); + return csvs && csvs.length > 0; + }); +} + +/** + * Resolves community folders from a user's folder selection + * @param {Folder} selectedFolder + * @returns {Array} + */ +function resolveCommunityFoldersFromFolder(selectedFolder) { + if (!(selectedFolder instanceof Folder)) { + return []; + } + var ownCSVs = selectedFolder.getFiles("*.csv"); + if (ownCSVs && ownCSVs.length > 0) { + return [selectedFolder]; + } + return getCommunityFolders(selectedFolder) || []; +} + +/** + * Checks whether the CSV file has at least header + one data row + * @param {File} csvFile - The CSV file to check + * @returns {boolean} + */ +function csvHasData(csvFile) { + if (csvFile.length === 0) return false; + + try { + csvFile.encoding = "UTF-16"; + if (!csvFile.open("r")) return false; + + var lineCount = 0; + while (!csvFile.eof && lineCount < 2) { + var line = csvFile.readln(); + if (line && String(line).replace(/^\s+|\s+$/g, "") !== "") { + lineCount++; + } + } + csvFile.close(); + return lineCount >= 2; + } catch (e) { + try { csvFile.close(); } catch (ee) { } + return false; + } +} + +(function () { + // === Configuration === + var max_data_merge_retries = 20; + var data_merge_retry_delay_ms = 200; + var log_file_name = "_merge_log.txt"; + + // === Path Configuration === + var userSelection = Folder.selectDialog("Select a folder", Folder(EXPORTS_DIR)); + if (!userSelection) { + log("ERROR", "Script aborted: No base folder selected.", true, true); + return; // Exit the script + } + + var templateRoot = Folder(TEMPLATES_DIR); + var outputRoot = Folder(OUTPUT_DIR); + + assertFolder(userSelection); + assertFolder(templateRoot); + + var communityFolders = resolveCommunityFoldersFromFolder(userSelection); + + if (communityFolders.length === 0) { + log("ERROR", "Could not find any CSV files in the selected folder or its subfolders: '" + userSelection.fsName + "'", true, true); + return; + } + + var timestampName = communityFolders[0].parent.name; + var outputSubFolder = communityFolders[0].parent.parent.name; + if (outputSubFolder === "exports") { + outputSubFolder = "" + } else { + outputSubFolder = "/" + outputSubFolder; + } + + var outputFolder = Folder(outputRoot + outputSubFolder + "/" + timestampName); + ensureFolder(outputFolder); + initLogFile(outputFolder + "/" + log_file_name); + + // === Process each community folder === + for (var i = 0; i < communityFolders.length; i++) { + var communityName = communityFolders[i].name; + var templateFolder = Folder(templateRoot + "/" + communityName); + if (!templateFolder.exists) { + log("ERROR", "Skipping pdf generation for '" + communityName + "' since there is no templates folder: '" + templateFolder.fsName + "'", true, true); + continue; + } + + var csvFiles = communityFolders[i].getFiles("*.csv"); + + // === Loop over all CSV files === + for (var j = 0; j < csvFiles.length; j++) { + var csvFile = csvFiles[j]; + var communityFile = getCommunityFile(csvFile, templateFolder, outputFolder); + + if (!csvHasData(csvFile)) { + log("ERROR", "Skipping pdf generation for '" + communityFile.csv.fsName + "' since it does not contain data rows", true, true); + continue; + } + + var templateFile = communityFile.indd; + if (!templateFile.exists) { + log("ERROR", "Skipping pdf generation for '" + communityFile.csv.fsName + "' since there is no template: '" + communityFile.indd.fsName + "'", true, true); + continue; + } + + var template = loadTemplate(templateFile); + log("INFO", "Loaded template: " + communityFile.indd.fsName, false, false); + try { + // === Remove old data source (if present) === + try { + template.dataMergeProperties.removeDataSource(); + log("INFO", "Removed old data source", false, false); + } catch (e) { + // No old data source present + } + + template.dataMergeProperties.selectDataSource(csvFile); + + // 🟢 Safely update data source (prevents "empty" first merges) + // InDesign loads data fields asynchronously - there are no callbacks/promises in ExtendScript! + // Therefore: Polling loop is the only option + template.dataMergeProperties.updateDataSource(); + + var attempt; + for (attempt = 1; attempt <= max_data_merge_retries; attempt++) { + if (template.dataMergeProperties.dataMergeFields.length > 0) { + log("INFO", "Loaded CSV: " + csvFile.fsName + " on attempt " + attempt, false, false); + break; + } + if (attempt < max_data_merge_retries) { + $.sleep(data_merge_retry_delay_ms); + } + } + + if (template.dataMergeProperties.dataMergeFields.length === 0) { + log("ERROR", "Could not load CSV data from: " + csvFile.name, true, true); + continue; + } + + // Merge records - creates a new document window with merged data + template.dataMergeProperties.mergeRecords(File(outputFolder)); + + // The merged document is now the active document + var mergedDoc = app.activeDocument; + if (!mergedDoc || !mergedDoc.isValid) { + log("ERROR", "Could not create merged document.", true, true); + continue; + } + + // Set the correct document title + mergedDoc.name = communityFile.outputIndd.name + "_merged"; + + if (mergedDoc && mergedDoc.isValid) { + log("INFO", "Merged document created with " + mergedDoc.pages.length + " page(s)", false, false); + + // Check for overset text + var overflows = getOverflows(mergedDoc); + + if (overflows.details.length > 0) { + // Scroll to first page with overset + if (overflows.firstPage >= 0 && mergedDoc.windows.length > 0) { + try { + var window = mergedDoc.windows[0]; + window.activeSpread = mergedDoc.pages[overflows.firstPage].parent; + log("INFO", "Scrolled to page " + (overflows.firstPage + 1), false, false); + } catch (e) { + log("ERROR", "Could not scroll to page: " + e.message, false, true); + } + } + + log("ERROR", "Found text overflows on:\n" + overflows.details.join("\n"), false, true); + } else { + log("INFO", "No overset text detected", false, false); + + // Save indd file since no oversets were found + try { + ensureFolder(outputFolder); + mergedDoc.save(File(communityFile.outputIndd)); + log("INFO", "Saved merged document: " + communityFile.outputIndd.fsName, false, true); + + // Close the merged document after successful export + mergedDoc.close(SaveOptions.NO); + } catch (saveErr) { + log("ERROR", "Error saving merged document: " + saveErr.message, true, true); + } + } + } else { + log("WARN", "Could not get merged document", false, true); + } + } catch (err) { + log("ERROR", "Error while generating '" + communityFile.outputIndd.fsName + "': " + err.message, true, true); + } finally { + // Close the template document + try { + if (template && template.isValid) { + template.close(SaveOptions.NO); + } + } catch (e) { + log("ERROR", "Error while closing the template " + e.message, true, true); + } + } + } + } +})(); diff --git a/scripts/build_binaries b/scripts/build_binaries index 2386692..ede97f4 100755 --- a/scripts/build_binaries +++ b/scripts/build_binaries @@ -45,6 +45,10 @@ echo "Creating release archive..." echo " Copying LICENSE file..." cp LICENSE "$OUTPUT_DIR/" +# Copy indesign script to output directory +mkdir -p "$OUTPUT_DIR/jsx" +cp jsx/*.jsx "$OUTPUT_DIR/jsx/" + # Get version from git VERSION="$(git describe --tags --always)" echo " Using version: $VERSION" @@ -60,6 +64,7 @@ tar -czf "$OUTPUT_DIR/$ARCHIVE_NAME" \ "ctRestClient-linux-amd64" \ "LICENSE" \ "VERSION" \ - "checksums.txt" + "checksums.txt" \ + "jsx/create_serial_letters.jsx" echo "Archive is located at $OUTPUT_DIR/$ARCHIVE_NAME" echo ""