From b277993ab74d6113581e9086446386f6b6408a66 Mon Sep 17 00:00:00 2001 From: brendanx67 Date: Fri, 13 Mar 2026 14:41:42 -0700 Subject: [PATCH 1/5] 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(); From 773b64f0af7d400239d3609bd8d55347ae21c7be Mon Sep 17 00:00:00 2001 From: vagisha Date: Mon, 16 Mar 2026 13:58:12 -0700 Subject: [PATCH 2/5] Hide "Support Board" button when no target container can be resolved Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../labkey/skylinetoolsstore/view/SkylineToolDetails.jsp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/SkylineToolsStore/src/org/labkey/skylinetoolsstore/view/SkylineToolDetails.jsp b/SkylineToolsStore/src/org/labkey/skylinetoolsstore/view/SkylineToolDetails.jsp index dab990a9..dd26bc2b 100644 --- a/SkylineToolsStore/src/org/labkey/skylinetoolsstore/view/SkylineToolDetails.jsp +++ b/SkylineToolsStore/src/org/labkey/skylinetoolsstore/view/SkylineToolDetails.jsp @@ -300,7 +300,6 @@ a { text-decoration: none; } <% } %>
- <% Container supportContainer = getContainer().getChild("Support"); Container toolSupportBoard = supportContainer != null ? supportContainer.getChild(tool.getName()) : null; @@ -309,9 +308,13 @@ a { text-decoration: none; } supportTarget = toolSupportBoard; else supportTarget = ContainerManager.getForPath("/home/support"); - if (supportTarget != null) + %> + <% if (supportTarget != null) { %> + + <% addHandler("tool-support-board-btn", "click", "window.open(" + q(urlProvider(ProjectUrls.class).getBeginURL(supportTarget)) + ", '_blank', 'noopener,noreferrer')"); %> + <% } %> <% if (toolEditor) { %> -<% if (tool.hasDocumentation() || suppIter.hasNext()) { %> +<% + boolean hasDocumentation = tool.hasDocumentation(); +%> +<% if (hasDocumentation || suppIter.hasNext()) { %>
Documentation -<% if (tool.hasDocumentation()) { %> +<% if (hasDocumentation) { %>
Documentation From 767b8bff37b3beef9d50d146033e4e323d3db30c Mon Sep 17 00:00:00 2001 From: vagisha Date: Mon, 16 Mar 2026 14:00:44 -0700 Subject: [PATCH 4/5] Change condition to use toolIcon == null to avoid scanning the entire tool ZIP Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../labkey/skylinetoolsstore/SkylineToolsStoreController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SkylineToolsStore/src/org/labkey/skylinetoolsstore/SkylineToolsStoreController.java b/SkylineToolsStore/src/org/labkey/skylinetoolsstore/SkylineToolsStoreController.java index 5a2f748d..dfe83111 100644 --- a/SkylineToolsStore/src/org/labkey/skylinetoolsstore/SkylineToolsStoreController.java +++ b/SkylineToolsStore/src/org/labkey/skylinetoolsstore/SkylineToolsStoreController.java @@ -176,7 +176,7 @@ protected SkylineTool getToolFromZip(MultipartFile zip) throws IOException { ZipEntry zipEntry; while ((zipEntry = zipStream.getNextEntry()) != null && - (tool == null || tool.getIcon() == null)) + (tool == null || toolIcon == null)) { String entryLower = zipEntry.getName().toLowerCase(); if (entryLower.startsWith("tool-inf/") && !entryLower.startsWith("tool-inf/docs/")) From 128a1db3e261e7f947b40e334f289ffa584eb7c1 Mon Sep 17 00:00:00 2001 From: Vagisha Sharma Date: Mon, 16 Mar 2026 15:26:13 -0700 Subject: [PATCH 5/5] - Fixed getDocsUrl() to use WebdavService.getPath() instead of hardcoded /_webdav string - Fixed hasDocumentation() to use Files.exists() instead of new File().exists() - getLocalPath() returns Path directly instead of converting to File - Updated getSupplementaryFileBasenames() to use Files.list() instead of File.list() - Refactored extractDocsFromZip() to use NIO Path - Use FileUtil.copyDirectory(Path, Path) to copy docs from previous tool version --- .../SkylineToolsStoreController.java | 46 +++++++++---------- .../skylinetoolsstore/model/SkylineTool.java | 14 +++++- 2 files changed, 35 insertions(+), 25 deletions(-) diff --git a/SkylineToolsStore/src/org/labkey/skylinetoolsstore/SkylineToolsStoreController.java b/SkylineToolsStore/src/org/labkey/skylinetoolsstore/SkylineToolsStoreController.java index dfe83111..c89fb7d1 100644 --- a/SkylineToolsStore/src/org/labkey/skylinetoolsstore/SkylineToolsStoreController.java +++ b/SkylineToolsStore/src/org/labkey/skylinetoolsstore/SkylineToolsStoreController.java @@ -100,6 +100,7 @@ import java.net.URLDecoder; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.text.DateFormat; import java.text.SimpleDateFormat; import java.util.ArrayList; @@ -230,11 +231,11 @@ protected byte[] unzip(ZipInputStream stream) } } - protected static boolean extractDocsFromZip(File zipFile, File containerDir) throws IOException + protected static boolean extractDocsFromZip(Path zipFile, Path containerDir) throws IOException { - File docsDir = new File(containerDir, "docs"); + Path docsDir = containerDir.resolve("docs"); boolean extracted = false; - try (ZipFile zf = new ZipFile(zipFile)) + try (ZipFile zf = new ZipFile(zipFile.toFile())) { Enumeration entries = zf.entries(); while (entries.hasMoreElements()) @@ -247,15 +248,14 @@ protected static boolean extractDocsFromZip(File zipFile, File containerDir) thr String relativePath = name.substring("tool-inf/docs/".length()); if (relativePath.isEmpty()) continue; - File destFile = new File(docsDir, relativePath); + Path destPath = docsDir.resolve(relativePath).normalize(); // Zip-slip protection - if (!destFile.getCanonicalPath().startsWith(docsDir.getCanonicalPath() + File.separator)) + if (!destPath.startsWith(docsDir.normalize())) 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)) + Files.createDirectories(destPath.getParent()); + try (InputStream in = zf.getInputStream(entry)) { - in.transferTo(out); + Files.copy(in, destPath, StandardCopyOption.REPLACE_EXISTING); } extracted = true; } @@ -265,12 +265,12 @@ protected static boolean extractDocsFromZip(File zipFile, File containerDir) thr public static File makeFile(Container c, String filename) { - return new File(getLocalPath(c), FileUtil.makeLegalName(filename)); + return getLocalPath(c).resolve(FileUtil.makeLegalName(filename)).toFile(); } - public static File getLocalPath(Container c) + public static Path getLocalPath(Container c) { - return FileContentService.get().getFileRootPath(c, FileContentService.ContentType.files).toFile(); + return FileContentService.get().getFileRootPath(c, FileContentService.ContentType.files); } protected Container makeContainer(Container parent, String folderName, List users, Role role) throws IOException @@ -417,7 +417,7 @@ public static ArrayList getToolRelevantUsers(SkylineTool tool, Role[] ro return new ArrayList<>(users); } - public static HashMap getSupplementaryFiles(SkylineTool tool) + public static HashMap getSupplementaryFiles(SkylineTool tool) throws IOException { // Store supporting files in map final String[] knownExtensions = {"pdf", "zip"}; @@ -435,15 +435,15 @@ public static HashMap getSupplementaryFiles(SkylineTool tool) return suppFiles; } - public static HashSet getSupplementaryFileBasenames(SkylineTool tool) + public static HashSet getSupplementaryFileBasenames(SkylineTool tool) throws IOException { HashSet suppFiles = new HashSet<>(); - File localToolDir = getLocalPath(tool.lookupContainer()); - for (String suppFile : localToolDir.list()) + Path localToolDir = getLocalPath(tool.lookupContainer()); + try (var stream = Files.list(localToolDir)) { - final String basename = new File(suppFile).getName(); - if (!basename.startsWith(".") && !basename.equals(tool.getZipName()) && !basename.equals("icon.png") && !basename.equals("docs")) - suppFiles.add(suppFile); + stream.map(p -> p.getFileName().toString()) + .filter(name -> !name.startsWith(".") && !name.equals(tool.getZipName()) && !name.equals("icon.png") && !name.equals("docs")) + .forEach(suppFiles::add); } return suppFiles; } @@ -622,12 +622,12 @@ else if (!getContainer().hasPermission(getUser(), InsertPermission.class)) 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)); + boolean hasDocs = extractDocsFromZip(storedZip.toPath(), getLocalPath(c)); if (!hasDocs && existingVersionContainer != null) { - File oldDocs = new File(getLocalPath(existingVersionContainer), "docs"); - if (oldDocs.isDirectory()) - FileUtils.copyDirectory(oldDocs, new File(getLocalPath(c), "docs")); + Path oldDocs = getLocalPath(existingVersionContainer).resolve("docs"); + if (Files.isDirectory(oldDocs)) + FileUtil.copyDirectory(oldDocs, getLocalPath(c).resolve("docs")); } if (copyFiles != null && existingVersionContainer != null) diff --git a/SkylineToolsStore/src/org/labkey/skylinetoolsstore/model/SkylineTool.java b/SkylineToolsStore/src/org/labkey/skylinetoolsstore/model/SkylineTool.java index 9e846cf8..95aab276 100644 --- a/SkylineToolsStore/src/org/labkey/skylinetoolsstore/model/SkylineTool.java +++ b/SkylineToolsStore/src/org/labkey/skylinetoolsstore/model/SkylineTool.java @@ -3,10 +3,15 @@ import org.apache.commons.lang3.StringUtils; import org.labkey.api.data.Container; import org.labkey.api.data.Entity; +import org.labkey.api.files.FileContentService; import org.labkey.api.settings.AppProps; import org.labkey.api.util.Pair; +import org.labkey.api.webdav.WebdavService; import org.labkey.skylinetoolsstore.SkylineToolsStoreController; +import java.nio.file.Files; +import java.nio.file.Path; + import javax.imageio.ImageIO; import java.io.BufferedReader; import java.io.ByteArrayInputStream; @@ -295,12 +300,17 @@ public String getFolderUrl() public boolean hasDocumentation() { - return new File(SkylineToolsStoreController.getLocalPath(lookupContainer()), "docs/index.html").exists(); + Path localPath = SkylineToolsStoreController.getLocalPath(lookupContainer()); + return localPath != null && Files.exists(localPath.resolve("docs/index.html")); } public String getDocsUrl() { - return AppProps.getInstance().getContextPath() + "/_webdav" + lookupContainer().getPath() + "/@files/docs/index.html"; + org.labkey.api.util.Path path = WebdavService.getPath() + .append(lookupContainer().getParsedPath()) + .append(FileContentService.FILES_LINK) + .append(new org.labkey.api.util.Path("docs", "index.html")); + return AppProps.getInstance().getContextPath() + path.encode(); } public String getIconUrl()