diff --git a/core/src/main/java/dev/vml/es/acm/core/code/Executor.java b/core/src/main/java/dev/vml/es/acm/core/code/Executor.java index 8510e9949..ed751d651 100644 --- a/core/src/main/java/dev/vml/es/acm/core/code/Executor.java +++ b/core/src/main/java/dev/vml/es/acm/core/code/Executor.java @@ -248,7 +248,7 @@ private ContextualExecution executeInternal(ExecutionContext context) { useLocker(resolverFactory, l -> l.lock(lockName)); } context.notifyStatus(ExecutionStatus.RUNNING); - if (config.logPrintingEnabled()) { + if (!healthChecking && config.logPrintingEnabled()) { context.getOut().fromSelfLogger(); context.getOut().fromLoggers(config.logPrintingNames()); context.getOut().setLoggerTimestamps(config.logPrintingTimestamps()); diff --git a/core/src/main/java/dev/vml/es/acm/core/code/FileManager.java b/core/src/main/java/dev/vml/es/acm/core/code/FileManager.java index 39fba9055..8ba34b1b3 100644 --- a/core/src/main/java/dev/vml/es/acm/core/code/FileManager.java +++ b/core/src/main/java/dev/vml/es/acm/core/code/FileManager.java @@ -17,13 +17,15 @@ public class FileManager { + public static final String ROOT = AcmConstants.VAR_ROOT + "/file"; + private Repo repo; private RepoResource root; public FileManager(ResourceResolver resolver) { this.repo = Repo.quiet(resolver); - this.root = repo.get(AcmConstants.VAR_ROOT + "/file"); + this.root = repo.get(ROOT); } public Optional find(String path) { @@ -64,6 +66,9 @@ public List deleteAll(List paths) { } public String delete(String path) { + if (path.startsWith("/") && !path.startsWith(ROOT + "/")) { + throw new AcmException(String.format("File is outside managed root and cannot be deleted '%s'!", path)); + } RepoResource resource = findResource(path); if (resource == null) { throw new AcmException(String.format("File to be deleted does not exist '%s'!", path)); diff --git a/core/src/main/java/dev/vml/es/acm/core/code/TextOutput.java b/core/src/main/java/dev/vml/es/acm/core/code/TextOutput.java index 8e8ec0683..f5e395c9b 100644 --- a/core/src/main/java/dev/vml/es/acm/core/code/TextOutput.java +++ b/core/src/main/java/dev/vml/es/acm/core/code/TextOutput.java @@ -1,5 +1,6 @@ package dev.vml.es.acm.core.code; +import java.util.Collection; import java.util.Map; import java.util.stream.Collectors; @@ -49,4 +50,18 @@ public String links(Map links) { .map(entry -> "- [" + entry.getKey() + "](" + entry.getValue() + ")") .collect(Collectors.joining(System.lineSeparator())); } + + public String lines(String[] lines) { + return String.join(System.lineSeparator(), lines); + } + + public String lines(Collection lines) { + return String.join(System.lineSeparator(), lines); + } + + public String lines(Map values) { + return values.entrySet().stream() + .map(entry -> "- **" + entry.getKey() + ":** " + entry.getValue()) + .collect(Collectors.joining(System.lineSeparator())); + } } diff --git a/core/src/main/java/dev/vml/es/acm/core/code/input/AbstractFileInput.java b/core/src/main/java/dev/vml/es/acm/core/code/input/AbstractFileInput.java index 7ce6dfd8b..ac57574af 100644 --- a/core/src/main/java/dev/vml/es/acm/core/code/input/AbstractFileInput.java +++ b/core/src/main/java/dev/vml/es/acm/core/code/input/AbstractFileInput.java @@ -1,10 +1,13 @@ package dev.vml.es.acm.core.code.input; +import dev.vml.es.acm.core.AcmException; +import dev.vml.es.acm.core.code.FileManager; import dev.vml.es.acm.core.code.Input; import dev.vml.es.acm.core.code.InputType; import java.util.Arrays; import java.util.Collections; import java.util.List; +import org.apache.commons.lang3.StringUtils; abstract class AbstractFileInput extends Input { @@ -14,6 +17,15 @@ public AbstractFileInput(String name, InputType type, Class valueType) { super(name, type, valueType); } + protected void validatePath(String path) { + if (StringUtils.isNotBlank(path) && !path.startsWith(FileManager.ROOT + "/")) { + throw new AcmException(String.format( + "File input '%s' cannot have a value '%s' pointing outside ACM file storage '%s'. " + + "For selecting files from DAM or other repository locations, use path input instead.", + getName(), path, FileManager.ROOT)); + } + } + public List getMimeTypes() { return mimeTypes; } diff --git a/core/src/main/java/dev/vml/es/acm/core/code/input/FileInput.java b/core/src/main/java/dev/vml/es/acm/core/code/input/FileInput.java index c8eb74f9f..66b9de692 100644 --- a/core/src/main/java/dev/vml/es/acm/core/code/input/FileInput.java +++ b/core/src/main/java/dev/vml/es/acm/core/code/input/FileInput.java @@ -7,4 +7,10 @@ public class FileInput extends AbstractFileInput { public FileInput(String name) { super(name, InputType.FILE, String.class); } + + @Override + public void setValue(String value) { + validatePath(value); + super.setValue(value); + } } diff --git a/core/src/main/java/dev/vml/es/acm/core/code/input/MultiFileInput.java b/core/src/main/java/dev/vml/es/acm/core/code/input/MultiFileInput.java index 9b8155425..aba53641d 100644 --- a/core/src/main/java/dev/vml/es/acm/core/code/input/MultiFileInput.java +++ b/core/src/main/java/dev/vml/es/acm/core/code/input/MultiFileInput.java @@ -12,6 +12,16 @@ public MultiFileInput(String name) { super(name, InputType.MULTIFILE, String[].class); } + @Override + public void setValue(String[] values) { + if (values != null) { + for (String value : values) { + validatePath(value); + } + } + super.setValue(values); + } + public Integer getMin() { return min; } diff --git a/core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java b/core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java index fabff0181..ad87c0b1b 100644 --- a/core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java +++ b/core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java @@ -667,15 +667,11 @@ private void setFileContent( } public InputStream readFileAsStream() { - Resource resource = require(); - Resource contentResource = resource.getChild(JcrConstants.JCR_CONTENT); - if (contentResource == null) { - throw new RepoException(String.format("Cannot read file at path '%s' as it does not have content!", path)); - } - InputStream inputStream = contentResource.adaptTo(InputStream.class); + RepoResource fileResource = requireFile(); + InputStream inputStream = fileResource.resolve().adaptTo(InputStream.class); if (inputStream == null) { throw new RepoException( - String.format("Cannot read file at path '%s' as it does not have content stream!", path)); + String.format("Cannot read file at path '%s' as it does not have binary data!", path)); } return inputStream; } @@ -688,8 +684,40 @@ public String readFileAsString() { } } + public RepoResource resolveFile() { + return file().orElse(null); + } + + public RepoResource requireFile() { + return file().orElseThrow(() -> + new RepoException(String.format("Resource at path '%s' does not have file content!", path))); + } + + private Optional file() { + Resource resource = resolve(); + if (resource == null) { + return Optional.empty(); + } + Resource contentResource = resource.getChild(JcrConstants.JCR_CONTENT); + if (contentResource == null) { + return Optional.empty(); + } + if (path.startsWith(AemConstants.DAM_ROOT + "/")) { + String damOriginalPath = AemConstants.DAM_RENDITION_FOLDER + "/" + AemConstants.DAM_RENDITION_ORIGINAL + "/" + + JcrConstants.JCR_CONTENT; + Resource damContent = contentResource.getChild(damOriginalPath); + if (damContent != null && JcrUtils.hasBinaryData(damContent.adaptTo(Node.class))) { + return Optional.of(new RepoResource(repo, damContent.getPath())); + } + } + if (JcrUtils.hasBinaryData(contentResource.adaptTo(Node.class))) { + return Optional.of(new RepoResource(repo, contentResource.getPath())); + } + return Optional.empty(); + } + public boolean isFile() { - return isType(JcrConstants.NT_FILE); + return file().isPresent(); } public String type() { diff --git a/core/src/main/java/dev/vml/es/acm/core/util/AemConstants.java b/core/src/main/java/dev/vml/es/acm/core/util/AemConstants.java new file mode 100644 index 000000000..f6aff2f86 --- /dev/null +++ b/core/src/main/java/dev/vml/es/acm/core/util/AemConstants.java @@ -0,0 +1,14 @@ +package dev.vml.es.acm.core.util; + +public final class AemConstants { + + public static final String DAM_ROOT = "/content/dam"; + + public static final String DAM_RENDITION_FOLDER = "renditions"; + + public static final String DAM_RENDITION_ORIGINAL = "original"; + + private AemConstants() { + // constants only + } +} diff --git a/core/src/main/java/dev/vml/es/acm/core/util/JcrUtils.java b/core/src/main/java/dev/vml/es/acm/core/util/JcrUtils.java index b91ee7afe..e150bdb11 100644 --- a/core/src/main/java/dev/vml/es/acm/core/util/JcrUtils.java +++ b/core/src/main/java/dev/vml/es/acm/core/util/JcrUtils.java @@ -1,9 +1,19 @@ package dev.vml.es.acm.core.util; +import static javax.jcr.Property.JCR_CONTENT; +import static javax.jcr.Property.JCR_DATA; +import static javax.jcr.Property.JCR_FROZEN_PRIMARY_TYPE; +import static javax.jcr.nodetype.NodeType.NT_FILE; +import static javax.jcr.nodetype.NodeType.NT_FROZEN_NODE; +import static javax.jcr.nodetype.NodeType.NT_LINKED_FILE; + import java.util.Iterator; import java.util.stream.Stream; +import javax.jcr.Item; import javax.jcr.Node; import javax.jcr.NodeIterator; +import javax.jcr.Property; +import javax.jcr.RepositoryException; public class JcrUtils { @@ -23,4 +33,41 @@ public Node next() { } }); } + + /** + * Returns the binary data property of the given node (e.g. for nt:file, nt:linkedFile, dam:Asset). + * Copied from internal Sling class. + * + * @see NodeUtil#getPrimaryProperty + */ + public static Property getBinaryDataProperty(Node node) throws RepositoryException { + Node content = (node.isNodeType(NT_FILE) + || (node.isNodeType(NT_FROZEN_NODE) + && node.getProperty(JCR_FROZEN_PRIMARY_TYPE) + .getString() + .equals(NT_FILE))) + ? node.getNode(JCR_CONTENT) + : node.isNodeType(NT_LINKED_FILE) + ? node.getProperty(JCR_CONTENT).getNode() + : node; + if (content.hasProperty(JCR_DATA)) { + return content.getProperty(JCR_DATA); + } + Item item = content.getPrimaryItem(); + while (item.isNode()) { + item = ((Node) item).getPrimaryItem(); + } + return (Property) item; + } + + public static boolean hasBinaryData(Node node) { + if (node == null) { + return false; + } + try { + return getBinaryDataProperty(node) != null; + } catch (RepositoryException e) { + return false; + } + } } diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACME-204_input-xls.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACME-204_input-xls.groovy new file mode 100644 index 000000000..591041275 --- /dev/null +++ b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACME-204_input-xls.groovy @@ -0,0 +1,107 @@ +import org.apache.poi.ss.usermodel.* +import org.apache.poi.xssf.usermodel.XSSFWorkbook +import java.time.LocalDate +import java.time.ZoneId + +/* +--- +author: +--- +Reads an XLSX file (e.g. generated by ACME-203) and outputs summary statistics. +Demonstrates how to use file input for uploading and parsing spreadsheet files. + +```mermaid +graph TD + A[Upload XLS File] --> B[Open Workbook] + B --> C[Read Sheet] + C --> D[Parse Rows] + D --> E[Calculate Statistics] + E --> F[Output Summary] +``` +*/ + +void describeRun() { + inputs.file("xlsFile") { + label = "XLS/XLSX File" + description = "Upload a spreadsheet file to analyze (e.g. report.xlsx from ACME-203)" + mimeTypes = [ + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.ms-excel" + ] + } +} + +boolean canRun() { + return conditions.always() +} + +void doRun() { + out.info "Reading XLS file..." + + def xlsFile = repo.get(inputs.value("xlsFile")) + + try { + Workbook workbook = new XSSFWorkbook(xlsFile.readFileAsStream()) + Sheet sheet = workbook.getSheetAt(0) + + // Build column name -> index map from header row + Row headerRow = sheet.getRow(0) + def columns = [:] + for (Cell cell : headerRow) { + columns[cell.getStringCellValue()] = cell.getColumnIndex() + } + out.info "Found columns: ${columns.keySet().join(', ')}" + + def rowCount = 0 + def names = [] as Set + def surnames = [] as Set + def oldestDate = null + def youngestDate = null + + for (Row row : sheet) { + if (row.getRowNum() == 0) continue // skip header + + context.checkAborted() + rowCount++ + + def nameCell = row.getCell(columns["Name"]) + def surnameCell = row.getCell(columns["Surname"]) + def dateCell = row.getCell(columns["Birth Date"]) + + if (nameCell) names.add(nameCell.getStringCellValue()) + if (surnameCell) surnames.add(surnameCell.getStringCellValue()) + + if (dateCell) { + def date = null + if (DateUtil.isCellDateFormatted(dateCell)) { + date = dateCell.getDateCellValue().toInstant().atZone(ZoneId.systemDefault()).toLocalDate() + } else if (dateCell.getCellType() == CellType.STRING) { + date = LocalDate.parse(dateCell.getStringCellValue()) + } + if (date != null) { + if (oldestDate == null || date.isBefore(oldestDate)) oldestDate = date + if (youngestDate == null || date.isAfter(youngestDate)) youngestDate = date + } + } + + if (rowCount % 1000 == 0) out.info("Processed ${rowCount} rows...") + } + + workbook.close() + + outputs.text("summary") { + label = "Summary" + value = lines([ + "Rows processed": rowCount, + "Unique names": names.size(), + "Unique surnames": surnames.size(), + "Oldest birth date": oldestDate ?: 'N/A', + "Youngest birth date": youngestDate ?: 'N/A' + ]) + } + + out.success "XLS file analysis completed" + } finally { + xlsFile.delete() + } +}