diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index 961f9495a36..8a67c0fb3b0 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -10,6 +10,6 @@
\ No newline at end of file
+-->
diff --git a/announcements/src/org/labkey/announcements/announcementThread.jsp b/announcements/src/org/labkey/announcements/announcementThread.jsp
index b636af6df12..1b8711bc415 100644
--- a/announcements/src/org/labkey/announcements/announcementThread.jsp
+++ b/announcements/src/org/labkey/announcements/announcementThread.jsp
@@ -127,7 +127,7 @@ if (!announcementModel.getAttachments().isEmpty())
{
ActionURL downloadURL = AnnouncementsController.getDownloadURL(announcementModel, d.getName());
%>
-
<%=h(d.getName())%> <%
+ <%=d.renderDownloadLink(downloadURL)%> <%
} %>
<%
@@ -210,7 +210,7 @@ if (!announcementModel.getResponses().isEmpty())
{
ActionURL downloadURL = AnnouncementsController.getDownloadURL(r, rd.getName());
%>
-
<%=h(rd.getName())%> <%
+ <%=rd.renderDownloadLink(downloadURL)%> <%
}
%>
diff --git a/announcements/src/org/labkey/announcements/announcementWebPartSimple.jsp b/announcements/src/org/labkey/announcements/announcementWebPartSimple.jsp
index 713869f8ee8..dac75ebc99c 100644
--- a/announcements/src/org/labkey/announcements/announcementWebPartSimple.jsp
+++ b/announcements/src/org/labkey/announcements/announcementWebPartSimple.jsp
@@ -195,7 +195,7 @@ for (AnnouncementModel a : bean.announcementModels)
for (Attachment d : a.getAttachments())
{
ActionURL downloadURL = AnnouncementsController.getDownloadURL(a, d.getName());
- %>
<%=h(d.getName())%> <%
+ %><%=d.renderDownloadLink(downloadURL)%> <%
}
%><%
}
diff --git a/announcements/src/org/labkey/announcements/announcementWebPartWithExpandos.jsp b/announcements/src/org/labkey/announcements/announcementWebPartWithExpandos.jsp
index 4322dc4b7ed..8f061e75af7 100644
--- a/announcements/src/org/labkey/announcements/announcementWebPartWithExpandos.jsp
+++ b/announcements/src/org/labkey/announcements/announcementWebPartWithExpandos.jsp
@@ -217,7 +217,7 @@ for (AnnouncementModel a : bean.announcementModels)
for (Attachment d : a.getAttachments())
{
ActionURL downloadURL = AnnouncementsController.getDownloadURL(a, d.getName());
- %>
<%=h(d.getName())%> <%
+ %><%=d.renderDownloadLink(downloadURL)%> <%
}
%><%
}
diff --git a/announcements/src/org/labkey/announcements/update.jsp b/announcements/src/org/labkey/announcements/update.jsp
index 1797588ed54..c5e54ef947b 100644
--- a/announcements/src/org/labkey/announcements/update.jsp
+++ b/announcements/src/org/labkey/announcements/update.jsp
@@ -140,7 +140,6 @@ if (settings.hasExpires())
<%
int x = -1;
- String id;
for (Attachment att : ann.getAttachments())
{
x++;
diff --git a/api/src/org/labkey/api/assay/AbstractAssayProvider.java b/api/src/org/labkey/api/assay/AbstractAssayProvider.java
index 0f4b1988530..c620c02341b 100644
--- a/api/src/org/labkey/api/assay/AbstractAssayProvider.java
+++ b/api/src/org/labkey/api/assay/AbstractAssayProvider.java
@@ -124,6 +124,7 @@
import javax.script.ScriptEngine;
import java.io.File;
import java.io.IOException;
+import java.io.InputStream;
import java.net.URI;
import java.net.URL;
import java.sql.ResultSet;
@@ -1274,9 +1275,9 @@ public Pair> setValidationAndAnalysisS
if (!(engine instanceof ExternalScriptEngine && ((ExternalScriptEngine) engine).isBinary(scriptFile)))
{
String scriptText;
- try
+ try (InputStream is = scriptFile.openInputStream())
{
- scriptText = IOUtils.toString(scriptFile.openInputStream(), StringUtilsLabKey.DEFAULT_CHARSET);
+ scriptText = IOUtils.toString(is, StringUtilsLabKey.DEFAULT_CHARSET);
}
catch (IOException e)
{
diff --git a/api/src/org/labkey/api/attachments/Attachment.java b/api/src/org/labkey/api/attachments/Attachment.java
index 904972dae6e..b946c8012d4 100644
--- a/api/src/org/labkey/api/attachments/Attachment.java
+++ b/api/src/org/labkey/api/attachments/Attachment.java
@@ -21,9 +21,13 @@
import org.labkey.api.security.User;
import org.labkey.api.security.UserManager;
import org.labkey.api.services.ServiceRegistry;
+import org.labkey.api.util.DOM;
+import org.labkey.api.util.HtmlString;
import org.labkey.api.util.MemTracker;
import org.labkey.api.util.MimeMap;
+import org.labkey.api.util.PageFlowUtil;
import org.labkey.api.util.Path;
+import org.labkey.api.view.ActionURL;
import org.labkey.api.view.ViewServlet;
import org.labkey.api.webdav.WebdavResolver;
@@ -350,4 +354,29 @@ public void setDocumentSize(int documentSize)
{
_documentSize = documentSize;
}
+
+ /**
+ * Returns an HtmlString rendering a download link: an anchor containing a file type icon and the filename.
+ * The icon is marked aria-hidden since it is decorative; the link text serves as the accessible name.
+ */
+ public HtmlString renderDownloadLink(ActionURL downloadURL)
+ {
+ return renderDownloadLink(downloadURL, getName());
+ }
+
+ /**
+ * Returns an HtmlString rendering a download link: an anchor containing a file type icon and custom link text.
+ * Use this overload when the visible link label differs from the filename (e.g. "Study Protocol Document").
+ * The icon is marked aria-hidden since it is decorative; linkText serves as the accessible name.
+ */
+ public HtmlString renderDownloadLink(ActionURL downloadURL, String linkText)
+ {
+ return DOM.createHtmlFragment(
+ DOM.A(DOM.at(DOM.Attribute.href, downloadURL.toString()),
+ DOM.IMG(DOM.at(DOM.Attribute.alt, "").at(DOM.Attribute.src, PageFlowUtil.staticResourceUrl(getFileIcon()))),
+ HtmlString.NBSP,
+ linkText
+ )
+ );
+ }
}
diff --git a/api/src/org/labkey/api/mcp/McpService.java b/api/src/org/labkey/api/mcp/McpService.java
index 63bfcaabfa6..6ad04872537 100644
--- a/api/src/org/labkey/api/mcp/McpService.java
+++ b/api/src/org/labkey/api/mcp/McpService.java
@@ -29,11 +29,11 @@
/// ### MCP Development Guide
/// `McpService` lets you expose functionality over the MCP protocol (only simple http for now). This allows external
/// chat sessions to pull information from LabKey Server. Exposed functionality is also made available to chat sessions
-/// hosted by LabKey (see `AbstractAgentAction``).
+/// hosted by LabKey (see `AbstractAgentAction`).
///
/// ### Adding a new MCP class
/// 1. Create a new class that implements `McpImpl` (see below) in the appropriate module
-/// 2. Register that class in your module `init()` method: `McpService.get().register(new MyMcp())`
+/// 2. Register that class in your module's `startup()` method: `McpService.get().register(new MyMcp())`
/// 3. Add tools and resources
///
/// ### Adding a new MCP tool
@@ -44,13 +44,13 @@
/// permission annotation is required, otherwise your tool will not be registered.**
/// 4. Add `ToolContext` as the first parameter to the method
/// 5. Add additional required or optional parameters to the method signature, as needed. Note that "required" is the
-/// default. Again here, the parameter descriptions are very important. Provide examples.
+/// default. Again here, the parameter descriptions are very important. Provide examples of parameter values.
/// 6. Use the helper method `getContext(ToolContext)` to retrieve the current `Container` and `User`
/// 7. Use the helper method `getUser(ToolContext)` in the rare cases where you need just a `User`
/// 8. Perform additional permissions checking (beyond what the annotations offer), where appropriate
/// 9. Filter all results to the current container, of course
/// 10. For any error conditions, throw exceptions with detailed information. These will get translated into appropriate
-/// failure responses and the LLM client will attempt to correct the problem.
+/// failure responses and the LLM client will attempt to correct any problems (hopefully).
/// 11. For success cases, return a String with a message or JSON content, for example, `JSONObject.toString()`. Spring
/// has some limited ability to convert other objects into JSON strings, but we haven't experimented with that. See
/// `DefaultToolCallResultConverter` and the ability to provide a custom result converter via the `@Tool` annotation.
@@ -126,6 +126,7 @@ static void setInstance(McpService service)
boolean isReady();
+ // Register MCPs in Module.startup()
default void register(McpImpl mcp)
{
try
diff --git a/api/src/org/labkey/api/module/ModuleLoader.java b/api/src/org/labkey/api/module/ModuleLoader.java
index a43af596db6..6248d1b0e75 100644
--- a/api/src/org/labkey/api/module/ModuleLoader.java
+++ b/api/src/org/labkey/api/module/ModuleLoader.java
@@ -580,19 +580,6 @@ private void doInit(Execution execution) throws ServletException
throw new IllegalStateException("Core module was not first or could not find the Core module. Ensure that Tomcat user can create directories under the /modules directory.");
setProjectRoot(coreModule);
- for (Module module : modules)
- {
- module.registerFilters(_servletContext);
- }
- for (Module module : modules)
- {
- module.registerServlets(_servletContext);
- }
- for (Module module : modules)
- {
- module.registerFinalServlets(_servletContext);
- }
-
// Do this after we've checked to see if we can find the core module. See issue 22797.
verifyProductionModeMatchesBuild();
@@ -790,12 +777,34 @@ public void addStaticWarnings(@NotNull Warnings warnings, boolean showAllWarning
if (!modulesRequiringUpgrade.isEmpty() || !additionalSchemasRequiringUpgrade.isEmpty())
setUpgradeState(UpgradeState.UpgradeRequired);
- // Don't accept any requests if we're bootstrapping empty schemas or migrating from SQL Server
+ // Don't accept any requests if we're bootstrapping empty schemas or doing a database migration
if (!shouldInsertData())
execution = Execution.Synchronous;
startNonCoreUpgradeAndStartup(execution, lockFile);
+ // Register filters and servlets at the last minute, just before Tomcat starts. At this point, the list of
+ // modules is final. We've had one case where the CSP filter was getting initialized before the core module
+ // was initialized, GitHub Issue 1008. We have no idea how that happened, but registering late won't hurt.
+
+ _log.info("Registering filters");
+
+ for (Module module : _modules)
+ {
+ module.registerFilters(_servletContext);
+ }
+
+ _log.info("Registering servlets");
+
+ for (Module module : _modules)
+ {
+ module.registerServlets(_servletContext);
+ }
+ for (Module module : _modules)
+ {
+ module.registerFinalServlets(_servletContext);
+ }
+
_log.info("LabKey Server startup is complete; {}", execution.getLogMessage());
}
diff --git a/api/src/org/labkey/api/study/actions/ParticipantVisitResolverChooser.java b/api/src/org/labkey/api/study/actions/ParticipantVisitResolverChooser.java
index c9a6d34f1ef..d0b733ea6b6 100644
--- a/api/src/org/labkey/api/study/actions/ParticipantVisitResolverChooser.java
+++ b/api/src/org/labkey/api/study/actions/ParticipantVisitResolverChooser.java
@@ -168,7 +168,7 @@ public void renderInputHtml(RenderContext ctx, HtmlWriter out, Object value)
return ret2;
}
),
- disabledInput ? InputBuilder.hidden().name(_typeInputName).value(finalSelected.getName()).appendTo(out) : null
+ disabledInput ? InputBuilder.hidden().name(_typeInputName).value(finalSelected.getName()).getHtmlString() : null
)
).appendTo(out);
}
diff --git a/api/src/org/labkey/api/util/DOM.java b/api/src/org/labkey/api/util/DOM.java
index 1feb02da7b1..140e91deba9 100644
--- a/api/src/org/labkey/api/util/DOM.java
+++ b/api/src/org/labkey/api/util/DOM.java
@@ -362,6 +362,54 @@ public enum Attribute
action,
align,
alt,
+ aria_activedescendant,
+ aria_atomic,
+ aria_autocomplete,
+ aria_busy,
+ aria_checked,
+ aria_colcount,
+ aria_colindex,
+ aria_colspan,
+ aria_controls,
+ aria_current,
+ aria_describedby,
+ aria_details,
+ aria_disabled,
+ aria_dropeffect,
+ aria_errormessage,
+ aria_expanded,
+ aria_flowto,
+ aria_grabbed,
+ aria_haspopup,
+ aria_hidden,
+ aria_invalid,
+ aria_keyshortcuts,
+ aria_label,
+ aria_labelledby,
+ aria_level,
+ aria_live,
+ aria_modal,
+ aria_multiline,
+ aria_multiselectable,
+ aria_orientation,
+ aria_owns,
+ aria_placeholder,
+ aria_posinset,
+ aria_pressed,
+ aria_readonly,
+ aria_relevant,
+ aria_required,
+ aria_roledescription,
+ aria_rowcount,
+ aria_rowindex,
+ aria_rowspan,
+ aria_selected,
+ aria_setsize,
+ aria_sort,
+ aria_valuemax,
+ aria_valuemin,
+ aria_valuenow,
+ aria_valuetext,
async,
autocomplete,
autofocus,
@@ -570,7 +618,6 @@ public _Attributes data(boolean condition, String datakey, Object value)
}
return this;
}
-
public _Attributes cl(String...names)
{
if (null != names)
@@ -891,7 +938,7 @@ private static Appendable appendAttribute(Appendable html, Attribute key, Object
if (null==value)
return html;
html.append(" ");
- html.append(key.name());
+ html.append(key.name().replace('_', '-'));
html.append("=\"");
// NOTE it is somewhat unusual to pass in a Renderable, but it is possible that we
// want to render HTML into an attribute. We still need to re-encode the value before trying to wrap with "".
diff --git a/api/src/org/labkey/api/workflow/Action.java b/api/src/org/labkey/api/workflow/Action.java
new file mode 100644
index 00000000000..204d8356115
--- /dev/null
+++ b/api/src/org/labkey/api/workflow/Action.java
@@ -0,0 +1,130 @@
+package org.labkey.api.workflow;
+
+import org.apache.commons.lang3.StringUtils;
+import org.json.JSONObject;
+import org.labkey.api.data.CreatedModified;
+import org.labkey.api.util.GUID;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+public abstract class Action extends CreatedModified
+{
+ public static final String ASSAY_TYPES_KEY = "assayTypes";
+ protected Long _rowId;
+ protected GUID _containerId;
+ protected String _name;
+ protected boolean _isUpdatable = false;
+ protected Long _taskId;
+ protected WorkflowService.ActionType _type;
+ protected JSONObject _inputParameters;
+
+
+ public Long getRowId()
+ {
+ return _rowId;
+ }
+
+ public void setRowId(Long rowId)
+ {
+ _rowId = rowId;
+ }
+
+ public GUID getContainerId()
+ {
+ return _containerId;
+ }
+
+ public void setContainerId(GUID containerId)
+ {
+ _containerId = containerId;
+ }
+
+ public String getName()
+ {
+ return _name;
+ }
+
+ public void setName(String name)
+ {
+ _name = name;
+ }
+
+ public boolean getIsUpdatable()
+ {
+ return _isUpdatable;
+ }
+
+ public void setIsUpdatable(boolean updatable)
+ {
+ _isUpdatable = updatable;
+ }
+
+ public Long getTaskId()
+ {
+ return _taskId;
+ }
+
+ public void setTaskId(Long taskId)
+ {
+ _taskId = taskId;
+ }
+
+ public WorkflowService.ActionType getType()
+ {
+ return _type;
+ }
+
+ public void setType(WorkflowService.ActionType type)
+ {
+ _type = type;
+ }
+
+ public JSONObject getInputParameters()
+ {
+ return _inputParameters;
+ }
+
+ public void setInputParameters(JSONObject inputParameters)
+ {
+ _inputParameters = inputParameters;
+ }
+
+ @Override
+ public boolean equals(Object o)
+ {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ Action action = (Action) o;
+
+ // GitHub Issue 799: Workflow Automation: Attempting to add a sample filter on an existing template errors
+ // Migration script generated action.name, but they are currently not used. Allow name to be changed to null or empty string.
+ if (!java.util.Objects.equals(_name, action._name) && !StringUtils.isEmpty(action.getName()))
+ return false;
+
+ return _isUpdatable == action._isUpdatable &&
+ java.util.Objects.equals(_rowId, action._rowId) &&
+ java.util.Objects.equals(_taskId, action._taskId) &&
+ java.util.Objects.equals(_type, action._type) &&
+ java.util.Objects.equals(
+ _inputParameters == null ? null : _inputParameters.toString(),
+ action._inputParameters == null ? null : action._inputParameters.toString()
+ );
+ }
+
+ public Map toAuditDetailMap()
+ {
+ Map map = new LinkedHashMap<>();
+ map.put("rowId", _rowId);
+ map.put("name", _name);
+ map.put("isUpdatable", _isUpdatable);
+ map.put("taskId", _taskId);
+ if (_type != null)
+ map.put("type", _type.name());
+ if (_inputParameters != null)
+ map.put("inputParameters", _inputParameters.toString());
+ return map;
+ }
+
+ public abstract Task getTask();
+}
diff --git a/api/src/org/labkey/api/workflow/Job.java b/api/src/org/labkey/api/workflow/Job.java
new file mode 100644
index 00000000000..7765baf45d0
--- /dev/null
+++ b/api/src/org/labkey/api/workflow/Job.java
@@ -0,0 +1,365 @@
+package org.labkey.api.workflow;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import org.jetbrains.annotations.NotNull;
+import org.json.JSONObject;
+import org.labkey.api.attachments.AttachmentFile;
+import org.labkey.api.data.Container;
+import org.labkey.api.data.ContainerManager;
+import org.labkey.api.data.CreatedModified;
+import org.labkey.api.exp.Identifiable;
+import org.labkey.api.exp.Lsid;
+import org.labkey.api.exp.ObjectProperty;
+import org.labkey.api.exp.PropertyDescriptor;
+import org.labkey.api.exp.api.ExpMaterial;
+import org.labkey.api.query.ValidationException;
+import org.labkey.api.security.Group;
+import org.labkey.api.security.SecurityManager;
+import org.labkey.api.security.User;
+import org.labkey.api.security.UserManager;
+import org.labkey.api.util.GUID;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+public abstract class Job extends CreatedModified implements Identifiable
+{
+ protected Long _rowId;
+ protected GUID _containerId;
+ protected Container _container;
+ protected GUID _entityId;
+
+ protected String _name;
+ protected String _id;
+ protected String _description;
+ protected Date _startDate;
+ protected Date _dueDate;
+ protected Integer _priority;
+ protected Long _templateId;
+ protected Integer _assignee;
+ protected List _notifyList;
+ protected boolean _isTemplate;
+ protected Job _template;
+ protected Integer _jobCount; // only applies to templates
+ protected Integer _domainId;
+ protected String _lsid; // needed for attaching domain properties
+ protected List _tasks; // ordered by ordinal value
+ protected List _attachments;
+ protected List