diff --git a/docs/release-notes/2026.10.0.md b/docs/release-notes/2026.10.0.md index 2b5d447e2..4732b1375 100644 --- a/docs/release-notes/2026.10.0.md +++ b/docs/release-notes/2026.10.0.md @@ -4,6 +4,7 @@ - Add a Markdown preview tab to the log entry editor. - Add the ability to maximize the log entry editor. +- Add PDF downloads for individual log entries and complete log documents, including metadata, sections, replies, Markdown formatting, referenced images, and attachment links. - Add an All Logbooks page for viewing and filtering log documents across every logbook type. - Add a default owner group setting for each logbook type. New log documents inherit the configured owner group. - Improve logbook selection and document lists with hierarchy-aware names and filtering. diff --git a/src/java/LogrPortal/lib/flexmark-pdf-converter-0.64.8.jar b/src/java/LogrPortal/lib/flexmark-pdf-converter-0.64.8.jar new file mode 100644 index 000000000..8f68acb05 Binary files /dev/null and b/src/java/LogrPortal/lib/flexmark-pdf-converter-0.64.8.jar differ diff --git a/src/java/LogrPortal/lib/graphics2d-0.32.jar b/src/java/LogrPortal/lib/graphics2d-0.32.jar new file mode 100644 index 000000000..b02ea333d Binary files /dev/null and b/src/java/LogrPortal/lib/graphics2d-0.32.jar differ diff --git a/src/java/LogrPortal/lib/jsoup-1.15.4.jar b/src/java/LogrPortal/lib/jsoup-1.15.4.jar new file mode 100644 index 000000000..5feaa00b9 Binary files /dev/null and b/src/java/LogrPortal/lib/jsoup-1.15.4.jar differ diff --git a/src/java/LogrPortal/lib/openhtmltopdf-core-1.0.10.jar b/src/java/LogrPortal/lib/openhtmltopdf-core-1.0.10.jar new file mode 100644 index 000000000..fb53e9655 Binary files /dev/null and b/src/java/LogrPortal/lib/openhtmltopdf-core-1.0.10.jar differ diff --git a/src/java/LogrPortal/lib/openhtmltopdf-pdfbox-1.0.10.jar b/src/java/LogrPortal/lib/openhtmltopdf-pdfbox-1.0.10.jar new file mode 100644 index 000000000..57ca6a61a Binary files /dev/null and b/src/java/LogrPortal/lib/openhtmltopdf-pdfbox-1.0.10.jar differ diff --git a/src/java/LogrPortal/lib/xmpbox-2.0.24.jar b/src/java/LogrPortal/lib/xmpbox-2.0.24.jar new file mode 100644 index 000000000..c31e347b6 Binary files /dev/null and b/src/java/LogrPortal/lib/xmpbox-2.0.24.jar differ diff --git a/src/java/LogrPortal/nbproject/build-impl.xml b/src/java/LogrPortal/nbproject/build-impl.xml index 17ada41fc..3a9a9766f 100644 --- a/src/java/LogrPortal/nbproject/build-impl.xml +++ b/src/java/LogrPortal/nbproject/build-impl.xml @@ -1044,6 +1044,12 @@ exists or setup the property manually. For example like this: + + + + + + @@ -1110,6 +1116,12 @@ exists or setup the property manually. For example like this: + + + + + + diff --git a/src/java/LogrPortal/nbproject/genfiles.properties b/src/java/LogrPortal/nbproject/genfiles.properties index c4e6cfe2f..6415db160 100644 --- a/src/java/LogrPortal/nbproject/genfiles.properties +++ b/src/java/LogrPortal/nbproject/genfiles.properties @@ -3,8 +3,8 @@ build.xml.script.CRC32=67492cbd build.xml.stylesheet.CRC32=1707db4f@1.94.0.1 # This file is used by a NetBeans-based IDE to track changes in generated files such as build-impl.xml. # Do not edit this file. You may delete it but then the IDE will never regenerate such files for you. -nbproject/build-impl.xml.data.CRC32=c3c1315b -nbproject/build-impl.xml.script.CRC32=f37f2b81 +nbproject/build-impl.xml.data.CRC32=d6be2672 +nbproject/build-impl.xml.script.CRC32=23affe2b nbproject/build-impl.xml.stylesheet.CRC32=334708a0@1.94.0.1 nbproject/rest-build.xml.data.CRC32=83e13ee9 nbproject/rest-build.xml.script.CRC32=0d1fb1b4 diff --git a/src/java/LogrPortal/nbproject/project.properties b/src/java/LogrPortal/nbproject/project.properties index adc8a59d4..ae8e507b0 100644 --- a/src/java/LogrPortal/nbproject/project.properties +++ b/src/java/LogrPortal/nbproject/project.properties @@ -48,6 +48,7 @@ file.reference.dm-base-3.3.1.jar=lib/dm-base-3.3.1.jar file.reference.ejb-api-3.0.jar=lib/ejb-api-3.0.jar file.reference.flexmark-0.64.8.jar=lib/flexmark-0.64.8.jar file.reference.flexmark-ext-tables-0.64.8.jar=lib/flexmark-ext-tables-0.64.8.jar +file.reference.flexmark-pdf-converter-0.64.8.jar=lib/flexmark-pdf-converter-0.64.8.jar file.reference.flexmark-util-ast-0.64.8.jar=lib/flexmark-util-ast-0.64.8.jar file.reference.flexmark-util-builder-0.64.8.jar=lib/flexmark-util-builder-0.64.8.jar file.reference.flexmark-util-collection-0.64.8.jar=lib/flexmark-util-collection-0.64.8.jar @@ -60,6 +61,7 @@ file.reference.flexmark-util-sequence-0.64.8.jar=lib/flexmark-util-sequence-0.64 file.reference.flexmark-util-visitor-0.64.8.jar=lib/flexmark-util-visitor-0.64.8.jar file.reference.font-awesome-4.7.0.jar=lib/font-awesome-4.7.0.jar file.reference.fontbox-2.0.24.jar=lib/fontbox-2.0.24.jar +file.reference.graphics2d-0.32.jar=lib/graphics2d-0.32.jar file.reference.gson-2.10.1.jar=lib/gson-2.10.1.jar file.reference.itextpdf-5.5.13.1.jar=lib/itextpdf-5.5.13.1.jar file.reference.jackson-annotations-2.11.2.jar=lib/jackson-annotations-2.11.2.jar @@ -76,12 +78,15 @@ file.reference.javax.inject-1.jar=lib/javax.inject-1.jar file.reference.javax.servlet-api-4.0.1.jar=lib/javax.servlet-api-4.0.1.jar file.reference.javax.xml.soap-api-1.4.0.jar=lib/javax.xml.soap-api-1.4.0.jar file.reference.jersey-media-json-jackson-2.28.jar=lib/jersey-media-json-jackson-2.28.jar +file.reference.jsoup-1.15.4.jar=lib/jsoup-1.15.4.jar file.reference.libphonenumber-8.12.3.jar=lib/libphonenumber-8.12.3.jar file.reference.log4j-api-2.25.3.jar=lib/log4j-api-2.25.3.jar file.reference.log4j-core-2.25.3.jar=lib/log4j-core-2.25.3.jar file.reference.metadata-extractor-2.17.0.jar=lib/metadata-extractor-2.17.0.jar file.reference.mqtt-jca-api-1.0.0.jar=lib/mqtt-jca-api-1.0.0.jar file.reference.omnifaces-3.10.1.jar-1=lib/omnifaces-3.10.1.jar +file.reference.openhtmltopdf-core-1.0.10.jar=lib/openhtmltopdf-core-1.0.10.jar +file.reference.openhtmltopdf-pdfbox-1.0.10.jar=lib/openhtmltopdf-pdfbox-1.0.10.jar file.reference.pdfbox-2.0.24.jar=lib/pdfbox-2.0.24.jar file.reference.poi-4.0.1.jar=lib/poi-4.0.1.jar file.reference.poi-ooxml-4.0.1.jar=lib/poi-ooxml-4.0.1.jar @@ -98,6 +103,7 @@ file.reference.commons-collections4-4.2.jar=lib/commons-collections4-4.2.jar file.reference.commons-compress-1.18.jar=lib/commons-compress-1.18.jar file.reference.slf4j-api-1.7.26.jar=lib/slf4j-api-1.7.26.jar file.reference.snakeyaml-1.24.jar=lib/snakeyaml-1.24.jar +file.reference.xmpbox-2.0.24.jar=lib/xmpbox-2.0.24.jar file.reference.xmpcore-6.1.10.jar=lib/xmpcore-6.1.10.jar includes=** j2ee.compile.on.save=false @@ -174,7 +180,13 @@ javac.classpath=\ ${file.reference.flexmark-util-html-0.64.8.jar}:\ ${file.reference.flexmark-ext-tables-0.64.8.jar}:\ ${file.reference.jakarta.resource-api-2.1.0.jar}:\ - ${file.reference.mqtt-jca-api-1.0.0.jar} + ${file.reference.mqtt-jca-api-1.0.0.jar}:\ + ${file.reference.flexmark-pdf-converter-0.64.8.jar}:\ + ${file.reference.graphics2d-0.32.jar}:\ + ${file.reference.jsoup-1.15.4.jar}:\ + ${file.reference.openhtmltopdf-core-1.0.10.jar}:\ + ${file.reference.xmpbox-2.0.24.jar}:\ + ${file.reference.openhtmltopdf-pdfbox-1.0.10.jar} # Space-separated list of extra javac options javac.compilerargs= javac.debug=true diff --git a/src/java/LogrPortal/nbproject/project.xml b/src/java/LogrPortal/nbproject/project.xml index f9b51b0e7..ffc4a4b2f 100644 --- a/src/java/LogrPortal/nbproject/project.xml +++ b/src/java/LogrPortal/nbproject/project.xml @@ -254,6 +254,30 @@ ${file.reference.mqtt-jca-api-1.0.0.jar} WEB-INF/lib + + ${file.reference.flexmark-pdf-converter-0.64.8.jar} + WEB-INF/lib + + + ${file.reference.graphics2d-0.32.jar} + WEB-INF/lib + + + ${file.reference.jsoup-1.15.4.jar} + WEB-INF/lib + + + ${file.reference.openhtmltopdf-core-1.0.10.jar} + WEB-INF/lib + + + ${file.reference.xmpbox-2.0.24.jar} + WEB-INF/lib + + + ${file.reference.openhtmltopdf-pdfbox-1.0.10.jar} + WEB-INF/lib + diff --git a/src/java/LogrPortal/src/java/gov/anl/aps/logr/portal/controllers/ItemDomainLogbookController.java b/src/java/LogrPortal/src/java/gov/anl/aps/logr/portal/controllers/ItemDomainLogbookController.java index d30b72a85..f6e816fe6 100644 --- a/src/java/LogrPortal/src/java/gov/anl/aps/logr/portal/controllers/ItemDomainLogbookController.java +++ b/src/java/LogrPortal/src/java/gov/anl/aps/logr/portal/controllers/ItemDomainLogbookController.java @@ -42,6 +42,7 @@ import gov.anl.aps.logr.portal.model.db.entities.SettingType; import gov.anl.aps.logr.portal.model.db.entities.UserInfo; import gov.anl.aps.logr.portal.model.db.utilities.EntityInfoUtility; +import gov.anl.aps.logr.portal.utilities.LogbookPdfExportUtility; import gov.anl.aps.logr.portal.utilities.MarkdownParser; import gov.anl.aps.logr.portal.utilities.SearchResult; import gov.anl.aps.logr.portal.utilities.SessionUtility; @@ -74,6 +75,7 @@ import javax.inject.Named; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.primefaces.model.StreamedContent; /** * Controller that provides functionality to create, edit, delete, and view @@ -189,6 +191,26 @@ public static ItemDomainLogbookController getInstance() { return (ItemDomainLogbookController) SessionUtility.findBean(controllerNamed); } + public StreamedContent downloadCurrentDocumentPdf() { + try { + return LogbookPdfExportUtility.exportDocument(getCurrent()); + } catch (Exception ex) { + logger.error("Could not export log document PDF", ex); + SessionUtility.addErrorMessage("PDF Export Error", "Could not create the log document PDF."); + return null; + } + } + + public StreamedContent downloadLogEntryPdf(Log log) { + try { + return LogbookPdfExportUtility.exportEntry(getCurrent(), log); + } catch (Exception ex) { + logger.error("Could not export log entry PDF", ex); + SessionUtility.addErrorMessage("PDF Export Error", "Could not create the log entry PDF."); + return null; + } + } + @Override public ItemDomainLogbookLazyDataModel createItemLazyDataModel() { return new ItemDomainLogbookLazyDataModel(itemDomainLogbookFacade, getDefaultDomain(), settingObject); diff --git a/src/java/LogrPortal/src/java/gov/anl/aps/logr/portal/utilities/LogbookPdfExportUtility.java b/src/java/LogrPortal/src/java/gov/anl/aps/logr/portal/utilities/LogbookPdfExportUtility.java new file mode 100644 index 000000000..d4395b33f --- /dev/null +++ b/src/java/LogrPortal/src/java/gov/anl/aps/logr/portal/utilities/LogbookPdfExportUtility.java @@ -0,0 +1,182 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.logr.portal.utilities; + +import com.vladsch.flexmark.pdf.converter.PdfConverterExtension; +import com.vladsch.flexmark.util.data.MutableDataSet; +import gov.anl.aps.logr.portal.model.db.entities.EntityInfo; +import gov.anl.aps.logr.portal.model.db.entities.ItemDomainLogbook; +import gov.anl.aps.logr.portal.model.db.entities.Log; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.List; +import org.primefaces.model.DefaultStreamedContent; +import org.primefaces.model.StreamedContent; + +/** Builds PDF downloads for log entries and log documents. */ +public final class LogbookPdfExportUtility { + + private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("MM/dd/yyyy hh:mm a"); + + private LogbookPdfExportUtility() { + } + + public static StreamedContent exportEntry(ItemDomainLogbook document, Log log) throws IOException { + byte[] pdf = renderPdf(buildEntryHtml(document, log)); + return stream(pdf, getEntryFilename(log)); + } + + public static StreamedContent exportDocument(ItemDomainLogbook document) throws IOException { + byte[] pdf = renderPdf(buildDocumentHtml(document)); + return stream(pdf, getDocumentFilename(document)); + } + + static String buildEntryHtml(ItemDomainLogbook document, Log log) { + StringBuilder html = startHtml("Log Entry " + log.getId()); + html.append("
"); + appendMetadata(html, "Document ID", document.getId()); + html.append("
"); + appendEntry(html, log, false); + return finishHtml(html); + } + + static String buildDocumentHtml(ItemDomainLogbook document) { + StringBuilder html = startHtml(document.getName()); + html.append("

