From b277993ab74d6113581e9086446386f6b6408a66 Mon Sep 17 00:00:00 2001 From: brendanx67 Date: Fri, 13 Mar 2026 14:41:42 -0700 Subject: [PATCH] Added documentation support to Skyline Tool Store * Extract docs from tool-inf/docs/ in uploaded ZIPs and serve via WebDAV * Carry forward docs from previous version when new ZIP has none * Show "Online Documentation" link in Documentation box when docs exist * Skip tool-inf/docs/ images during icon extraction * Exclude docs dir from supplementary file listings * Fall back to /home/support when no tool-specific support board exists Co-Authored-By: Claude --- .../SkylineToolsStoreController.java | 50 +++++++++++++++++-- .../skylinetoolsstore/model/SkylineTool.java | 10 ++++ .../view/SkylineToolDetails.jsp | 24 ++++++++- 3 files changed, 79 insertions(+), 5 deletions(-) diff --git a/SkylineToolsStore/src/org/labkey/skylinetoolsstore/SkylineToolsStoreController.java b/SkylineToolsStore/src/org/labkey/skylinetoolsstore/SkylineToolsStoreController.java index ae44422c..5a2f748d 100644 --- a/SkylineToolsStore/src/org/labkey/skylinetoolsstore/SkylineToolsStoreController.java +++ b/SkylineToolsStore/src/org/labkey/skylinetoolsstore/SkylineToolsStoreController.java @@ -178,7 +178,8 @@ protected SkylineTool getToolFromZip(MultipartFile zip) throws IOException while ((zipEntry = zipStream.getNextEntry()) != null && (tool == null || tool.getIcon() == null)) { - if (zipEntry.getName().toLowerCase().startsWith("tool-inf/")) + String entryLower = zipEntry.getName().toLowerCase(); + if (entryLower.startsWith("tool-inf/") && !entryLower.startsWith("tool-inf/docs/")) { String lowerBaseName = new File(zipEntry.getName()).getName().toLowerCase(); @@ -229,6 +230,39 @@ protected byte[] unzip(ZipInputStream stream) } } + protected static boolean extractDocsFromZip(File zipFile, File containerDir) throws IOException + { + File docsDir = new File(containerDir, "docs"); + boolean extracted = false; + try (ZipFile zf = new ZipFile(zipFile)) + { + Enumeration entries = zf.entries(); + while (entries.hasMoreElements()) + { + ZipEntry entry = entries.nextElement(); + String name = entry.getName(); + if (!name.toLowerCase().startsWith("tool-inf/docs/") || entry.isDirectory()) + continue; + // Strip "tool-inf/docs/" prefix to get relative path within docs dir + String relativePath = name.substring("tool-inf/docs/".length()); + if (relativePath.isEmpty()) + continue; + File destFile = new File(docsDir, relativePath); + // Zip-slip protection + if (!destFile.getCanonicalPath().startsWith(docsDir.getCanonicalPath() + File.separator)) + throw new IOException("Zip entry outside target directory: " + name); + Files.createDirectories(destFile.getParentFile().toPath()); + try (InputStream in = zf.getInputStream(entry); + FileOutputStream out = new FileOutputStream(destFile)) + { + in.transferTo(out); + } + extracted = true; + } + } + return extracted; + } + public static File makeFile(Container c, String filename) { return new File(getLocalPath(c), FileUtil.makeLegalName(filename)); @@ -408,7 +442,7 @@ public static HashSet getSupplementaryFileBasenames(SkylineTool tool) for (String suppFile : localToolDir.list()) { final String basename = new File(suppFile).getName(); - if (!basename.startsWith(".") && !basename.equals(tool.getZipName()) && !basename.equals("icon.png")) + if (!basename.startsWith(".") && !basename.equals(tool.getZipName()) && !basename.equals("icon.png") && !basename.equals("docs")) suppFiles.add(suppFile); } return suppFiles; @@ -583,9 +617,19 @@ else if (!getContainer().hasPermission(getUser(), InsertPermission.class)) { Container c = makeContainer(getContainer(), folderName, toolOwnersUsers, RoleManager.getRole(EditorRole.class)); copyContainerPermissions(existingVersionContainer, c); - zip.transferTo(makeFile(c, zip.getOriginalFilename())); + File storedZip = makeFile(c, zip.getOriginalFilename()); + zip.transferTo(storedZip); tool.writeIconToFile(makeFile(c, "icon.png"), "png"); + // Extract docs from tool-inf/docs/ in the ZIP; carry forward from previous version if absent + boolean hasDocs = extractDocsFromZip(storedZip, getLocalPath(c)); + if (!hasDocs && existingVersionContainer != null) + { + File oldDocs = new File(getLocalPath(existingVersionContainer), "docs"); + if (oldDocs.isDirectory()) + FileUtils.copyDirectory(oldDocs, new File(getLocalPath(c), "docs")); + } + if (copyFiles != null && existingVersionContainer != null) for (String copyFile : copyFiles) FileUtils.copyFile(makeFile(existingVersionContainer, copyFile), makeFile(c, copyFile), true); diff --git a/SkylineToolsStore/src/org/labkey/skylinetoolsstore/model/SkylineTool.java b/SkylineToolsStore/src/org/labkey/skylinetoolsstore/model/SkylineTool.java index 684f8397..9e846cf8 100644 --- a/SkylineToolsStore/src/org/labkey/skylinetoolsstore/model/SkylineTool.java +++ b/SkylineToolsStore/src/org/labkey/skylinetoolsstore/model/SkylineTool.java @@ -293,6 +293,16 @@ public String getFolderUrl() return AppProps.getInstance().getContextPath() + "/files" + lookupContainer().getPath() + "/"; } + public boolean hasDocumentation() + { + return new File(SkylineToolsStoreController.getLocalPath(lookupContainer()), "docs/index.html").exists(); + } + + public String getDocsUrl() + { + return AppProps.getInstance().getContextPath() + "/_webdav" + lookupContainer().getPath() + "/@files/docs/index.html"; + } + public String getIconUrl() { return (SkylineToolsStoreController.makeFile(lookupContainer(), "icon.png").exists()) ? diff --git a/SkylineToolsStore/src/org/labkey/skylinetoolsstore/view/SkylineToolDetails.jsp b/SkylineToolsStore/src/org/labkey/skylinetoolsstore/view/SkylineToolDetails.jsp index 86bd347a..dab990a9 100644 --- a/SkylineToolsStore/src/org/labkey/skylinetoolsstore/view/SkylineToolDetails.jsp +++ b/SkylineToolsStore/src/org/labkey/skylinetoolsstore/view/SkylineToolDetails.jsp @@ -1,4 +1,6 @@ <%@ page import="org.apache.commons.lang3.StringUtils" %> +<%@ page import="org.labkey.api.data.Container" %> +<%@ page import="org.labkey.api.data.ContainerManager" %> <%@ page import="org.labkey.api.portal.ProjectUrls" %> <%@ page import="org.labkey.api.security.permissions.DeletePermission" %> <%@ page import="org.labkey.api.security.permissions.InsertPermission" %> @@ -299,7 +301,17 @@ a { text-decoration: none; } - <% addHandler("tool-support-board-btn", "click", "window.open(" + q(urlProvider(ProjectUrls.class).getBeginURL(getContainer().getChild("Support").getChild(tool.getName()))) + ", '_blank', 'noopener,noreferrer')"); %> + <% + Container supportContainer = getContainer().getChild("Support"); + Container toolSupportBoard = supportContainer != null ? supportContainer.getChild(tool.getName()) : null; + Container supportTarget; + if (toolSupportBoard != null) + supportTarget = toolSupportBoard; + else + supportTarget = ContainerManager.getForPath("/home/support"); + if (supportTarget != null) + addHandler("tool-support-board-btn", "click", "window.open(" + q(urlProvider(ProjectUrls.class).getBeginURL(supportTarget)) + ", '_blank', 'noopener,noreferrer')"); + %> <% if (toolEditor) { %> -<% if (suppIter.hasNext()) { %> +<% if (tool.hasDocumentation() || suppIter.hasNext()) { %>
Documentation +<% if (tool.hasDocumentation()) { %> + +<% } %> <% while (suppIter.hasNext()) { Map.Entry suppPair = (Map.Entry)suppIter.next();