Main.java
package pro.verron.officestamper;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.opencsv.CSVReader;
import com.opencsv.exceptions.CsvException;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import pro.verron.officestamper.api.OfficeStamperException;
import pro.verron.officestamper.excel.ExcelContext;
import pro.verron.officestamper.experimental.ExperimentalStampers;
import pro.verron.officestamper.preset.OfficeStamperConfigurations;
import pro.verron.officestamper.preset.OfficeStampers;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Properties;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.Collectors;
import static java.nio.file.Files.newOutputStream;
/// Main class for the CLI.
@Command(name = "officestamper", mixinStandardHelpOptions = true, description = "Office Stamper CLI tool")
public class Main
implements Runnable {
private static final Logger logger = Utils.getLogger();
// Flags: template (-t/--template) and data (-d/--data)
@Option(names = {"-t", "--template"},
required = true,
description = "Template file path (.docx|.pptx), or a keyword (diagnostic) for a packaged sample template")
private String templatePath;
@Option(names = {"-d", "--data"},
required = false,
description = "Data input: file (json|yaml|yml|properties|csv|xlsx|xml|html), a directory, or 'diagnostic'")
private String dataPath;
@Option(names = {"-o", "--output"},
defaultValue = "output.docx",
description = "Output file path")
private String outputPath;
@Option(names = {"--dry-run"},
description = "Validate template + data and variables, but do not produce the output file")
private boolean dryRun;
@Option(names = {"--report"},
description = "Optional JSON report file path with run metadata and validation results")
private String reportPath;
@Option(names = {"--log-format"},
defaultValue = "human",
description = "Logging format: 'human' (default) or 'json' (structured logs to stdout)")
private String logFormat;
/// Default constructor.
public Main() {
}
static void main(String[] args) {
var main = new Main();
var cli = new CommandLine(main);
int exitCode = cli.execute(args);
System.exit(exitCode);
}
private static InputStream streamFile(Path path) {
try {
return Files.newInputStream(path);
} catch (IOException e) {
throw new OfficeStamperException(e);
}
}
private static boolean isSupportedDataFile(Path p) {
var n = p.getFileName()
.toString()
.toLowerCase();
return n.endsWith(".csv") || n.endsWith(".properties") || n.endsWith(".html") ||
n.endsWith(".xml") || n.endsWith(".json") || n.endsWith(".yaml") ||
n.endsWith(".yml") || n.endsWith(".xlsx");
}
private static String baseName(Path f) {
var base = f.getFileName()
.toString();
var idx = base.lastIndexOf('.');
if (idx > 0) base = base.substring(0, idx);
return base;
}
@Override
public void run() {
// Normalize log format
var lf = logFormat == null
? "human"
: logFormat.trim()
.toLowerCase();
// Basic CLI validation according to new flags
if (templatePath == null || templatePath.isBlank()) {
emit("ERROR", "Missing required --template path", null, lf);
throw new CommandLine.ParameterException(new CommandLine(this), "--template is required");
}
if ((dataPath == null || dataPath.isBlank()) && !"diagnostic".equals(templatePath)) {
emit("ERROR", "Missing required --data when not using diagnostic template", null, lf);
throw new CommandLine.ParameterException(new CommandLine(this),
"--data is required when template != diagnostic");
}
emit("INFO", "Start", Map.of(
"template", templatePath,
"data", dataPath == null ? "<none>" : dataPath,
"output", outputPath,
"dryRun", dryRun
), lf);
try {
var ext = templateKind(templatePath);
// Folder semantics: each top-level file is its own context and yields one output;
// each top-level subfolder merges its files (recursively) into a bigger context and yields one output.
if (dataPath != null && !dataPath.isBlank() && Files.isDirectory(Path.of(dataPath))) {
var items = buildItemsFromDataDirectory(Path.of(dataPath));
var results = new java.util.ArrayList<RunResult>(items.size());
int idx = 0;
for (var item : items) {
idx++;
emit("INFO", "Processing item", Map.of("index", idx, "name", item.name, "total", items.size()), lf);
try (var templateStream = extractTemplateNew(templatePath)) {
if (dryRun) {
var configuration = OfficeStamperConfigurations.standard()
.setExceptionResolver(pro.verron.officestamper.preset.ExceptionResolvers.throwing());
switch (ext) {
case WORD -> {
var stamper = OfficeStampers.docxStamper(configuration);
stamper.stamp(templateStream, item.context, OutputStream.nullOutputStream());
}
case POWERPOINT -> {
var stamper = ExperimentalStampers.pptxStamper();
stamper.stamp(templateStream, item.context, OutputStream.nullOutputStream());
}
}
results.add(new RunResult(item.name, "ok", null, null));
}
else {
var out = computeOutputPath(outputPath, item.name, ext);
try (var os = createOutputStream(out)) {
switch (ext) {
case WORD -> {
var stamper = OfficeStampers.docxStamper();
stamper.stamp(templateStream, item.context, os);
}
case POWERPOINT -> {
var stamper = ExperimentalStampers.pptxStamper();
stamper.stamp(templateStream, item.context, os);
}
}
}
results.add(new RunResult(item.name, "ok", out.toString(), null));
}
} catch (Exception ex) {
emit("ERROR", "Item failed", Map.of("name", item.name, "error", ex.getMessage()), lf);
results.add(new RunResult(item.name, "error", null, ex.getMessage()));
// Continue with next item; overall exit code should be non-zero if any failed
}
}
var anyError = results.stream()
.anyMatch(r -> "error".equals(r.status));
if (dryRun) emit("INFO",
"Validation completed (dry-run)",
Map.of("items", results.size(), "errors", anyError),
lf);
else emit("INFO", "Stamping completed", Map.of("items", results.size(), "errors", anyError), lf);
writeReport(results);
if (anyError) throw new OfficeStamperException("One or more items failed");
return;
}
// Single context path
final var context = extractContextNew(dataPath);
try (var templateStream = extractTemplateNew(templatePath)) {
if (dryRun) {
// Validate: fail on unresolved placeholders but do not write any file
var configuration = OfficeStamperConfigurations.standard()
.setExceptionResolver(pro.verron.officestamper.preset.ExceptionResolvers.throwing());
switch (ext) {
case WORD -> {
var stamper = OfficeStampers.docxStamper(configuration);
stamper.stamp(templateStream, context, OutputStream.nullOutputStream());
}
case POWERPOINT -> {
var stamper = ExperimentalStampers.pptxStamper(); // no config variant exposed for PPTX yet
stamper.stamp(templateStream, context, OutputStream.nullOutputStream());
}
}
emit("INFO", "Validation successful (dry-run)", null, lf);
writeReport("ok", null);
return;
}
// Real stamping (single file)
try (var outputStream = createOutputStream(Path.of(outputPath))) {
switch (ext) {
case WORD -> {
var stamper = OfficeStampers.docxStamper();
stamper.stamp(templateStream, context, outputStream);
}
case POWERPOINT -> {
var stamper = ExperimentalStampers.pptxStamper();
stamper.stamp(templateStream, context, outputStream);
}
}
}
}
emit("INFO", "Stamping completed", Map.of("output", outputPath), lf);
writeReport("ok", null);
} catch (Exception e) {
emit("ERROR",
e.getMessage(),
Map.of("exception",
e.getClass()
.getSimpleName()),
lf);
writeReport("error", e.getMessage());
// Re-throw to ensure non-zero exit code from picocli
throw (e instanceof RuntimeException re) ? re : new OfficeStamperException(e);
}
}
private Object extractContextNew(String model) {
if (model == null || model.isBlank()) {
// allowed only when templatePath == diagnostic; caller validated earlier
return Diagnostic.context();
}
if ("diagnostic".equals(model)) return Diagnostic.context();
var path = Path.of(model);
if (Files.isDirectory(path)) return contextualiseDirectory(path);
return contextualise(path);
}
private InputStream extractTemplateNew(String template) {
if ("diagnostic".equals(template)) return Diagnostic.template();
return streamFile(Path.of(template));
}
private OutputStream createOutputStream(Path path) {
try {
var parent = path.getParent();
if (parent != null) Files.createDirectories(parent);
return newOutputStream(path);
} catch (IOException e) {
throw new OfficeStamperException(e);
}
}
private java.util.List<Item> buildItemsFromDataDirectory(Path dir) {
try (var stream = Files.list(dir)) {
var entries = stream.sorted()
.toList();
var items = new java.util.ArrayList<Item>();
for (var entry : entries) {
if (Files.isRegularFile(entry) && isSupportedDataFile(entry)) {
items.add(new Item(baseName(entry), contextualise(entry)));
}
else if (Files.isDirectory(entry)) {
items.add(new Item(entry.getFileName()
.toString(), contextualiseDirectoryRecursive(entry)));
}
}
return java.util.List.copyOf(items);
} catch (IOException e) {
throw new OfficeStamperException(e);
}
}
private Map<String, Object> contextualiseDirectoryRecursive(Path dir) {
try (var stream = Files.walk(dir)) {
var files = stream.filter(Files::isRegularFile)
.filter(Main::isSupportedDataFile)
.sorted()
.toList();
var map = new LinkedHashMap<String, Object>();
for (var f : files) {
map.put(baseName(f), contextualise(f));
}
return map;
} catch (IOException e) {
throw new OfficeStamperException(e);
}
}
private Path computeOutputPath(String output, String itemName, TemplateKind ext) {
var desiredExt = (ext == TemplateKind.WORD) ? ".docx" : ".pptx";
var out = Path.of(output);
// If output is an existing directory, place <itemName><ext> inside it
if (Files.exists(out) && Files.isDirectory(out)) {
return out.resolve(itemName + desiredExt);
}
var fn = out.getFileName() == null
? output
: out.getFileName()
.toString();
var dot = fn.lastIndexOf('.');
if (dot > 0) {
var base = fn.substring(0, dot);
var extPart = fn.substring(dot);
// Normalize to template extension
var finalExt = desiredExt;
var newName = base + "-" + itemName + finalExt;
var parent = out.getParent();
return parent == null ? Path.of(newName) : parent.resolve(newName);
}
else {
// Treat as directory path (may or may not exist)
return out.resolve(itemName + desiredExt);
}
}
private Object contextualise(Path path) {
var name = path.getFileName()
.toString()
.toLowerCase();
if (name.endsWith(".csv")) return processCsv(path);
if (name.endsWith(".properties")) return processProperties(path);
if (name.endsWith(".html") || name.endsWith(".xml")) return processXmlOrHtml(path);
if (name.endsWith(".json")) return processJson(path);
if (name.endsWith(".yaml") || name.endsWith(".yml")) return processYaml(path);
if (name.endsWith(".xlsx")) return processExcel(path);
throw new OfficeStamperException("Unsupported file type: " + path);
}
private Map<String, Object> contextualiseDirectory(Path dir) {
try (var stream = Files.list(dir)) {
var files = stream.filter(Files::isRegularFile)
.filter(p -> {
var n = p.getFileName()
.toString()
.toLowerCase();
return n.endsWith(".csv") || n.endsWith(".properties") || n.endsWith(".html") ||
n.endsWith(".xml") || n.endsWith(".json") || n.endsWith(".yaml") ||
n.endsWith(".yml") || n.endsWith(".xlsx");
})
.sorted()
.toList();
var map = new LinkedHashMap<String, Object>();
for (var f : files) {
var base = f.getFileName()
.toString();
var idx = base.lastIndexOf('.');
if (idx > 0) base = base.substring(0, idx);
map.put(base, contextualise(f));
}
return map;
} catch (IOException e) {
throw new OfficeStamperException(e);
}
}
/// Return a list of objects with the csv properties
private Object processCsv(Path path) {
try (var reader = new CSVReader(new InputStreamReader(Files.newInputStream(path)))) {
String[] headers = reader.readNext();
return reader.readAll()
.stream()
.map(row -> {
Map<String, String> map = new LinkedHashMap<>();
for (int i = 0; i < headers.length; i++) {
map.put(headers[i], row[i]);
}
return map;
})
.toList();
} catch (IOException | CsvException e) {
throw new OfficeStamperException(e);
}
}
private Object processProperties(Path path) {
var properties = new Properties();
try (var inputStream = Files.newInputStream(path)) {
properties.load(inputStream);
return new LinkedHashMap<>(properties.entrySet()
.stream()
.collect(Collectors.toMap(e -> String.valueOf(e.getKey()),
e -> String.valueOf(e.getValue()),
(a, b) -> b,
LinkedHashMap::new)));
} catch (IOException e) {
throw new OfficeStamperException(e);
}
}
private Object processXmlOrHtml(Path path) {
try {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
factory.setExpandEntityReferences(false);
DocumentBuilder builder = factory.newDocumentBuilder();
Document document = builder.parse(Files.newInputStream(path));
return processNode(document.getDocumentElement());
} catch (ParserConfigurationException | SAXException | IOException e) {
throw new OfficeStamperException(e);
}
}
private Map<String, Object> processNode(Element element) {
Map<String, Object> result = new LinkedHashMap<>();
NodeList children = element.getChildNodes();
for (int i = 0; i < children.getLength(); i++) {
Node node = children.item(i);
if (node instanceof Element childElement) {
String name = childElement.getTagName();
if (childElement.hasChildNodes() && childElement.getFirstChild()
.getNodeType() != Node.TEXT_NODE) {
result.put(name, processNode(childElement));
}
else {
result.put(name, childElement.getTextContent());
}
}
}
return result;
}
private Object processJson(Path path) {
try {
ObjectMapper mapper = new ObjectMapper();
TypeReference<LinkedHashMap<String, Object>> typeRef = new TypeReference<>() {};
return mapper.readValue(Files.newInputStream(path), typeRef);
} catch (IOException e) {
throw new OfficeStamperException(e);
}
}
private Object processYaml(Path path) {
try {
// Lazy YAML support using Jackson if available on classpath; else, provide a clear error.
var mapperClass = Class.forName("com.fasterxml.jackson.dataformat.yaml.YAMLFactory");
var mapper = new ObjectMapper((com.fasterxml.jackson.core.JsonFactory) mapperClass.getDeclaredConstructor()
.newInstance());
TypeReference<LinkedHashMap<String, Object>> typeRef = new TypeReference<>() {};
return mapper.readValue(Files.newInputStream(path), typeRef);
} catch (ClassNotFoundException e) {
throw new OfficeStamperException("YAML support requires 'jackson-dataformat-yaml' on the classpath");
} catch (Exception e) {
throw new OfficeStamperException(e);
}
}
private Object processExcel(Path path) {
try {
return ExcelContext.from(Files.newInputStream(path));
} catch (IOException e) {
throw new OfficeStamperException(e);
}
}
private TemplateKind templateKind(String input) {
if ("diagnostic".equals(input)) return TemplateKind.WORD;
var lower = input.toLowerCase();
if (lower.endsWith(".docx")) return TemplateKind.WORD;
if (lower.endsWith(".pptx")) return TemplateKind.POWERPOINT;
throw new OfficeStamperException("Unsupported template type (expected .docx or .pptx): " + input);
}
private void writeReport(String status, String errorMessage) {
if (reportPath == null || reportPath.isBlank()) return;
var report = new LinkedHashMap<String, Object>();
report.put("status", status);
report.put("template", templatePath);
report.put("data", dataPath == null ? "<none>" : dataPath);
report.put("output", outputPath);
report.put("dryRun", dryRun);
report.put("timestamp",
java.time.OffsetDateTime.now()
.toString());
if (errorMessage != null) report.put("error", errorMessage);
try {
var mapper = new ObjectMapper();
try (var os = createOutputStream(Path.of(reportPath))) {
mapper.writeValue(os, report);
}
} catch (Exception e) {
// Best-effort: do not fail the run because report writing failed
logger.log(Level.WARNING, "Failed to write report: " + e.getMessage(), e);
}
}
private void writeReport(java.util.List<RunResult> results) {
if (reportPath == null || reportPath.isBlank()) return;
var report = new LinkedHashMap<String, Object>();
var anyError = results.stream()
.anyMatch(r -> "error".equals(r.status));
report.put("status", anyError ? "error" : "ok");
report.put("template", templatePath);
report.put("data", dataPath);
report.put("dryRun", dryRun);
report.put("timestamp",
java.time.OffsetDateTime.now()
.toString());
var items = new java.util.ArrayList<Map<String, Object>>();
for (var r : results) {
var it = new LinkedHashMap<String, Object>();
it.put("name", r.name);
it.put("status", r.status);
if (r.output != null) it.put("output", r.output);
if (r.error != null) it.put("error", r.error);
items.add(it);
}
report.put("items", items);
try {
var mapper = new ObjectMapper();
try (var os = createOutputStream(Path.of(reportPath))) {
mapper.writeValue(os, report);
}
} catch (Exception e) {
logger.log(Level.WARNING, "Failed to write report: " + e.getMessage(), e);
}
}
// Minimal structured logging when --log-format=json
private void emit(String level, String message, Map<String, Object> fields, String lf) {
if (!"json".equals(lf)) {
// Human logs via java.util.logging
var lvl = switch (level) {
case "ERROR" -> Level.SEVERE;
case "WARN" -> Level.WARNING;
default -> Level.INFO;
};
if (fields == null || fields.isEmpty()) logger.log(lvl, message);
else logger.log(lvl, message + " | " + fields);
return;
}
try {
var map = new LinkedHashMap<String, Object>();
map.put("ts",
java.time.OffsetDateTime.now()
.toString());
map.put("level", level.toLowerCase());
map.put("msg", message);
if (fields != null && !fields.isEmpty()) map.put("fields", fields);
var json = new ObjectMapper().writeValueAsString(map);
System.out.println(json);
} catch (Exception ignored) {
System.out.println("{\"level\":\"error\",\"msg\":\"failed to emit json log\"}");
}
}
private enum TemplateKind {
WORD,
POWERPOINT
}
private record Item(String name, Object context) {}
/**
* @param status ok | error
* @param output nullable
* @param error nullable */
private record RunResult(String name, String status, String output, String error) {
}
}