").append(escape(document.getName())).append("

"); + appendDocumentMetadata(html, document); + + List sections = document.getLogbookSections(); + if (sections == null || sections.isEmpty()) { + appendEntries(html, document.getLogList()); + } else { + for (ItemDomainLogbook section : sections) { + html.append("

") + .append(escape(section.getName())).append("

"); + appendEntries(html, section.getLogList()); + html.append("
"); + } + } + return finishHtml(html); + } + + private static void appendDocumentMetadata(StringBuilder html, ItemDomainLogbook document) { + EntityInfo info = document.getEntityInfo(); + html.append("
"); + appendMetadata(html, "Document ID", document.getId()); + appendMetadata(html, "Type", document.getEntityTypeString()); + if (info != null) { + appendMetadata(html, "Created", format(info.getCreatedOnDateTime())); + appendMetadata(html, "Created by", info.getCreatedByDisplayName()); + appendMetadata(html, "Last modified", format(info.getLastModifiedOnDateTime())); + appendMetadata(html, "Last modified by", info.getLastModifiedByDisplayName()); + } + html.append("
"); + } + + private static void appendEntries(StringBuilder html, List entries) { + if (entries == null) { + return; + } + for (Log entry : entries) { + appendEntry(html, entry, false); + List replies = entry.getChildLogList(); + if (replies != null) { + for (Log reply : replies) { + appendEntry(html, reply, true); + } + } + } + } + + private static void appendEntry(StringBuilder html, Log log, boolean reply) { + html.append("
"); + appendMetadata(html, "Entry ID", log.getId()); + appendMetadata(html, "Added", format(log.getEnteredOnDateTime())); + appendMetadata(html, "Author", log.getEnteredByUser() == null ? "" : log.getEnteredByUser().getUsername()); + if (log.getLogTopic() != null) { + appendMetadata(html, "Topic", log.getLogTopic().getName()); + } + if (log.isModifiedEntry()) { + appendMetadata(html, "Edited", format(log.getLastModifiedOnDateTime())); + appendMetadata(html, "Edited by", log.getLastModifiedByUser() == null ? "" : log.getLastModifiedByUser().getUsername()); + } + html.append("
") + .append(MarkdownParser.parseMarkdownAsPdfHTML(log.getText())) + .append("
"); + } + + private static void appendMetadata(StringBuilder html, String label, Object value) { + if (value != null && !value.toString().isEmpty()) { + html.append("").append(escape(label)).append(": ") + .append(escape(value.toString())).append(" "); + } + } + + private static StringBuilder startHtml(String title) { + return new StringBuilder("") + .append(escape(title)).append(""); + } + + private static String finishHtml(StringBuilder html) { + return html.append("").toString(); + } + + static byte[] renderPdf(String html) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + PdfConverterExtension.exportToPdf(output, html, "", new MutableDataSet()); + byte[] pdf = output.toByteArray(); + if (pdf.length < 5 || pdf[0] != '%' || pdf[1] != 'P' || pdf[2] != 'D' || pdf[3] != 'F') { + throw new IOException("PDF conversion failed"); + } + return pdf; + } + + private static StreamedContent stream(byte[] pdf, String filename) { + return DefaultStreamedContent.builder() + .name(filename) + .contentType("application/pdf") + .stream(() -> new java.io.ByteArrayInputStream(pdf)) + .build(); + } + + static String getEntryFilename(Log log) { + return "log-entry-" + (log.getId() == null ? "new" : log.getId()) + ".pdf"; + } + + static String getDocumentFilename(ItemDomainLogbook document) { + String name = sanitizeFilename(document.getName()); + if (name.isEmpty()) { + name = "log-document"; + } + return name + "-" + (document.getId() == null ? "new" : document.getId()) + ".pdf"; + } + + private static String sanitizeFilename(String value) { + return value == null ? "" : value.trim().replaceAll("[^A-Za-z0-9._-]+", "-") + .replaceAll("^-+|-+$", ""); + } + + private static synchronized String format(Date date) { + return date == null ? "" : DATE_FORMAT.format(date); + } + + private static String escape(String value) { + if (value == null) { + return ""; + } + return value.replace("&", "&").replace("<", "<").replace(">", ">") + .replace("\"", """).replace("'", "'"); + } +} diff --git a/src/java/LogrPortal/src/java/gov/anl/aps/logr/portal/utilities/MarkdownParser.java b/src/java/LogrPortal/src/java/gov/anl/aps/logr/portal/utilities/MarkdownParser.java index 78048395c..d2658d772 100644 --- a/src/java/LogrPortal/src/java/gov/anl/aps/logr/portal/utilities/MarkdownParser.java +++ b/src/java/LogrPortal/src/java/gov/anl/aps/logr/portal/utilities/MarkdownParser.java @@ -16,14 +16,17 @@ import com.vladsch.flexmark.util.ast.Node; import com.vladsch.flexmark.util.ast.TextCollectingVisitor; import com.vladsch.flexmark.util.data.DataHolder; +import com.vladsch.flexmark.util.data.DataKey; import com.vladsch.flexmark.util.data.MutableDataHolder; import com.vladsch.flexmark.util.data.MutableDataSet; import com.vladsch.flexmark.util.misc.Extension; import com.vladsch.flexmark.util.sequence.BasedSequence; import com.vladsch.flexmark.util.sequence.Escaping; import com.vladsch.flexmark.ext.tables.TablesExtension; +import gov.anl.aps.logr.common.constants.CdbProperty; import gov.anl.aps.logr.common.constants.CdbPropertyValue; +import java.io.File; import java.util.HashSet; import java.util.Set; import java.util.Arrays; @@ -73,12 +76,15 @@ public class MarkdownParser { private static String contextRoot = null; + private static String permanentContextRootUrl = null; private static final String LOG_ATTACHMENT_PREFIX = "/log/attachments/"; private static final String API_DOWNLOAD_PREFIX = "/api/Downloads/Attachments/"; private static String markdownExampleAsHtml = null; + private static final DataKey PDF_EXPORT = new DataKey<>("PDF_EXPORT", false); + private static MutableDataHolder options = new MutableDataSet() .set(Parser.EXTENSIONS, Arrays.asList( new Extension[]{ @@ -91,10 +97,17 @@ public class MarkdownParser { private static HtmlRenderer renderer = HtmlRenderer.builder(options).build(); public static String parseMarkdownAsHTML(String text) { - Node document = parser.parse(text); - String html = renderer.render(document); + return renderMarkdown(text, renderer); + } + + public static String parseMarkdownAsPdfHTML(String text) { + MutableDataHolder pdfOptions = new MutableDataSet(options).set(PDF_EXPORT, true); + return renderMarkdown(text, HtmlRenderer.builder(pdfOptions).build()); + } - return html; + private static String renderMarkdown(String text, HtmlRenderer htmlRenderer) { + Node document = parser.parse(text == null ? "" : text); + return htmlRenderer.render(document); } public static String getMarkdownExampleText() { @@ -129,8 +142,10 @@ static LogrFlexmarkExtension create() { // Node renderer to customize markdown html output. static class LogrNodeRenderer implements NodeRenderer { - public LogrNodeRenderer(DataHolder options) { + private final boolean pdfExport; + public LogrNodeRenderer(DataHolder options) { + pdfExport = PDF_EXPORT.get(options); } @Override @@ -147,19 +162,41 @@ private String getContextRoot() { } return contextRoot; } + + private String getPermanentContextRootUrl() { + if (permanentContextRootUrl == null) { + permanentContextRootUrl = ConfigurationUtility.getPortalProperty( + CdbProperty.PERMANENT_CONTEXT_ROOT_URL_PROPERTY_NAME); + } + return permanentContextRootUrl; + } private String getAttachmentEndpoint(String url, boolean scaled) { - if (url.startsWith(LOG_ATTACHMENT_PREFIX)) { - url = url.replace(LOG_ATTACHMENT_PREFIX, API_DOWNLOAD_PREFIX); + if (url.startsWith(LOG_ATTACHMENT_PREFIX)) { + String attachmentName = url.substring(LOG_ATTACHMENT_PREFIX.length()); + if (pdfExport && scaled) { + return getPdfAttachmentUrl(attachmentName, true); + } - url = getContextRoot() + url; - + String root = pdfExport ? getPermanentContextRootUrl() : getContextRoot(); + url = root + API_DOWNLOAD_PREFIX + attachmentName; if (scaled) { - url = url + '/' + CdbPropertyValue.SCALED_IMAGE_SCALING; - } + url = url + '/' + CdbPropertyValue.SCALED_IMAGE_SCALING; + } } - - return url; + + return url; + } + + private String getPdfAttachmentUrl(String attachmentName, boolean scaled) { + File attachmentFile = new File(StorageUtility.getFileSystemLogAttachmentPath(attachmentName)); + if (scaled) { + File scaledFile = new File(attachmentFile.getPath() + CdbPropertyValue.SCALED_IMAGE_EXTENSION); + if (scaledFile.isFile()) { + attachmentFile = scaledFile; + } + } + return attachmentFile.toURI().toString(); } private void render(Image node, NodeRendererContext context, HtmlWriter html) { @@ -170,20 +207,22 @@ private void render(Image node, NodeRendererContext context, HtmlWriter html) { String fullResUrl = aLink.getUrl(); String scaledUrl = imgLink.getUrl(); - if (nodeUrl.startsWith("/")) { - fullResUrl = getAttachmentEndpoint(fullResUrl, false); - scaledUrl = getAttachmentEndpoint(scaledUrl, true); + boolean storedAttachment = nodeUrl.startsWith("/"); + if (storedAttachment) { + String attachmentName = nodeUrl.subSequence(LOG_ATTACHMENT_PREFIX.length(), nodeUrl.length()).toString(); + if (pdfExport) { + scaledUrl = getPdfAttachmentUrl(attachmentName, false); + } else { + fullResUrl = getAttachmentEndpoint(fullResUrl, false); + scaledUrl = getAttachmentEndpoint(scaledUrl, true); + } } - // Create a link to full size image. - html.attr("href", fullResUrl); - html.attr("target", "_log_img"); - html.srcPos(node.getChars()).withAttr(aLink).tag("a"); - - // Update image node to use scaled image - if (nodeUrl.startsWith("/")) { - nodeUrl = nodeUrl.append(CdbPropertyValue.SCALED_IMAGE_EXTENSION); - node.setUrl(nodeUrl); + if (!pdfExport) { + // Web images link to the full-size attachment. + html.attr("href", fullResUrl); + html.attr("target", "_log_img"); + html.srcPos(node.getChars()).withAttr(aLink).tag("a"); } // Standard image render function. Original is "private" @@ -210,8 +249,9 @@ private void render(Image node, NodeRendererContext context, HtmlWriter html) { } // End of image block - // Close a tag after adding image - html.tag("/a"); + if (!pdfExport) { + html.tag("/a"); + } } //See https://github.com/vsch/flexmark-java/blob/cc3a2f59ba6e532833f4805f8134b4dc966ff837/flexmark/src/main/java/com/vladsch/flexmark/html/renderer/CoreNodeRenderer.java#L642 diff --git a/src/java/LogrPortal/test/gov/anl/aps/logr/portal/utilities/LogbookPdfExportUtilityTest.java b/src/java/LogrPortal/test/gov/anl/aps/logr/portal/utilities/LogbookPdfExportUtilityTest.java new file mode 100644 index 000000000..9f3ceacb5 --- /dev/null +++ b/src/java/LogrPortal/test/gov/anl/aps/logr/portal/utilities/LogbookPdfExportUtilityTest.java @@ -0,0 +1,98 @@ +package gov.anl.aps.logr.portal.utilities; + +import gov.anl.aps.logr.portal.model.db.entities.EntityInfo; +import gov.anl.aps.logr.portal.model.db.entities.ItemDomainLogbook; +import gov.anl.aps.logr.portal.model.db.entities.Log; +import gov.anl.aps.logr.portal.model.db.entities.LogTopic; +import gov.anl.aps.logr.portal.model.db.entities.UserInfo; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; + +/** Plain-Java regression checks; this project does not wire JUnit into the test classpath. */ +public class LogbookPdfExportUtilityTest { + + public static void main(String[] args) throws Exception { + Log first = log(1, "First \n\n| A | B |\n|---|---|\n| 1 | 2 |", "alice"); + Log reply = log(2, "Reply", "bob"); + first.setChildLogList(Collections.singletonList(reply)); + Log second = log(3, "Second", "carol"); + + TestDocument document = new TestDocument(); + document.setId(7); + document.setName("Shift "); + document.setTestLogList(Arrays.asList(first, second)); + + String html = LogbookPdfExportUtility.buildDocumentHtml(document); + assertContains(html, "Shift <A/B>"); + assertContains(html, "First "); + assertContains(html, ""); + assertContains(html, "Author: alice"); + assertContains(html, "Topic: Operations"); + assertTrue(html.indexOf("First ") < html.indexOf("Reply"), "reply follows parent"); + assertTrue(html.indexOf("Reply") < html.indexOf("Second"), "entries retain chronological order"); + assertContains(html, "class=\"log-entry reply\""); + assertTrue(!html.contains("unreferenced.dat"), "unreferenced attachments are absent"); + assertTrue(LogbookPdfExportUtility.getDocumentFilename(document).equals("Shift-A-B-7.pdf"), "safe document filename"); + assertTrue(LogbookPdfExportUtility.getEntryFilename(first).equals("log-entry-1.pdf"), "stable entry filename"); + + String entryHtml = LogbookPdfExportUtility.buildEntryHtml(document, first); + assertContains(entryHtml, "Document ID: 7"); + byte[] pdf = LogbookPdfExportUtility.renderPdf(entryHtml); + assertTrue(pdf.length > 4, "PDF has content"); + assertTrue(new String(pdf, 0, 4, "US-ASCII").equals("%PDF"), "valid PDF signature"); + } + + private static Log log(int id, String text, String username) { + UserInfo user = new UserInfo(); + user.setUsername(username); + LogTopic topic = new LogTopic(); + topic.setName("Operations"); + Date entered = new Date(id * 1000L); + Log log = new Log(id, text, entered); + log.setEnteredByUser(user); + log.setLastModifiedByUser(user); + log.setLastModifiedOnDateTime(entered); + log.setLogTopic(topic); + log.setChildLogList(Collections.emptyList()); + return log; + } + + private static void assertContains(String value, String expected) { + assertTrue(value.contains(expected), "expected: " + expected); + } + + private static void assertTrue(boolean condition, String message) { + if (!condition) { + throw new AssertionError(message); + } + } + + private static class TestDocument extends ItemDomainLogbook { + private java.util.List testLogList; + + void setTestLogList(java.util.List testLogList) { + this.testLogList = testLogList; + } + + @Override + public java.util.List getLogList() { + return testLogList; + } + + @Override + public EntityInfo getEntityInfo() { + return null; + } + + @Override + public String getEntityTypeString() { + return "Ops Shift"; + } + + @Override + public java.util.List getLogbookSections() { + return Collections.singletonList(this); + } + } +} diff --git a/src/java/LogrPortal/web/views/itemDomainLogbook/private/templates/logEntryObjectTemplate.xhtml b/src/java/LogrPortal/web/views/itemDomainLogbook/private/templates/logEntryObjectTemplate.xhtml index 315e18d9c..0f38c9a9d 100644 --- a/src/java/LogrPortal/web/views/itemDomainLogbook/private/templates/logEntryObjectTemplate.xhtml +++ b/src/java/LogrPortal/web/views/itemDomainLogbook/private/templates/logEntryObjectTemplate.xhtml @@ -137,6 +137,13 @@ See LICENSE file. + + + + + diff --git a/src/java/LogrPortal/web/views/itemDomainLogbook/view.xhtml b/src/java/LogrPortal/web/views/itemDomainLogbook/view.xhtml index 20e3b2650..3280762a4 100644 --- a/src/java/LogrPortal/web/views/itemDomainLogbook/view.xhtml +++ b/src/java/LogrPortal/web/views/itemDomainLogbook/view.xhtml @@ -127,6 +127,12 @@ See LICENSE file. + + + + + ComposeResult: with Vertical(id="compose-dialog", classes="dialog"): yield Static(title, id="compose-title") yield TextArea(self._initial_text, language="markdown", id="compose-area") - with Horizontal(id="compose-attach-row"): - yield Static("Attachment:", id="compose-attach-label") - yield Input(placeholder="optional file path", id="compose-attach") with DialogButtons(): yield Button(hinted_label("Save", SAVE_HINT), variant="primary", id="compose-save") yield Button(hinted_label("Edit in $EDITOR"), id="compose-editor") @@ -91,8 +87,7 @@ def action_cancel(self): def _is_dirty(self): area = self.query_one("#compose-area", TextArea) - attach_path = self.query_one("#compose-attach", Input).value.strip() - return editor_changed(self._initial_text, area.text) or bool(attach_path) + return editor_changed(self._initial_text, area.text) # -- cancel, with a dirty-buffer confirmation -- @@ -137,29 +132,13 @@ async def _save(self): self.dismiss(None) return - attach_path = self.query_one("#compose-attach", Input).value.strip() - if attach_path: - try: - attach_path = core.validate_attachment_path(attach_path) - except ValueError as e: - self.notify(str(e), severity="error") - return - - if not self.is_new and not editor_changed(self._initial_text, text) and not attach_path: + if not self.is_new and not editor_changed(self._initial_text, text): self.notify("No changes made.") self.dismiss(None) return try: saved_entry = await asyncio.to_thread(core.save_entry, self.api, self.entry, text) - if attach_path: - await self.app.push_screen(LoadingScreen("Uploading attachment…")) - try: - await asyncio.to_thread( - core.upload_attachment, self.api, self.doc.id, - saved_entry.log_id, attach_path) - finally: - self.app.pop_screen() except Exception as e: self.notify( f"Save failed: {format_error_message(e, self.factory)}", severity="error") diff --git a/tools/developer_tools/bely-cli/test/test_tui_screens.py b/tools/developer_tools/bely-cli/test/test_tui_screens.py index e13bd68a5..90e2b214c 100644 --- a/tools/developer_tools/bely-cli/test/test_tui_screens.py +++ b/tools/developer_tools/bely-cli/test/test_tui_screens.py @@ -636,25 +636,18 @@ async def test_reply_loads_blank_template_sets_parent_and_titles_composer(self): self.assertEqual(saved.parent_log_id, 7) self.assertEqual(saved.log_entry, "reply text") - async def test_reply_uses_existing_attachment_flow(self): + async def test_reply_does_not_show_attachment_field(self): api = FakeLogbookApi() - api.upload_attachment = lambda **kwargs: None doc = SimpleNamespace(id=1, name="Doc") parent = SimpleNamespace(log_id=7) app = App() async with app.run_test() as pilot: task = app.run_worker(open_composer(app, doc, api, reply_to=parent)) await pilot.pause() - screen = app.screen - screen.query_one("#compose-area", TextArea).text = "reply text" - screen.query_one("#compose-attach", Input).value = "/tmp/reply.txt" - with patch("bely_cli.core.validate_attachment_path", return_value="/tmp/reply.txt"), \ - patch("bely_cli.core.upload_attachment") as upload: - screen.query_one("#compose-save", Button).press() - await pilot.pause() - await pilot.pause() - saved = await task.wait() - upload.assert_called_once_with(api, 1, saved.log_id, "/tmp/reply.txt") + self.assertEqual(len(app.screen.query("#compose-attach")), 0) + app.screen.query_one("#compose-cancel", Button).press() + await pilot.pause() + await task.wait() async def test_reply_cancel_returns_none(self): api = FakeLogbookApi() @@ -774,40 +767,20 @@ async def test_editor_button_opens_editor_and_updates_buffer(self): screen.query_one("#compose-area", TextArea).text, "existing text edited") # buffer is dirty now, so skip Cancel here -- not what this test covers - async def test_tab_from_attachment_field_reaches_save_next(self): - api = FakeLogbookApi() - doc = SimpleNamespace(id=1, name="Doc") - entry = SimpleNamespace(log_id=7, log_entry="existing text") - app = App() - async with app.run_test() as pilot: - app.run_worker(app.push_screen_wait(ComposeScreen(doc, entry, api, is_new=False))) - await pilot.pause() - screen = app.screen - screen.query_one("#compose-attach", Input).focus() - await pilot.pause() - await pilot.press("tab") - await pilot.pause() - self.assertIs(screen.focused, screen.query_one("#compose-save", Button)) - - async def test_down_and_up_arrows_move_between_attachment_and_buttons(self): + async def test_composer_modes_do_not_show_attachment_field(self): api = FakeLogbookApi() doc = SimpleNamespace(id=1, name="Doc") - entry = SimpleNamespace(log_id=7, log_entry="existing text") - app = App() - async with app.run_test() as pilot: - app.run_worker(app.push_screen_wait(ComposeScreen(doc, entry, api, is_new=False))) - await pilot.pause() - screen = app.screen - screen.query_one("#compose-attach", Input).focus() - await pilot.pause() - - await pilot.press("down") - await pilot.pause() - self.assertIs(screen.focused, screen.query_one("#compose-save", Button)) - - await pilot.press("up") - await pilot.pause() - self.assertIs(screen.focused, screen.query_one("#compose-attach", Input)) + for is_new, entry in ( + (True, SimpleNamespace(log_id=None, log_entry="")), + (False, SimpleNamespace(log_id=7, log_entry="existing text")), + ): + with self.subTest(is_new=is_new): + app = App() + async with app.run_test() as pilot: + app.run_worker(app.push_screen_wait( + ComposeScreen(doc, entry, api, is_new=is_new))) + await pilot.pause() + self.assertEqual(len(app.screen.query("#compose-attach")), 0) async def test_left_and_right_arrows_cycle_between_buttons(self): api = FakeLogbookApi()