From fde49252423e187b87457804c28f71d380403886 Mon Sep 17 00:00:00 2001 From: labkey-jeckels Date: Sat, 18 Oct 2025 08:52:34 -0700 Subject: [PATCH 01/16] More file path improvements, mostly around pipeline jobs --- .../labkey/api/exp/AbstractFileXarSource.java | 23 +- api/src/org/labkey/api/exp/FileXarSource.java | 11 +- api/src/org/labkey/api/exp/XarContext.java | 694 +- api/src/org/labkey/api/exp/XarSource.java | 589 +- .../labkey/api/exp/api/ExperimentService.java | 2 +- .../labkey/api/files/FileContentService.java | 724 +- .../org/labkey/api/pipeline/AnalyzeForm.java | 491 +- .../org/labkey/api/pipeline/PipelineJob.java | 4049 ++-- .../labkey/api/pipeline/PipelineProtocol.java | 443 +- .../api/pipeline/PipelineProtocolFactory.java | 475 +- .../labkey/api/pipeline/PipelineService.java | 549 +- .../api/pipeline/PipelineStatusFile.java | 283 +- .../labkey/api/pipeline/RecordedAction.java | 1092 +- .../api/pipeline/RecordedActionSet.java | 204 +- .../labkey/api/pipeline/WorkDirectory.java | 283 +- .../api/pipeline/browse/PipelinePathForm.java | 423 +- .../file/AbstractFileAnalysisJob.java | 992 +- .../file/AbstractFileAnalysisProtocol.java | 585 +- .../AbstractFileAnalysisProtocolFactory.java | 754 +- .../pipeline/file/FileAnalysisJobSupport.java | 367 +- .../file/FileAnalysisTaskPipeline.java | 234 +- .../labkey/api/study/SpecimenTransform.java | 173 +- api/src/org/labkey/api/util/FileType.java | 1587 +- api/src/org/labkey/api/util/FileUtil.java | 4840 ++--- ...PossiblyGZIPpedFileInputStreamFactory.java | 120 +- api/src/org/labkey/api/writer/ZipUtil.java | 406 +- api/src/org/labkey/vfs/FileLike.java | 7 + .../CompressedInputStreamXarSource.java | 8 +- .../experiment/api/ExperimentServiceImpl.java | 4 +- .../controllers/exp/ExperimentController.java | 16742 ++++++++-------- .../controllers/exp/ImportXarForm.java | 8 +- .../pipeline/ExperimentPipelineJob.java | 361 +- .../experiment/pipeline/MoveRunsTask.java | 570 +- .../pipeline/XarGeneratorSource.java | 95 +- .../experiment/pipeline/XarGeneratorTask.java | 507 +- .../samples/AbstractExpFolderImporter.java | 33 +- .../samples/SampleStatusFolderImporter.java | 8 +- .../experiment/xar/CompressedXarSource.java | 21 +- .../xar/FolderXarImporterFactory.java | 498 +- .../filecontent/FileContentServiceImpl.java | 3928 ++-- .../pipeline/analysis/AnalysisController.java | 1578 +- .../pipeline/analysis/FileAnalysisJob.java | 443 +- .../analysis/FileAnalysisProtocol.java | 149 +- .../pipeline/api/PipelineServiceImpl.java | 25 +- query/package-lock.json | 10541 ++++++++++ query/src/client/Hello/BrowserApp.tsx | 601 + query/src/client/Hello/app.tsx | 10 + query/src/client/Hello/hello.tsx | 6 + query/src/client/entryPoints.js | 8 + query/tsconfig.json | 5 + .../specimen/actions/SpecimenController.java | 15 +- .../specimen/pipeline/SpecimenArchive.java | 12 +- .../specimen/pipeline/SpecimenBatch.java | 5 +- .../specimen/pipeline/SpecimenReloadJob.java | 3 +- .../pipeline/SpecimenReloadJobSupport.java | 3 +- .../specimen/pipeline/SpecimenReloadTask.java | 3 +- .../labkey/api/study/pipeline/StudyBatch.java | 10 +- .../study/controllers/StudyController.java | 15657 +++++++-------- .../labkey/study/pipeline/StudyPipeline.java | 251 +- 59 files changed, 41823 insertions(+), 30685 deletions(-) create mode 100644 query/package-lock.json create mode 100644 query/src/client/Hello/BrowserApp.tsx create mode 100644 query/src/client/Hello/app.tsx create mode 100644 query/src/client/Hello/hello.tsx create mode 100644 query/src/client/entryPoints.js create mode 100644 query/tsconfig.json diff --git a/api/src/org/labkey/api/exp/AbstractFileXarSource.java b/api/src/org/labkey/api/exp/AbstractFileXarSource.java index 1b57d449e29..b52b70d40c3 100644 --- a/api/src/org/labkey/api/exp/AbstractFileXarSource.java +++ b/api/src/org/labkey/api/exp/AbstractFileXarSource.java @@ -27,6 +27,7 @@ import org.labkey.api.util.FileUtil; import org.labkey.api.util.NetworkDrive; import org.labkey.api.util.XmlBeansUtil; +import org.labkey.vfs.FileLike; import java.io.IOException; import java.io.InputStream; @@ -42,9 +43,9 @@ */ public abstract class AbstractFileXarSource extends XarSource { - protected Path _xmlFile; + protected FileLike _xmlFile; - protected Path getXmlFile() + protected FileLike getXmlFile() { return _xmlFile; } @@ -77,7 +78,7 @@ public ExperimentArchiveDocument getDocument() throws XmlException, IOException try { NetworkDrive.exists(getXmlFile()); - fIn = Files.newInputStream(getXmlFile()); + fIn = getXmlFile().openInputStream(); return ExperimentArchiveDocument.Factory.parse(fIn, XmlBeansUtil.getDefaultParseOptions()); } finally @@ -88,9 +89,7 @@ public ExperimentArchiveDocument getDocument() throws XmlException, IOException { fIn.close(); } - catch (IOException e) - { - } + catch (IOException ignored) {} } } } @@ -99,7 +98,7 @@ public ExperimentArchiveDocument getDocument() throws XmlException, IOException @Nullable public Path getRootPath() { - return null != getXmlFile()? getXmlFile().getParent(): null; + return null != getXmlFile()? getXmlFile().toNioPathForRead().getParent(): null; } @Override @@ -137,15 +136,15 @@ public String canonicalizeDataFileURL(String dataFileURL) } } - public static Path getLogFileFor(Path f) throws IOException + public static FileLike getLogFileFor(FileLike f) throws IOException { - Path xarDirectory = f.getParent(); - if (!Files.exists(xarDirectory)) + FileLike xarDirectory = f.getParent(); + if (!xarDirectory.exists()) { throw new IOException("Xar file parent directory does not exist"); } - String xarShortName = f.getFileName().toString(); + String xarShortName = f.getName(); int index = xarShortName.toLowerCase().lastIndexOf(".xml"); if (index == -1) { @@ -157,6 +156,6 @@ public static Path getLogFileFor(Path f) throws IOException xarShortName = xarShortName.substring(0, index); } - return xarDirectory.resolve(xarShortName + LOG_FILE_NAME_SUFFIX); + return xarDirectory.resolveChild(xarShortName + LOG_FILE_NAME_SUFFIX); } } diff --git a/api/src/org/labkey/api/exp/FileXarSource.java b/api/src/org/labkey/api/exp/FileXarSource.java index d518701264e..ccdcc593f1c 100644 --- a/api/src/org/labkey/api/exp/FileXarSource.java +++ b/api/src/org/labkey/api/exp/FileXarSource.java @@ -19,6 +19,7 @@ import org.jetbrains.annotations.Nullable; import org.labkey.api.data.Container; import org.labkey.api.pipeline.PipelineJob; +import org.labkey.vfs.FileLike; import java.io.IOException; import java.nio.file.Path; @@ -30,25 +31,25 @@ */ public class FileXarSource extends AbstractFileXarSource { - public FileXarSource(Path file, PipelineJob job) + public FileXarSource(FileLike file, PipelineJob job) { super(job); - _xmlFile = file.normalize(); + _xmlFile = file; } - public FileXarSource(Path file, PipelineJob job, Container targetContainer, @Nullable Map substitutions) + public FileXarSource(FileLike file, PipelineJob job, Container targetContainer, @Nullable Map substitutions) { super(job.getDescription(), targetContainer, job.getUser(), job, substitutions); _xmlFile = file; } - public FileXarSource(Path file, PipelineJob job, Container targetContainer) + public FileXarSource(FileLike file, PipelineJob job, Container targetContainer) { this(file, job, targetContainer, null); } @Override - public Path getLogFilePath() throws IOException + public FileLike getLogFilePath() throws IOException { return getLogFileFor(_xmlFile); } diff --git a/api/src/org/labkey/api/exp/XarContext.java b/api/src/org/labkey/api/exp/XarContext.java index cc5fbfbede4..3be02f9accc 100644 --- a/api/src/org/labkey/api/exp/XarContext.java +++ b/api/src/org/labkey/api/exp/XarContext.java @@ -1,346 +1,348 @@ -/* - * Copyright (c) 2008-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.labkey.api.exp; - -import org.jetbrains.annotations.Nullable; -import org.labkey.api.collections.CaseInsensitiveHashMap; -import org.labkey.api.data.Container; -import org.labkey.api.data.ContainerManager; -import org.labkey.api.exp.api.ExpData; -import org.labkey.api.exp.api.ExpRun; -import org.labkey.api.pipeline.PipelineJob; -import org.labkey.api.pipeline.PipelineJobService; -import org.labkey.api.pipeline.RemoteExecutionEngine; -import org.labkey.api.security.User; -import org.labkey.api.settings.AppProps; -import org.labkey.api.util.GUID; -import org.labkey.api.util.NetworkDrive; - -import java.io.File; -import java.net.URI; -import java.net.URISyntaxException; -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -/** - * Helper to expand LSID templates and translate file paths from URIs during a XAR import. - * User: jeckels - * Date: Dec 7, 2006 - */ -public class XarContext -{ - private final Map _originalURLs; - private final Map _originalCaseInsensitiveURLs; - private final String _jobDescription; - private final Container _container; - private final User _user; - private PipelineJob _job; - - private final Map _substitutions; - - public static final String XAR_JOB_ID_NAME = "XarJobId"; // XarJobId is the same for all tasks in the job - public static final String XAR_JOB_ID_NAME_SUB = "${XarJobId}"; - private static final String XAR_FILE_ID_NAME = "XarFileId"; // XarFileId differs per import task for the same job - private static final String EXPERIMENT_RUN_ID_NAME = "ExperimentRun.RowId"; - private static final String CONTAINER_ID_NAME = "Container.RowId"; - private static final String SHARED_CONTAINER_ID_NAME = "SharedContainer.RowId"; - private static final String FOLDER_LSID_BASE_NAME = "FolderLSIDBase"; - private static final String RUN_LSID_BASE_NAME = "RunLSIDBase"; - private static final String LSID_AUTHORITY_NAME = "LSIDAuthority"; - - public static final String XAR_FILE_ID_SUBSTITUTION = createSubstitution(XAR_FILE_ID_NAME); - public static final String EXPERIMENT_RUN_ID_SUBSTITUTION = createSubstitution(EXPERIMENT_RUN_ID_NAME); - public static final String CONTAINER_ID_SUBSTITUTION = createSubstitution(CONTAINER_ID_NAME); - public static final String SHARED_CONTAINER_ID_SUBSTITUTION = createSubstitution(SHARED_CONTAINER_ID_NAME); - public static final String FOLDER_LSID_BASE_SUBSTITUTION = createSubstitution(FOLDER_LSID_BASE_NAME); - public static final String RUN_LSID_BASE_SUBSTITUTION = createSubstitution(RUN_LSID_BASE_NAME); - public static final String LSID_AUTHORITY_SUBSTITUTION = createSubstitution(LSID_AUTHORITY_NAME); - private static final String EXPERIMENT_RUN_LSID_NAME = "ExperimentRun.LSID"; - private static final String EXPERIMENT_RUN_NAME_NAME = "ExperimentRun.Name"; - - public XarContext(XarContext parent) - { - _jobDescription = parent._jobDescription; - _originalURLs = new HashMap<>(parent._originalURLs); - _originalCaseInsensitiveURLs = new CaseInsensitiveHashMap<>(parent._originalURLs); - _substitutions = new HashMap<>(parent._substitutions); - _container = parent.getContainer(); - _user = parent.getUser(); - } - - public XarContext(PipelineJob job) - { - this(job.getDescription(), job.getContainer(), job.getUser(), job); - } - - public XarContext(String jobDescription, Container c, User user) - { - this(jobDescription, c, user, null); - } - - public XarContext(String jobDescription, Container c, User user, @Nullable PipelineJob job) - { - this(jobDescription, c, user, job, AppProps.getInstance().getDefaultLsidAuthority(), null); - } - - public XarContext(String jobDescription, Container c, User user, @Nullable PipelineJob job, String defaultLsidAuthority) - { - this(jobDescription, c, user, job, defaultLsidAuthority, null); - } - - public XarContext(String jobDescription, Container c, User user, @Nullable PipelineJob job, @Nullable Map substitutions) - { - this(jobDescription, c, user, job, AppProps.getInstance().getDefaultLsidAuthority(), substitutions); - } - - public XarContext(String jobDescription, Container c, User user, @Nullable PipelineJob job, String defaultLsidAuthority, @Nullable Map substitutions) - { - _jobDescription = jobDescription; - _originalURLs = new HashMap<>(); - _originalCaseInsensitiveURLs = new CaseInsensitiveHashMap<>(); - _substitutions = new HashMap<>(); - if (substitutions != null) - _substitutions.putAll(substitutions); - - _job = job; - - String path = c.getPath(); - if (path.startsWith("/")) - { - path = path.substring(1); - } - path = path.replace('/', '.'); - - _substitutions.put("Container.path", path); - _substitutions.put(CONTAINER_ID_NAME, Integer.toString(c.getRowId())); - _substitutions.put(SHARED_CONTAINER_ID_NAME, Integer.toString(ContainerManager.getSharedContainer().getRowId())); - - _substitutions.put(XAR_FILE_ID_NAME, "Xar-" + GUID.makeGUID()); - if (user != null) - { - _substitutions.put("UserEmail", user.getEmail()); - _substitutions.put("UserName", user.getFullName()); - } - _substitutions.put(FOLDER_LSID_BASE_NAME, "urn:lsid:" + LSID_AUTHORITY_SUBSTITUTION + ":${LSIDNamespace.Prefix}.Folder-" + CONTAINER_ID_SUBSTITUTION); - _substitutions.put(RUN_LSID_BASE_NAME, "urn:lsid:" + LSID_AUTHORITY_SUBSTITUTION + ":${LSIDNamespace.Prefix}.Run-" + EXPERIMENT_RUN_ID_SUBSTITUTION); - - _substitutions.put(LSID_AUTHORITY_NAME, defaultLsidAuthority); - - _container = c; - _user = user; - } - - public String getJobDescription() - { - return _jobDescription; - } - - public void addData(ExpData data, String originalURL) - { - originalURL = originalURL.replace('\\', '/'); - _originalURLs.put(originalURL, data); - _originalCaseInsensitiveURLs.put(originalURL, data); - - if (originalURL.startsWith("file:/") && originalURL.length() > "file:/X:/".length()) - { - int index = "file:/".length(); - if (Character.isLetter(originalURL.charAt(index++)) && - ':' == originalURL.charAt(index++) && - '/' == originalURL.charAt(index++)) - { - String originalWithoutDriveLetter = "file:/" + originalURL.substring(index); - _originalURLs.put(originalWithoutDriveLetter, data); - _originalCaseInsensitiveURLs.put(originalWithoutDriveLetter, data); - } - } - } - - private static final Pattern CYGDRIVE_PATTERN = Pattern.compile("/cygdrive/([a-z])/(.*)"); - - public File findFile(String path, File relativeFile) - { - File f = findFile(path); - if (f != null) - { - return f; - } - - // If file can't be reached and it doesn't already have a drive letter, then attempt to append - // the drive letter, if any, of the relativeFile - if (null == NetworkDrive.getDrive(path)) - { - String drivePrefix = NetworkDrive.getDrive(relativeFile.toString()); - String pathWithDrive = path; - if (null != drivePrefix) - { - if (!path.isEmpty() && path.charAt(0) != '\\' && path.charAt(0) != '/') - { - pathWithDrive = drivePrefix + "/" + path; - } - else - { - pathWithDrive = drivePrefix + path; - } - } - - f = new File(pathWithDrive); - if (NetworkDrive.exists(f)) - { - return f; - } - } - - f = new File(relativeFile, path); - if (NetworkDrive.exists(f)) - { - return f; - } - - // Check if it's in the current directory, stripping off any extra path from the file name - int index = path.lastIndexOf("/"); - f = resolveFile(path, relativeFile, index); - if (f != null) return f; - - // Do the same for Windows paths - index = path.lastIndexOf("\\"); - f = resolveFile(path, relativeFile, index); - if (f != null) return f; - - // Finally, try using the pipeline's path mapper if we have one to - // translate from a cluster path to a webserver path - // Path mappers deal with URIs, not file paths - String uri = "file:" + (path.startsWith("/") ? path : "/" + path); - // This PathMapper considers "local" from a cluster node's point of view. - for (RemoteExecutionEngine engine : PipelineJobService.get().getRemoteExecutionEngines()) - { - String mappedURI = engine.getConfig().getPathMapper().localToRemote(uri); - // If we have translated Windows paths, they won't be legal URIs, so convert slashes - mappedURI = mappedURI.replace('\\', '/'); - try - { - f = new File(new URI(mappedURI)); - if (NetworkDrive.exists(f)) - { - return f; - } - } - catch (URISyntaxException ignored) {} - } - - return null; - } - - @Nullable - private File resolveFile(String path, File relativeFile, int index) - { - File f; - if (index != -1) - { - String filename = path.substring(index + 1); - if (!filename.isEmpty()) - { - f = new File(relativeFile, filename); - if (NetworkDrive.exists(f)) - { - return f; - } - } - } - return null; - } - - public File findFile(String path) - { - String lookupPath = path; - if (!lookupPath.contains(":/")) - { - lookupPath = "file:/" + lookupPath; - } - - // First, check if the XAR contains a file that was originally at that path - lookupPath = lookupPath.replace('\\', '/'); - ExpData data = _originalURLs.get(lookupPath); - if (data != null) - { - return data.getFile(); - } - - // Second, try looking for a case-insensitive match - data = _originalCaseInsensitiveURLs.get(lookupPath); - if (data != null) - { - return data.getFile(); - } - - // Next, check if the file exists on the file system at that exact location - File f = new File(path); - if (NetworkDrive.exists(f)) - { - return f; - } - - // Try resolving the Cygwin paths like /cygdrive/c/somepath/somefile.extension - // to c:/somepath/somefile.extension - Matcher matcher = CYGDRIVE_PATTERN.matcher(path); - if (matcher.matches()) - { - return findFile(matcher.group(1) + ":/" + matcher.group(2)); - } - - return null; - } - - public Map getSubstitutions() - { - return Collections.unmodifiableMap(_substitutions); - } - - public void addSubstitution(String name, String value) - { - _substitutions.put(name, value); - } - - public void setCurrentRun(ExpRun run) - { - addSubstitution(EXPERIMENT_RUN_ID_NAME, Long.toString(run.getRowId())); - addSubstitution(EXPERIMENT_RUN_LSID_NAME, run.getLSID()); - addSubstitution(EXPERIMENT_RUN_NAME_NAME, run.getName()); - } - - public static String createSubstitution(String name) - { - return "${" + name + "}"; - } - - public Container getContainer() - { - return _container; - } - - public User getUser() - { - return _user; - } - - public @Nullable PipelineJob getJob() - { - return _job; - } -} +/* + * Copyright (c) 2008-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.labkey.api.exp; + +import org.jetbrains.annotations.Nullable; +import org.labkey.api.collections.CaseInsensitiveHashMap; +import org.labkey.api.data.Container; +import org.labkey.api.data.ContainerManager; +import org.labkey.api.exp.api.ExpData; +import org.labkey.api.exp.api.ExpRun; +import org.labkey.api.pipeline.PipelineJob; +import org.labkey.api.pipeline.PipelineJobService; +import org.labkey.api.pipeline.RemoteExecutionEngine; +import org.labkey.api.security.User; +import org.labkey.api.settings.AppProps; +import org.labkey.api.util.GUID; +import org.labkey.api.util.NetworkDrive; +import org.labkey.api.util.Path; +import org.labkey.vfs.FileLike; + +import java.io.File; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Helper to expand LSID templates and translate file paths from URIs during a XAR import. + * User: jeckels + * Date: Dec 7, 2006 + */ +public class XarContext +{ + private final Map _originalURLs; + private final Map _originalCaseInsensitiveURLs; + private final String _jobDescription; + private final Container _container; + private final User _user; + private PipelineJob _job; + + private final Map _substitutions; + + public static final String XAR_JOB_ID_NAME = "XarJobId"; // XarJobId is the same for all tasks in the job + public static final String XAR_JOB_ID_NAME_SUB = "${XarJobId}"; + private static final String XAR_FILE_ID_NAME = "XarFileId"; // XarFileId differs per import task for the same job + private static final String EXPERIMENT_RUN_ID_NAME = "ExperimentRun.RowId"; + private static final String CONTAINER_ID_NAME = "Container.RowId"; + private static final String SHARED_CONTAINER_ID_NAME = "SharedContainer.RowId"; + private static final String FOLDER_LSID_BASE_NAME = "FolderLSIDBase"; + private static final String RUN_LSID_BASE_NAME = "RunLSIDBase"; + private static final String LSID_AUTHORITY_NAME = "LSIDAuthority"; + + public static final String XAR_FILE_ID_SUBSTITUTION = createSubstitution(XAR_FILE_ID_NAME); + public static final String EXPERIMENT_RUN_ID_SUBSTITUTION = createSubstitution(EXPERIMENT_RUN_ID_NAME); + public static final String CONTAINER_ID_SUBSTITUTION = createSubstitution(CONTAINER_ID_NAME); + public static final String SHARED_CONTAINER_ID_SUBSTITUTION = createSubstitution(SHARED_CONTAINER_ID_NAME); + public static final String FOLDER_LSID_BASE_SUBSTITUTION = createSubstitution(FOLDER_LSID_BASE_NAME); + public static final String RUN_LSID_BASE_SUBSTITUTION = createSubstitution(RUN_LSID_BASE_NAME); + public static final String LSID_AUTHORITY_SUBSTITUTION = createSubstitution(LSID_AUTHORITY_NAME); + private static final String EXPERIMENT_RUN_LSID_NAME = "ExperimentRun.LSID"; + private static final String EXPERIMENT_RUN_NAME_NAME = "ExperimentRun.Name"; + + public XarContext(XarContext parent) + { + _jobDescription = parent._jobDescription; + _originalURLs = new HashMap<>(parent._originalURLs); + _originalCaseInsensitiveURLs = new CaseInsensitiveHashMap<>(parent._originalURLs); + _substitutions = new HashMap<>(parent._substitutions); + _container = parent.getContainer(); + _user = parent.getUser(); + } + + public XarContext(PipelineJob job) + { + this(job.getDescription(), job.getContainer(), job.getUser(), job); + } + + public XarContext(String jobDescription, Container c, User user) + { + this(jobDescription, c, user, null); + } + + public XarContext(String jobDescription, Container c, User user, @Nullable PipelineJob job) + { + this(jobDescription, c, user, job, AppProps.getInstance().getDefaultLsidAuthority(), null); + } + + public XarContext(String jobDescription, Container c, User user, @Nullable PipelineJob job, String defaultLsidAuthority) + { + this(jobDescription, c, user, job, defaultLsidAuthority, null); + } + + public XarContext(String jobDescription, Container c, User user, @Nullable PipelineJob job, @Nullable Map substitutions) + { + this(jobDescription, c, user, job, AppProps.getInstance().getDefaultLsidAuthority(), substitutions); + } + + public XarContext(String jobDescription, Container c, User user, @Nullable PipelineJob job, String defaultLsidAuthority, @Nullable Map substitutions) + { + _jobDescription = jobDescription; + _originalURLs = new HashMap<>(); + _originalCaseInsensitiveURLs = new CaseInsensitiveHashMap<>(); + _substitutions = new HashMap<>(); + if (substitutions != null) + _substitutions.putAll(substitutions); + + _job = job; + + String path = c.getPath(); + if (path.startsWith("/")) + { + path = path.substring(1); + } + path = path.replace('/', '.'); + + _substitutions.put("Container.path", path); + _substitutions.put(CONTAINER_ID_NAME, Integer.toString(c.getRowId())); + _substitutions.put(SHARED_CONTAINER_ID_NAME, Integer.toString(ContainerManager.getSharedContainer().getRowId())); + + _substitutions.put(XAR_FILE_ID_NAME, "Xar-" + GUID.makeGUID()); + if (user != null) + { + _substitutions.put("UserEmail", user.getEmail()); + _substitutions.put("UserName", user.getFullName()); + } + _substitutions.put(FOLDER_LSID_BASE_NAME, "urn:lsid:" + LSID_AUTHORITY_SUBSTITUTION + ":${LSIDNamespace.Prefix}.Folder-" + CONTAINER_ID_SUBSTITUTION); + _substitutions.put(RUN_LSID_BASE_NAME, "urn:lsid:" + LSID_AUTHORITY_SUBSTITUTION + ":${LSIDNamespace.Prefix}.Run-" + EXPERIMENT_RUN_ID_SUBSTITUTION); + + _substitutions.put(LSID_AUTHORITY_NAME, defaultLsidAuthority); + + _container = c; + _user = user; + } + + public String getJobDescription() + { + return _jobDescription; + } + + public void addData(ExpData data, String originalURL) + { + originalURL = originalURL.replace('\\', '/'); + _originalURLs.put(originalURL, data); + _originalCaseInsensitiveURLs.put(originalURL, data); + + if (originalURL.startsWith("file:/") && originalURL.length() > "file:/X:/".length()) + { + int index = "file:/".length(); + if (Character.isLetter(originalURL.charAt(index++)) && + ':' == originalURL.charAt(index++) && + '/' == originalURL.charAt(index++)) + { + String originalWithoutDriveLetter = "file:/" + originalURL.substring(index); + _originalURLs.put(originalWithoutDriveLetter, data); + _originalCaseInsensitiveURLs.put(originalWithoutDriveLetter, data); + } + } + } + + private static final Pattern CYGDRIVE_PATTERN = Pattern.compile("/cygdrive/([a-z])/(.*)"); + + public File findFile(String path, File relativeFile) + { + File f = findFile(path); + if (f != null) + { + return f; + } + + // If file can't be reached and it doesn't already have a drive letter, then attempt to append + // the drive letter, if any, of the relativeFile + if (null == NetworkDrive.getDrive(path)) + { + String drivePrefix = NetworkDrive.getDrive(relativeFile.toString()); + String pathWithDrive = path; + if (null != drivePrefix) + { + if (!path.isEmpty() && path.charAt(0) != '\\' && path.charAt(0) != '/') + { + pathWithDrive = drivePrefix + "/" + path; + } + else + { + pathWithDrive = drivePrefix + path; + } + } + + f = new File(pathWithDrive); + if (NetworkDrive.exists(f)) + { + return f; + } + } + + f = new File(relativeFile, path); + if (NetworkDrive.exists(f)) + { + return f; + } + + // Check if it's in the current directory, stripping off any extra path from the file name + int index = path.lastIndexOf("/"); + f = resolveFile(path, relativeFile, index); + if (f != null) return f; + + // Do the same for Windows paths + index = path.lastIndexOf("\\"); + f = resolveFile(path, relativeFile, index); + if (f != null) return f; + + // Finally, try using the pipeline's path mapper if we have one to + // translate from a cluster path to a webserver path + // Path mappers deal with URIs, not file paths + String uri = "file:" + (path.startsWith("/") ? path : "/" + path); + // This PathMapper considers "local" from a cluster node's point of view. + for (RemoteExecutionEngine engine : PipelineJobService.get().getRemoteExecutionEngines()) + { + String mappedURI = engine.getConfig().getPathMapper().localToRemote(uri); + // If we have translated Windows paths, they won't be legal URIs, so convert slashes + mappedURI = mappedURI.replace('\\', '/'); + try + { + f = new File(new URI(mappedURI)); + if (NetworkDrive.exists(f)) + { + return f; + } + } + catch (URISyntaxException ignored) {} + } + + return null; + } + + @Nullable + private File resolveFile(String path, File relativeFile, int index) + { + File f; + if (index != -1) + { + String filename = path.substring(index + 1); + if (!filename.isEmpty()) + { + f = new File(relativeFile, filename); + if (NetworkDrive.exists(f)) + { + return f; + } + } + } + return null; + } + + public File findFile(String path) + { + String lookupPath = path; + if (!lookupPath.contains(":/")) + { + lookupPath = "file:/" + lookupPath; + } + + // First, check if the XAR contains a file that was originally at that path + lookupPath = lookupPath.replace('\\', '/'); + ExpData data = _originalURLs.get(lookupPath); + if (data != null) + { + return data.getFile(); + } + + // Second, try looking for a case-insensitive match + data = _originalCaseInsensitiveURLs.get(lookupPath); + if (data != null) + { + return data.getFile(); + } + + // Next, check if the file exists on the file system at that exact location + File f = new File(path); + if (NetworkDrive.exists(f)) + { + return f; + } + + // Try resolving the Cygwin paths like /cygdrive/c/somepath/somefile.extension + // to c:/somepath/somefile.extension + Matcher matcher = CYGDRIVE_PATTERN.matcher(path); + if (matcher.matches()) + { + return findFile(matcher.group(1) + ":/" + matcher.group(2)); + } + + return null; + } + + public Map getSubstitutions() + { + return Collections.unmodifiableMap(_substitutions); + } + + public void addSubstitution(String name, String value) + { + _substitutions.put(name, value); + } + + public void setCurrentRun(ExpRun run) + { + addSubstitution(EXPERIMENT_RUN_ID_NAME, Long.toString(run.getRowId())); + addSubstitution(EXPERIMENT_RUN_LSID_NAME, run.getLSID()); + addSubstitution(EXPERIMENT_RUN_NAME_NAME, run.getName()); + } + + public static String createSubstitution(String name) + { + return "${" + name + "}"; + } + + public Container getContainer() + { + return _container; + } + + public User getUser() + { + return _user; + } + + public @Nullable PipelineJob getJob() + { + return _job; + } +} diff --git a/api/src/org/labkey/api/exp/XarSource.java b/api/src/org/labkey/api/exp/XarSource.java index 084b367f7cd..de8c4e90b6c 100644 --- a/api/src/org/labkey/api/exp/XarSource.java +++ b/api/src/org/labkey/api/exp/XarSource.java @@ -1,294 +1,295 @@ -/* - * Copyright (c) 2008-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.api.exp; - -import org.apache.xmlbeans.XmlException; -import org.fhcrc.cpas.exp.xml.ExperimentArchiveDocument; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.labkey.api.data.Container; -import org.labkey.api.exp.api.ExpData; -import org.labkey.api.exp.api.ExpDataClass; -import org.labkey.api.exp.api.ExpMaterial; -import org.labkey.api.exp.api.ExpProtocol; -import org.labkey.api.exp.api.ExpProtocolApplication; -import org.labkey.api.exp.api.ExpRun; -import org.labkey.api.exp.api.ExpSampleType; -import org.labkey.api.exp.api.ExperimentService; -import org.labkey.api.pipeline.PipeRoot; -import org.labkey.api.pipeline.PipelineJob; -import org.labkey.api.security.User; -import org.labkey.api.util.FileUtil; - -import java.io.IOException; -import java.io.Serializable; -import java.net.URI; -import java.net.URISyntaxException; -import java.nio.file.Path; -import java.util.HashMap; -import java.util.Map; - -/** - * Something that knows how to a produce a XAR (experiment archive), whether it's a from an existing file or - * being dynamically generated on demand. - * User: jeckels - * Date: Oct 14, 2005 - */ -public abstract class XarSource implements Serializable -{ - public static final String LOG_FILE_NAME_SUFFIX = ".log"; - - private Long _experimentRunId; - private final Map _xarProtocols = new HashMap<>(); - private final Map _databaseProtocols = new HashMap<>(); - private final Map> _materials = new HashMap<>(); - private final Map> _data = new HashMap<>(); - - private final Map _xarSampleTypes = new HashMap<>(); - private final Map _xarDataClasses = new HashMap<>(); - - protected final Map _dataFileURLs = new HashMap<>(); - - @NotNull - private final XarContext _xarContext; - - public XarSource(String description, Container container, User user, @Nullable PipelineJob job) - { - _xarContext = new XarContext(description, container, user, job); - } - - public XarSource(String description, Container container, User user, @Nullable PipelineJob job, @Nullable Map substitutions) - { - _xarContext = new XarContext(description, container, user, job, substitutions); - } - - public XarSource(PipelineJob job) - { - _xarContext = new XarContext(job); - } - - public abstract ExperimentArchiveDocument getDocument() throws XmlException, IOException; - - public abstract Path getRootPath(); - - public Path getJobRootPath() { return getRootPath(); } - - /** - * Should be true if this was uploaded XML that was not part of a full XAR - */ - public abstract boolean shouldIgnoreDataFiles(); - - /** - * Transforms the dataFileURL, which may be relative, to a canonical, absolute URI - */ - public final String getCanonicalDataFileURL(String dataFileURL) throws XarFormatException - { - if (dataFileURL == null) - { - return null; - } - String result = _dataFileURLs.get(dataFileURL); - if (result == null) - { - String urlToLookup = dataFileURL; - try - { - URI uri = new URI(dataFileURL); - if (FileUtil.FILE_SCHEME.equalsIgnoreCase(uri.getScheme()) || FileUtil.hasCloudScheme(uri)) - { - urlToLookup = FileUtil.uriToString(uri); - } - } - catch (IllegalArgumentException | URISyntaxException ignored) {} - result = canonicalizeDataFileURL(urlToLookup); - _dataFileURLs.put(dataFileURL, result); - _dataFileURLs.put(urlToLookup, result); - } - return result; - } - - protected abstract String canonicalizeDataFileURL(String dataFileURL) throws XarFormatException; - - public abstract Path getLogFilePath() throws IOException; - - /** - * Called before trying to import this XAR to let the source set up any resources that are required - */ - public void init() throws IOException, ExperimentException - { - } - - public void setExperimentRunRowId(Long experimentRowId) - { - _experimentRunId = experimentRowId; - } - - public ExpRun getExperimentRun() - { - if (_experimentRunId != null) - { - return ExperimentService.get().getExpRun(_experimentRunId.intValue()); - } - return null; - } - - public void addData(String experimentRunLSID, ExpData data, @Nullable String additionalDataLSID) - { - Map map = _data.computeIfAbsent(experimentRunLSID, k -> new HashMap<>()); - map.put(data.getLSID(), data); - if (additionalDataLSID != null) - { - map.put(additionalDataLSID, data); - } - } - - public void addMaterial(String experimentRunLSID, ExpMaterial material, @Nullable String additionalMaterialLSID) - { - Map map = _materials.computeIfAbsent(experimentRunLSID, k -> new HashMap<>()); - map.put(material.getLSID(), material); - if (additionalMaterialLSID != null) - { - map.put(additionalMaterialLSID, material); - } - } - - public ExpData getData(ExpRun experimentRun, ExpProtocolApplication protApp, String dataLSID) throws XarFormatException - { - String experimentRunLSID = experimentRun == null ? null : experimentRun.getLSID(); - Map map = _data.computeIfAbsent(experimentRunLSID, k -> new HashMap<>()); - ExpData result = map.get(dataLSID); - if (result == null) - { - if (experimentRun == null) - { - result = ExperimentService.get().getExpData(dataLSID); - } - if (result == null) - { - // Try for a non-run scoped variant - result = _data.computeIfAbsent(null, k -> new HashMap<>()).get(dataLSID); - } - if (result == null) - { - throw new XarFormatException(createIllegalReferenceMessage(experimentRun, protApp, dataLSID, ExpData.DEFAULT_CPAS_TYPE)); - } - map.put(result.getLSID(), result); - } - return result; - } - - private String createIllegalReferenceMessage(ExpRun experimentRun, ExpProtocolApplication protApp, String lsid, String type) - { - String message = "Illegal reference to " + type + " '" + lsid + "'"; - if (protApp != null) - { - message += " from ProtocolApplication '" + protApp.getLSID() + "'"; - } - if (experimentRun != null) - { - message += " in ExperimentRun '" + experimentRun.getLSID() + "'"; - } - return message; - } - - - public ExpMaterial getMaterial(ExpRun experimentRun, ExpProtocolApplication protApp, String materialLSID) throws XarFormatException - { - String experimentRunLSID = experimentRun == null ? null : experimentRun.getLSID(); - Map map = _materials.computeIfAbsent(experimentRunLSID, k -> new HashMap<>()); - ExpMaterial result = map.get(materialLSID); - if (result == null) - { - // Try for a non-run scoped variant - result = _materials.computeIfAbsent(null, k -> new HashMap<>()).get(materialLSID); - if (null == result) - { - result = ExperimentService.get().getExpMaterial(materialLSID); - } - if (result == null) - { - throw new XarFormatException(createIllegalReferenceMessage(experimentRun, protApp, materialLSID, ExpMaterial.DEFAULT_CPAS_TYPE)); - } - map.put(result.getLSID(), result); - } - return result; - } - - - public void addProtocol(ExpProtocol protocol) - { - _xarProtocols.put(protocol.getLSID(), protocol); - } - - public ExpProtocol getProtocol(String lsid, String errorDescription) throws XarFormatException - { - ExpProtocol result = _xarProtocols.get(lsid); - if (result == null) - { - result = _databaseProtocols.get(lsid); - } - if (result == null) - { - result = ExperimentService.get().getExpProtocol(lsid); - _databaseProtocols.put(lsid, result); - } - if (result == null) - { - throw new XarFormatException("Could not find " + errorDescription + " protocol with LSID " + lsid); - } - return result; - } - - public boolean allowImport(PipeRoot pr, Container container, Path path) - { - try - { - return (pr != null && pr.isUnderRoot(path)) || - (!FileUtil.pathToString(path).equalsIgnoreCase(FileUtil.relativizeUnix(getRootPath(), path, true))); - } - catch (IOException e) - { - return false; - } - } - - @NotNull - public XarContext getXarContext() - { - return _xarContext; - } - - public void addSampleType(String sampleTypeLSID, ExpSampleType sampleType) - { - _xarSampleTypes.put(sampleTypeLSID, sampleType); - } - - public ExpSampleType getSampleType(String sampleTypeLSID) - { - return _xarSampleTypes.get(sampleTypeLSID); - } - - public void addDataClass(String sampleTypeLSID, ExpDataClass dataClass) - { - _xarDataClasses.put(sampleTypeLSID, dataClass); - } - - public ExpDataClass getDataClass(String sampleTypeLSID) - { - return _xarDataClasses.get(sampleTypeLSID); - } - -} +/* + * Copyright (c) 2008-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.api.exp; + +import org.apache.xmlbeans.XmlException; +import org.fhcrc.cpas.exp.xml.ExperimentArchiveDocument; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.labkey.api.data.Container; +import org.labkey.api.exp.api.ExpData; +import org.labkey.api.exp.api.ExpDataClass; +import org.labkey.api.exp.api.ExpMaterial; +import org.labkey.api.exp.api.ExpProtocol; +import org.labkey.api.exp.api.ExpProtocolApplication; +import org.labkey.api.exp.api.ExpRun; +import org.labkey.api.exp.api.ExpSampleType; +import org.labkey.api.exp.api.ExperimentService; +import org.labkey.api.pipeline.PipeRoot; +import org.labkey.api.pipeline.PipelineJob; +import org.labkey.api.security.User; +import org.labkey.api.util.FileUtil; +import org.labkey.vfs.FileLike; + +import java.io.IOException; +import java.io.Serializable; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; + +/** + * Something that knows how to a produce a XAR (experiment archive), whether it's a from an existing file or + * being dynamically generated on demand. + * User: jeckels + * Date: Oct 14, 2005 + */ +public abstract class XarSource implements Serializable +{ + public static final String LOG_FILE_NAME_SUFFIX = ".log"; + + private Long _experimentRunId; + private final Map _xarProtocols = new HashMap<>(); + private final Map _databaseProtocols = new HashMap<>(); + private final Map> _materials = new HashMap<>(); + private final Map> _data = new HashMap<>(); + + private final Map _xarSampleTypes = new HashMap<>(); + private final Map _xarDataClasses = new HashMap<>(); + + protected final Map _dataFileURLs = new HashMap<>(); + + @NotNull + private final XarContext _xarContext; + + public XarSource(String description, Container container, User user, @Nullable PipelineJob job) + { + _xarContext = new XarContext(description, container, user, job); + } + + public XarSource(String description, Container container, User user, @Nullable PipelineJob job, @Nullable Map substitutions) + { + _xarContext = new XarContext(description, container, user, job, substitutions); + } + + public XarSource(PipelineJob job) + { + _xarContext = new XarContext(job); + } + + public abstract ExperimentArchiveDocument getDocument() throws XmlException, IOException; + + public abstract Path getRootPath(); + + public Path getJobRootPath() { return getRootPath(); } + + /** + * Should be true if this was uploaded XML that was not part of a full XAR + */ + public abstract boolean shouldIgnoreDataFiles(); + + /** + * Transforms the dataFileURL, which may be relative, to a canonical, absolute URI + */ + public final String getCanonicalDataFileURL(String dataFileURL) throws XarFormatException + { + if (dataFileURL == null) + { + return null; + } + String result = _dataFileURLs.get(dataFileURL); + if (result == null) + { + String urlToLookup = dataFileURL; + try + { + URI uri = new URI(dataFileURL); + if (FileUtil.FILE_SCHEME.equalsIgnoreCase(uri.getScheme()) || FileUtil.hasCloudScheme(uri)) + { + urlToLookup = FileUtil.uriToString(uri); + } + } + catch (IllegalArgumentException | URISyntaxException ignored) {} + result = canonicalizeDataFileURL(urlToLookup); + _dataFileURLs.put(dataFileURL, result); + _dataFileURLs.put(urlToLookup, result); + } + return result; + } + + protected abstract String canonicalizeDataFileURL(String dataFileURL) throws XarFormatException; + + public abstract FileLike getLogFilePath() throws IOException; + + /** + * Called before trying to import this XAR to let the source set up any resources that are required + */ + public void init() throws IOException, ExperimentException + { + } + + public void setExperimentRunRowId(Long experimentRowId) + { + _experimentRunId = experimentRowId; + } + + public ExpRun getExperimentRun() + { + if (_experimentRunId != null) + { + return ExperimentService.get().getExpRun(_experimentRunId.intValue()); + } + return null; + } + + public void addData(String experimentRunLSID, ExpData data, @Nullable String additionalDataLSID) + { + Map map = _data.computeIfAbsent(experimentRunLSID, k -> new HashMap<>()); + map.put(data.getLSID(), data); + if (additionalDataLSID != null) + { + map.put(additionalDataLSID, data); + } + } + + public void addMaterial(String experimentRunLSID, ExpMaterial material, @Nullable String additionalMaterialLSID) + { + Map map = _materials.computeIfAbsent(experimentRunLSID, k -> new HashMap<>()); + map.put(material.getLSID(), material); + if (additionalMaterialLSID != null) + { + map.put(additionalMaterialLSID, material); + } + } + + public ExpData getData(ExpRun experimentRun, ExpProtocolApplication protApp, String dataLSID) throws XarFormatException + { + String experimentRunLSID = experimentRun == null ? null : experimentRun.getLSID(); + Map map = _data.computeIfAbsent(experimentRunLSID, k -> new HashMap<>()); + ExpData result = map.get(dataLSID); + if (result == null) + { + if (experimentRun == null) + { + result = ExperimentService.get().getExpData(dataLSID); + } + if (result == null) + { + // Try for a non-run scoped variant + result = _data.computeIfAbsent(null, k -> new HashMap<>()).get(dataLSID); + } + if (result == null) + { + throw new XarFormatException(createIllegalReferenceMessage(experimentRun, protApp, dataLSID, ExpData.DEFAULT_CPAS_TYPE)); + } + map.put(result.getLSID(), result); + } + return result; + } + + private String createIllegalReferenceMessage(ExpRun experimentRun, ExpProtocolApplication protApp, String lsid, String type) + { + String message = "Illegal reference to " + type + " '" + lsid + "'"; + if (protApp != null) + { + message += " from ProtocolApplication '" + protApp.getLSID() + "'"; + } + if (experimentRun != null) + { + message += " in ExperimentRun '" + experimentRun.getLSID() + "'"; + } + return message; + } + + + public ExpMaterial getMaterial(ExpRun experimentRun, ExpProtocolApplication protApp, String materialLSID) throws XarFormatException + { + String experimentRunLSID = experimentRun == null ? null : experimentRun.getLSID(); + Map map = _materials.computeIfAbsent(experimentRunLSID, k -> new HashMap<>()); + ExpMaterial result = map.get(materialLSID); + if (result == null) + { + // Try for a non-run scoped variant + result = _materials.computeIfAbsent(null, k -> new HashMap<>()).get(materialLSID); + if (null == result) + { + result = ExperimentService.get().getExpMaterial(materialLSID); + } + if (result == null) + { + throw new XarFormatException(createIllegalReferenceMessage(experimentRun, protApp, materialLSID, ExpMaterial.DEFAULT_CPAS_TYPE)); + } + map.put(result.getLSID(), result); + } + return result; + } + + + public void addProtocol(ExpProtocol protocol) + { + _xarProtocols.put(protocol.getLSID(), protocol); + } + + public ExpProtocol getProtocol(String lsid, String errorDescription) throws XarFormatException + { + ExpProtocol result = _xarProtocols.get(lsid); + if (result == null) + { + result = _databaseProtocols.get(lsid); + } + if (result == null) + { + result = ExperimentService.get().getExpProtocol(lsid); + _databaseProtocols.put(lsid, result); + } + if (result == null) + { + throw new XarFormatException("Could not find " + errorDescription + " protocol with LSID " + lsid); + } + return result; + } + + public boolean allowImport(PipeRoot pr, Container container, Path path) + { + try + { + return (pr != null && pr.isUnderRoot(path)) || + (!FileUtil.pathToString(path).equalsIgnoreCase(FileUtil.relativizeUnix(getRootPath(), path, true))); + } + catch (IOException e) + { + return false; + } + } + + @NotNull + public XarContext getXarContext() + { + return _xarContext; + } + + public void addSampleType(String sampleTypeLSID, ExpSampleType sampleType) + { + _xarSampleTypes.put(sampleTypeLSID, sampleType); + } + + public ExpSampleType getSampleType(String sampleTypeLSID) + { + return _xarSampleTypes.get(sampleTypeLSID); + } + + public void addDataClass(String sampleTypeLSID, ExpDataClass dataClass) + { + _xarDataClasses.put(sampleTypeLSID, dataClass); + } + + public ExpDataClass getDataClass(String sampleTypeLSID) + { + return _xarDataClasses.get(sampleTypeLSID); + } + +} diff --git a/api/src/org/labkey/api/exp/api/ExperimentService.java b/api/src/org/labkey/api/exp/api/ExperimentService.java index bdf36dc10f8..f1ca905d090 100644 --- a/api/src/org/labkey/api/exp/api/ExperimentService.java +++ b/api/src/org/labkey/api/exp/api/ExperimentService.java @@ -987,7 +987,7 @@ List getExpProtocolsWithParameterValue( * * @return the job responsible for doing the work */ - PipelineJob importXarAsync(ViewBackgroundInfo info, File file, String description, PipeRoot root) throws IOException; + PipelineJob importXarAsync(ViewBackgroundInfo info, FileLike file, String description, PipeRoot root) throws IOException; /** * Loads the xar synchronously, in the context of the pipelineJob diff --git a/api/src/org/labkey/api/files/FileContentService.java b/api/src/org/labkey/api/files/FileContentService.java index 9791ba68902..14a04bab81e 100644 --- a/api/src/org/labkey/api/files/FileContentService.java +++ b/api/src/org/labkey/api/files/FileContentService.java @@ -1,360 +1,364 @@ -/* - * Copyright (c) 2009-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.labkey.api.files; - -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.labkey.api.attachments.AttachmentDirectory; -import org.labkey.api.data.Container; -import org.labkey.api.data.SQLFragment; -import org.labkey.api.data.TableInfo; -import org.labkey.api.exp.api.DataType; -import org.labkey.api.exp.api.ExpData; -import org.labkey.api.exp.query.ExpDataTable; -import org.labkey.api.query.QueryUpdateService; -import org.labkey.api.security.User; -import org.labkey.api.services.ServiceRegistry; -import org.labkey.api.util.FileUtil; -import org.labkey.api.webdav.WebdavResource; - -import java.io.File; -import java.net.URI; -import java.nio.file.Path; -import java.util.Collection; -import java.util.List; -import java.util.Map; - -/** - * User: klum - * Date: Dec 9, 2009 - */ -public interface FileContentService -{ - String UPLOADED_FILE_NAMESPACE_PREFIX = "UploadedFile"; - DataType UPLOADED_FILE = new DataType(UPLOADED_FILE_NAMESPACE_PREFIX); - - String FILES_LINK = "@files"; - String FILE_SETS_LINK = "@filesets"; - String PIPELINE_LINK = "@pipeline"; - String SCRIPTS_LINK = "@scripts"; - String CLOUD_LINK = "@cloud"; - String ASSAY_FILES = "@assayfiles"; - - String CLOUD_ROOT_PREFIX = "/@cloud"; - - static @Nullable FileContentService get() - { - return ServiceRegistry.get().getService(FileContentService.class); - } - - static void setInstance(FileContentService impl) - { - ServiceRegistry.get().registerService(FileContentService.class, impl); - } - - /** - * Returns a list of Container in which the path resides. - */ - @NotNull - List getContainersForFilePath(java.nio.file.Path path); - - /** - * Returns the file root of the specified container. If not explicitly defined, - * it will default to a path relative to the first parent container with an override - */ - @Nullable - File getFileRoot(@NotNull Container c); - - @Nullable - java.nio.file.Path getFileRootPath(@NotNull Container c); - - /** - * Returns the file root of the specified content type for a container - */ - @Nullable - File getFileRoot(@NotNull Container c, @NotNull ContentType type); - - @Nullable - java.nio.file.Path getFileRootPath(@NotNull Container c, @NotNull ContentType type); - - @Nullable - URI getFileRootUri(@NotNull Container c, @NotNull ContentType type, @Nullable String filePath); - - void setFileRoot(@NotNull Container c, @Nullable File root); - - void setFileRootPath(@NotNull Container c, @Nullable String root); - - void setCloudRoot(@NotNull Container c, String cloudRootName); - - boolean isCloudRoot(Container container); - - String getCloudRootName(Container c); - - void disableFileRoot(Container container); - - boolean isFileRootDisabled(Container container); - - /** - * A file root can use a default root based on a single site wide root that mirrors the folder structure of - * a project. - */ - boolean isUseDefaultRoot(Container container); - - void setIsUseDefaultRoot(Container container, boolean useDefaultRoot); - - - @NotNull - File getSiteDefaultRoot(); - - @NotNull - Path getSiteDefaultRootPath(); - - @Nullable - String getProblematicFileRootMessage(); - - void setSiteDefaultRoot(File root, User user); - - void setFileRootSetViaStartupProperty(boolean fileRootSetViaStartupProperty); - - boolean isFileRootSetViaStartupProperty(); - - /** - * Create an attachmentParent object that will allow storing files in the file system - * - * @param c Container this will be attached to - * @param name Name of the parent used in getMappedAttachmentDirectory - * @param path Path to the file. If relative is true, this is the name of a subdirectory of the directory mapped to this c - * container. If relative is false, this is a fully qualified path name - * @param relative if true, path is a relative path from the directory mapped from the container - * @return the created attachment parent - */ - AttachmentDirectory registerDirectory(Container c, String name, String path, boolean relative); - - /** - * Forget about a named directory - * - * @param c Container for this attachmentParent - * @param label Name of the parent used in registerDirectory - */ - void unregisterDirectory(Container c, String label); - - /** - * Return an AttachmentParent for files in the directory mapped to this container - * - * @param c Container in the file system - * @param createDir Create the mapped directory if it doesn't exist - * @return AttachmentParent that can be passed to other methods of this interface - */ - @Nullable - AttachmentDirectory getMappedAttachmentDirectory(Container c, boolean createDir) throws UnsetRootDirectoryException, MissingRootDirectoryException; - - @Nullable - AttachmentDirectory getMappedAttachmentDirectory(Container c, ContentType contentType, boolean createDir) throws UnsetRootDirectoryException, MissingRootDirectoryException; - - /** - * Return a named AttachmentParent for files in the directory mapped to this container - * - * @param c Container in the file system - * @return AttachmentParent that can be passed to other methods of this interface - */ - AttachmentDirectory getRegisteredDirectory(Container c, String label); - - /** - * Return a named AttachmentParent for files in the directory mapped to this container - * - * @param c Container in the file system - * @return AttachmentParent that can be passed to other methods of this interface - */ - AttachmentDirectory getRegisteredDirectoryFromEntityId(Container c, String entityId); - - /** - * Return true if the supplied string is a valid project root - * - * @param root String to use as the file path - * @return boolean - */ - boolean isValidProjectRoot(String root); - - /** - * Return all AttachmentParents for files in the directory mapped to this container - * - * @param c Container in the file system - * @return Collection of attachment directories that have previously been registered - */ - @NotNull Collection getRegisteredDirectories(Container c); - - enum ContentType { - files, - pipeline, - assay, - scripts, - assayfiles - } - - String getFolderName(ContentType type); - - FilesAdminOptions getAdminOptions(Container c); - - void setAdminOptions(Container c, FilesAdminOptions options); - - void setAdminOptions(Container c, String properties); - - /** - * Returns the default file root of the specified container. This will default to a path - * relative to the first parent container with an override - */ - File getDefaultRoot(Container c, boolean createDir); - Path getDefaultRootPath(@NotNull Container c, boolean createDir); - - class DefaultRootInfo - { - private final java.nio.file.Path _path; - private final String _prettyStr; - private final boolean _isCloud; - private final String _cloudName; - - public DefaultRootInfo(java.nio.file.Path path, String prettyStr, boolean isCloud, String cloudName) - { - _path = path; - _prettyStr = prettyStr; - _isCloud = isCloud; - _cloudName = cloudName; - } - - public java.nio.file.Path getPath() - { - return _path; - } - - public String getPrettyStr() - { - return _prettyStr; - } - - public boolean isCloud() - { - return _isCloud; - } - - public String getCloudName() - { - return _cloudName; - } - } - - DefaultRootInfo getDefaultRootInfo(Container container); - - String getDomainURI(Container c); - - String getDomainURI(Container c, FilesAdminOptions.fileConfig config); - - ExpData getDataObject(WebdavResource resource, Container c); - QueryUpdateService getFilePropsUpdateService(TableInfo tinfo, Container container); - - void moveFileRoot(File prev, File dest, @Nullable User user, @Nullable Container container); - default void moveFileRoot(Path prev, Path dest, @Nullable User user, @Nullable Container container) - { - if (!FileUtil.hasCloudScheme(prev) && !FileUtil.hasCloudScheme(dest)) - { - moveFileRoot(prev.toFile(), dest.toFile(), user, container); - } - } - - /** Notifies all registered FileListeners that a file or directory has been created */ - void fireFileCreateEvent(@NotNull File created, @Nullable User user, @Nullable Container container); - default void fireFileCreateEvent(@NotNull Path created, @Nullable User user, @Nullable Container container) - { - if (!FileUtil.hasCloudScheme(created)) - fireFileCreateEvent(created.toFile(), user, container); - } - /** - * Notifies all registered FileListeners that a file or directory has moved - * @return number of rows updated across all listeners - */ - int fireFileMoveEvent(@NotNull File src, @NotNull File dest, @Nullable User user, @Nullable Container container); - default int fireFileMoveEvent(@NotNull Path src, @NotNull Path dest, @Nullable User user, @Nullable Container container) - { - if (!FileUtil.hasCloudScheme(src) && !FileUtil.hasCloudScheme(dest)) - return fireFileMoveEvent(src.toFile(), dest.toFile(), user, container); - return 0; - } - default int fireFileMoveEvent(@NotNull Path src, @NotNull Path dest, @Nullable User user, @Nullable Container sourceContainer, @Nullable Container targetContainer) - { - return fireFileMoveEvent(src, dest, user, sourceContainer); - } - - /** Notifies all registered FileListeners that a file or directory has been replaced */ - default void fireFileReplacedEvent(@NotNull Path replaced, @Nullable User user, @Nullable Container container){} - - /** Notifies all registered FileListeners that a file or directory has been deleted */ - default void fireFileDeletedEvent(@NotNull Path deleted, @Nullable User user, @Nullable Container container){} - - /** Add a listener that will be notified when files are created or are moved */ - void addFileListener(FileListener listener); - - Map> listFiles(@NotNull Container container); - - /** - * Returns a SQLFragment for file paths that this FileListener is aware of when the user is a site admin, or empty - * results otherwise. - * The expected columns are: - *
    - *
  • Container
  • - *
  • Created
  • - *
  • CreatedBy
  • - *
  • Modified
  • - *
  • ModifiedBy
  • - *
  • FilePath
  • - *
  • SourceKey
  • - *
  • SourceName
  • - *
- */ - SQLFragment listFilesQuery(@NotNull User currentUser); - - void setWebfilesEnabled(boolean enabled, User user); - - /** - * Return file's virtual folder path that's relative to container's file root. Roots are matched in order of @assayfiles, @files, @pipeline and then each @filesets. - * @param dataFileUrl The data file Url of file - * @param container Container in the file system - * @return folder relative to file root - */ - String getDataFileRelativeFileRootPath(@NotNull String dataFileUrl, Container container); - - enum PathType { full, serverRelative, folderRelative } - - @Nullable - URI getWebDavUrl(@NotNull Path path, @NotNull Container container, @NotNull PathType type); - - /** - * Ensure an entry in the exp.data table exists for all files in the container's file root. - */ - void ensureFileData(@NotNull ExpDataTable table); - - /** - * Allows a module to register a directory pattern to be checked in the files webpart in order to zip the matching directory before uploading. - * @param directoryPattern DirectoryPattern - * */ - void addZiploaderPattern(DirectoryPattern directoryPattern); - - /** - * Returns a list of DirectoryPattern objects for the active modules in the given container. - * */ - List getZiploaderPatterns(Container container); - - File getMoveTargetFile(String absoluteFilePath, @NotNull Container sourceContainer, @NotNull Container targetContainer); -} +/* + * Copyright (c) 2009-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.labkey.api.files; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.labkey.api.attachments.AttachmentDirectory; +import org.labkey.api.data.Container; +import org.labkey.api.data.SQLFragment; +import org.labkey.api.data.TableInfo; +import org.labkey.api.exp.api.DataType; +import org.labkey.api.exp.api.ExpData; +import org.labkey.api.exp.query.ExpDataTable; +import org.labkey.api.query.QueryUpdateService; +import org.labkey.api.security.User; +import org.labkey.api.services.ServiceRegistry; +import org.labkey.api.util.FileUtil; +import org.labkey.api.webdav.WebdavResource; +import org.labkey.vfs.FileLike; + +import java.io.File; +import java.net.URI; +import java.nio.file.Path; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +/** + * User: klum + * Date: Dec 9, 2009 + */ +public interface FileContentService +{ + String UPLOADED_FILE_NAMESPACE_PREFIX = "UploadedFile"; + DataType UPLOADED_FILE = new DataType(UPLOADED_FILE_NAMESPACE_PREFIX); + + String FILES_LINK = "@files"; + String FILE_SETS_LINK = "@filesets"; + String PIPELINE_LINK = "@pipeline"; + String SCRIPTS_LINK = "@scripts"; + String CLOUD_LINK = "@cloud"; + String ASSAY_FILES = "@assayfiles"; + + String CLOUD_ROOT_PREFIX = "/@cloud"; + + static @Nullable FileContentService get() + { + return ServiceRegistry.get().getService(FileContentService.class); + } + + static void setInstance(FileContentService impl) + { + ServiceRegistry.get().registerService(FileContentService.class, impl); + } + + /** + * Returns a list of Container in which the path resides. + */ + @NotNull + List getContainersForFilePath(java.nio.file.Path path); + + /** + * Returns the file root of the specified container. If not explicitly defined, + * it will default to a path relative to the first parent container with an override + */ + @Nullable + File getFileRoot(@NotNull Container c); + + @Nullable + java.nio.file.Path getFileRootPath(@NotNull Container c); + + /** + * Returns the file root of the specified content type for a container + */ + @Nullable + File getFileRoot(@NotNull Container c, @NotNull ContentType type); + + @Nullable + java.nio.file.Path getFileRootPath(@NotNull Container c, @NotNull ContentType type); + + @Nullable + URI getFileRootUri(@NotNull Container c, @NotNull ContentType type, @Nullable String filePath); + + void setFileRoot(@NotNull Container c, @Nullable File root); + + void setFileRootPath(@NotNull Container c, @Nullable String root); + + void setCloudRoot(@NotNull Container c, String cloudRootName); + + boolean isCloudRoot(Container container); + + String getCloudRootName(Container c); + + void disableFileRoot(Container container); + + boolean isFileRootDisabled(Container container); + + /** + * A file root can use a default root based on a single site wide root that mirrors the folder structure of + * a project. + */ + boolean isUseDefaultRoot(Container container); + + void setIsUseDefaultRoot(Container container, boolean useDefaultRoot); + + + @NotNull + File getSiteDefaultRoot(); + + @NotNull + Path getSiteDefaultRootPath(); + + @Nullable + String getProblematicFileRootMessage(); + + void setSiteDefaultRoot(File root, User user); + + void setFileRootSetViaStartupProperty(boolean fileRootSetViaStartupProperty); + + boolean isFileRootSetViaStartupProperty(); + + /** + * Create an attachmentParent object that will allow storing files in the file system + * + * @param c Container this will be attached to + * @param name Name of the parent used in getMappedAttachmentDirectory + * @param path Path to the file. If relative is true, this is the name of a subdirectory of the directory mapped to this c + * container. If relative is false, this is a fully qualified path name + * @param relative if true, path is a relative path from the directory mapped from the container + * @return the created attachment parent + */ + AttachmentDirectory registerDirectory(Container c, String name, String path, boolean relative); + + /** + * Forget about a named directory + * + * @param c Container for this attachmentParent + * @param label Name of the parent used in registerDirectory + */ + void unregisterDirectory(Container c, String label); + + /** + * Return an AttachmentParent for files in the directory mapped to this container + * + * @param c Container in the file system + * @param createDir Create the mapped directory if it doesn't exist + * @return AttachmentParent that can be passed to other methods of this interface + */ + @Nullable + AttachmentDirectory getMappedAttachmentDirectory(Container c, boolean createDir) throws UnsetRootDirectoryException, MissingRootDirectoryException; + + @Nullable + AttachmentDirectory getMappedAttachmentDirectory(Container c, ContentType contentType, boolean createDir) throws UnsetRootDirectoryException, MissingRootDirectoryException; + + /** + * Return a named AttachmentParent for files in the directory mapped to this container + * + * @param c Container in the file system + * @return AttachmentParent that can be passed to other methods of this interface + */ + AttachmentDirectory getRegisteredDirectory(Container c, String label); + + /** + * Return a named AttachmentParent for files in the directory mapped to this container + * + * @param c Container in the file system + * @return AttachmentParent that can be passed to other methods of this interface + */ + AttachmentDirectory getRegisteredDirectoryFromEntityId(Container c, String entityId); + + /** + * Return true if the supplied string is a valid project root + * + * @param root String to use as the file path + * @return boolean + */ + boolean isValidProjectRoot(String root); + + /** + * Return all AttachmentParents for files in the directory mapped to this container + * + * @param c Container in the file system + * @return Collection of attachment directories that have previously been registered + */ + @NotNull Collection getRegisteredDirectories(Container c); + + enum ContentType { + files, + pipeline, + assay, + scripts, + assayfiles + } + + String getFolderName(ContentType type); + + FilesAdminOptions getAdminOptions(Container c); + + void setAdminOptions(Container c, FilesAdminOptions options); + + void setAdminOptions(Container c, String properties); + + /** + * Returns the default file root of the specified container. This will default to a path + * relative to the first parent container with an override + */ + File getDefaultRoot(Container c, boolean createDir); + Path getDefaultRootPath(@NotNull Container c, boolean createDir); + + class DefaultRootInfo + { + private final java.nio.file.Path _path; + private final String _prettyStr; + private final boolean _isCloud; + private final String _cloudName; + + public DefaultRootInfo(java.nio.file.Path path, String prettyStr, boolean isCloud, String cloudName) + { + _path = path; + _prettyStr = prettyStr; + _isCloud = isCloud; + _cloudName = cloudName; + } + + public java.nio.file.Path getPath() + { + return _path; + } + + public String getPrettyStr() + { + return _prettyStr; + } + + public boolean isCloud() + { + return _isCloud; + } + + public String getCloudName() + { + return _cloudName; + } + } + + DefaultRootInfo getDefaultRootInfo(Container container); + + String getDomainURI(Container c); + + String getDomainURI(Container c, FilesAdminOptions.fileConfig config); + + ExpData getDataObject(WebdavResource resource, Container c); + QueryUpdateService getFilePropsUpdateService(TableInfo tinfo, Container container); + + void moveFileRoot(File prev, File dest, @Nullable User user, @Nullable Container container); + default void moveFileRoot(Path prev, Path dest, @Nullable User user, @Nullable Container container) + { + if (!FileUtil.hasCloudScheme(prev) && !FileUtil.hasCloudScheme(dest)) + { + moveFileRoot(prev.toFile(), dest.toFile(), user, container); + } + } + + /** Notifies all registered FileListeners that a file or directory has been created */ + void fireFileCreateEvent(@NotNull File created, @Nullable User user, @Nullable Container container); + default void fireFileCreateEvent(@NotNull Path created, @Nullable User user, @Nullable Container container) + { + if (!FileUtil.hasCloudScheme(created)) + fireFileCreateEvent(created.toFile(), user, container); + } + /** + * Notifies all registered FileListeners that a file or directory has moved + * @return number of rows updated across all listeners + */ + int fireFileMoveEvent(@NotNull File src, @NotNull File dest, @Nullable User user, @Nullable Container container); + default int fireFileMoveEvent(@NotNull Path src, @NotNull Path dest, @Nullable User user, @Nullable Container container) + { + if (!FileUtil.hasCloudScheme(src) && !FileUtil.hasCloudScheme(dest)) + return fireFileMoveEvent(src.toFile(), dest.toFile(), user, container); + return 0; + } + default int fireFileMoveEvent(@NotNull Path src, @NotNull Path dest, @Nullable User user, @Nullable Container sourceContainer, @Nullable Container targetContainer) + { + return fireFileMoveEvent(src, dest, user, sourceContainer); + } + + /** Notifies all registered FileListeners that a file or directory has been replaced */ + default void fireFileReplacedEvent(@NotNull Path replaced, @Nullable User user, @Nullable Container container){} + + /** Notifies all registered FileListeners that a file or directory has been deleted */ + default void fireFileDeletedEvent(@NotNull Path deleted, @Nullable User user, @Nullable Container container){} + + /** Add a listener that will be notified when files are created or are moved */ + void addFileListener(FileListener listener); + + Map> listFiles(@NotNull Container container); + + /** + * Returns a SQLFragment for file paths that this FileListener is aware of when the user is a site admin, or empty + * results otherwise. + * The expected columns are: + *
    + *
  • Container
  • + *
  • Created
  • + *
  • CreatedBy
  • + *
  • Modified
  • + *
  • ModifiedBy
  • + *
  • FilePath
  • + *
  • SourceKey
  • + *
  • SourceName
  • + *
+ */ + SQLFragment listFilesQuery(@NotNull User currentUser); + + void setWebfilesEnabled(boolean enabled, User user); + + /** + * Return file's virtual folder path that's relative to container's file root. Roots are matched in order of @assayfiles, @files, @pipeline and then each @filesets. + * @param dataFileUrl The data file Url of file + * @param container Container in the file system + * @return folder relative to file root + */ + String getDataFileRelativeFileRootPath(@NotNull String dataFileUrl, Container container); + + enum PathType { full, serverRelative, folderRelative } + + @Nullable + URI getWebDavUrl(@NotNull Path path, @NotNull Container container, @NotNull PathType type); + + @Nullable + URI getWebDavUrl(@NotNull FileLike path, @NotNull Container container, @NotNull PathType type); + + /** + * Ensure an entry in the exp.data table exists for all files in the container's file root. + */ + void ensureFileData(@NotNull ExpDataTable table); + + /** + * Allows a module to register a directory pattern to be checked in the files webpart in order to zip the matching directory before uploading. + * @param directoryPattern DirectoryPattern + * */ + void addZiploaderPattern(DirectoryPattern directoryPattern); + + /** + * Returns a list of DirectoryPattern objects for the active modules in the given container. + * */ + List getZiploaderPatterns(Container container); + + File getMoveTargetFile(String absoluteFilePath, @NotNull Container sourceContainer, @NotNull Container targetContainer); +} diff --git a/api/src/org/labkey/api/pipeline/AnalyzeForm.java b/api/src/org/labkey/api/pipeline/AnalyzeForm.java index 9a69e371a9c..fba680500e5 100644 --- a/api/src/org/labkey/api/pipeline/AnalyzeForm.java +++ b/api/src/org/labkey/api/pipeline/AnalyzeForm.java @@ -1,245 +1,246 @@ -/* - * Copyright (c) 2017 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.api.pipeline; - -import org.labkey.api.data.Container; -import org.labkey.api.pipeline.browse.PipelinePathForm; -import org.labkey.api.pipeline.file.AbstractFileAnalysisProtocol; -import org.labkey.api.security.User; -import org.labkey.api.util.FileType; -import org.labkey.api.util.FileUtil; - -import java.nio.file.Path; - -/** - * User: tgaluhn - * Date: 2/1/2017 - * - * Moved from AnalysisController - */ -public class AnalyzeForm extends PipelinePathForm -{ - public enum Params - { - path, taskId, file - } - - private String taskId = ""; - private String protocolName = ""; - private String protocolDescription = ""; - private String[] fileInputStatus = null; - private String configureXml; - private String configureJson; - private boolean saveProtocol = false; - private boolean runAnalysis = false; - private boolean activeJobs = false; - private Boolean allowNonExistentFiles; - private Boolean includeWorkbooks = false; - private boolean allowProtocolRedefinition = false; - private String pipelineDescription; - - private static final String UNKNOWN_STATUS = "UNKNOWN"; - - public AnalyzeForm() - {} - - public AnalyzeForm(Container container, User user, String taskId, String protocolName) - { - setContainer(container); - setUser(user); - setTaskId(taskId); - setProtocolName(protocolName); - } - - public void initStatus(AbstractFileAnalysisProtocol protocol, Path dirData, Path dirAnalysis) - { - if (fileInputStatus != null) - return; - - activeJobs = false; - - int len = getFile().length; - fileInputStatus = new String[len + 1]; - for (int i = 0; i < len; i++) - fileInputStatus[i] = initStatusFile(protocol, dirData, dirAnalysis, getFile()[i], true); - - // TODO comment why this special status is added at the end (or make this a separate variable) - fileInputStatus[len] = initStatusFile(protocol, dirData, dirAnalysis, null, false); - } - - private String initStatusFile(AbstractFileAnalysisProtocol protocol, Path dirData, Path dirAnalysis, - String fileInputName, boolean statusSingle) - { - if (protocol == null) - { - return UNKNOWN_STATUS; - } - - Path fileStatus = null; - - if (!statusSingle) - { - fileStatus = PipelineJob.FT_LOG.newFile(dirAnalysis, - protocol.getJoinedBaseName()); - } - else if (fileInputName != null) - { - Path fileInput = FileUtil.appendName(dirData, fileInputName); - FileType ft = protocol.findInputType(fileInput); - if (ft != null) - fileStatus = PipelineJob.FT_LOG.newFile(dirAnalysis, ft.getBaseName(fileInput)); - } - - if (fileStatus != null) - { - PipelineStatusFile sf = PipelineService.get().getStatusFile(getContainer(), fileStatus); - if (sf == null) - return null; - - activeJobs = activeJobs || sf.isActive(); - return sf.getStatus(); - } - - // Failed to get status. Assume job is active, and return unknown status. - activeJobs = true; - return UNKNOWN_STATUS; - } - - public String getTaskId() - { - return taskId; - } - - public void setTaskId(String taskId) - { - this.taskId = taskId; - } - - public String getConfigureXml() - { - return configureXml; - } - - public void setConfigureXml(String configureXml) - { - this.configureXml = (configureXml == null ? "" : configureXml); - } - - public String getConfigureJson() - { - return configureJson; - } - - public void setConfigureJson(String configureJson) - { - this.configureJson = configureJson; - } - - public String getProtocolName() - { - return protocolName; - } - - public void setProtocolName(String protocolName) - { - this.protocolName = (protocolName == null ? "" : protocolName); - } - - public String getProtocolDescription() - { - return protocolDescription; - } - - public void setProtocolDescription(String protocolDescription) - { - this.protocolDescription = (protocolDescription == null ? "" : protocolDescription); - } - - public String[] getFileInputStatus() - { - return fileInputStatus; - } - - public boolean isActiveJobs() - { - return activeJobs; - } - - public Boolean getIncludeWorkbooks() - { - return includeWorkbooks; - } - - public void setIncludeWorkbooks(Boolean includeWorkbooks) - { - this.includeWorkbooks = includeWorkbooks; - } - - public boolean isSaveProtocol() - { - return saveProtocol; - } - - public void setSaveProtocol(boolean saveProtocol) - { - this.saveProtocol = saveProtocol; - } - - public boolean isRunAnalysis() - { - return runAnalysis; - } - - public void setRunAnalysis(boolean runAnalysis) - { - this.runAnalysis = runAnalysis; - } - - public Boolean isAllowNonExistentFiles() - { - return allowNonExistentFiles; - } - - public void setAllowNonExistentFiles(Boolean allowNonExistentFiles) - { - this.allowNonExistentFiles = allowNonExistentFiles; - } - - public boolean isAllowProtocolRedefinition() - { - return allowProtocolRedefinition; - } - - public void setAllowProtocolRedefinition(boolean allowProtocolRedefinition) - { - this.allowProtocolRedefinition = allowProtocolRedefinition; - } - - public static String getDefaultXMLParameters() - { - return ""; - } - - public String getPipelineDescription() - { - return pipelineDescription; - } - - public void setPipelineDescription(String pipelineDescription) - { - this.pipelineDescription = pipelineDescription; - } -} +/* + * Copyright (c) 2017 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.api.pipeline; + +import org.labkey.api.data.Container; +import org.labkey.api.pipeline.browse.PipelinePathForm; +import org.labkey.api.pipeline.file.AbstractFileAnalysisProtocol; +import org.labkey.api.security.User; +import org.labkey.api.util.FileType; +import org.labkey.api.util.FileUtil; +import org.labkey.vfs.FileLike; + +import java.nio.file.Path; + +/** + * User: tgaluhn + * Date: 2/1/2017 + * + * Moved from AnalysisController + */ +public class AnalyzeForm extends PipelinePathForm +{ + public enum Params + { + path, taskId, file + } + + private String taskId = ""; + private String protocolName = ""; + private String protocolDescription = ""; + private String[] fileInputStatus = null; + private String configureXml; + private String configureJson; + private boolean saveProtocol = false; + private boolean runAnalysis = false; + private boolean activeJobs = false; + private Boolean allowNonExistentFiles; + private Boolean includeWorkbooks = false; + private boolean allowProtocolRedefinition = false; + private String pipelineDescription; + + private static final String UNKNOWN_STATUS = "UNKNOWN"; + + public AnalyzeForm() + {} + + public AnalyzeForm(Container container, User user, String taskId, String protocolName) + { + setContainer(container); + setUser(user); + setTaskId(taskId); + setProtocolName(protocolName); + } + + public void initStatus(AbstractFileAnalysisProtocol protocol, FileLike dirData, FileLike dirAnalysis) + { + if (fileInputStatus != null) + return; + + activeJobs = false; + + int len = getFile().length; + fileInputStatus = new String[len + 1]; + for (int i = 0; i < len; i++) + fileInputStatus[i] = initStatusFile(protocol, dirData, dirAnalysis, getFile()[i], true); + + // TODO comment why this special status is added at the end (or make this a separate variable) + fileInputStatus[len] = initStatusFile(protocol, dirData, dirAnalysis, null, false); + } + + private String initStatusFile(AbstractFileAnalysisProtocol protocol, FileLike dirData, FileLike dirAnalysis, + String fileInputName, boolean statusSingle) + { + if (protocol == null) + { + return UNKNOWN_STATUS; + } + + FileLike fileStatus = null; + + if (!statusSingle) + { + fileStatus = PipelineJob.FT_LOG.newFile(dirAnalysis, + protocol.getJoinedBaseName()); + } + else if (fileInputName != null) + { + FileLike fileInput = dirData.resolveChild(fileInputName); + FileType ft = protocol.findInputType(fileInput); + if (ft != null) + fileStatus = PipelineJob.FT_LOG.newFile(dirAnalysis, ft.getBaseName(fileInput)); + } + + if (fileStatus != null) + { + PipelineStatusFile sf = PipelineService.get().getStatusFile(getContainer(), fileStatus); + if (sf == null) + return null; + + activeJobs = activeJobs || sf.isActive(); + return sf.getStatus(); + } + + // Failed to get status. Assume job is active, and return unknown status. + activeJobs = true; + return UNKNOWN_STATUS; + } + + public String getTaskId() + { + return taskId; + } + + public void setTaskId(String taskId) + { + this.taskId = taskId; + } + + public String getConfigureXml() + { + return configureXml; + } + + public void setConfigureXml(String configureXml) + { + this.configureXml = (configureXml == null ? "" : configureXml); + } + + public String getConfigureJson() + { + return configureJson; + } + + public void setConfigureJson(String configureJson) + { + this.configureJson = configureJson; + } + + public String getProtocolName() + { + return protocolName; + } + + public void setProtocolName(String protocolName) + { + this.protocolName = (protocolName == null ? "" : protocolName); + } + + public String getProtocolDescription() + { + return protocolDescription; + } + + public void setProtocolDescription(String protocolDescription) + { + this.protocolDescription = (protocolDescription == null ? "" : protocolDescription); + } + + public String[] getFileInputStatus() + { + return fileInputStatus; + } + + public boolean isActiveJobs() + { + return activeJobs; + } + + public Boolean getIncludeWorkbooks() + { + return includeWorkbooks; + } + + public void setIncludeWorkbooks(Boolean includeWorkbooks) + { + this.includeWorkbooks = includeWorkbooks; + } + + public boolean isSaveProtocol() + { + return saveProtocol; + } + + public void setSaveProtocol(boolean saveProtocol) + { + this.saveProtocol = saveProtocol; + } + + public boolean isRunAnalysis() + { + return runAnalysis; + } + + public void setRunAnalysis(boolean runAnalysis) + { + this.runAnalysis = runAnalysis; + } + + public Boolean isAllowNonExistentFiles() + { + return allowNonExistentFiles; + } + + public void setAllowNonExistentFiles(Boolean allowNonExistentFiles) + { + this.allowNonExistentFiles = allowNonExistentFiles; + } + + public boolean isAllowProtocolRedefinition() + { + return allowProtocolRedefinition; + } + + public void setAllowProtocolRedefinition(boolean allowProtocolRedefinition) + { + this.allowProtocolRedefinition = allowProtocolRedefinition; + } + + public static String getDefaultXMLParameters() + { + return ""; + } + + public String getPipelineDescription() + { + return pipelineDescription; + } + + public void setPipelineDescription(String pipelineDescription) + { + this.pipelineDescription = pipelineDescription; + } +} diff --git a/api/src/org/labkey/api/pipeline/PipelineJob.java b/api/src/org/labkey/api/pipeline/PipelineJob.java index e872a09ce1f..9ea7b07c162 100644 --- a/api/src/org/labkey/api/pipeline/PipelineJob.java +++ b/api/src/org/labkey/api/pipeline/PipelineJob.java @@ -1,2018 +1,2031 @@ -/* - * Copyright (c) 2008-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.api.pipeline; - -import com.fasterxml.jackson.annotation.JsonAutoDetect; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.PropertyAccessor; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.SerializationFeature; -import com.fasterxml.jackson.databind.module.SimpleModule; -import datadog.trace.api.CorrelationIdentifier; -import datadog.trace.api.Trace; -import org.apache.commons.io.FileUtils; -import org.apache.commons.lang3.StringUtils; -import org.apache.logging.log4j.Level; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.apache.logging.log4j.Marker; -import org.apache.logging.log4j.ThreadContext; -import org.apache.logging.log4j.message.Message; -import org.apache.logging.log4j.simple.SimpleLogger; -import org.apache.logging.log4j.util.PropertiesUtil; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.labkey.api.action.NullSafeBindException; -import org.labkey.api.assay.AssayFileWriter; -import org.labkey.api.data.Container; -import org.labkey.api.exp.api.ExpRun; -import org.labkey.api.gwt.client.util.PropertyUtil; -import org.labkey.api.pipeline.file.FileAnalysisJobSupport; -import org.labkey.api.query.FieldKey; -import org.labkey.api.query.QueryKey; -import org.labkey.api.query.SchemaKey; -import org.labkey.api.reader.Readers; -import org.labkey.api.security.User; -import org.labkey.api.util.DateUtil; -import org.labkey.api.util.ExceptionUtil; -import org.labkey.api.util.FileType; -import org.labkey.api.util.FileUtil; -import org.labkey.api.util.GUID; -import org.labkey.api.util.Job; -import org.labkey.api.util.JsonUtil; -import org.labkey.api.util.NetworkDrive; -import org.labkey.api.util.QuietCloser; -import org.labkey.api.util.URLHelper; -import org.labkey.api.util.logging.LogHelper; -import org.labkey.api.view.ActionURL; -import org.labkey.api.view.ViewBackgroundInfo; -import org.labkey.api.writer.ContainerUser; -import org.labkey.api.writer.PrintWriters; -import org.labkey.remoteapi.query.Filter; -import org.quartz.CronExpression; - -import java.io.BufferedReader; -import java.io.BufferedWriter; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.FileWriter; -import java.io.IOException; -import java.io.InputStream; -import java.io.PrintWriter; -import java.io.Serializable; -import java.net.URI; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardOpenOption; -import java.sql.Time; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.Date; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicLong; - -/** - * A job represents the invocation of a pipeline on a certain set of inputs. It can be monolithic (a single run() method) - * or be comprised of multiple tasks ({@link Task}) that can be checkpointed and restarted individually. - */ -@JsonIgnoreProperties(value={"_logFilePathName"}, allowGetters = true) //Property removed. Added here for backwards compatibility -abstract public class PipelineJob extends Job implements Serializable, ContainerUser -{ - public static final FileType FT_LOG = new FileType(Arrays.asList(".log"), ".log", Arrays.asList("text/plain")); - - public static final String PIPELINE_EMAIL_ADDRESS_PARAM = "pipeline, email address"; - public static final String PIPELINE_USERNAME_PARAM = "pipeline, username"; - public static final String PIPELINE_PROTOCOL_NAME_PARAM = "pipeline, protocol name"; - public static final String PIPELINE_PROTOCOL_DESCRIPTION_PARAM = "pipeline, protocol description"; - public static final String PIPELINE_LOAD_FOLDER_PARAM = "pipeline, load folder"; - public static final String PIPELINE_JOB_INFO_PARAM = "pipeline, jobInfo"; - public static final String PIPELINE_TASK_INFO_PARAM = "pipeline, taskInfo"; - public static final String PIPELINE_TASK_OUTPUT_PARAMS_PARAM = "pipeline, taskOutputParams"; - - protected static Logger _log = LogHelper.getLogger(PipelineJob.class, "Execution and queuing of pipeline jobs"); - // Send start/stop messages to a separate logger because the default logger for this class is set to - // only write ERROR level events to the system log - private static final Logger _logJobStopStart = LogManager.getLogger(Job.class); - - public static Logger getJobLogger(Class clazz) - { - return LogManager.getLogger(PipelineJob.class.getName() + ".." + clazz.getName()); - } - - public RecordedActionSet getActionSet() - { - return _actionSet; - } - - /** - * Clear out the set of recorded actions - * @param run run that represents the previous set of recorded actions - */ - public void clearActionSet(ExpRun run) - { - _actionSet = new RecordedActionSet(); - } - - public enum TaskStatus - { - /** Job is in the queue, waiting for its turn to run */ - waiting - { - @Override - public boolean isActive() { return true; } - - @Override - public boolean matches(String statusText) - { - if (statusText == null) - return false; - else if (!TaskStatus.splitWaiting.matches(statusText) && statusText.toLowerCase().endsWith("waiting")) - return true; - return super.matches(statusText); - } - }, - /** Job is doing its work */ - running - { - @Override - public boolean isActive() { return true; } - }, - /** Terminal state, job is finished and completed without errors */ - complete - { - @Override - public boolean isActive() { return false; } - }, - /** Terminal state (but often retryable), job is done running and completed with error(s) */ - error - { - @Override - public boolean isActive() { return false; } - }, - /** Job is in the process of being cancelled, but may still be running or queued at the moment */ - cancelling - { - @Override - public boolean isActive() { return true; } - }, - /** Terminal state, indicating that a user cancelled the job before it completed or errored */ - cancelled - { - @Override - public boolean isActive() { return false; } - }, - splitWaiting - { - @Override - public boolean isActive() { return false; } - - @Override - public String toString() { return "SPLIT WAITING"; } - }; - - /** @return whether this step is considered to be actively running */ - public abstract boolean isActive(); - - public String toString() - { - return super.toString().toUpperCase(); - } - - public boolean matches(String statusText) - { - return toString().equalsIgnoreCase(statusText); - } - - public final String getNotificationType() - { - return getClass().getName() + "." + name(); - } - } - - /** - * Implements a runnable to complete a part of the - * processing associated with a particular PipelineJob. This is often the execution of an external tool, - * the importing of files into the database, etc. - */ - abstract static public class Task - { - private final PipelineJob _job; - protected FactoryType _factory; - - public Task(FactoryType factory, PipelineJob job) - { - _job = job; - _factory = factory; - } - - public PipelineJob getJob() - { - return _job; - } - - /** - * Do the work of the task. The task should not set the status of the job to complete - this will be handled - * by the caller. - * @return the files used as inputs and generated as outputs, and the steps that operated on them - * @throws PipelineJobException if something went wrong during the execution of the job. The caller will - * handle setting the job's status to ERROR. - */ - @NotNull - public abstract RecordedActionSet run() throws PipelineJobException; - } - - /* - * JMS message header names - */ - private static final String HEADER_PREFIX = "LABKEY_"; - public static final String LABKEY_JOBTYPE_PROPERTY = HEADER_PREFIX + "JOBTYPE"; - public static final String LABKEY_JOBID_PROPERTY = HEADER_PREFIX + "JOBID"; - public static final String LABKEY_CONTAINERID_PROPERTY = HEADER_PREFIX + "CONTAINERID"; - public static final String LABKEY_TASKPIPELINE_PROPERTY = HEADER_PREFIX + "TASKPIPELINE"; - public static final String LABKEY_TASKID_PROPERTY = HEADER_PREFIX + "TASKID"; - public static final String LABKEY_TASKSTATUS_PROPERTY = HEADER_PREFIX + "TASKSTATUS"; - /** The execution location to which the job's current task is assigned */ - public static final String LABKEY_LOCATION_PROPERTY = HEADER_PREFIX + "LOCATION"; - - private String _provider; - private ViewBackgroundInfo _info; - private String _jobGUID; - private String _parentGUID; - private TaskId _activeTaskId; - @NotNull - private TaskStatus _activeTaskStatus; - private int _activeTaskRetries; - @NotNull - private PipeRoot _pipeRoot; - volatile private boolean _interrupted; - private boolean _submitted; - private int _errors; - private RecordedActionSet _actionSet = new RecordedActionSet(); - - private String _loggerLevel = Level.DEBUG.toString(); - - // Don't save these - protected transient Logger _logger; - private transient boolean _settingStatus; - private transient PipelineQueue _queue; - - private Path _logFile; - private LocalDirectory _localDirectory; - - // Default constructor for serialization - protected PipelineJob() - { - } - - /** Although having a null provider is legal, it is recommended that one be used - * so that it can respond to events as needed */ - public PipelineJob(@Nullable String provider, ViewBackgroundInfo info, @NotNull PipeRoot root) - { - _info = info; - _provider = provider; - _jobGUID = GUID.makeGUID(); - _activeTaskStatus = TaskStatus.waiting; - - - _pipeRoot = root; - - _actionSet = new RecordedActionSet(); - } - - public PipelineJob(PipelineJob job) - { - // Not yet queued - _queue = null; - - // New ID - _jobGUID = GUID.makeGUID(); - - // Copy everything else - _info = job._info; - _provider = job._provider; - _parentGUID = job._jobGUID; - _pipeRoot = job._pipeRoot; - _interrupted = job._interrupted; - _submitted = job._submitted; - _errors = job._errors; - _loggerLevel = job._loggerLevel; - _logger = job._logger; - _logFile = job._logFile; - - _activeTaskId = job._activeTaskId; - _activeTaskStatus = job._activeTaskStatus; - - _actionSet = new RecordedActionSet(job.getActionSet()); - _localDirectory = job._localDirectory; - } - - public String getProvider() - { - return _provider; - } - - @Deprecated - public void setProvider(String provider) - { - _provider = provider; - } - - public int getErrors() - { - return _errors; - } - - public void setErrors(int errors) - { - if (errors > 0) - _activeTaskStatus = TaskStatus.error; - - _errors = errors; - } - - /** - * This job has been restored from a checkpoint for the purpose of - * a retry. Record retry information before it is checkpointed again. - */ - public void retryUpdate() - { - _errors++; - _activeTaskRetries++; - } - - public Map getParameters() - { - return Collections.emptyMap(); - } - - public String getJobGUID() - { - return _jobGUID; - } - - public String getParentGUID() - { - return _parentGUID; - } - - @Nullable - public TaskId getActiveTaskId() - { - return _activeTaskId; - } - - public boolean setActiveTaskId(@Nullable TaskId activeTaskId) - { - return setActiveTaskId(activeTaskId, true); - } - - public boolean setActiveTaskId(@Nullable TaskId activeTaskId, boolean updateStatus) - { - if (activeTaskId == null || !activeTaskId.equals(_activeTaskId)) - { - _activeTaskId = activeTaskId; - _activeTaskRetries = 0; - } - if (_activeTaskId == null) - _activeTaskStatus = TaskStatus.complete; - else - _activeTaskStatus = TaskStatus.waiting; - - return !updateStatus || updateStatusForTask(); - } - - @NotNull - public TaskStatus getActiveTaskStatus() - { - return _activeTaskStatus; - } - - /** @return whether the status was set successfully */ - public boolean setActiveTaskStatus(@NotNull TaskStatus activeTaskStatus) - { - _activeTaskStatus = activeTaskStatus; - return updateStatusForTask(); - } - - public TaskFactory getActiveTaskFactory() - { - if (getActiveTaskId() == null) - return null; - - return PipelineJobService.get().getTaskFactory(getActiveTaskId()); - } - - @NotNull - public PipeRoot getPipeRoot() - { - return _pipeRoot; - } - - @Deprecated //Please switch to the Path version - public void setLogFile(File logFile) - { - setLogFile(logFile.toPath()); - } - - public void setLogFile(Path logFile) - { - // Set Log file path and clear/reset logger - _logFile = logFile.toAbsolutePath().normalize(); - _logger = null; //This should trigger getting the new Logger next time getLogger is called - } - - public File getLogFile() - { - Path logFilePath = getLogFilePath(); - if (null != logFilePath && !FileUtil.hasCloudScheme(logFilePath)) - return logFilePath.toFile(); - return null; - } - - public Path getLogFilePath() - { - return _logFile; - } - - /** - * Get the remote log path (if local dir set) else return getLogFilePath - * - * TODO: Better name getStatusKeyPath? or similar - */ - public Path getRemoteLogPath() - { - LocalDirectory dir = getLocalDirectory(); - if (dir == null) - return getLogFilePath(); - - return dir.getRemoteLogFilePath(); - } - - /** Finds a file name that hasn't been used yet, appending ".2", ".3", etc as needed */ - public static File findUniqueLogFile(File primaryFile, String baseName) - { - String validBaseName = FileUtil.makeLegalName(baseName); - // need to look in current and archived dirs for any unused log file names (issue 20987) - File fileLog = FT_LOG.newFile(primaryFile.getParentFile(), validBaseName); - File archivedDir = FileUtil.appendName(primaryFile.getParentFile(), AssayFileWriter.ARCHIVED_DIR_NAME); - File fileLogArchived = FT_LOG.newFile(archivedDir, validBaseName); - - int index = 1; - while (NetworkDrive.exists(fileLog) || NetworkDrive.exists(fileLogArchived)) - { - fileLog = FT_LOG.newFile(primaryFile.getParentFile(), validBaseName + "." + (index)); - fileLogArchived = FT_LOG.newFile(archivedDir, validBaseName + "." + (index++)); - } - - return fileLog; - } - - - public LocalDirectory getLocalDirectory() - { - return _localDirectory; - } - - protected void setLocalDirectory(LocalDirectory localDirectory) - { - _localDirectory = localDirectory; - } - - public static PipelineJob readFromFile(File file) throws IOException, PipelineJobException - { - StringBuilder serializedJob = new StringBuilder(); - try (InputStream fIn = new FileInputStream(file)) - { - BufferedReader reader = Readers.getReader(fIn); - String line; - while ((line = reader.readLine()) != null) - { - serializedJob.append(line); - } - } - - PipelineJob job = PipelineJob.deserializeJob(serializedJob.toString()); - if (null == job) - { - throw new PipelineJobException("Unable to deserialize job"); - } - return job; - } - - - public void writeToFile(File file) throws IOException - { - File newFile = new File(file.getPath() + ".new"); - File origFile = new File(file.getPath() + ".orig"); - - String serializedJob = serializeJob(true); - - try (FileOutputStream fOut = new FileOutputStream(newFile)) - { - PrintWriter writer = PrintWriters.getPrintWriter(fOut); - writer.write(serializedJob); - writer.flush(); - } - - if (NetworkDrive.exists(file)) - { - if (origFile.exists()) - { - // Might be left over from some bad previous run - origFile.delete(); - } - // Don't use File.renameTo() because it doesn't always work depending on the underlying file system - FileUtils.moveFile(file, origFile); - FileUtils.moveFile(newFile, file); - origFile.delete(); - } - else - { - FileUtils.moveFile(newFile, file); - } - PipelineJobService.get().getWorkDirFactory().setPermissions(file); - } - - public boolean updateStatusForTask() - { - TaskFactory factory = getActiveTaskFactory(); - TaskStatus status = getActiveTaskStatus(); - - if (factory != null && !TaskStatus.error.equals(status) && !TaskStatus.cancelled.equals(status)) - return setStatus(factory.getStatusName() + " " + status.toString().toUpperCase()); - else - return setStatus(status); - } - - /** Used for setting status to one of the standard states */ - public boolean setStatus(@NotNull TaskStatus status) - { - return setStatus(status.toString()); - } - - /** - * Used for setting status to a custom state, which is considered to be equivalent to TaskStatus.running - * unless it matches one of the standard states - * @throws CancelledException if the job was cancelled by a user and should stop execution - */ - public boolean setStatus(@NotNull String status) - { - return setStatus(status, null); - } - - /** - * Used for setting status to one of the standard states - * @param info more verbose detail on the job's status, such as a percent complete - * @throws CancelledException if the job was cancelled by a user and should stop execution - */ - public boolean setStatus(@NotNull TaskStatus status, @Nullable String info) - { - return setStatus(status.toString(), info); - } - - /** - * @param info more verbose detail on the job's status, such as a percent complete - * @throws CancelledException if the job was cancelled by a user and should stop execution - */ - public boolean setStatus(@NotNull String status, @Nullable String info) - { - return setStatus(status, info, false); - } - - /** - * Used for setting status to a custom state, which is considered to be equivalent to TaskStatus.running - * unless it matches one of the standard states - * @throws CancelledException if the job was cancelled by a user and should stop execution - */ - public boolean setStatus(@NotNull String status, @Nullable String info, boolean allowInsert) - { - if (_settingStatus) - return true; - - _settingStatus = true; - try - { - boolean statusSet = PipelineJobService.get().getStatusWriter().setStatus(this, status, info, allowInsert); - if (!statusSet) - { - setActiveTaskStatus(TaskStatus.error); - } - return statusSet; - } - // Rethrow so it doesn't get handled like other RuntimeExceptions - catch (CancelledException e) - { - _activeTaskStatus = TaskStatus.cancelled; - throw e; - } - catch (RuntimeException e) - { - Path f = this.getLogFilePath(); - error("Failed to set status to '" + status + "' for '" + - (f == null ? "" : f.toString()) + "'.", e); - throw e; - } - catch (Exception e) - { - Path f = this.getLogFilePath(); - error("Failed to set status to '" + status + "' for '" + - (f == null ? "" : f.toString()) + "'.", e); - } - finally - { - _settingStatus = false; - } - return false; - } - - public void restoreQueue(PipelineQueue queue) - { - // Recursive split and join combinations may cause the queue - // to be restored to a job with a queue already. Would be good - // to have better safe-guards against double-queueing of jobs. - if (queue == _queue) - return; - if (null != _queue) - throw new IllegalStateException(); - _queue = queue; - } - - public void restoreLocalDirectory() - { - if (null != _localDirectory) - setLogFile(_localDirectory.restore()); - } - - public void validateParameters() throws PipelineValidationException - { - TaskPipeline taskPipeline = getTaskPipeline(); - if (taskPipeline != null) - { - for (TaskId taskId : taskPipeline.getTaskProgression()) - { - TaskFactory taskFactory = PipelineJobService.get().getTaskFactory(taskId); - if (taskFactory == null) - throw new PipelineValidationException("Task '" + taskId + "' not found"); - taskFactory.validateParameters(this); - } - } - } - - public boolean setQueue(PipelineQueue queue, TaskStatus initialState) - { - return setQueue(queue, initialState.toString()); - } - - public boolean setQueue(PipelineQueue queue, String initialState) - { - restoreQueue(queue); - - // Initialize the task pipeline - TaskPipeline taskPipeline = getTaskPipeline(); - if (taskPipeline != null) - { - // Save the current job state marshalled to XML, in case of error. - String serializedJob = serializeJob(true); - - // Note runStateMachine returns false, if the job cannot be run locally. - // The job may still need to be put on a JMS queue for remote processing. - // Therefore, the return value cannot be used to determine whether the - // job should be queued. - runStateMachine(); - - // If an error occurred trying to find the first runnable state, then - // store the original job state to allow retry. - if (getActiveTaskStatus() == TaskStatus.error) - { - try - { - PipelineJob originalJob = PipelineJob.deserializeJob(serializedJob); - if (null != originalJob) - originalJob.store(); - else - warn("Failed to checkpoint '" + getDescription() + "' job."); - - } - catch (Exception e) - { - warn("Failed to checkpoint '" + getDescription() + "' job.", e); - } - return false; - } - - // If initialization put this job into a state where it is - // waiting, then it should not be put on the queue. - return !isSplitWaiting(); - } - // Initialize status for non-task pipeline jobs. - else if (_logFile != null) - { - setStatus(initialState); - try - { - store(); - } - catch (Exception e) - { - warn("Failed to checkpoint '" + getDescription() + "' job before queuing.", e); - } - } - - return true; - } - - public void clearQueue() - { - _queue = null; - } - - abstract public URLHelper getStatusHref(); - - abstract public String getDescription(); - - public String toString() - { - return super.toString() + " " + StringUtils.trimToEmpty(getDescription()); - } - - public T getJobSupport(Class inter) - { - if (inter.isInstance(this)) - return (T) this; - - throw new UnsupportedOperationException("Job type " + getClass().getName() + - " does not implement " + inter.getName()); - } - - /** - * Override to provide a TaskPipeline with the option of - * running some tasks remotely. Override the run() function - * to implement the job as a single monolithic task. - * - * @return a task pipeline to run for this job - */ - @Nullable - public TaskPipeline getTaskPipeline() - { - return null; - } - - public boolean isActiveTaskLocal() - { - TaskFactory factory = getActiveTaskFactory(); - return (factory != null && - TaskFactory.WEBSERVER.equalsIgnoreCase(factory.getExecutionLocation())); - } - - public void runActiveTask() throws IOException, PipelineJobException - { - TaskFactory factory = getActiveTaskFactory(); - if (factory == null) - return; - - if (!factory.isJobComplete(this)) - { - Task task = factory.createTask(this); - if (task == null) - return; // Bad task key. - - if (!setActiveTaskStatus(TaskStatus.running)) - { - // The user has deleted (cancelled) the job. - // Throwing this exception will cause the job to go to the ERROR state and stop running - throw new PipelineJobException("Job no longer in database - aborting"); - } - - WorkDirectory workDirectory = null; - RecordedActionSet actions; - - boolean success = false; - try - { - logStartStopInfo("Starting to run task '" + factory.getId() + "' for job '" + this + "' with log file " + getLogFilePath()); - getLogger().info("Starting to run task '" + factory.getId() + "' at location '" + factory.getExecutionLocation() + "'"); - if (PipelineJobService.get().getLocationType() != PipelineJobService.LocationType.WebServer) - { - PipelineJobService.RemoteServerProperties remoteProps = PipelineJobService.get().getRemoteServerProperties(); - if (remoteProps != null) - { - getLogger().info("on host: '" + remoteProps.getHostName() + "'"); - } - } - - if (task instanceof WorkDirectoryTask wdTask) - { - workDirectory = factory.createWorkDirectory(getJobGUID(), getJobSupport(FileAnalysisJobSupport.class), getLogger()); - wdTask.setWorkDirectory(workDirectory); - } - - actions = task.run(); - success = true; - } - finally - { - getLogger().info((success ? "Successfully completed" : "Failed to complete") + " task '" + factory.getId() + "'"); - logStartStopInfo((success ? "Successfully completed" : "Failed to complete") + " task '" + factory.getId() + "' for job '" + this + "' with log file " + getLogFile()); - - try - { - if (workDirectory != null) - { - workDirectory.remove(success); - ((WorkDirectoryTask)task).setWorkDirectory(null); - } - } - catch (IOException e) - { - // Don't let this cleanup error mask an original error that causes the job to fail - if (success) - { - // noinspection ThrowFromFinallyBlock - throw e; - } - else - { - if (e.getMessage() != null) - { - error(e.getMessage()); - } - else - { - error("Failed to clean up work directory after error condition, see full error information below.", e); - } - } - } - } - _actionSet.add(actions); - - // An error occurred running the task. Do not complete. - if (TaskStatus.error.equals(getActiveTaskStatus())) - return; - } - else - { - logStartStopInfo("Skipping already completed task '" + factory.getId() + "' for job '" + this + "' with log file " + getLogFile()); - getLogger().info("Skipping already completed task '" + factory.getId() + "' at location '" + factory.getExecutionLocation() + "'"); - } - - if (getActiveTaskStatus() != TaskStatus.complete && getActiveTaskStatus() != TaskStatus.cancelled) - setActiveTaskStatus(TaskStatus.complete); - } - - public static void logStartStopInfo(String message) - { - _logJobStopStart.info(message); - } - - public boolean runStateMachine() - { - TaskPipeline pipeline = getTaskPipeline(); - if (pipeline == null) - { - assert false : "Either override getTaskPipeline() or run() for " + getClass(); - - // Best we can do is to complete the job. - setActiveTaskId(null); - return false; - } - - TaskId[] progression = pipeline.getTaskProgression(); - int i = 0; - if (_activeTaskId != null) - { - i = indexOfActiveTask(progression); - if (i == -1) - { - error("Active task " + _activeTaskId + " not found in task pipeline."); - return false; - } - } - - switch (_activeTaskStatus) - { - case waiting: - return findRunnableTask(progression, i); - - case complete: - // See if the job has already completed. - if (_activeTaskId == null) - return false; - - return findRunnableTask(progression, i + 1); - - case error: - // Make sure the status is in error state, so that any auto-retry that - // may occur will record the error. And, if no retry occurs, then this - // job must be in error state. - try - { - PipelineJobService.get().getStatusWriter().ensureError(this); - } - catch (Exception e) - { - warn("Failed to ensure error status on task error.", e); - } - - // Run auto-retry, and retry if appropriate. - autoRetry(); - return false; - - case running: - case cancelled: - case cancelling: - default: - return false; // Do not run the active task. - } - } - - private int indexOfActiveTask(TaskId[] progression) - { - for (int i = 0; i < progression.length; i++) - { - TaskFactory factory = PipelineJobService.get().getTaskFactory(progression[i]); - if (factory == null) - { - throw new IllegalStateException("Could not find factory for " + progression[i]); - } - if (factory.getId().equals(_activeTaskId) || - factory.getActiveId(this).equals(_activeTaskId)) - return i; - } - return -1; - } - - private boolean findRunnableTask(TaskId[] progression, int i) - { - // Search for next task that is not already complete - TaskFactory factory = null; - while (i < progression.length) - { - try - { - factory = PipelineJobService.get().getTaskFactory(progression[i]); - if (factory == null) - { - throw new IllegalStateException("Could not find factory for " + progression[i]); - } - // Stop, if this task requires a change in join state - if ((factory.isJoin() && isSplitJob()) || (!factory.isJoin() && isSplittable())) - break; - // Stop, if this task is part of processing this job, and not complete - if (factory.isParticipant(this) && !factory.isJobComplete(this)) - break; - } - catch (IOException e) - { - error(e.getMessage()); - return false; - } - - i++; - } - - if (i < progression.length) - { - if (factory.isJoin() && isSplitJob()) - { - setActiveTaskId(factory.getId(), false); // ID is just a marker for state machine - join(); - return false; - } - else if (!factory.isJoin() && isSplittable()) - { - setActiveTaskId(factory.getId(), false); // ID is just a marker for state machine - split(); - return false; - } - - // Set next task to be run - if (!setActiveTaskId(factory.getActiveId(this))) - { - return false; - } - - // If it is local, then it can be run - return isActiveTaskLocal(); - } - else - { - // Job is complete - if (isSplitJob()) - { - setActiveTaskId(null, false); - join(); - } - else - { - setActiveTaskId(null); - } - return false; - } - } - - public boolean isAutoRetry() - { - TaskFactory factory = getActiveTaskFactory(); - return null != factory && _activeTaskRetries < factory.getAutoRetry() && factory.isAutoRetryEnabled(this); - } - - public boolean autoRetry() - { - try - { - if (isAutoRetry()) - { - info("Attempting to auto-retry"); - PipelineJobService.get().getJobStore().retry(getJobGUID()); - // Retry has been queued - return true; - } - } - catch (IOException | NoSuchJobException e) - { - warn("Failed to start automatic retry.", e); - } - return false; - } - - /** - * Subclasses that override this method instead of defining a task pipeline are responsible for setting the job's - * status at the end of their execution to either COMPLETE or ERROR - */ - @Override @Trace - public void run() - { - assert ThreadContext.isEmpty(); // Prevent/detect leaks - // Connect log messages with the active trace and span - ThreadContext.put(CorrelationIdentifier.getTraceIdKey(), CorrelationIdentifier.getTraceId()); - ThreadContext.put(CorrelationIdentifier.getSpanIdKey(), CorrelationIdentifier.getSpanId()); - - try - { - // The act of queueing the job runs the state machine for the first time. - do - { - try - { - runActiveTask(); - } - catch (IOException | PipelineJobException e) - { - error(e.getMessage(), e); - } - catch (CancelledException e) - { - throw e; - } - catch (RuntimeException e) - { - error(e.getMessage(), e); - ExceptionUtil.logExceptionToMothership(null, e); - // Rethrow to let the standard Mule exception handler fire and deal with the job state - throw e; - } - } - while (runStateMachine()); - } - catch (CancelledException e) - { - _activeTaskStatus = TaskStatus.cancelled; - // Don't need to do anything else, job has already been set to CANCELLED - } - finally - { - PipelineService.get().getPipelineQueue().almostDone(this); - - ThreadContext.remove(CorrelationIdentifier.getTraceIdKey()); - ThreadContext.remove(CorrelationIdentifier.getSpanIdKey()); - } - } - - // Should be called in run()'s finally by any class that overrides run(), if class uses LocalDirectory - protected void finallyCleanUpLocalDirectory() - { - if (null != _localDirectory && isDone()) - { - try - { - Path remoteLogFilePath = _localDirectory.cleanUpLocalDirectory(); - - //Update job log entry's log location to remote path - if (null != remoteLogFilePath) - { - //NOTE: any errors here can't be recorded to job log as it may no longer be local and writable - setLogFile(remoteLogFilePath); - setStatus(getActiveTaskStatus()); // Force writing to statusFiles - } - } - catch (JobLogInaccessibleException e) - { - // Can't write to job log as the log file is either null or inaccessible. - ExceptionUtil.logExceptionToMothership(null, e); - } - catch (Exception e) - { - // Attempt to record the error to the log. Move failed, so log should still be local and writable. - error("Error trying to move log file", e); - } - } - } - - /** - * Override and return true for job that may be split. Also, override - * the createSplitJobs() method to return the sub-jobs. - * - * @return true if the job may be split - */ - public boolean isSplittable() - { - return false; - } - - /** - * @return true if this is a split job, as determined by whether it has a parent. - */ - public boolean isSplitJob() - { - return getParentGUID() != null; - } - - /** - * @return true if this is a join job waiting for split jobs to complete. - */ - public boolean isSplitWaiting() - { - // Return false, if this job cannot be split. - if (!isSplittable()) - return false; - - // A join job with an active task that is not a join task, - // is waiting for a split to complete. - TaskFactory factory = getActiveTaskFactory(); - return (factory != null && !factory.isJoin()); - } - - /** - * Override and return instances of sub-jobs for a splittable job. - * - * @return sub-jobs requiring separate processing - */ - public List createSplitJobs() - { - return Collections.singletonList(this); - } - - /** - * Handles merging accumulated changes from split jobs into this job, which - * is a joined job. - * - * @param job the split job that has run to completion - */ - public void mergeSplitJob(PipelineJob job) - { - // Add experiment actions recorded. - _actionSet.add(job.getActionSet()); - - // Add any errors that happened in the split job. - _errors += job._errors; - } - - public void store() throws NoSuchJobException - { - PipelineJobService.get().getJobStore().storeJob(this); - } - - private void split() - { - try - { - PipelineJobService.get().getJobStore().split(this); - } - catch (IOException e) - { - error(e.getMessage(), e); - } - } - - private void join() - { - try - { - PipelineJobService.get().getJobStore().join(this); - } - catch (IOException | NoSuchJobException e) - { - error(e.getMessage(), e); - } - } - - ///////////////////////////////////////////////////////////////////////// - // Support for running processes - - @Nullable - private PrintWriter createPrintWriter(@Nullable File outputFile, boolean append) throws PipelineJobException - { - if (outputFile == null) - return null; - - try - { - return new PrintWriter(new BufferedWriter(new FileWriter(outputFile, append))); - } - catch (IOException e) - { - throw new PipelineJobException("Could not create the " + outputFile + " file.", e); - } - } - - public void runSubProcess(ProcessBuilder pb, File dirWork) throws PipelineJobException - { - runSubProcess(pb, dirWork, null, 0, false); - } - - /** - * If logLineInterval is greater than 1, the first logLineInterval lines of output will be written to the - * job's main log file. - */ - public void runSubProcess(ProcessBuilder pb, File dirWork, File outputFile, int logLineInterval, boolean append) - throws PipelineJobException - { - runSubProcess(pb, dirWork, outputFile, logLineInterval, append, 0, null); - } - - public void runSubProcess(ProcessBuilder pb, File dirWork, File outputFile, int logLineInterval, boolean append, long timeout, TimeUnit timeoutUnit) - throws PipelineJobException - { - Process proc; - - String commandName = pb.command().get(0); - commandName = commandName.substring( - Math.max(commandName.lastIndexOf('/'), commandName.lastIndexOf('\\')) + 1); - header(commandName + " output"); - - // Update PATH environment variable to make sure all files in the tools - // directory and the directory of the executable or on the path. - String toolDir = PipelineJobService.get().getAppProperties().getToolsDirectory(); - if (!StringUtils.isEmpty(toolDir)) - { - String path = System.getenv("PATH"); - if (path == null) - { - path = toolDir; - } - else - { - path = toolDir + File.pathSeparatorChar + path; - } - - // If the command has a path, then prepend its parent directory to the PATH - // environment variable as well. - String exePath = pb.command().get(0); - if (exePath != null && !exePath.isEmpty() && exePath.indexOf(File.separatorChar) != -1) - { - File fileExe = new File(exePath); - String exeDir = fileExe.getParent(); - if (!exeDir.equals(toolDir) && fileExe.exists()) - path = fileExe.getParent() + File.pathSeparatorChar + path; - } - - pb.environment().put("PATH", path); - - String dyld = System.getenv("DYLD_LIBRARY_PATH"); - if (dyld == null) - { - dyld = toolDir; - } - else - { - dyld = toolDir + File.pathSeparatorChar + dyld; - } - pb.environment().put("DYLD_LIBRARY_PATH", dyld); - } - - // tell more modern TPP tools to run headless (so no perl calls etc) bpratt 4-14-09 - pb.environment().put("XML_ONLY", "1"); - // tell TPP tools not to mess with tmpdirs, we handle this at higher level - pb.environment().put("WEBSERVER_TMP",""); - - try - { - pb.directory(dirWork); - - // TODO: Errors should go to log even when output is redirected to a file. - pb.redirectErrorStream(true); - - info("Working directory is " + dirWork.getAbsolutePath()); - info("running: " + StringUtils.join(pb.command().iterator(), " ")); - - proc = pb.start(); - } - catch (SecurityException se) - { - throw new PipelineJobException("Failed starting process '" + pb.command() + "'. Permissions do not allow execution.", se); - } - catch (IOException eio) - { - throw new PipelineJobException("Failed starting process '" + pb.command() + "'", eio); - } - - - try (QuietCloser ignored = PipelineJobService.get().trackForCancellation(proc)) - { - // create thread pool for collecting the process output - ExecutorService pool = Executors.newSingleThreadExecutor(); - - try (PrintWriter fileWriter = createPrintWriter(outputFile, append)) - { - // collect output using separate thread so we can enforce a timeout on the process - Future output = pool.submit(() -> { - try (BufferedReader procReader = Readers.getReader(proc.getInputStream())) - { - String line; - int count = 0; - while ((line = procReader.readLine()) != null) - { - count++; - if (fileWriter == null) - info(line); - else - { - if (logLineInterval > 0 && count < logLineInterval) - info(line); - else if (count == logLineInterval) - info("Writing additional tool output lines to " + outputFile.getName()); - fileWriter.println(line); - } - } - return count; - } - }); - - try - { - if (timeout > 0) - { - if (!proc.waitFor(timeout, timeoutUnit)) - { - proc.destroyForcibly().waitFor(); - - error("Process killed after exceeding timeout of " + timeout + " " + timeoutUnit.name().toLowerCase()); - } - } - else - { - proc.waitFor(); - } - - int result = proc.exitValue(); - if (result != 0) - { - throw new ToolExecutionException("Failed running " + pb.command().get(0) + ", exit code " + result, result); - } - - int count = output.get(); - if (fileWriter != null) - info(count + " lines written total to " + outputFile.getName()); - } - catch (InterruptedException ei) - { - throw new PipelineJobException("Interrupted process for '" + dirWork.getPath() + "'.", ei); - } - catch (ExecutionException e) - { - // Exception thrown in output collecting thread - Throwable cause = e.getCause(); - if (cause instanceof IOException) - throw new PipelineJobException("Failed writing output for process in '" + dirWork.getPath() + "'.", cause); - - throw new PipelineJobException(cause); - } - } - finally - { - pool.shutdownNow(); - } - } - } - - public String getLogLevel() - { - return _loggerLevel; - } - - public void setLogLevel(String level) - { - if (!_loggerLevel.equals(level)) - { - _loggerLevel = level; - _logger = null; // Reset the logger - } - } - - public Logger getClassLogger() - { - return _log; - } - - private static class OutputLogger extends SimpleLogger - { - private final PipelineJob _job; - private boolean _isSettingStatus; - private final Path _file; - private final String LINE_SEP = System.lineSeparator(); - private final String datePattern = "dd MMM yyyy HH:mm:ss,SSS"; - - protected OutputLogger(PipelineJob job, Path file, String name, Level level) - { - super(name, level, false, false, false, false, "", null, new PropertiesUtil(PropertiesUtil.getSystemProperties()), null); - _job = job; - _file = file; - } - - // called from LogOutputStream.flush() - @Override - public void log(Level level, String message) - { - _job.getClassLogger().log(level, message); - write(message, null, level.toString()); - } - - private String getSystemLogMessage(Object message) - { - StringBuilder sb = new StringBuilder(); - sb.append("(from pipeline job log file "); - sb.append(_job.getLogFile().toString()); - if (message != null) - { - sb.append(": "); - String stringMessage = message.toString(); - // Limit the maximum line length - final int maxLength = 10000; - if (stringMessage.length() > maxLength) - { - stringMessage = stringMessage.substring(0, maxLength) + "..."; - } - sb.append(stringMessage); - } - sb.append(")"); - return sb.toString(); - } - - public void setErrorStatus(Object message) - { - if (_isSettingStatus || _job._activeTaskStatus == TaskStatus.cancelled) - return; - - _isSettingStatus = true; - try - { - _job.setStatus(TaskStatus.error, message == null ? "ERROR" : message.toString()); - } - finally - { - _isSettingStatus = false; - } - } - - @Override - public void logMessage(String fqcn, Level mgsLevel, Marker marker, Message msg, Throwable throwable) - { - if (_job.getClassLogger().isEnabled(mgsLevel, marker)) - { - _job.getClassLogger().log(mgsLevel, marker, new Message() - { - @Override - public String getFormattedMessage() - { - return getSystemLogMessage(msg.getFormattedMessage()); - } - - @Override - public Object[] getParameters() - { - return msg.getParameters(); - } - - @Override - public Throwable getThrowable() - { - return msg.getThrowable(); - } - }, throwable); - } - - // Write to the job's log before setting the error status, which may end up throwing a CancelledException - // to signal that we need to bail out right away - write(msg.getFormattedMessage(), throwable, mgsLevel.getStandardLevel().name()); - - if (mgsLevel.isMoreSpecificThan(Level.ERROR)) - { - setErrorStatus(msg.getFormattedMessage()); - } - } - - private void write(String message, @Nullable Throwable t, String level) - { - String formattedDate = DateUtil.formatDateTime(new Date(), datePattern); - - try (PrintWriter writer = new PrintWriter(Files.newBufferedWriter(_file, StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.APPEND))) - { - var line = formattedDate + " " + - String.format("%-5s", level) + - ": " + - message; - writer.write(line); - writer.write(LINE_SEP); - if (null != t) - { - t.printStackTrace(writer); - } - } - catch (IOException e) - { - Path parentFile = _file.getParent(); - if (parentFile != null && !NetworkDrive.exists(parentFile)) - { - try - { - FileUtil.createDirectories(parentFile); - write(message, t, level); - } - catch (IOException dirE) - { - _log.error("Failed appending to file. Unable to create parent directories", e); - } - } - else - _log.error("Failed appending to file.", e); - } - } - } - - public static class JobLogInaccessibleException extends IllegalStateException - { - public JobLogInaccessibleException(String message) - { - super(message); - } - } - - // Multiple threads log messages, so synchronize to make sure that no one gets a partially initialized logger - public synchronized Logger getLogger() - { - if (_logger == null) - { - if (null == _logFile || FileUtil.hasCloudScheme(_logFile)) - throw new JobLogInaccessibleException("LogFile null or cloud."); - - // Create appending logger. - String loggerName = PipelineJob.class.getSimpleName() + ".Logger." + _logFile.toString(); - _logger = new OutputLogger(this, _logFile, loggerName, Level.toLevel(_loggerLevel)); - } - - return _logger; - } - - public void error(String message) - { - error(message, null); - } - - public void error(String message, @Nullable Throwable t) - { - setErrors(getErrors() + 1); - if (getLogger() != null) - getLogger().error(message, t); - } - - public void debug(String message) - { - debug(message, null); - } - - public void debug(String message, @Nullable Throwable t) - { - if (getLogger() != null) - getLogger().debug(message, t); - } - - public void warn(String message) - { - warn(message, null); - } - - public void warn(String message, @Nullable Throwable t) - { - if (getLogger() != null) - getLogger().warn(message, t); - } - - public void info(String message) - { - info(message, null); - } - - public void info(String message, @Nullable Throwable t) - { - if (getLogger() != null) - getLogger().info(message, t); - } - - public void header(String message) - { - info(message); - info("======================================="); - } - - ///////////////////////////////////////////////////////////////////////// - // ViewBackgroundInfo access - // WARNING: Some access of ViewBackgroundInfo is not supported when - // the job is running outside the LabKey Server. - - /** - * Gets the container ID from the ViewBackgroundInfo. - * - * @return the ID for the container in which the job was started - */ - public String getContainerId() - { - return getInfo().getContainerId(); - } - - /** - * Gets the User instance from the ViewBackgroundInfo. - * WARNING: Not supported if job is not running in the LabKey web server. - * - * @return the user who started the job - * @throws IllegalStateException if invoked on a remote pipeline server - */ - @Override - public User getUser() - { - if (!PipelineJobService.get().isWebServer()) - { - throw new IllegalStateException("User lookup not available on remote pipeline servers"); - } - return getInfo().getUser(); - } - - /** - * Gets the Container instance from the ViewBackgroundInfo. - * WARNING: Not supported if job is not running in the LabKey web server. - * - * @return the container in which the job was started - * @throws IllegalStateException if invoked on a remote pipeline server - */ - @Override - public Container getContainer() - { - if (!PipelineJobService.get().isWebServer()) - { - throw new IllegalStateException("User lookup not available on remote pipeline servers"); - } - return getInfo().getContainer(); - } - - /** - * Gets the ActionURL instance from the ViewBackgroundInfo. - * WARNING: Not supported if job is not running in the LabKey Server. - * - * @return the URL of the request that started the job - */ - public ActionURL getActionURL() - { - return getInfo().getURL(); - } - - /** - * Gets the ViewBackgroundInfo associated with this job in its contstructor. - * WARNING: Although this function is supported outside the LabKey Server, certain - * accessors on the ViewBackgroundInfo itself are not. - * - * @return information from the starting request, for use in background processing - */ - public ViewBackgroundInfo getInfo() - { - return _info; - } - - ///////////////////////////////////////////////////////////////////////// - // Scheduling interface - // TODO: Figure out how these apply to the Enterprise Pipeline - - protected boolean canInterrupt() - { - return false; - } - - public synchronized boolean interrupt() - { - PipelineJobService.get().cancelForJob(getJobGUID()); - if (!canInterrupt()) - return false; - _interrupted = true; - return true; - } - - public synchronized boolean checkInterrupted() - { - return _interrupted; - } - - public boolean allowMultipleSimultaneousJobs() - { - return false; - } - - synchronized public void setSubmitted() - { - _submitted = true; - notifyAll(); - } - - synchronized private boolean isSubmitted() - { - return _submitted; - } - - synchronized private void waitUntilSubmitted() - { - while (!_submitted) - { - try - { - wait(); - } - catch (InterruptedException ignored) {} - } - } - - ///////////////////////////////////////////////////////////////////////// - // JobRunner.Job interface - - @Override - public Object get() throws InterruptedException, ExecutionException - { - waitUntilSubmitted(); - return super.get(); - } - - @Override - public Object get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException - { - return get(); - } - - @Override - protected void starting(Thread thread) - { - _queue.starting(this, thread); - } - - @Override - public boolean cancel(boolean mayInterruptIfRunning) - { - if (isSubmitted()) - { - PipelineJobService.get().cancelForJob(getJobGUID()); - return super.cancel(mayInterruptIfRunning); - } - return true; - } - - @Override - public boolean isDone() - { - if (!isSubmitted()) - return false; - return super.isDone(); - } - - @Override - public boolean isCancelled() - { - if (!isSubmitted()) - return false; - return super.isCancelled(); - } - - @Override - public void done(Throwable throwable) - { - if (null != throwable) - { - try - { - error("Uncaught exception in PipelineJob: " + this, throwable); - } - catch (Exception ignored) {} - } - if (_queue != null) - { - _queue.done(this); - } - - PipelineJobNotificationProvider notificationProvider = PipelineService.get().getPipelineJobNotificationProvider(getJobNotificationProvider(), this); - if (notificationProvider != null) - notificationProvider.onJobDone(this); - - finallyCleanUpLocalDirectory(); //Since this potentially contains the job log, it should be run after the notifications tasks are executed - } - - protected String getJobNotificationProvider() - { - return null; - } - - protected String getNotificationType(PipelineJob.TaskStatus status) - { - return status.getNotificationType(); - } - - public String serializeJob(boolean ensureDeserialize) - { - return PipelineJobService.get().getJobStore().serializeToJSON(this, ensureDeserialize); - } - - public static String getClassNameFromJson(String serialized) - { - // Expect [ "org.labkey....", {.... - if (StringUtils.startsWith(serialized, "[")) - { - return StringUtils.substringBetween(serialized, "\""); - } - else - { - throw new RuntimeException("Unexpected serialized JSON"); - } - } - - @Nullable - public static PipelineJob deserializeJob(@NotNull String serialized) - { - try - { - String className = PipelineJob.getClassNameFromJson(serialized); - return PipelineJobService.get().getJobStore().deserializeFromJSON(serialized, (Class)Class.forName(className)); - } - catch (ClassNotFoundException e) - { - _log.error("Deserialized class not found.", e); - } - return null; - } - - public static ObjectMapper createObjectMapper() - { - ObjectMapper mapper = JsonUtil.DEFAULT_MAPPER.copy() - .setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE) - .setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY) - .disable(SerializationFeature.FAIL_ON_EMPTY_BEANS) - .enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL); - - SimpleModule module = new SimpleModule(); - module.addSerializer(new SqlTimeSerialization.SqlTimeSerializer()); - module.addDeserializer(Time.class, new SqlTimeSerialization.SqlTimeDeserializer()); - module.addDeserializer(AtomicLong.class, new AtomicLongDeserializer()); - module.addSerializer(NullSafeBindException.class, new NullSafeBindExceptionSerializer()); - module.addSerializer(QueryKey.class, new QueryKeySerialization.Serializer()); - module.addDeserializer(SchemaKey.class, new QueryKeySerialization.SchemaKeyDeserializer()); - module.addDeserializer(FieldKey.class, new QueryKeySerialization.FieldKeyDeserializer()); - module.addSerializer(Path.class, new PathSerialization.Serializer()); - module.addDeserializer(Path.class, new PathSerialization.Deserializer()); - module.addSerializer(CronExpression.class, new CronExpressionSerialization.Serializer()); - module.addDeserializer(CronExpression.class, new CronExpressionSerialization.Deserializer()); - module.addSerializer(URI.class, new URISerialization.Serializer()); - module.addDeserializer(URI.class, new URISerialization.Deserializer()); - module.addSerializer(File.class, new FileSerialization.Serializer()); - module.addDeserializer(File.class, new FileSerialization.Deserializer()); - module.addDeserializer(Filter.class, new FilterDeserializer()); - - mapper.registerModule(module); - return mapper; - } - - public abstract static class TestSerialization extends org.junit.Assert - { - public void testSerialize(PipelineJob job, @Nullable Logger log) - { - PipelineStatusFile.JobStore jobStore = PipelineJobService.get().getJobStore(); - try - { - if (null != log) - log.info("Hi Logger is here!"); - String json = jobStore.serializeToJSON(job, true); - if (null != log) - log.info(json); - PipelineJob job2 = jobStore.deserializeFromJSON(json, job.getClass()); - if (null != log) - log.info(job2.toString()); - - List errors = job.compareJobs(job2); - if (!errors.isEmpty()) - { - fail("Pipeline objects don't match: " + StringUtils.join(errors, ",")); - } - } - catch (Exception e) - { - if (null != log) - log.error("Class not found", e); - } - } - } - - @Override - public boolean equals(Object o) - { - // Fix issue 35876: Second run of a split XTandem pipeline job not completing - don't rely on the job being - // represented in memory as a single object - if (this == o) return true; - if (!(o instanceof PipelineJob that)) return false; - return Objects.equals(_jobGUID, that._jobGUID); - } - - @Override - public int hashCode() - { - return Objects.hash(_jobGUID); - } - - public List compareJobs(PipelineJob job2) - { - PipelineJob job1 = this; - List errors = new ArrayList<>(); - if (!PropertyUtil.nullSafeEquals(job1._activeTaskId, job2._activeTaskId)) - errors.add("_activeTaskId"); - if (job1._activeTaskRetries != job2._activeTaskRetries) - errors.add("_activeTaskRetries"); - if (!PropertyUtil.nullSafeEquals(job1._activeTaskStatus, job2._activeTaskStatus)) - errors.add("_activeTaskStatus"); - if (job1._errors != job2._errors) - errors.add("_errors"); - if (job1._interrupted != job2._interrupted) - errors.add("_interrupted"); - if (!PropertyUtil.nullSafeEquals(job1._jobGUID, job2._jobGUID)) - errors.add("_jobGUID"); - if (!PropertyUtil.nullSafeEquals(job1._logFile, job2._logFile)) - { - if (null == job1._logFile || null == job2._logFile) - errors.add("_logFile"); - else if (!FileUtil.getAbsoluteCaseSensitiveFile(job1._logFile.toFile()).getAbsolutePath().equalsIgnoreCase(FileUtil.getAbsoluteCaseSensitiveFile(job2._logFile.toFile()).getAbsolutePath())) - errors.add("_logFile"); - } - if (!PropertyUtil.nullSafeEquals(job1._parentGUID, job2._parentGUID)) - errors.add("_parentGUID"); - if (!PropertyUtil.nullSafeEquals(job1._provider, job2._provider)) - errors.add("_provider"); - if (job1._submitted != job2._submitted) - errors.add("_submitted"); - - return errors; - } - - /** - * @return Path String for a local working directory, temporary if root is cloud based - */ - protected Path getWorkingDirectoryString() - { - return !getPipeRoot().isCloudRoot() ? getPipeRoot().getRootNioPath() : FileUtil.getTempDirectory().toPath(); - } - - /** - * Generate a LocalDirectory and log file, temporary if need be, for use by the job - * Note: Override getDefaultLocalDirectoryString if piperoot isn't the desired local directory - * - * @param pipeRoot Pipeline's root directory - * @param moduleName supplying the pipeline - * @param baseLogFileName base name of the log file - */ - protected final void setupLocalDirectoryAndJobLog(PipeRoot pipeRoot, String moduleName, String baseLogFileName) - { - LocalDirectory localDirectory = LocalDirectory.create(pipeRoot, moduleName, baseLogFileName, getWorkingDirectoryString()); - setLocalDirectory(localDirectory); - setLogFile(localDirectory.determineLogFile()); - } -} +/* + * Copyright (c) 2008-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.api.pipeline; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.PropertyAccessor; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.module.SimpleModule; +import datadog.trace.api.CorrelationIdentifier; +import datadog.trace.api.Trace; +import org.apache.commons.io.FileUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.logging.log4j.Marker; +import org.apache.logging.log4j.ThreadContext; +import org.apache.logging.log4j.message.Message; +import org.apache.logging.log4j.simple.SimpleLogger; +import org.apache.logging.log4j.util.PropertiesUtil; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.labkey.api.action.NullSafeBindException; +import org.labkey.api.assay.AssayFileWriter; +import org.labkey.api.data.Container; +import org.labkey.api.exp.api.ExpRun; +import org.labkey.api.gwt.client.util.PropertyUtil; +import org.labkey.api.pipeline.file.FileAnalysisJobSupport; +import org.labkey.api.query.FieldKey; +import org.labkey.api.query.QueryKey; +import org.labkey.api.query.SchemaKey; +import org.labkey.api.reader.Readers; +import org.labkey.api.security.User; +import org.labkey.api.util.DateUtil; +import org.labkey.api.util.ExceptionUtil; +import org.labkey.api.util.FileType; +import org.labkey.api.util.FileUtil; +import org.labkey.api.util.GUID; +import org.labkey.api.util.Job; +import org.labkey.api.util.JsonUtil; +import org.labkey.api.util.NetworkDrive; +import org.labkey.api.util.QuietCloser; +import org.labkey.api.util.URLHelper; +import org.labkey.api.util.logging.LogHelper; +import org.labkey.api.view.ActionURL; +import org.labkey.api.view.ViewBackgroundInfo; +import org.labkey.api.writer.ContainerUser; +import org.labkey.api.writer.PrintWriters; +import org.labkey.remoteapi.query.Filter; +import org.labkey.vfs.FileLike; +import org.labkey.vfs.FileSystemLike; +import org.quartz.CronExpression; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.FileWriter; +import java.io.IOException; +import java.io.InputStream; +import java.io.PrintWriter; +import java.io.Serializable; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.sql.Time; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +/** + * A job represents the invocation of a pipeline on a certain set of inputs. It can be monolithic (a single run() method) + * or be comprised of multiple tasks ({@link Task}) that can be checkpointed and restarted individually. + */ +@JsonIgnoreProperties(value={"_logFilePathName"}, allowGetters = true) //Property removed. Added here for backwards compatibility +abstract public class PipelineJob extends Job implements Serializable, ContainerUser +{ + public static final FileType FT_LOG = new FileType(Arrays.asList(".log"), ".log", Arrays.asList("text/plain")); + + public static final String PIPELINE_EMAIL_ADDRESS_PARAM = "pipeline, email address"; + public static final String PIPELINE_USERNAME_PARAM = "pipeline, username"; + public static final String PIPELINE_PROTOCOL_NAME_PARAM = "pipeline, protocol name"; + public static final String PIPELINE_PROTOCOL_DESCRIPTION_PARAM = "pipeline, protocol description"; + public static final String PIPELINE_LOAD_FOLDER_PARAM = "pipeline, load folder"; + public static final String PIPELINE_JOB_INFO_PARAM = "pipeline, jobInfo"; + public static final String PIPELINE_TASK_INFO_PARAM = "pipeline, taskInfo"; + public static final String PIPELINE_TASK_OUTPUT_PARAMS_PARAM = "pipeline, taskOutputParams"; + + protected static Logger _log = LogHelper.getLogger(PipelineJob.class, "Execution and queuing of pipeline jobs"); + // Send start/stop messages to a separate logger because the default logger for this class is set to + // only write ERROR level events to the system log + private static final Logger _logJobStopStart = LogManager.getLogger(Job.class); + + public static Logger getJobLogger(Class clazz) + { + return LogManager.getLogger(PipelineJob.class.getName() + ".." + clazz.getName()); + } + + public RecordedActionSet getActionSet() + { + return _actionSet; + } + + /** + * Clear out the set of recorded actions + * @param run run that represents the previous set of recorded actions + */ + public void clearActionSet(ExpRun run) + { + _actionSet = new RecordedActionSet(); + } + + public FileLike getLogFileLike() + { + return FileSystemLike.wrapFile(getLogFilePath()); + } + + public enum TaskStatus + { + /** Job is in the queue, waiting for its turn to run */ + waiting + { + @Override + public boolean isActive() { return true; } + + @Override + public boolean matches(String statusText) + { + if (statusText == null) + return false; + else if (!TaskStatus.splitWaiting.matches(statusText) && statusText.toLowerCase().endsWith("waiting")) + return true; + return super.matches(statusText); + } + }, + /** Job is doing its work */ + running + { + @Override + public boolean isActive() { return true; } + }, + /** Terminal state, job is finished and completed without errors */ + complete + { + @Override + public boolean isActive() { return false; } + }, + /** Terminal state (but often retryable), job is done running and completed with error(s) */ + error + { + @Override + public boolean isActive() { return false; } + }, + /** Job is in the process of being cancelled, but may still be running or queued at the moment */ + cancelling + { + @Override + public boolean isActive() { return true; } + }, + /** Terminal state, indicating that a user cancelled the job before it completed or errored */ + cancelled + { + @Override + public boolean isActive() { return false; } + }, + splitWaiting + { + @Override + public boolean isActive() { return false; } + + @Override + public String toString() { return "SPLIT WAITING"; } + }; + + /** @return whether this step is considered to be actively running */ + public abstract boolean isActive(); + + public String toString() + { + return super.toString().toUpperCase(); + } + + public boolean matches(String statusText) + { + return toString().equalsIgnoreCase(statusText); + } + + public final String getNotificationType() + { + return getClass().getName() + "." + name(); + } + } + + /** + * Implements a runnable to complete a part of the + * processing associated with a particular PipelineJob. This is often the execution of an external tool, + * the importing of files into the database, etc. + */ + abstract static public class Task + { + private final PipelineJob _job; + protected FactoryType _factory; + + public Task(FactoryType factory, PipelineJob job) + { + _job = job; + _factory = factory; + } + + public PipelineJob getJob() + { + return _job; + } + + /** + * Do the work of the task. The task should not set the status of the job to complete - this will be handled + * by the caller. + * @return the files used as inputs and generated as outputs, and the steps that operated on them + * @throws PipelineJobException if something went wrong during the execution of the job. The caller will + * handle setting the job's status to ERROR. + */ + @NotNull + public abstract RecordedActionSet run() throws PipelineJobException; + } + + /* + * JMS message header names + */ + private static final String HEADER_PREFIX = "LABKEY_"; + public static final String LABKEY_JOBTYPE_PROPERTY = HEADER_PREFIX + "JOBTYPE"; + public static final String LABKEY_JOBID_PROPERTY = HEADER_PREFIX + "JOBID"; + public static final String LABKEY_CONTAINERID_PROPERTY = HEADER_PREFIX + "CONTAINERID"; + public static final String LABKEY_TASKPIPELINE_PROPERTY = HEADER_PREFIX + "TASKPIPELINE"; + public static final String LABKEY_TASKID_PROPERTY = HEADER_PREFIX + "TASKID"; + public static final String LABKEY_TASKSTATUS_PROPERTY = HEADER_PREFIX + "TASKSTATUS"; + /** The execution location to which the job's current task is assigned */ + public static final String LABKEY_LOCATION_PROPERTY = HEADER_PREFIX + "LOCATION"; + + private String _provider; + private ViewBackgroundInfo _info; + private String _jobGUID; + private String _parentGUID; + private TaskId _activeTaskId; + @NotNull + private TaskStatus _activeTaskStatus; + private int _activeTaskRetries; + @NotNull + private PipeRoot _pipeRoot; + volatile private boolean _interrupted; + private boolean _submitted; + private int _errors; + private RecordedActionSet _actionSet = new RecordedActionSet(); + + private String _loggerLevel = Level.DEBUG.toString(); + + // Don't save these + protected transient Logger _logger; + private transient boolean _settingStatus; + private transient PipelineQueue _queue; + + private Path _logFile; + private LocalDirectory _localDirectory; + + // Default constructor for serialization + protected PipelineJob() + { + } + + /** Although having a null provider is legal, it is recommended that one be used + * so that it can respond to events as needed */ + public PipelineJob(@Nullable String provider, ViewBackgroundInfo info, @NotNull PipeRoot root) + { + _info = info; + _provider = provider; + _jobGUID = GUID.makeGUID(); + _activeTaskStatus = TaskStatus.waiting; + + + _pipeRoot = root; + + _actionSet = new RecordedActionSet(); + } + + public PipelineJob(PipelineJob job) + { + // Not yet queued + _queue = null; + + // New ID + _jobGUID = GUID.makeGUID(); + + // Copy everything else + _info = job._info; + _provider = job._provider; + _parentGUID = job._jobGUID; + _pipeRoot = job._pipeRoot; + _interrupted = job._interrupted; + _submitted = job._submitted; + _errors = job._errors; + _loggerLevel = job._loggerLevel; + _logger = job._logger; + _logFile = job._logFile; + + _activeTaskId = job._activeTaskId; + _activeTaskStatus = job._activeTaskStatus; + + _actionSet = new RecordedActionSet(job.getActionSet()); + _localDirectory = job._localDirectory; + } + + public String getProvider() + { + return _provider; + } + + @Deprecated + public void setProvider(String provider) + { + _provider = provider; + } + + public int getErrors() + { + return _errors; + } + + public void setErrors(int errors) + { + if (errors > 0) + _activeTaskStatus = TaskStatus.error; + + _errors = errors; + } + + /** + * This job has been restored from a checkpoint for the purpose of + * a retry. Record retry information before it is checkpointed again. + */ + public void retryUpdate() + { + _errors++; + _activeTaskRetries++; + } + + public Map getParameters() + { + return Collections.emptyMap(); + } + + public String getJobGUID() + { + return _jobGUID; + } + + public String getParentGUID() + { + return _parentGUID; + } + + @Nullable + public TaskId getActiveTaskId() + { + return _activeTaskId; + } + + public boolean setActiveTaskId(@Nullable TaskId activeTaskId) + { + return setActiveTaskId(activeTaskId, true); + } + + public boolean setActiveTaskId(@Nullable TaskId activeTaskId, boolean updateStatus) + { + if (activeTaskId == null || !activeTaskId.equals(_activeTaskId)) + { + _activeTaskId = activeTaskId; + _activeTaskRetries = 0; + } + if (_activeTaskId == null) + _activeTaskStatus = TaskStatus.complete; + else + _activeTaskStatus = TaskStatus.waiting; + + return !updateStatus || updateStatusForTask(); + } + + @NotNull + public TaskStatus getActiveTaskStatus() + { + return _activeTaskStatus; + } + + /** @return whether the status was set successfully */ + public boolean setActiveTaskStatus(@NotNull TaskStatus activeTaskStatus) + { + _activeTaskStatus = activeTaskStatus; + return updateStatusForTask(); + } + + public TaskFactory getActiveTaskFactory() + { + if (getActiveTaskId() == null) + return null; + + return PipelineJobService.get().getTaskFactory(getActiveTaskId()); + } + + @NotNull + public PipeRoot getPipeRoot() + { + return _pipeRoot; + } + + @Deprecated //Please switch to the FileLike version + public void setLogFile(File logFile) + { + setLogFile(logFile.toPath()); + } + + public void setLogFile(FileLike logFile) + { + setLogFile(logFile.toNioPathForWrite()); + } + + @Deprecated //Please switch to the FileLike version + public void setLogFile(Path logFile) + { + // Set Log file path and clear/reset logger + _logFile = logFile.toAbsolutePath().normalize(); + _logger = null; //This should trigger getting the new Logger next time getLogger is called + } + + public File getLogFile() + { + Path logFilePath = getLogFilePath(); + if (null != logFilePath && !FileUtil.hasCloudScheme(logFilePath)) + return logFilePath.toFile(); + return null; + } + + public Path getLogFilePath() + { + return _logFile; + } + + /** + * Get the remote log path (if local dir set) else return getLogFilePath + * + * TODO: Better name getStatusKeyPath? or similar + */ + public Path getRemoteLogPath() + { + LocalDirectory dir = getLocalDirectory(); + if (dir == null) + return getLogFilePath(); + + return dir.getRemoteLogFilePath(); + } + + /** Finds a file name that hasn't been used yet, appending ".2", ".3", etc as needed */ + public static File findUniqueLogFile(File primaryFile, String baseName) + { + String validBaseName = FileUtil.makeLegalName(baseName); + // need to look in current and archived dirs for any unused log file names (issue 20987) + File fileLog = FT_LOG.newFile(primaryFile.getParentFile(), validBaseName); + File archivedDir = FileUtil.appendName(primaryFile.getParentFile(), AssayFileWriter.ARCHIVED_DIR_NAME); + File fileLogArchived = FT_LOG.newFile(archivedDir, validBaseName); + + int index = 1; + while (NetworkDrive.exists(fileLog) || NetworkDrive.exists(fileLogArchived)) + { + fileLog = FT_LOG.newFile(primaryFile.getParentFile(), validBaseName + "." + (index)); + fileLogArchived = FT_LOG.newFile(archivedDir, validBaseName + "." + (index++)); + } + + return fileLog; + } + + + public LocalDirectory getLocalDirectory() + { + return _localDirectory; + } + + protected void setLocalDirectory(LocalDirectory localDirectory) + { + _localDirectory = localDirectory; + } + + public static PipelineJob readFromFile(File file) throws IOException, PipelineJobException + { + StringBuilder serializedJob = new StringBuilder(); + try (InputStream fIn = new FileInputStream(file)) + { + BufferedReader reader = Readers.getReader(fIn); + String line; + while ((line = reader.readLine()) != null) + { + serializedJob.append(line); + } + } + + PipelineJob job = PipelineJob.deserializeJob(serializedJob.toString()); + if (null == job) + { + throw new PipelineJobException("Unable to deserialize job"); + } + return job; + } + + + public void writeToFile(File file) throws IOException + { + File newFile = new File(file.getPath() + ".new"); + File origFile = new File(file.getPath() + ".orig"); + + String serializedJob = serializeJob(true); + + try (FileOutputStream fOut = new FileOutputStream(newFile)) + { + PrintWriter writer = PrintWriters.getPrintWriter(fOut); + writer.write(serializedJob); + writer.flush(); + } + + if (NetworkDrive.exists(file)) + { + if (origFile.exists()) + { + // Might be left over from some bad previous run + origFile.delete(); + } + // Don't use File.renameTo() because it doesn't always work depending on the underlying file system + FileUtils.moveFile(file, origFile); + FileUtils.moveFile(newFile, file); + origFile.delete(); + } + else + { + FileUtils.moveFile(newFile, file); + } + PipelineJobService.get().getWorkDirFactory().setPermissions(file); + } + + public boolean updateStatusForTask() + { + TaskFactory factory = getActiveTaskFactory(); + TaskStatus status = getActiveTaskStatus(); + + if (factory != null && !TaskStatus.error.equals(status) && !TaskStatus.cancelled.equals(status)) + return setStatus(factory.getStatusName() + " " + status.toString().toUpperCase()); + else + return setStatus(status); + } + + /** Used for setting status to one of the standard states */ + public boolean setStatus(@NotNull TaskStatus status) + { + return setStatus(status.toString()); + } + + /** + * Used for setting status to a custom state, which is considered to be equivalent to TaskStatus.running + * unless it matches one of the standard states + * @throws CancelledException if the job was cancelled by a user and should stop execution + */ + public boolean setStatus(@NotNull String status) + { + return setStatus(status, null); + } + + /** + * Used for setting status to one of the standard states + * @param info more verbose detail on the job's status, such as a percent complete + * @throws CancelledException if the job was cancelled by a user and should stop execution + */ + public boolean setStatus(@NotNull TaskStatus status, @Nullable String info) + { + return setStatus(status.toString(), info); + } + + /** + * @param info more verbose detail on the job's status, such as a percent complete + * @throws CancelledException if the job was cancelled by a user and should stop execution + */ + public boolean setStatus(@NotNull String status, @Nullable String info) + { + return setStatus(status, info, false); + } + + /** + * Used for setting status to a custom state, which is considered to be equivalent to TaskStatus.running + * unless it matches one of the standard states + * @throws CancelledException if the job was cancelled by a user and should stop execution + */ + public boolean setStatus(@NotNull String status, @Nullable String info, boolean allowInsert) + { + if (_settingStatus) + return true; + + _settingStatus = true; + try + { + boolean statusSet = PipelineJobService.get().getStatusWriter().setStatus(this, status, info, allowInsert); + if (!statusSet) + { + setActiveTaskStatus(TaskStatus.error); + } + return statusSet; + } + // Rethrow so it doesn't get handled like other RuntimeExceptions + catch (CancelledException e) + { + _activeTaskStatus = TaskStatus.cancelled; + throw e; + } + catch (RuntimeException e) + { + Path f = this.getLogFilePath(); + error("Failed to set status to '" + status + "' for '" + + (f == null ? "" : f.toString()) + "'.", e); + throw e; + } + catch (Exception e) + { + Path f = this.getLogFilePath(); + error("Failed to set status to '" + status + "' for '" + + (f == null ? "" : f.toString()) + "'.", e); + } + finally + { + _settingStatus = false; + } + return false; + } + + public void restoreQueue(PipelineQueue queue) + { + // Recursive split and join combinations may cause the queue + // to be restored to a job with a queue already. Would be good + // to have better safe-guards against double-queueing of jobs. + if (queue == _queue) + return; + if (null != _queue) + throw new IllegalStateException(); + _queue = queue; + } + + public void restoreLocalDirectory() + { + if (null != _localDirectory) + setLogFile(_localDirectory.restore()); + } + + public void validateParameters() throws PipelineValidationException + { + TaskPipeline taskPipeline = getTaskPipeline(); + if (taskPipeline != null) + { + for (TaskId taskId : taskPipeline.getTaskProgression()) + { + TaskFactory taskFactory = PipelineJobService.get().getTaskFactory(taskId); + if (taskFactory == null) + throw new PipelineValidationException("Task '" + taskId + "' not found"); + taskFactory.validateParameters(this); + } + } + } + + public boolean setQueue(PipelineQueue queue, TaskStatus initialState) + { + return setQueue(queue, initialState.toString()); + } + + public boolean setQueue(PipelineQueue queue, String initialState) + { + restoreQueue(queue); + + // Initialize the task pipeline + TaskPipeline taskPipeline = getTaskPipeline(); + if (taskPipeline != null) + { + // Save the current job state marshalled to XML, in case of error. + String serializedJob = serializeJob(true); + + // Note runStateMachine returns false, if the job cannot be run locally. + // The job may still need to be put on a JMS queue for remote processing. + // Therefore, the return value cannot be used to determine whether the + // job should be queued. + runStateMachine(); + + // If an error occurred trying to find the first runnable state, then + // store the original job state to allow retry. + if (getActiveTaskStatus() == TaskStatus.error) + { + try + { + PipelineJob originalJob = PipelineJob.deserializeJob(serializedJob); + if (null != originalJob) + originalJob.store(); + else + warn("Failed to checkpoint '" + getDescription() + "' job."); + + } + catch (Exception e) + { + warn("Failed to checkpoint '" + getDescription() + "' job.", e); + } + return false; + } + + // If initialization put this job into a state where it is + // waiting, then it should not be put on the queue. + return !isSplitWaiting(); + } + // Initialize status for non-task pipeline jobs. + else if (_logFile != null) + { + setStatus(initialState); + try + { + store(); + } + catch (Exception e) + { + warn("Failed to checkpoint '" + getDescription() + "' job before queuing.", e); + } + } + + return true; + } + + public void clearQueue() + { + _queue = null; + } + + abstract public URLHelper getStatusHref(); + + abstract public String getDescription(); + + public String toString() + { + return super.toString() + " " + StringUtils.trimToEmpty(getDescription()); + } + + public T getJobSupport(Class inter) + { + if (inter.isInstance(this)) + return (T) this; + + throw new UnsupportedOperationException("Job type " + getClass().getName() + + " does not implement " + inter.getName()); + } + + /** + * Override to provide a TaskPipeline with the option of + * running some tasks remotely. Override the run() function + * to implement the job as a single monolithic task. + * + * @return a task pipeline to run for this job + */ + @Nullable + public TaskPipeline getTaskPipeline() + { + return null; + } + + public boolean isActiveTaskLocal() + { + TaskFactory factory = getActiveTaskFactory(); + return (factory != null && + TaskFactory.WEBSERVER.equalsIgnoreCase(factory.getExecutionLocation())); + } + + public void runActiveTask() throws IOException, PipelineJobException + { + TaskFactory factory = getActiveTaskFactory(); + if (factory == null) + return; + + if (!factory.isJobComplete(this)) + { + Task task = factory.createTask(this); + if (task == null) + return; // Bad task key. + + if (!setActiveTaskStatus(TaskStatus.running)) + { + // The user has deleted (cancelled) the job. + // Throwing this exception will cause the job to go to the ERROR state and stop running + throw new PipelineJobException("Job no longer in database - aborting"); + } + + WorkDirectory workDirectory = null; + RecordedActionSet actions; + + boolean success = false; + try + { + logStartStopInfo("Starting to run task '" + factory.getId() + "' for job '" + this + "' with log file " + getLogFilePath()); + getLogger().info("Starting to run task '" + factory.getId() + "' at location '" + factory.getExecutionLocation() + "'"); + if (PipelineJobService.get().getLocationType() != PipelineJobService.LocationType.WebServer) + { + PipelineJobService.RemoteServerProperties remoteProps = PipelineJobService.get().getRemoteServerProperties(); + if (remoteProps != null) + { + getLogger().info("on host: '" + remoteProps.getHostName() + "'"); + } + } + + if (task instanceof WorkDirectoryTask wdTask) + { + workDirectory = factory.createWorkDirectory(getJobGUID(), getJobSupport(FileAnalysisJobSupport.class), getLogger()); + wdTask.setWorkDirectory(workDirectory); + } + + actions = task.run(); + success = true; + } + finally + { + getLogger().info((success ? "Successfully completed" : "Failed to complete") + " task '" + factory.getId() + "'"); + logStartStopInfo((success ? "Successfully completed" : "Failed to complete") + " task '" + factory.getId() + "' for job '" + this + "' with log file " + getLogFile()); + + try + { + if (workDirectory != null) + { + workDirectory.remove(success); + ((WorkDirectoryTask)task).setWorkDirectory(null); + } + } + catch (IOException e) + { + // Don't let this cleanup error mask an original error that causes the job to fail + if (success) + { + // noinspection ThrowFromFinallyBlock + throw e; + } + else + { + if (e.getMessage() != null) + { + error(e.getMessage()); + } + else + { + error("Failed to clean up work directory after error condition, see full error information below.", e); + } + } + } + } + _actionSet.add(actions); + + // An error occurred running the task. Do not complete. + if (TaskStatus.error.equals(getActiveTaskStatus())) + return; + } + else + { + logStartStopInfo("Skipping already completed task '" + factory.getId() + "' for job '" + this + "' with log file " + getLogFile()); + getLogger().info("Skipping already completed task '" + factory.getId() + "' at location '" + factory.getExecutionLocation() + "'"); + } + + if (getActiveTaskStatus() != TaskStatus.complete && getActiveTaskStatus() != TaskStatus.cancelled) + setActiveTaskStatus(TaskStatus.complete); + } + + public static void logStartStopInfo(String message) + { + _logJobStopStart.info(message); + } + + public boolean runStateMachine() + { + TaskPipeline pipeline = getTaskPipeline(); + if (pipeline == null) + { + assert false : "Either override getTaskPipeline() or run() for " + getClass(); + + // Best we can do is to complete the job. + setActiveTaskId(null); + return false; + } + + TaskId[] progression = pipeline.getTaskProgression(); + int i = 0; + if (_activeTaskId != null) + { + i = indexOfActiveTask(progression); + if (i == -1) + { + error("Active task " + _activeTaskId + " not found in task pipeline."); + return false; + } + } + + switch (_activeTaskStatus) + { + case waiting: + return findRunnableTask(progression, i); + + case complete: + // See if the job has already completed. + if (_activeTaskId == null) + return false; + + return findRunnableTask(progression, i + 1); + + case error: + // Make sure the status is in error state, so that any auto-retry that + // may occur will record the error. And, if no retry occurs, then this + // job must be in error state. + try + { + PipelineJobService.get().getStatusWriter().ensureError(this); + } + catch (Exception e) + { + warn("Failed to ensure error status on task error.", e); + } + + // Run auto-retry, and retry if appropriate. + autoRetry(); + return false; + + case running: + case cancelled: + case cancelling: + default: + return false; // Do not run the active task. + } + } + + private int indexOfActiveTask(TaskId[] progression) + { + for (int i = 0; i < progression.length; i++) + { + TaskFactory factory = PipelineJobService.get().getTaskFactory(progression[i]); + if (factory == null) + { + throw new IllegalStateException("Could not find factory for " + progression[i]); + } + if (factory.getId().equals(_activeTaskId) || + factory.getActiveId(this).equals(_activeTaskId)) + return i; + } + return -1; + } + + private boolean findRunnableTask(TaskId[] progression, int i) + { + // Search for next task that is not already complete + TaskFactory factory = null; + while (i < progression.length) + { + try + { + factory = PipelineJobService.get().getTaskFactory(progression[i]); + if (factory == null) + { + throw new IllegalStateException("Could not find factory for " + progression[i]); + } + // Stop, if this task requires a change in join state + if ((factory.isJoin() && isSplitJob()) || (!factory.isJoin() && isSplittable())) + break; + // Stop, if this task is part of processing this job, and not complete + if (factory.isParticipant(this) && !factory.isJobComplete(this)) + break; + } + catch (IOException e) + { + error(e.getMessage()); + return false; + } + + i++; + } + + if (i < progression.length) + { + if (factory.isJoin() && isSplitJob()) + { + setActiveTaskId(factory.getId(), false); // ID is just a marker for state machine + join(); + return false; + } + else if (!factory.isJoin() && isSplittable()) + { + setActiveTaskId(factory.getId(), false); // ID is just a marker for state machine + split(); + return false; + } + + // Set next task to be run + if (!setActiveTaskId(factory.getActiveId(this))) + { + return false; + } + + // If it is local, then it can be run + return isActiveTaskLocal(); + } + else + { + // Job is complete + if (isSplitJob()) + { + setActiveTaskId(null, false); + join(); + } + else + { + setActiveTaskId(null); + } + return false; + } + } + + public boolean isAutoRetry() + { + TaskFactory factory = getActiveTaskFactory(); + return null != factory && _activeTaskRetries < factory.getAutoRetry() && factory.isAutoRetryEnabled(this); + } + + public boolean autoRetry() + { + try + { + if (isAutoRetry()) + { + info("Attempting to auto-retry"); + PipelineJobService.get().getJobStore().retry(getJobGUID()); + // Retry has been queued + return true; + } + } + catch (IOException | NoSuchJobException e) + { + warn("Failed to start automatic retry.", e); + } + return false; + } + + /** + * Subclasses that override this method instead of defining a task pipeline are responsible for setting the job's + * status at the end of their execution to either COMPLETE or ERROR + */ + @Override @Trace + public void run() + { + assert ThreadContext.isEmpty(); // Prevent/detect leaks + // Connect log messages with the active trace and span + ThreadContext.put(CorrelationIdentifier.getTraceIdKey(), CorrelationIdentifier.getTraceId()); + ThreadContext.put(CorrelationIdentifier.getSpanIdKey(), CorrelationIdentifier.getSpanId()); + + try + { + // The act of queueing the job runs the state machine for the first time. + do + { + try + { + runActiveTask(); + } + catch (IOException | PipelineJobException e) + { + error(e.getMessage(), e); + } + catch (CancelledException e) + { + throw e; + } + catch (RuntimeException e) + { + error(e.getMessage(), e); + ExceptionUtil.logExceptionToMothership(null, e); + // Rethrow to let the standard Mule exception handler fire and deal with the job state + throw e; + } + } + while (runStateMachine()); + } + catch (CancelledException e) + { + _activeTaskStatus = TaskStatus.cancelled; + // Don't need to do anything else, job has already been set to CANCELLED + } + finally + { + PipelineService.get().getPipelineQueue().almostDone(this); + + ThreadContext.remove(CorrelationIdentifier.getTraceIdKey()); + ThreadContext.remove(CorrelationIdentifier.getSpanIdKey()); + } + } + + // Should be called in run()'s finally by any class that overrides run(), if class uses LocalDirectory + protected void finallyCleanUpLocalDirectory() + { + if (null != _localDirectory && isDone()) + { + try + { + Path remoteLogFilePath = _localDirectory.cleanUpLocalDirectory(); + + //Update job log entry's log location to remote path + if (null != remoteLogFilePath) + { + //NOTE: any errors here can't be recorded to job log as it may no longer be local and writable + setLogFile(remoteLogFilePath); + setStatus(getActiveTaskStatus()); // Force writing to statusFiles + } + } + catch (JobLogInaccessibleException e) + { + // Can't write to job log as the log file is either null or inaccessible. + ExceptionUtil.logExceptionToMothership(null, e); + } + catch (Exception e) + { + // Attempt to record the error to the log. Move failed, so log should still be local and writable. + error("Error trying to move log file", e); + } + } + } + + /** + * Override and return true for job that may be split. Also, override + * the createSplitJobs() method to return the sub-jobs. + * + * @return true if the job may be split + */ + public boolean isSplittable() + { + return false; + } + + /** + * @return true if this is a split job, as determined by whether it has a parent. + */ + public boolean isSplitJob() + { + return getParentGUID() != null; + } + + /** + * @return true if this is a join job waiting for split jobs to complete. + */ + public boolean isSplitWaiting() + { + // Return false, if this job cannot be split. + if (!isSplittable()) + return false; + + // A join job with an active task that is not a join task, + // is waiting for a split to complete. + TaskFactory factory = getActiveTaskFactory(); + return (factory != null && !factory.isJoin()); + } + + /** + * Override and return instances of sub-jobs for a splittable job. + * + * @return sub-jobs requiring separate processing + */ + public List createSplitJobs() + { + return Collections.singletonList(this); + } + + /** + * Handles merging accumulated changes from split jobs into this job, which + * is a joined job. + * + * @param job the split job that has run to completion + */ + public void mergeSplitJob(PipelineJob job) + { + // Add experiment actions recorded. + _actionSet.add(job.getActionSet()); + + // Add any errors that happened in the split job. + _errors += job._errors; + } + + public void store() throws NoSuchJobException + { + PipelineJobService.get().getJobStore().storeJob(this); + } + + private void split() + { + try + { + PipelineJobService.get().getJobStore().split(this); + } + catch (IOException e) + { + error(e.getMessage(), e); + } + } + + private void join() + { + try + { + PipelineJobService.get().getJobStore().join(this); + } + catch (IOException | NoSuchJobException e) + { + error(e.getMessage(), e); + } + } + + ///////////////////////////////////////////////////////////////////////// + // Support for running processes + + @Nullable + private PrintWriter createPrintWriter(@Nullable File outputFile, boolean append) throws PipelineJobException + { + if (outputFile == null) + return null; + + try + { + return new PrintWriter(new BufferedWriter(new FileWriter(outputFile, append))); + } + catch (IOException e) + { + throw new PipelineJobException("Could not create the " + outputFile + " file.", e); + } + } + + public void runSubProcess(ProcessBuilder pb, File dirWork) throws PipelineJobException + { + runSubProcess(pb, dirWork, null, 0, false); + } + + /** + * If logLineInterval is greater than 1, the first logLineInterval lines of output will be written to the + * job's main log file. + */ + public void runSubProcess(ProcessBuilder pb, File dirWork, File outputFile, int logLineInterval, boolean append) + throws PipelineJobException + { + runSubProcess(pb, dirWork, outputFile, logLineInterval, append, 0, null); + } + + public void runSubProcess(ProcessBuilder pb, File dirWork, File outputFile, int logLineInterval, boolean append, long timeout, TimeUnit timeoutUnit) + throws PipelineJobException + { + Process proc; + + String commandName = pb.command().get(0); + commandName = commandName.substring( + Math.max(commandName.lastIndexOf('/'), commandName.lastIndexOf('\\')) + 1); + header(commandName + " output"); + + // Update PATH environment variable to make sure all files in the tools + // directory and the directory of the executable or on the path. + String toolDir = PipelineJobService.get().getAppProperties().getToolsDirectory(); + if (!StringUtils.isEmpty(toolDir)) + { + String path = System.getenv("PATH"); + if (path == null) + { + path = toolDir; + } + else + { + path = toolDir + File.pathSeparatorChar + path; + } + + // If the command has a path, then prepend its parent directory to the PATH + // environment variable as well. + String exePath = pb.command().get(0); + if (exePath != null && !exePath.isEmpty() && exePath.indexOf(File.separatorChar) != -1) + { + File fileExe = new File(exePath); + String exeDir = fileExe.getParent(); + if (!exeDir.equals(toolDir) && fileExe.exists()) + path = fileExe.getParent() + File.pathSeparatorChar + path; + } + + pb.environment().put("PATH", path); + + String dyld = System.getenv("DYLD_LIBRARY_PATH"); + if (dyld == null) + { + dyld = toolDir; + } + else + { + dyld = toolDir + File.pathSeparatorChar + dyld; + } + pb.environment().put("DYLD_LIBRARY_PATH", dyld); + } + + // tell more modern TPP tools to run headless (so no perl calls etc) bpratt 4-14-09 + pb.environment().put("XML_ONLY", "1"); + // tell TPP tools not to mess with tmpdirs, we handle this at higher level + pb.environment().put("WEBSERVER_TMP",""); + + try + { + pb.directory(dirWork); + + // TODO: Errors should go to log even when output is redirected to a file. + pb.redirectErrorStream(true); + + info("Working directory is " + dirWork.getAbsolutePath()); + info("running: " + StringUtils.join(pb.command().iterator(), " ")); + + proc = pb.start(); + } + catch (SecurityException se) + { + throw new PipelineJobException("Failed starting process '" + pb.command() + "'. Permissions do not allow execution.", se); + } + catch (IOException eio) + { + throw new PipelineJobException("Failed starting process '" + pb.command() + "'", eio); + } + + + try (QuietCloser ignored = PipelineJobService.get().trackForCancellation(proc)) + { + // create thread pool for collecting the process output + ExecutorService pool = Executors.newSingleThreadExecutor(); + + try (PrintWriter fileWriter = createPrintWriter(outputFile, append)) + { + // collect output using separate thread so we can enforce a timeout on the process + Future output = pool.submit(() -> { + try (BufferedReader procReader = Readers.getReader(proc.getInputStream())) + { + String line; + int count = 0; + while ((line = procReader.readLine()) != null) + { + count++; + if (fileWriter == null) + info(line); + else + { + if (logLineInterval > 0 && count < logLineInterval) + info(line); + else if (count == logLineInterval) + info("Writing additional tool output lines to " + outputFile.getName()); + fileWriter.println(line); + } + } + return count; + } + }); + + try + { + if (timeout > 0) + { + if (!proc.waitFor(timeout, timeoutUnit)) + { + proc.destroyForcibly().waitFor(); + + error("Process killed after exceeding timeout of " + timeout + " " + timeoutUnit.name().toLowerCase()); + } + } + else + { + proc.waitFor(); + } + + int result = proc.exitValue(); + if (result != 0) + { + throw new ToolExecutionException("Failed running " + pb.command().get(0) + ", exit code " + result, result); + } + + int count = output.get(); + if (fileWriter != null) + info(count + " lines written total to " + outputFile.getName()); + } + catch (InterruptedException ei) + { + throw new PipelineJobException("Interrupted process for '" + dirWork.getPath() + "'.", ei); + } + catch (ExecutionException e) + { + // Exception thrown in output collecting thread + Throwable cause = e.getCause(); + if (cause instanceof IOException) + throw new PipelineJobException("Failed writing output for process in '" + dirWork.getPath() + "'.", cause); + + throw new PipelineJobException(cause); + } + } + finally + { + pool.shutdownNow(); + } + } + } + + public String getLogLevel() + { + return _loggerLevel; + } + + public void setLogLevel(String level) + { + if (!_loggerLevel.equals(level)) + { + _loggerLevel = level; + _logger = null; // Reset the logger + } + } + + public Logger getClassLogger() + { + return _log; + } + + private static class OutputLogger extends SimpleLogger + { + private final PipelineJob _job; + private boolean _isSettingStatus; + private final Path _file; + private final String LINE_SEP = System.lineSeparator(); + private final String datePattern = "dd MMM yyyy HH:mm:ss,SSS"; + + protected OutputLogger(PipelineJob job, Path file, String name, Level level) + { + super(name, level, false, false, false, false, "", null, new PropertiesUtil(PropertiesUtil.getSystemProperties()), null); + _job = job; + _file = file; + } + + // called from LogOutputStream.flush() + @Override + public void log(Level level, String message) + { + _job.getClassLogger().log(level, message); + write(message, null, level.toString()); + } + + private String getSystemLogMessage(Object message) + { + StringBuilder sb = new StringBuilder(); + sb.append("(from pipeline job log file "); + sb.append(_job.getLogFile().toString()); + if (message != null) + { + sb.append(": "); + String stringMessage = message.toString(); + // Limit the maximum line length + final int maxLength = 10000; + if (stringMessage.length() > maxLength) + { + stringMessage = stringMessage.substring(0, maxLength) + "..."; + } + sb.append(stringMessage); + } + sb.append(")"); + return sb.toString(); + } + + public void setErrorStatus(Object message) + { + if (_isSettingStatus || _job._activeTaskStatus == TaskStatus.cancelled) + return; + + _isSettingStatus = true; + try + { + _job.setStatus(TaskStatus.error, message == null ? "ERROR" : message.toString()); + } + finally + { + _isSettingStatus = false; + } + } + + @Override + public void logMessage(String fqcn, Level mgsLevel, Marker marker, Message msg, Throwable throwable) + { + if (_job.getClassLogger().isEnabled(mgsLevel, marker)) + { + _job.getClassLogger().log(mgsLevel, marker, new Message() + { + @Override + public String getFormattedMessage() + { + return getSystemLogMessage(msg.getFormattedMessage()); + } + + @Override + public Object[] getParameters() + { + return msg.getParameters(); + } + + @Override + public Throwable getThrowable() + { + return msg.getThrowable(); + } + }, throwable); + } + + // Write to the job's log before setting the error status, which may end up throwing a CancelledException + // to signal that we need to bail out right away + write(msg.getFormattedMessage(), throwable, mgsLevel.getStandardLevel().name()); + + if (mgsLevel.isMoreSpecificThan(Level.ERROR)) + { + setErrorStatus(msg.getFormattedMessage()); + } + } + + private void write(String message, @Nullable Throwable t, String level) + { + String formattedDate = DateUtil.formatDateTime(new Date(), datePattern); + + try (PrintWriter writer = new PrintWriter(Files.newBufferedWriter(_file, StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.APPEND))) + { + var line = formattedDate + " " + + String.format("%-5s", level) + + ": " + + message; + writer.write(line); + writer.write(LINE_SEP); + if (null != t) + { + t.printStackTrace(writer); + } + } + catch (IOException e) + { + Path parentFile = _file.getParent(); + if (parentFile != null && !NetworkDrive.exists(parentFile)) + { + try + { + FileUtil.createDirectories(parentFile); + write(message, t, level); + } + catch (IOException dirE) + { + _log.error("Failed appending to file. Unable to create parent directories", e); + } + } + else + _log.error("Failed appending to file.", e); + } + } + } + + public static class JobLogInaccessibleException extends IllegalStateException + { + public JobLogInaccessibleException(String message) + { + super(message); + } + } + + // Multiple threads log messages, so synchronize to make sure that no one gets a partially initialized logger + public synchronized Logger getLogger() + { + if (_logger == null) + { + if (null == _logFile || FileUtil.hasCloudScheme(_logFile)) + throw new JobLogInaccessibleException("LogFile null or cloud."); + + // Create appending logger. + String loggerName = PipelineJob.class.getSimpleName() + ".Logger." + _logFile.toString(); + _logger = new OutputLogger(this, _logFile, loggerName, Level.toLevel(_loggerLevel)); + } + + return _logger; + } + + public void error(String message) + { + error(message, null); + } + + public void error(String message, @Nullable Throwable t) + { + setErrors(getErrors() + 1); + if (getLogger() != null) + getLogger().error(message, t); + } + + public void debug(String message) + { + debug(message, null); + } + + public void debug(String message, @Nullable Throwable t) + { + if (getLogger() != null) + getLogger().debug(message, t); + } + + public void warn(String message) + { + warn(message, null); + } + + public void warn(String message, @Nullable Throwable t) + { + if (getLogger() != null) + getLogger().warn(message, t); + } + + public void info(String message) + { + info(message, null); + } + + public void info(String message, @Nullable Throwable t) + { + if (getLogger() != null) + getLogger().info(message, t); + } + + public void header(String message) + { + info(message); + info("======================================="); + } + + ///////////////////////////////////////////////////////////////////////// + // ViewBackgroundInfo access + // WARNING: Some access of ViewBackgroundInfo is not supported when + // the job is running outside the LabKey Server. + + /** + * Gets the container ID from the ViewBackgroundInfo. + * + * @return the ID for the container in which the job was started + */ + public String getContainerId() + { + return getInfo().getContainerId(); + } + + /** + * Gets the User instance from the ViewBackgroundInfo. + * WARNING: Not supported if job is not running in the LabKey web server. + * + * @return the user who started the job + * @throws IllegalStateException if invoked on a remote pipeline server + */ + @Override + public User getUser() + { + if (!PipelineJobService.get().isWebServer()) + { + throw new IllegalStateException("User lookup not available on remote pipeline servers"); + } + return getInfo().getUser(); + } + + /** + * Gets the Container instance from the ViewBackgroundInfo. + * WARNING: Not supported if job is not running in the LabKey web server. + * + * @return the container in which the job was started + * @throws IllegalStateException if invoked on a remote pipeline server + */ + @Override + public Container getContainer() + { + if (!PipelineJobService.get().isWebServer()) + { + throw new IllegalStateException("User lookup not available on remote pipeline servers"); + } + return getInfo().getContainer(); + } + + /** + * Gets the ActionURL instance from the ViewBackgroundInfo. + * WARNING: Not supported if job is not running in the LabKey Server. + * + * @return the URL of the request that started the job + */ + public ActionURL getActionURL() + { + return getInfo().getURL(); + } + + /** + * Gets the ViewBackgroundInfo associated with this job in its contstructor. + * WARNING: Although this function is supported outside the LabKey Server, certain + * accessors on the ViewBackgroundInfo itself are not. + * + * @return information from the starting request, for use in background processing + */ + public ViewBackgroundInfo getInfo() + { + return _info; + } + + ///////////////////////////////////////////////////////////////////////// + // Scheduling interface + // TODO: Figure out how these apply to the Enterprise Pipeline + + protected boolean canInterrupt() + { + return false; + } + + public synchronized boolean interrupt() + { + PipelineJobService.get().cancelForJob(getJobGUID()); + if (!canInterrupt()) + return false; + _interrupted = true; + return true; + } + + public synchronized boolean checkInterrupted() + { + return _interrupted; + } + + public boolean allowMultipleSimultaneousJobs() + { + return false; + } + + synchronized public void setSubmitted() + { + _submitted = true; + notifyAll(); + } + + synchronized private boolean isSubmitted() + { + return _submitted; + } + + synchronized private void waitUntilSubmitted() + { + while (!_submitted) + { + try + { + wait(); + } + catch (InterruptedException ignored) {} + } + } + + ///////////////////////////////////////////////////////////////////////// + // JobRunner.Job interface + + @Override + public Object get() throws InterruptedException, ExecutionException + { + waitUntilSubmitted(); + return super.get(); + } + + @Override + public Object get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException + { + return get(); + } + + @Override + protected void starting(Thread thread) + { + _queue.starting(this, thread); + } + + @Override + public boolean cancel(boolean mayInterruptIfRunning) + { + if (isSubmitted()) + { + PipelineJobService.get().cancelForJob(getJobGUID()); + return super.cancel(mayInterruptIfRunning); + } + return true; + } + + @Override + public boolean isDone() + { + if (!isSubmitted()) + return false; + return super.isDone(); + } + + @Override + public boolean isCancelled() + { + if (!isSubmitted()) + return false; + return super.isCancelled(); + } + + @Override + public void done(Throwable throwable) + { + if (null != throwable) + { + try + { + error("Uncaught exception in PipelineJob: " + this, throwable); + } + catch (Exception ignored) {} + } + if (_queue != null) + { + _queue.done(this); + } + + PipelineJobNotificationProvider notificationProvider = PipelineService.get().getPipelineJobNotificationProvider(getJobNotificationProvider(), this); + if (notificationProvider != null) + notificationProvider.onJobDone(this); + + finallyCleanUpLocalDirectory(); //Since this potentially contains the job log, it should be run after the notifications tasks are executed + } + + protected String getJobNotificationProvider() + { + return null; + } + + protected String getNotificationType(PipelineJob.TaskStatus status) + { + return status.getNotificationType(); + } + + public String serializeJob(boolean ensureDeserialize) + { + return PipelineJobService.get().getJobStore().serializeToJSON(this, ensureDeserialize); + } + + public static String getClassNameFromJson(String serialized) + { + // Expect [ "org.labkey....", {.... + if (StringUtils.startsWith(serialized, "[")) + { + return StringUtils.substringBetween(serialized, "\""); + } + else + { + throw new RuntimeException("Unexpected serialized JSON"); + } + } + + @Nullable + public static PipelineJob deserializeJob(@NotNull String serialized) + { + try + { + String className = PipelineJob.getClassNameFromJson(serialized); + return PipelineJobService.get().getJobStore().deserializeFromJSON(serialized, (Class)Class.forName(className)); + } + catch (ClassNotFoundException e) + { + _log.error("Deserialized class not found.", e); + } + return null; + } + + public static ObjectMapper createObjectMapper() + { + ObjectMapper mapper = JsonUtil.DEFAULT_MAPPER.copy() + .setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE) + .setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY) + .disable(SerializationFeature.FAIL_ON_EMPTY_BEANS) + .enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL); + + SimpleModule module = new SimpleModule(); + module.addSerializer(new SqlTimeSerialization.SqlTimeSerializer()); + module.addDeserializer(Time.class, new SqlTimeSerialization.SqlTimeDeserializer()); + module.addDeserializer(AtomicLong.class, new AtomicLongDeserializer()); + module.addSerializer(NullSafeBindException.class, new NullSafeBindExceptionSerializer()); + module.addSerializer(QueryKey.class, new QueryKeySerialization.Serializer()); + module.addDeserializer(SchemaKey.class, new QueryKeySerialization.SchemaKeyDeserializer()); + module.addDeserializer(FieldKey.class, new QueryKeySerialization.FieldKeyDeserializer()); + module.addSerializer(Path.class, new PathSerialization.Serializer()); + module.addDeserializer(Path.class, new PathSerialization.Deserializer()); + module.addSerializer(CronExpression.class, new CronExpressionSerialization.Serializer()); + module.addDeserializer(CronExpression.class, new CronExpressionSerialization.Deserializer()); + module.addSerializer(URI.class, new URISerialization.Serializer()); + module.addDeserializer(URI.class, new URISerialization.Deserializer()); + module.addSerializer(File.class, new FileSerialization.Serializer()); + module.addDeserializer(File.class, new FileSerialization.Deserializer()); + module.addDeserializer(Filter.class, new FilterDeserializer()); + + mapper.registerModule(module); + return mapper; + } + + public abstract static class TestSerialization extends org.junit.Assert + { + public void testSerialize(PipelineJob job, @Nullable Logger log) + { + PipelineStatusFile.JobStore jobStore = PipelineJobService.get().getJobStore(); + try + { + if (null != log) + log.info("Hi Logger is here!"); + String json = jobStore.serializeToJSON(job, true); + if (null != log) + log.info(json); + PipelineJob job2 = jobStore.deserializeFromJSON(json, job.getClass()); + if (null != log) + log.info(job2.toString()); + + List errors = job.compareJobs(job2); + if (!errors.isEmpty()) + { + fail("Pipeline objects don't match: " + StringUtils.join(errors, ",")); + } + } + catch (Exception e) + { + if (null != log) + log.error("Class not found", e); + } + } + } + + @Override + public boolean equals(Object o) + { + // Fix issue 35876: Second run of a split XTandem pipeline job not completing - don't rely on the job being + // represented in memory as a single object + if (this == o) return true; + if (!(o instanceof PipelineJob that)) return false; + return Objects.equals(_jobGUID, that._jobGUID); + } + + @Override + public int hashCode() + { + return Objects.hash(_jobGUID); + } + + public List compareJobs(PipelineJob job2) + { + PipelineJob job1 = this; + List errors = new ArrayList<>(); + if (!PropertyUtil.nullSafeEquals(job1._activeTaskId, job2._activeTaskId)) + errors.add("_activeTaskId"); + if (job1._activeTaskRetries != job2._activeTaskRetries) + errors.add("_activeTaskRetries"); + if (!PropertyUtil.nullSafeEquals(job1._activeTaskStatus, job2._activeTaskStatus)) + errors.add("_activeTaskStatus"); + if (job1._errors != job2._errors) + errors.add("_errors"); + if (job1._interrupted != job2._interrupted) + errors.add("_interrupted"); + if (!PropertyUtil.nullSafeEquals(job1._jobGUID, job2._jobGUID)) + errors.add("_jobGUID"); + if (!PropertyUtil.nullSafeEquals(job1._logFile, job2._logFile)) + { + if (null == job1._logFile || null == job2._logFile) + errors.add("_logFile"); + else if (!FileUtil.getAbsoluteCaseSensitiveFile(job1._logFile.toFile()).getAbsolutePath().equalsIgnoreCase(FileUtil.getAbsoluteCaseSensitiveFile(job2._logFile.toFile()).getAbsolutePath())) + errors.add("_logFile"); + } + if (!PropertyUtil.nullSafeEquals(job1._parentGUID, job2._parentGUID)) + errors.add("_parentGUID"); + if (!PropertyUtil.nullSafeEquals(job1._provider, job2._provider)) + errors.add("_provider"); + if (job1._submitted != job2._submitted) + errors.add("_submitted"); + + return errors; + } + + /** + * @return Path String for a local working directory, temporary if root is cloud based + */ + protected Path getWorkingDirectoryString() + { + return !getPipeRoot().isCloudRoot() ? getPipeRoot().getRootNioPath() : FileUtil.getTempDirectory().toPath(); + } + + /** + * Generate a LocalDirectory and log file, temporary if need be, for use by the job + * Note: Override getDefaultLocalDirectoryString if piperoot isn't the desired local directory + * + * @param pipeRoot Pipeline's root directory + * @param moduleName supplying the pipeline + * @param baseLogFileName base name of the log file + */ + protected final void setupLocalDirectoryAndJobLog(PipeRoot pipeRoot, String moduleName, String baseLogFileName) + { + LocalDirectory localDirectory = LocalDirectory.create(pipeRoot, moduleName, baseLogFileName, getWorkingDirectoryString()); + setLocalDirectory(localDirectory); + setLogFile(localDirectory.determineLogFile()); + } +} diff --git a/api/src/org/labkey/api/pipeline/PipelineProtocol.java b/api/src/org/labkey/api/pipeline/PipelineProtocol.java index f9700804715..9aec39a64cb 100644 --- a/api/src/org/labkey/api/pipeline/PipelineProtocol.java +++ b/api/src/org/labkey/api/pipeline/PipelineProtocol.java @@ -1,228 +1,215 @@ -/* - * Copyright (c) 2008-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.api.pipeline; - -import org.apache.commons.beanutils.PropertyUtils; -import org.apache.xmlbeans.XmlOptions; -import org.fhcrc.cpas.pipeline.protocol.xml.PipelineProtocolPropsDocument; -import org.labkey.api.util.FileUtil; -import org.labkey.api.util.NetworkDrive; - -import java.beans.PropertyDescriptor; -import java.io.BufferedWriter; -import java.io.File; -import java.io.IOException; -import java.lang.reflect.InvocationTargetException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardOpenOption; -import java.util.HashMap; -import java.util.Map; - -/** - * A protocol captures settings to be used when running a pipeline. Values are often passed to tools - * being invoked on the command line or similar. - * Created: Oct 7, 2005 - * @author bmaclean - */ -public abstract class PipelineProtocol -{ - public static final String _xmlNamespace = "http://cpas.fhcrc.org/pipeline/protocol/xml"; - - private String name; - private String template; - - public PipelineProtocol() - { - } - - public PipelineProtocol(String name) - { - this.name = name; - } - - public String getName() - { - return name; - } - - public void setName(String name) - { - this.name = name; - } - - public abstract PipelineProtocolFactory getFactory(); - - public void validateToSave(PipeRoot root, boolean validateName, boolean abortOnExists) throws PipelineValidationException - { - if (validateName) - { - validate(root); - } - - if (getFactory().exists(root, name, false)) - { - if (abortOnExists) - { - throw new PipelineValidationException("A protocol named '" + name + "' already exists."); - } - } - else if (getFactory().exists(root, name, true)) - { - throw new PipelineValidationException("An archived protocol named '" + name + "' already exists."); - } - } - - public void validate(PipeRoot root) throws PipelineValidationException - { - validateProtocolName(); - } - - protected void validateProtocolName() throws PipelineValidationException - { - if (name == null || name.trim().isEmpty()) - throw new PipelineValidationException("Missing protocol name."); - else if (!getFactory().isValidProtocolName(name)) - throw new PipelineValidationException("The name '" + name + "' is not a valid protocol name."); - } - - public Path getDefinitionFile(PipeRoot root) - { - return getFactory().getProtocolFile(root, name, false); - } - - public void saveDefinition(PipeRoot root) throws IOException - { - save(getDefinitionFile(root)); - } - - public void setProperty(String propertyName, String value) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException - { - PropertyUtils.setProperty(this, propertyName, value); - } - - /** - * Method that returns properties to be saved in the properties document representing - * this protocol. This method is used by the default save method to identify what should be - * saved in the protocol document. - * @return Map of properties to be saved by the default save routine. - */ - protected Map getSaveProperties() - { - PropertyDescriptor props[] = PropertyUtils.getPropertyDescriptors(this); - Map propMap = new HashMap<>(); - - for (PropertyDescriptor prop : props) - { - String name = prop.getName(); - if ("class".equals(name)) - continue; - if (!PropertyUtils.isReadable(this, name) || - !PropertyUtils.isWriteable(this, name)) - continue; - - try - { - Object value = PropertyUtils.getProperty(this, name); - if (value != null) - { - propMap.put(name, value.toString()); - } - } - catch (Exception e) - { - } - - } - - return propMap; - } - - @Deprecated - public void save(File file) throws IOException - { - save(file.toPath()); - } - - private void ensureDir(Path dir) throws IOException - { - try - { - if (!Files.exists(dir)) - { - FileUtil.createDirectories(dir); - } - } - catch (IOException e) - { - throw new IOException("Failed to create directory '" + dir + "'."); - } - } - - public void save(Path file) throws IOException - { - Path dir = file.getParent(); - try - { - ensureDir(dir); - } - catch (IOException e) - { - NetworkDrive.ensureDrive(dir.toString()); - ensureDir(dir); - } - - PipelineProtocolPropsDocument doc = - PipelineProtocolPropsDocument.Factory.newInstance(); - PipelineProtocolPropsDocument.PipelineProtocolProps ppp = - doc.addNewPipelineProtocolProps(); - ppp.setType(getClass().getName()); - - Map propMap = getSaveProperties(); - - for (Map.Entry prop : propMap.entrySet()) - { - PipelineProtocolPropsDocument.PipelineProtocolProps.Property p = - ppp.addNewProperty(); - p.setName(prop.getKey()); - p.setStringValue(prop.getValue()); - } - - if (null != template) - ppp.setTemplate(template); - - Map mapNS = new HashMap<>(); - mapNS.put("", _xmlNamespace); - XmlOptions opts = new XmlOptions() - .setSavePrettyPrint() - .setSaveImplicitNamespaces(mapNS); - try (BufferedWriter bfw = Files.newBufferedWriter(file, StandardCharsets.UTF_8, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) - { - doc.save(bfw, opts); - } - } - - public String getTemplate() - { - return template; - } - - public void setTemplate(String template) - { - this.template = template; - } -} +/* + * Copyright (c) 2008-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.api.pipeline; + +import org.apache.commons.beanutils.PropertyUtils; +import org.apache.xmlbeans.XmlOptions; +import org.fhcrc.cpas.pipeline.protocol.xml.PipelineProtocolPropsDocument; +import org.labkey.api.util.FileUtil; +import org.labkey.api.util.NetworkDrive; +import org.labkey.api.writer.PrintWriters; +import org.labkey.vfs.FileLike; + +import java.beans.PropertyDescriptor; +import java.io.IOException; +import java.io.PrintWriter; +import java.lang.reflect.InvocationTargetException; +import java.util.HashMap; +import java.util.Map; + +/** + * A protocol captures settings to be used when running a pipeline. Values are often passed to tools + * being invoked on the command line or similar. + * Created: Oct 7, 2005 + * @author bmaclean + */ +public abstract class PipelineProtocol +{ + public static final String _xmlNamespace = "http://cpas.fhcrc.org/pipeline/protocol/xml"; + + private String name; + private String template; + + public PipelineProtocol(String name) + { + this.name = name; + } + + public String getName() + { + return name; + } + + public void setName(String name) + { + this.name = name; + } + + public abstract PipelineProtocolFactory getFactory(); + + public void validateToSave(PipeRoot root, boolean validateName, boolean abortOnExists) throws PipelineValidationException + { + if (validateName) + { + validate(root); + } + + if (getFactory().exists(root, name, false)) + { + if (abortOnExists) + { + throw new PipelineValidationException("A protocol named '" + name + "' already exists."); + } + } + else if (getFactory().exists(root, name, true)) + { + throw new PipelineValidationException("An archived protocol named '" + name + "' already exists."); + } + } + + public void validate(PipeRoot root) throws PipelineValidationException + { + validateProtocolName(); + } + + protected void validateProtocolName() throws PipelineValidationException + { + if (name == null || name.trim().isEmpty()) + throw new PipelineValidationException("Missing protocol name."); + else if (!getFactory().isValidProtocolName(name)) + throw new PipelineValidationException("The name '" + name + "' is not a valid protocol name."); + } + + public FileLike getDefinitionFile(PipeRoot root) + { + return getFactory().getProtocolFile(root, name, false); + } + + public void saveDefinition(PipeRoot root) throws IOException + { + save(getDefinitionFile(root)); + } + + public void setProperty(String propertyName, String value) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException + { + PropertyUtils.setProperty(this, propertyName, value); + } + + /** + * Method that returns properties to be saved in the properties document representing + * this protocol. This method is used by the default save method to identify what should be + * saved in the protocol document. + * @return Map of properties to be saved by the default save routine. + */ + protected Map getSaveProperties() + { + PropertyDescriptor props[] = PropertyUtils.getPropertyDescriptors(this); + Map propMap = new HashMap<>(); + + for (PropertyDescriptor prop : props) + { + String name = prop.getName(); + if ("class".equals(name)) + continue; + if (!PropertyUtils.isReadable(this, name) || + !PropertyUtils.isWriteable(this, name)) + continue; + + try + { + Object value = PropertyUtils.getProperty(this, name); + if (value != null) + { + propMap.put(name, value.toString()); + } + } + catch (Exception e) + { + } + + } + + return propMap; + } + + private void ensureDir(FileLike dir) throws IOException + { + try + { + if (!dir.exists()) + { + FileUtil.createDirectories(dir); + } + } + catch (IOException e) + { + throw new IOException("Failed to create directory '" + dir + "'."); + } + } + + public void save(FileLike file) throws IOException + { + FileLike dir = file.getParent(); + try + { + ensureDir(dir); + } + catch (IOException e) + { + NetworkDrive.ensureDrive(dir.toString()); + ensureDir(dir); + } + + PipelineProtocolPropsDocument doc = + PipelineProtocolPropsDocument.Factory.newInstance(); + PipelineProtocolPropsDocument.PipelineProtocolProps ppp = + doc.addNewPipelineProtocolProps(); + ppp.setType(getClass().getName()); + + Map propMap = getSaveProperties(); + + for (Map.Entry prop : propMap.entrySet()) + { + PipelineProtocolPropsDocument.PipelineProtocolProps.Property p = + ppp.addNewProperty(); + p.setName(prop.getKey()); + p.setStringValue(prop.getValue()); + } + + if (null != template) + ppp.setTemplate(template); + + Map mapNS = new HashMap<>(); + mapNS.put("", _xmlNamespace); + XmlOptions opts = new XmlOptions() + .setSavePrettyPrint() + .setSaveImplicitNamespaces(mapNS); + try (PrintWriter pw = PrintWriters.getPrintWriter(file.toNioPathForWrite())) + { + doc.save(pw, opts); + } + } + + public String getTemplate() + { + return template; + } + + public void setTemplate(String template) + { + this.template = template; + } +} diff --git a/api/src/org/labkey/api/pipeline/PipelineProtocolFactory.java b/api/src/org/labkey/api/pipeline/PipelineProtocolFactory.java index 97e4d4dcba6..464922863b4 100644 --- a/api/src/org/labkey/api/pipeline/PipelineProtocolFactory.java +++ b/api/src/org/labkey/api/pipeline/PipelineProtocolFactory.java @@ -1,240 +1,235 @@ -/* - * Copyright (c) 2008-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.api.pipeline; - -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.apache.xmlbeans.XmlOptions; -import org.fhcrc.cpas.pipeline.protocol.xml.PipelineProtocolPropsDocument; -import org.labkey.api.util.FileUtil; -import org.labkey.api.util.NetworkDrive; - -import java.io.File; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Arrays; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Map; - -/** - * Knows how to deserialize protocol definitions that have been persisted on the server (as XML on the file system - * - * Created: Oct 7, 2005 - * @author bmaclean - */ -public abstract class PipelineProtocolFactory -{ - protected static final String _pipelineProtocolDir = "protocols"; - private static final String _archivedProtocolDir = "archived"; - - private static final Logger LOG = LogManager.getLogger(PipelineProtocolFactory.class); - - public static Path getProtocolRootDir(PipeRoot root) - { - Path systemDir = root.ensureSystemDirectoryPath(); - return systemDir.resolve(_pipelineProtocolDir); - } - - public static File locateProtocolRootDir(File rootDir, File systemDir) - { - File protocolRootDir = FileUtil.appendName(systemDir, _pipelineProtocolDir); - File protocolRootDirLegacy = FileUtil.appendName(rootDir, _pipelineProtocolDir); - if (NetworkDrive.exists(protocolRootDirLegacy)) - protocolRootDirLegacy.renameTo(protocolRootDir); - return protocolRootDir; - } - - public abstract String getName(); - - public T load(PipeRoot root, String name, boolean archived) throws IOException - { - Path file = getProtocolFile(root, name, archived); - try - { - Map mapNS = new HashMap<>(); - mapNS.put("", PipelineProtocol._xmlNamespace); - XmlOptions opts = new XmlOptions().setLoadSubstituteNamespaces(mapNS); - - PipelineProtocolPropsDocument doc = - PipelineProtocolPropsDocument.Factory.parse(Files.newInputStream(file), opts); - PipelineProtocolPropsDocument.PipelineProtocolProps ppp = - doc.getPipelineProtocolProps(); - String type = ppp.getType(); - - // Recognize very old files - if (type.startsWith("org.fhcrc.cpas.ms2.")) - { - type = type.replace("org.fhcrc.cpas.ms2.", "org.labkey.ms2."); - } - if (type.startsWith("org.labkey.ms2.protocol.")) - { - type = type.replace("org.labkey.ms2.protocol.", "org.labkey.ms2.pipeline."); - } - - PipelineProtocol protocol = (PipelineProtocol) Class.forName(type).getDeclaredConstructor().newInstance(); - PipelineProtocolPropsDocument.PipelineProtocolProps.Property[] props = - ppp.getPropertyArray(); - if (ppp.isSetTemplate()) - { - String template = ppp.getTemplate(); - protocol.setTemplate(template); - } - - for (PipelineProtocolPropsDocument.PipelineProtocolProps.Property prop : props) - { - protocol.setProperty(prop.getName(), prop.getStringValue()); - } - - return (T) protocol; - } - catch (Exception e) - { - throw new IOException("Failed to load protocol document " + file.toAbsolutePath() + ".", e); - } - } - - public boolean isValidProtocolName(String name) - { - return FileUtil.isLegalName(name); - } - - public boolean exists(PipeRoot root, String name, boolean archived) - { - return Files.exists(getProtocolFile(root, name, archived)); - } - - public Path getProtocolDir(PipeRoot root, boolean archived) - { - Path protocolDir = getProtocolRootDir(root).resolve(getName()); - if (archived) - protocolDir = protocolDir.resolve(_archivedProtocolDir); - return protocolDir; - } - - public Path getProtocolFile(PipeRoot root, String name, boolean archived) - { - return FileUtil.appendName(getProtocolDir(root, archived), name + ".xml"); - } - - /** @return sorted list of protocol names */ - public String[] getProtocolNames(PipeRoot root, Path dirData, boolean archived) - { - HashSet setNames = new HashSet<>(); - - // Add .xml files - File[] files = getProtocolDir(root, archived).toFile().listFiles(f -> f.getName().endsWith(".xml") && !f.isDirectory()); - if (files != null) - { - for (File file : files) - { - final String name = file.getName(); - setNames.add(name.substring(0, name.lastIndexOf('.'))); - } - } - - // Add all directories that already exist in the analysis root. - if (dirData != null && !archived) - { - files = dirData.resolve(getName()).toFile().listFiles(File::isDirectory); - - if (files != null) - { - for (File file : files) - setNames.add(file.getName()); - } - } - - String[] vals = setNames.toArray(new String[0]); - Arrays.sort(vals, String.CASE_INSENSITIVE_ORDER); - return vals; - } - - /** - * Move the file for the specified protocol to or from the archived directory - * @param root pipeline root for the container - * @param name the protocol name - * @param moveToArchive true if archiving the protocol; false for unarchiving - * @return true if the file was successfully moved or does not exist; false on error moving or if the archived directory - * can't be created - */ - public boolean changeArchiveStatus(PipeRoot root, String name, boolean moveToArchive) throws IOException - { - // Is the file's current location opposite the destination? No sense in moving it if it's already where the caller wants it. - if (exists(root, name, !moveToArchive)) - { - if (moveToArchive) - { - Path archiveDir = getProtocolDir(root, true); - if (!Files.exists(archiveDir)) - { - FileUtil.createDirectories(archiveDir); - } - else if (Files.isRegularFile(archiveDir)) - { - LOG.error("Unable to create archived directory because a file with that name exists in the protocol directory: " - + getProtocolDir(root, false).toAbsolutePath()); - return false; - } - } - - try - { - Files.move(getProtocolFile(root, name, !moveToArchive), getProtocolFile(root, name, moveToArchive)); - } - catch (IOException e) - { - return false; - } - - return true; - } - return true; // We don't care if the file doesn't exist (maybe was already in the destination?) - } - - /** - * Delete the xml file of the specified protocol. Tries to resolve the file in the main folder first. - * If the file doesn't exist there, look in the archived folder - * @param root pipeline root for the container - * @param name the protocol name - * @return true if the file was successfully deleted or does not exist - */ - public boolean deleteProtocolFile(PipeRoot root, String name) - { - Path protocolFile = getProtocolFile(root, name, false); - - //If it doesn't exist, check archive - if (!Files.exists(protocolFile)) - protocolFile = getProtocolFile(root, name, true); - - //If it still doesn't exist, move on - if (!Files.exists(protocolFile)) - { - return true; // We don't care if the file doesn't exist - } - - try - { - return Files.deleteIfExists(protocolFile); - } - catch (IOException e) - { - LogManager.getLogger(PipelineProtocolFactory.class).debug("Error attempting to delete protocol file " + protocolFile, e); - return false; - } - } -} +/* + * Copyright (c) 2008-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.api.pipeline; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.xmlbeans.XmlOptions; +import org.fhcrc.cpas.pipeline.protocol.xml.PipelineProtocolPropsDocument; +import org.labkey.api.util.FileUtil; +import org.labkey.api.util.NetworkDrive; +import org.labkey.vfs.FileLike; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; + +/** + * Knows how to deserialize protocol definitions that have been persisted on the server (as XML on the file system + * + * Created: Oct 7, 2005 + * @author bmaclean + */ +public abstract class PipelineProtocolFactory +{ + protected static final String _pipelineProtocolDir = "protocols"; + private static final String _archivedProtocolDir = "archived"; + + private static final Logger LOG = LogManager.getLogger(PipelineProtocolFactory.class); + + public static FileLike getProtocolRootDir(PipeRoot root) + { + FileLike systemDir = root.ensureSystemFileLike(); + return systemDir.resolveChild(_pipelineProtocolDir); + } + + public static File locateProtocolRootDir(File rootDir, File systemDir) + { + File protocolRootDir = FileUtil.appendName(systemDir, _pipelineProtocolDir); + File protocolRootDirLegacy = FileUtil.appendName(rootDir, _pipelineProtocolDir); + if (NetworkDrive.exists(protocolRootDirLegacy)) + protocolRootDirLegacy.renameTo(protocolRootDir); + return protocolRootDir; + } + + public abstract String getName(); + + public T load(PipeRoot root, String name, boolean archived) throws IOException + { + FileLike file = getProtocolFile(root, name, archived); + try + { + Map mapNS = new HashMap<>(); + mapNS.put("", PipelineProtocol._xmlNamespace); + XmlOptions opts = new XmlOptions().setLoadSubstituteNamespaces(mapNS); + + PipelineProtocolPropsDocument doc = + PipelineProtocolPropsDocument.Factory.parse(file.openInputStream(), opts); + PipelineProtocolPropsDocument.PipelineProtocolProps ppp = + doc.getPipelineProtocolProps(); + String type = ppp.getType(); + + // Recognize very old files + if (type.startsWith("org.fhcrc.cpas.ms2.")) + { + type = type.replace("org.fhcrc.cpas.ms2.", "org.labkey.ms2."); + } + if (type.startsWith("org.labkey.ms2.protocol.")) + { + type = type.replace("org.labkey.ms2.protocol.", "org.labkey.ms2.pipeline."); + } + + PipelineProtocol protocol = (PipelineProtocol) Class.forName(type).getDeclaredConstructor().newInstance(); + PipelineProtocolPropsDocument.PipelineProtocolProps.Property[] props = + ppp.getPropertyArray(); + if (ppp.isSetTemplate()) + { + String template = ppp.getTemplate(); + protocol.setTemplate(template); + } + + for (PipelineProtocolPropsDocument.PipelineProtocolProps.Property prop : props) + { + protocol.setProperty(prop.getName(), prop.getStringValue()); + } + + return (T) protocol; + } + catch (Exception e) + { + throw new IOException("Failed to load protocol document " + file + ".", e); + } + } + + public boolean isValidProtocolName(String name) + { + return FileUtil.isLegalName(name); + } + + public boolean exists(PipeRoot root, String name, boolean archived) + { + return getProtocolFile(root, name, archived).exists(); + } + + public FileLike getProtocolDir(PipeRoot root, boolean archived) + { + FileLike protocolDir = getProtocolRootDir(root).resolveChild(getName()); + if (archived) + protocolDir = protocolDir.resolveChild(_archivedProtocolDir); + return protocolDir; + } + + public FileLike getProtocolFile(PipeRoot root, String name, boolean archived) + { + return getProtocolDir(root, archived).resolveChild(name + ".xml"); + } + + /** @return sorted list of protocol names */ + public String[] getProtocolNames(PipeRoot root, FileLike dirData, boolean archived) + { + HashSet setNames = new HashSet<>(); + + // Add .xml files + List files = getProtocolDir(root, archived).getChildren(f -> f.getName().endsWith(".xml") && !f.isDirectory()); + for (FileLike file : files) + { + final String name = file.getName(); + setNames.add(name.substring(0, name.lastIndexOf('.'))); + } + + // Add all directories that already exist in the analysis root. + if (dirData != null && !archived) + { + files = dirData.resolveChild(getName()).getChildren(FileLike::isDirectory); + + for (FileLike file : files) + setNames.add(file.getName()); + } + + String[] vals = setNames.toArray(new String[0]); + Arrays.sort(vals, String.CASE_INSENSITIVE_ORDER); + return vals; + } + + /** + * Move the file for the specified protocol to or from the archived directory + * @param root pipeline root for the container + * @param name the protocol name + * @param moveToArchive true if archiving the protocol; false for unarchiving + * @return true if the file was successfully moved or does not exist; false on error moving or if the archived directory + * can't be created + */ + public boolean changeArchiveStatus(PipeRoot root, String name, boolean moveToArchive) throws IOException + { + // Is the file's current location opposite the destination? No sense in moving it if it's already where the caller wants it. + if (exists(root, name, !moveToArchive)) + { + if (moveToArchive) + { + FileLike archiveDir = getProtocolDir(root, true); + if (!archiveDir.exists()) + { + FileUtil.createDirectories(archiveDir); + } + else if (archiveDir.isFile()) + { + LOG.error("Unable to create archived directory because a file with that name exists in the protocol directory: " + + getProtocolDir(root, false)); + return false; + } + } + + try + { + Files.move(getProtocolFile(root, name, !moveToArchive).toNioPathForWrite(), getProtocolFile(root, name, moveToArchive).toNioPathForWrite()); + } + catch (IOException e) + { + return false; + } + + return true; + } + return true; // We don't care if the file doesn't exist (maybe was already in the destination?) + } + + /** + * Delete the xml file of the specified protocol. Tries to resolve the file in the main folder first. + * If the file doesn't exist there, look in the archived folder + * @param root pipeline root for the container + * @param name the protocol name + * @return true if the file was successfully deleted or does not exist + */ + public boolean deleteProtocolFile(PipeRoot root, String name) + { + FileLike protocolFile = getProtocolFile(root, name, false); + + //If it doesn't exist, check archive + if (!protocolFile.exists()) + protocolFile = getProtocolFile(root, name, true); + + //If it still doesn't exist, move on + if (!protocolFile.exists()) + { + return true; // We don't care if the file doesn't exist + } + + try + { + return protocolFile.delete(); + } + catch (IOException e) + { + LOG.debug("Error attempting to delete protocol file " + protocolFile, e); + return false; + } + } +} diff --git a/api/src/org/labkey/api/pipeline/PipelineService.java b/api/src/org/labkey/api/pipeline/PipelineService.java index 0bd346a4781..5e98e88dab5 100644 --- a/api/src/org/labkey/api/pipeline/PipelineService.java +++ b/api/src/org/labkey/api/pipeline/PipelineService.java @@ -1,274 +1,275 @@ -/* - * Copyright (c) 2008-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.api.pipeline; - -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.labkey.api.admin.ImportOptions; -import org.labkey.api.data.Container; -import org.labkey.api.data.ContainerFilter; -import org.labkey.api.data.TableInfo; -import org.labkey.api.exp.api.ExpRun; -import org.labkey.api.pipeline.file.AbstractFileAnalysisProtocolFactory; -import org.labkey.api.pipeline.view.SetupForm; -import org.labkey.api.query.QueryView; -import org.labkey.api.security.User; -import org.labkey.api.services.ServiceRegistry; -import org.labkey.api.study.FolderArchiveSource; -import org.labkey.api.trigger.TriggerConfiguration; -import org.labkey.api.view.ActionURL; -import org.labkey.api.view.HttpView; -import org.labkey.api.view.ViewBackgroundInfo; -import org.labkey.api.view.ViewContext; -import org.springframework.validation.BindException; - -import java.io.File; -import java.io.IOException; -import java.net.URI; -import java.nio.file.Path; -import java.sql.SQLException; -import java.util.Collection; -import java.util.Date; -import java.util.List; -import java.util.Map; - -/** - * Capabilities provided by the Pipeline module to other modules. These methods are only available to code - * that is running within the web server. {@link PipelineJobService} provides basic pipeline job and task - * functionality that is also available on remote execution pipeline servers. - */ -public interface PipelineService extends PipelineStatusFile.StatusReader, PipelineStatusFile.StatusWriter -{ - String MODULE_NAME = "Pipeline"; - String UNZIP_DIR = ".unzip"; // '.' prefix prevents search indexing - String EXPORT_DIR = "export"; - String CACHE_DIR = ".cache"; - - String PRIMARY_ROOT = "PRIMARY"; - - static PipelineService get() - { - return ServiceRegistry.get().getService(PipelineService.class); - } - - static void setInstance(PipelineService instance) - { - ServiceRegistry.get().registerService(PipelineService.class, instance); - } - - /** - * Statically register a single PipelineProvider that's implemented in code. - * @param provider PipelineProvider to register - * @param aliases Alternate names for this provider - */ - void registerPipelineProvider(PipelineProvider provider, String... aliases); - - void registerPipelineJobNotificationProvider(PipelineJobNotificationProvider provider); - - /** - * Register a supplier of (likely multiple) PipelineProviders. Suppliers are called any time the service returns all - * providers or resolves a single provider. This allows the provider list to be dynamic, for example, as file-based - * assay definitions change. - * @param supplier PipelineProviderSupplier - */ - void registerPipelineProviderSupplier(PipelineProviderSupplier supplier); - - /** - * Looks up the container hierarchy until it finds a pipeline root defined which is being - * inherited by the specified container - * @return null if there's no specific pipeline override and the default root is unavailable or misconfigured - */ - @Nullable - PipeRoot findPipelineRoot(Container container); - - /** - * Looks up the container hierarchy until it finds a pipeline root defined which is being - * inherited by the specified container - * @return null if there's no specific pipeline override and the default root is unavailable or misconfigured - */ - @Nullable - PipeRoot findPipelineRoot(Container container, String type); - - - /** @return true if this container (or an inherited parent container) has a pipeline root that exists on disk */ - boolean hasValidPipelineRoot(Container container); - - @NotNull - Map getAllPipelineRoots(); - - @Nullable - PipeRoot getPipelineRootSetting(Container container); - - /** - * Gets the pipeline root that was explicitly configured for this container, or falls back to the default file root. - * Does NOT look up the container hierarchy for a pipeline override defined in a parent container. In most - * places where not explicitly doing pipeline configuration, use findPipelineRoot() instead. - */ - @Nullable - PipeRoot getPipelineRootSetting(Container container, String type); - - void setPipelineRoot(User user, Container container, String type, boolean searchable, URI... roots) throws SQLException; - - boolean canModifyPipelineRoot(User user, Container container); - - @NotNull - List getPipelineProviders(); - - @Nullable - PipelineProvider getPipelineProvider(String name); - - boolean isEnterprisePipeline(); - - /** Generate command-line arguments to launch org.labkey.pipeline.cluster.ClusterStartup. Intended for automated tests */ - List getClusterStartupArguments() throws IOException; - - enum JmsType { none, inProcess, external, unknown } - @NotNull - JmsType getJmsType(); - - - @NotNull - PipelineQueue getPipelineQueue(); - - /** - * Add a PipelineJob to this queue to be run. - * - * @param job Job to be run - */ - void queueJob(PipelineJob job) throws PipelineValidationException; - - void queueJob(PipelineJob job, String jobNotificationProvider) throws PipelineValidationException; - - /** - * This will update the active task status of this job and re-queue that job if the task is complete - */ - void setPipelineJobStatus(PipelineJob job, PipelineJob.TaskStatus status) throws PipelineJobException; - - /** Update the path to the log file for this job */ - void setPipelineJobStatusFilePath(PipelineJob job, Path otherFile); - - void setPipelineProperty(Container container, String name, String value); - - String getPipelineProperty(Container container, String name); - - @NotNull - String startFileAnalysis(AnalyzeForm form, @Nullable Map variableMap, ViewContext viewContext) throws IOException, PipelineValidationException; - - @NotNull - String startFileAnalysis(AnalyzeForm form, @Nullable Map variableMap, ViewBackgroundInfo context) throws IOException, PipelineValidationException; - - @NotNull - String startFileAnalysis(AnalyzeForm form, @Nullable Map variableMap, ViewBackgroundInfo context, boolean timestampLog) throws IOException, PipelineValidationException; - - - /** Configurations for the pipeline job webpart ButtonBar */ - enum PipelineButtonOption { Minimal, Assay, Standard } - - QueryView getPipelineQueryView(ViewContext context, PipelineButtonOption buttonOption); - - HttpView getSetupView(SetupForm form); - - boolean savePipelineSetup(ViewContext context, SetupForm form, BindException errors) throws Exception; - - // TODO: This should be on PipelineProtocolFactory - String getLastProtocolSetting(PipelineProtocolFactory factory, Container container, User user); - - // TODO: This should be on PipelineProtocolFactory - void rememberLastProtocolSetting(PipelineProtocolFactory factory, Container container, - User user, String protocolName); - boolean hasSiteDefaultRoot(Container container); - - TableInfo getJobsTable(User user, Container container); - - TableInfo getJobsTable(User user, Container container, @Nullable ContainerFilter cf); - - boolean runFolderImportJob(Container c, User user, ActionURL url, Path folderXml, String originalFilename, PipeRoot pipelineRoot, ImportOptions options); - - /** - * Register a folder archive source implementation. A FolderArchiveSource creates folder artifacts that can be - * imported automatically via the folder import framework. The source of the artifacts could be an external - * repository or server. - */ - void registerFolderArchiveSource(FolderArchiveSource reloadSource); - - Collection getFolderArchiveSources(Container container); - - @Nullable - FolderArchiveSource getFolderArchiveSource(String name); - - boolean runGenerateFolderArchiveAndImportJob(Container c, User user, ActionURL url, String sourceName); - boolean runGenerateFolderArchiveAndImportJob(Container c, User user, ActionURL url, ImportOptions options); - - Long getJobId(User u, Container c, String jobGUID); - String getJobGUID(User u, Container c, long rowId); - - PathAnalysisProperties getFileAnalysisProperties(Container c, String taskId, String path); - - TriggerConfiguration getTriggerConfig(Container c, String name); - void saveTriggerConfig(Container c, User user, TriggerConfiguration config) throws Exception; - void setTriggeredTime(Container container, User user, int triggerConfigId, Path filePath, Date date); - - class PathAnalysisProperties - { - private final PipeRoot _pipeRoot; - private final Path _dirData; - private final AbstractFileAnalysisProtocolFactory _factory; - - public PathAnalysisProperties(PipeRoot pipeRoot, Path dirData, AbstractFileAnalysisProtocolFactory factory) - { - _pipeRoot = pipeRoot; - _dirData = dirData; - _factory = factory; - } - - public PipeRoot getPipeRoot() - { - return _pipeRoot; - } - - @Nullable - public Path getDirData() - { - return _dirData; - } - - public AbstractFileAnalysisProtocolFactory getFactory() - { - return _factory; - } - } - - boolean isProtocolDefined(AnalyzeForm form); - - @Nullable - File getProtocolParametersFile(ExpRun expRun); - - void deleteStatusFile(Container c, User u, boolean deleteExpRuns, Collection rowIds) throws PipelineProvider.HandlerException; - - PipelineJobNotificationProvider getPipelineJobNotificationProvider(@Nullable String name); - - PipelineJobNotificationProvider getPipelineJobNotificationProvider(@Nullable String name, PipelineJob job); - - Collection> getActivePipelineJobs(User u, Container c, String providerName); - - Collection> getActivePipelineJobs(User u, Container c, String providerName, @Nullable ContainerFilter cf); - - interface PipelineProviderSupplier - { - @NotNull Collection getAll(); - @Nullable PipelineProvider findPipelineProvider(String name); - } -} +/* + * Copyright (c) 2008-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.api.pipeline; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.labkey.api.admin.ImportOptions; +import org.labkey.api.data.Container; +import org.labkey.api.data.ContainerFilter; +import org.labkey.api.data.TableInfo; +import org.labkey.api.exp.api.ExpRun; +import org.labkey.api.pipeline.file.AbstractFileAnalysisProtocolFactory; +import org.labkey.api.pipeline.view.SetupForm; +import org.labkey.api.query.QueryView; +import org.labkey.api.security.User; +import org.labkey.api.services.ServiceRegistry; +import org.labkey.api.study.FolderArchiveSource; +import org.labkey.api.trigger.TriggerConfiguration; +import org.labkey.api.view.ActionURL; +import org.labkey.api.view.HttpView; +import org.labkey.api.view.ViewBackgroundInfo; +import org.labkey.api.view.ViewContext; +import org.labkey.vfs.FileLike; +import org.springframework.validation.BindException; + +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.nio.file.Path; +import java.sql.SQLException; +import java.util.Collection; +import java.util.Date; +import java.util.List; +import java.util.Map; + +/** + * Capabilities provided by the Pipeline module to other modules. These methods are only available to code + * that is running within the web server. {@link PipelineJobService} provides basic pipeline job and task + * functionality that is also available on remote execution pipeline servers. + */ +public interface PipelineService extends PipelineStatusFile.StatusReader, PipelineStatusFile.StatusWriter +{ + String MODULE_NAME = "Pipeline"; + String UNZIP_DIR = ".unzip"; // '.' prefix prevents search indexing + String EXPORT_DIR = "export"; + String CACHE_DIR = ".cache"; + + String PRIMARY_ROOT = "PRIMARY"; + + static PipelineService get() + { + return ServiceRegistry.get().getService(PipelineService.class); + } + + static void setInstance(PipelineService instance) + { + ServiceRegistry.get().registerService(PipelineService.class, instance); + } + + /** + * Statically register a single PipelineProvider that's implemented in code. + * @param provider PipelineProvider to register + * @param aliases Alternate names for this provider + */ + void registerPipelineProvider(PipelineProvider provider, String... aliases); + + void registerPipelineJobNotificationProvider(PipelineJobNotificationProvider provider); + + /** + * Register a supplier of (likely multiple) PipelineProviders. Suppliers are called any time the service returns all + * providers or resolves a single provider. This allows the provider list to be dynamic, for example, as file-based + * assay definitions change. + * @param supplier PipelineProviderSupplier + */ + void registerPipelineProviderSupplier(PipelineProviderSupplier supplier); + + /** + * Looks up the container hierarchy until it finds a pipeline root defined which is being + * inherited by the specified container + * @return null if there's no specific pipeline override and the default root is unavailable or misconfigured + */ + @Nullable + PipeRoot findPipelineRoot(Container container); + + /** + * Looks up the container hierarchy until it finds a pipeline root defined which is being + * inherited by the specified container + * @return null if there's no specific pipeline override and the default root is unavailable or misconfigured + */ + @Nullable + PipeRoot findPipelineRoot(Container container, String type); + + + /** @return true if this container (or an inherited parent container) has a pipeline root that exists on disk */ + boolean hasValidPipelineRoot(Container container); + + @NotNull + Map getAllPipelineRoots(); + + @Nullable + PipeRoot getPipelineRootSetting(Container container); + + /** + * Gets the pipeline root that was explicitly configured for this container, or falls back to the default file root. + * Does NOT look up the container hierarchy for a pipeline override defined in a parent container. In most + * places where not explicitly doing pipeline configuration, use findPipelineRoot() instead. + */ + @Nullable + PipeRoot getPipelineRootSetting(Container container, String type); + + void setPipelineRoot(User user, Container container, String type, boolean searchable, URI... roots) throws SQLException; + + boolean canModifyPipelineRoot(User user, Container container); + + @NotNull + List getPipelineProviders(); + + @Nullable + PipelineProvider getPipelineProvider(String name); + + boolean isEnterprisePipeline(); + + /** Generate command-line arguments to launch org.labkey.pipeline.cluster.ClusterStartup. Intended for automated tests */ + List getClusterStartupArguments() throws IOException; + + enum JmsType { none, inProcess, external, unknown } + @NotNull + JmsType getJmsType(); + + + @NotNull + PipelineQueue getPipelineQueue(); + + /** + * Add a PipelineJob to this queue to be run. + * + * @param job Job to be run + */ + void queueJob(PipelineJob job) throws PipelineValidationException; + + void queueJob(PipelineJob job, String jobNotificationProvider) throws PipelineValidationException; + + /** + * This will update the active task status of this job and re-queue that job if the task is complete + */ + void setPipelineJobStatus(PipelineJob job, PipelineJob.TaskStatus status) throws PipelineJobException; + + /** Update the path to the log file for this job */ + void setPipelineJobStatusFilePath(PipelineJob job, Path otherFile); + + void setPipelineProperty(Container container, String name, String value); + + String getPipelineProperty(Container container, String name); + + @NotNull + String startFileAnalysis(AnalyzeForm form, @Nullable Map variableMap, ViewContext viewContext) throws IOException, PipelineValidationException; + + @NotNull + String startFileAnalysis(AnalyzeForm form, @Nullable Map variableMap, ViewBackgroundInfo context) throws IOException, PipelineValidationException; + + @NotNull + String startFileAnalysis(AnalyzeForm form, @Nullable Map variableMap, ViewBackgroundInfo context, boolean timestampLog) throws IOException, PipelineValidationException; + + + /** Configurations for the pipeline job webpart ButtonBar */ + enum PipelineButtonOption { Minimal, Assay, Standard } + + QueryView getPipelineQueryView(ViewContext context, PipelineButtonOption buttonOption); + + HttpView getSetupView(SetupForm form); + + boolean savePipelineSetup(ViewContext context, SetupForm form, BindException errors) throws Exception; + + // TODO: This should be on PipelineProtocolFactory + String getLastProtocolSetting(PipelineProtocolFactory factory, Container container, User user); + + // TODO: This should be on PipelineProtocolFactory + void rememberLastProtocolSetting(PipelineProtocolFactory factory, Container container, + User user, String protocolName); + boolean hasSiteDefaultRoot(Container container); + + TableInfo getJobsTable(User user, Container container); + + TableInfo getJobsTable(User user, Container container, @Nullable ContainerFilter cf); + + boolean runFolderImportJob(Container c, User user, ActionURL url, Path folderXml, String originalFilename, PipeRoot pipelineRoot, ImportOptions options); + + /** + * Register a folder archive source implementation. A FolderArchiveSource creates folder artifacts that can be + * imported automatically via the folder import framework. The source of the artifacts could be an external + * repository or server. + */ + void registerFolderArchiveSource(FolderArchiveSource reloadSource); + + Collection getFolderArchiveSources(Container container); + + @Nullable + FolderArchiveSource getFolderArchiveSource(String name); + + boolean runGenerateFolderArchiveAndImportJob(Container c, User user, ActionURL url, String sourceName); + boolean runGenerateFolderArchiveAndImportJob(Container c, User user, ActionURL url, ImportOptions options); + + Long getJobId(User u, Container c, String jobGUID); + String getJobGUID(User u, Container c, long rowId); + + PathAnalysisProperties getFileAnalysisProperties(Container c, String taskId, String path); + + TriggerConfiguration getTriggerConfig(Container c, String name); + void saveTriggerConfig(Container c, User user, TriggerConfiguration config) throws Exception; + void setTriggeredTime(Container container, User user, int triggerConfigId, Path filePath, Date date); + + class PathAnalysisProperties + { + private final PipeRoot _pipeRoot; + private final FileLike _dirData; + private final AbstractFileAnalysisProtocolFactory _factory; + + public PathAnalysisProperties(PipeRoot pipeRoot, FileLike dirData, AbstractFileAnalysisProtocolFactory factory) + { + _pipeRoot = pipeRoot; + _dirData = dirData; + _factory = factory; + } + + public PipeRoot getPipeRoot() + { + return _pipeRoot; + } + + @Nullable + public FileLike getDirData() + { + return _dirData; + } + + public AbstractFileAnalysisProtocolFactory getFactory() + { + return _factory; + } + } + + boolean isProtocolDefined(AnalyzeForm form); + + @Nullable + File getProtocolParametersFile(ExpRun expRun); + + void deleteStatusFile(Container c, User u, boolean deleteExpRuns, Collection rowIds) throws PipelineProvider.HandlerException; + + PipelineJobNotificationProvider getPipelineJobNotificationProvider(@Nullable String name); + + PipelineJobNotificationProvider getPipelineJobNotificationProvider(@Nullable String name, PipelineJob job); + + Collection> getActivePipelineJobs(User u, Container c, String providerName); + + Collection> getActivePipelineJobs(User u, Container c, String providerName, @Nullable ContainerFilter cf); + + interface PipelineProviderSupplier + { + @NotNull Collection getAll(); + @Nullable PipelineProvider findPipelineProvider(String name); + } +} diff --git a/api/src/org/labkey/api/pipeline/PipelineStatusFile.java b/api/src/org/labkey/api/pipeline/PipelineStatusFile.java index e5337df65a8..4ce12d218f5 100644 --- a/api/src/org/labkey/api/pipeline/PipelineStatusFile.java +++ b/api/src/org/labkey/api/pipeline/PipelineStatusFile.java @@ -1,139 +1,144 @@ -/* - * Copyright (c) 2008-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.api.pipeline; - -import org.jetbrains.annotations.Nullable; -import org.labkey.api.data.Container; - -import java.io.File; -import java.io.IOException; -import java.nio.file.Path; -import java.sql.SQLException; -import java.util.Date; -import java.util.List; - -/** - * The serializable data object for the current state of a pipeline job. - * - * @author brendanx - */ -public interface PipelineStatusFile -{ - interface StatusReader - { - @Deprecated - PipelineStatusFile getStatusFile(File logFile); - PipelineStatusFile getStatusFile(Container container, Path logFile); - - PipelineStatusFile getStatusFile(long rowId); - - PipelineStatusFile getStatusFile(String jobGuid); - - List getQueuedStatusFiles() throws SQLException; - - List getQueuedStatusFiles(Container c) throws SQLException; - } - - interface StatusWriter - { - boolean setStatus(PipelineJob job, String status, @Nullable String statusInfo, boolean allowInsert) throws Exception; - - void ensureError(PipelineJob job) throws Exception; - - /** - * If a location can be serviced by multiple servers, we record the hostname of which server is RUNNING a given task. - * This is currently only supported for Remote Servers, but could be expanded for clusters. - * - * @param hostName The hostname the status writer should use when updating pipeline.StatusFiles - */ - void setHostName(String hostName); - } - - interface JobStore - { - void storeJob(PipelineJob job) throws NoSuchJobException; - - PipelineJob getJob(String jobId); - - PipelineJob getJob(long rowId); - - void retry(String jobId) throws IOException, NoSuchJobException; - - void retry(PipelineStatusFile sf) throws IOException, NoSuchJobException; - - void split(PipelineJob job) throws IOException; - - void join(PipelineJob job) throws IOException, NoSuchJobException; - - String serializeToJSON(PipelineJob job, boolean ensureDeserialize); - - PipelineJob deserializeFromJSON(String xml, Class cls); - } - - Container lookupContainer(); - - boolean isActive(); - - Date getCreated(); - - Date getModified(); - - long getRowId(); - - String getJobId(); - - String getJobParentId(); - - /** - * @return the name of the {@link PipelineProvider} for this job. Used to provide hooks for - * doing work before deletion of the job, etc - */ - @Nullable - String getProvider(); - - String getStatus(); - - void setStatus(String status); - - String getInfo(); - - void setInfo(String info); - - String getFilePath(); - - String getDataUrl(); - - String getDescription(); - - String getEmail(); - - boolean isHadError(); - - String getJobStore(); - - PipelineJob createJobInstance(); - - void save(); - - /** - * - * @return which of multiple hostnames for a location is RUNNING a task. Only set for tasks in a RUNNING state on a remote - * server. If active task is in an inactive state or running on the web server or a cluster, this will be null. - */ - @Nullable - String getActiveHostName(); -} - +/* + * Copyright (c) 2008-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.api.pipeline; + +import org.jetbrains.annotations.Nullable; +import org.labkey.api.data.Container; +import org.labkey.vfs.FileLike; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.sql.SQLException; +import java.util.Date; +import java.util.List; + +/** + * The serializable data object for the current state of a pipeline job. + * + * @author brendanx + */ +public interface PipelineStatusFile +{ + interface StatusReader + { + @Deprecated + PipelineStatusFile getStatusFile(File logFile); + PipelineStatusFile getStatusFile(Container container, Path logFile); + default PipelineStatusFile getStatusFile(Container container, FileLike logFile) + { + return getStatusFile(container, logFile.toNioPathForRead()); + } + + PipelineStatusFile getStatusFile(long rowId); + + PipelineStatusFile getStatusFile(String jobGuid); + + List getQueuedStatusFiles() throws SQLException; + + List getQueuedStatusFiles(Container c) throws SQLException; + } + + interface StatusWriter + { + boolean setStatus(PipelineJob job, String status, @Nullable String statusInfo, boolean allowInsert) throws Exception; + + void ensureError(PipelineJob job) throws Exception; + + /** + * If a location can be serviced by multiple servers, we record the hostname of which server is RUNNING a given task. + * This is currently only supported for Remote Servers, but could be expanded for clusters. + * + * @param hostName The hostname the status writer should use when updating pipeline.StatusFiles + */ + void setHostName(String hostName); + } + + interface JobStore + { + void storeJob(PipelineJob job) throws NoSuchJobException; + + PipelineJob getJob(String jobId); + + PipelineJob getJob(long rowId); + + void retry(String jobId) throws IOException, NoSuchJobException; + + void retry(PipelineStatusFile sf) throws IOException, NoSuchJobException; + + void split(PipelineJob job) throws IOException; + + void join(PipelineJob job) throws IOException, NoSuchJobException; + + String serializeToJSON(PipelineJob job, boolean ensureDeserialize); + + PipelineJob deserializeFromJSON(String xml, Class cls); + } + + Container lookupContainer(); + + boolean isActive(); + + Date getCreated(); + + Date getModified(); + + long getRowId(); + + String getJobId(); + + String getJobParentId(); + + /** + * @return the name of the {@link PipelineProvider} for this job. Used to provide hooks for + * doing work before deletion of the job, etc + */ + @Nullable + String getProvider(); + + String getStatus(); + + void setStatus(String status); + + String getInfo(); + + void setInfo(String info); + + String getFilePath(); + + String getDataUrl(); + + String getDescription(); + + String getEmail(); + + boolean isHadError(); + + String getJobStore(); + + PipelineJob createJobInstance(); + + void save(); + + /** + * + * @return which of multiple hostnames for a location is RUNNING a task. Only set for tasks in a RUNNING state on a remote + * server. If active task is in an inactive state or running on the web server or a cluster, this will be null. + */ + @Nullable + String getActiveHostName(); +} + diff --git a/api/src/org/labkey/api/pipeline/RecordedAction.java b/api/src/org/labkey/api/pipeline/RecordedAction.java index c895b3681ff..6461b63fb25 100644 --- a/api/src/org/labkey/api/pipeline/RecordedAction.java +++ b/api/src/org/labkey/api/pipeline/RecordedAction.java @@ -1,543 +1,549 @@ -/* - * Copyright (c) 2008-2018 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.api.pipeline; - -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.fasterxml.jackson.databind.annotation.JsonSerialize; -import org.labkey.api.exp.PropertyDescriptor; -import org.labkey.api.exp.PropertyType; -import org.labkey.api.util.FileUtil; -import org.labkey.api.util.Pair; - -import java.io.File; -import java.io.Serializable; -import java.net.URI; -import java.util.Collections; -import java.util.Date; -import java.util.HashSet; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.Map; -import java.util.Set; - -/** - * Used to record an action performed by the pipeline. Consumed by XarGeneratorTask, which will create a full - * experiment run to document the steps performed. - * User: jeckels - * Date: Jul 25, 2008 - */ -public class RecordedAction -{ - public static final ParameterType COMMAND_LINE_PARAM = new ParameterType("Command line", "terms.labkey.org#CommandLine", PropertyType.STRING); - - private final Set _inputs = new LinkedHashSet<>(); - private final Set _outputs = new LinkedHashSet<>(); - - @JsonSerialize(keyUsing = ObjectKeySerialization.Serializer.class) - @JsonDeserialize(keyUsing = ObjectKeySerialization.Deserializer.class) - private Map _params = new LinkedHashMap<>(); - @JsonSerialize(keyUsing = ObjectKeySerialization.Serializer.class) - @JsonDeserialize(keyUsing = ObjectKeySerialization.Deserializer.class) - private Map _outputParams = new LinkedHashMap<>(); - - @JsonSerialize(keyUsing = ObjectKeySerialization.Serializer.class) - @JsonDeserialize(keyUsing = ObjectKeySerialization.Deserializer.class) - private Map _props = new LinkedHashMap<>(); - private String _name; - private String _description; - private Date _activityDate; - private Date _startTime; - private Date _endTime; - private Integer _recordCount; - private String _runName; - private String _comments; - - // Provenance map (list of from and to lsid pairs) - private Set> _provenanceMap = new HashSet<>(); - // Set of lsids - private Set _materialInputs = new HashSet<>(); - private Set _materialOutputs = new HashSet<>(); - - // set of lsids - private Set _objectInputs = new HashSet<>(); - private Set _objectOutputs = new HashSet<>(); - - private boolean _isStart; - private boolean _isEnd; - - /** No-args constructor to support de-serialization in Java 7 and beyond */ - @SuppressWarnings({"UnusedDeclaration"}) - public RecordedAction() {} - - public RecordedAction(String name) - { - setName(name); - setDescription(name); - } - - public void addInput(File input, String role) - { - addInput(input.toURI(), role); - } - - private boolean uriExists(URI toTest, Set set) - { - for (DataFile df : set) - { - if (toTest.equals(df.getURI())) - { - return true; - } - } - - return false; - } - - public void addInput(URI input, String role) - { - addInput(input, role, true); - } - - public void addInputIfNotPresent(File input, String role) - { - addInput(input.toURI(), role, false); - } - - /** - * Exp.data has a constraint that will only allow a given file - * once per action, so by default this will throw an exception - * if the same file is added twice as an input. Alternately, - * addInputIfNotPresent() which will silently ignore duplicate files. - */ - private void addInput(URI input, String role, boolean throwIfExists) - { - if (!uriExists(input, _inputs)) - { - _inputs.add(new DataFile(input, role, false, false)); - } - else if (throwIfExists) - { - throw new IllegalArgumentException("Already has been added as an input for the action " + getName() + ":" + FileUtil.uriToString(input)); - } - } - - /** - * Exp.data has a constraint that will only allow a given file - * once per action, so by default this will throw an exception - * if the same file is added twice as an output. Alternately, - * addOutputIfNotPresent() which will silently ignore duplicate files. - */ - public void addOutput(File output, String role, boolean transientFile) - { - addOutput(output.toURI(), role, transientFile, false); - } - - public void addOutputIfNotPresent(File output, String role, boolean transientFile) - { - addOutput(output.toURI(), role, transientFile, false, false); - } - - public void addOutput(File output, String role, boolean transientFile, boolean generated) - { - addOutput(output.toURI(), role, transientFile, generated); - } - - public void addOutput(URI output, String role, boolean transientFile) - { - addOutput(output, role, transientFile, false); - } - - public void addOutput(URI output, String role, boolean transientFile, boolean generated) - { - addOutput(output, role, transientFile, generated, true); - } - - private void addOutput(URI output, String role, boolean transientFile, boolean generated, boolean throwIfExists) - { - if (!uriExists(output, _outputs)) - { - _outputs.add(new DataFile(output, role, transientFile, generated)); - } - else if (throwIfExists) - { - throw new IllegalArgumentException("Already has been added as an output for the action " + getName() + ":" + FileUtil.uriToString(output)); - } - } - - public Set getInputs() - { - return Collections.unmodifiableSet(_inputs); - } - - public Set getOutputs() - { - return Collections.unmodifiableSet(_outputs); - } - - public String getName() - { - return _name; - } - - public void setName(String name) - { - _name = name; - } - - public String getRunName() - { - return _runName; - } - - public void setRunName(String runName) - { - _runName = runName; - } - - public String getDescription() - { - return _description; - } - - public void setDescription(String description) - { - _description = description; - } - - public Date getActivityDate() - { - return _activityDate; - } - - public void setActivityDate(Date activityDate) - { - _activityDate = activityDate; - } - - public void setStartTime(Date startTime) - { - _startTime = startTime; - } - - public Date getStartTime() - { - return _startTime; - } - - public void setEndTime(Date endTime) - { - _endTime = endTime; - } - - public Date getEndTime() - { - return _endTime; - } - - public void setRecordCount(Integer recordCount) - { - _recordCount = recordCount; - } - - public Integer getRecordCount() - { - return _recordCount; - } - - public void addParameter(ParameterType type, Object value) - { - _params.put(type, value); - } - - public void addOutputParameter(ParameterType type, Object value) - { - _outputParams.put(type, value); - } - - public void addProperty(PropertyDescriptor pd, Object value ) - { - _props.put(pd,value); - } - - public Map getParams() - { - return Collections.unmodifiableMap(_params); - } - - public Map getOutputParams() - { - return Collections.unmodifiableMap(_outputParams); - } - - public Map getProps() - { - return Collections.unmodifiableMap(_props); - } - - public Set> getProvenanceMap() - { - return _provenanceMap; - } - - public void setProvenanceMap(Set> provenanceMap) - { - _provenanceMap = provenanceMap; - } - - public Set getMaterialInputs() - { - return _materialInputs; - } - - public void setMaterialInputs(Set materialInputs) - { - _materialInputs = materialInputs; - } - - public Set getMaterialOutputs() - { - return _materialOutputs; - } - - public void setMaterialOutputs(Set materialOutputs) - { - _materialOutputs = materialOutputs; - } - - public Set getObjectInputs() - { - return _objectInputs; - } - - public void setObjectInputs(Set objectInputs) - { - _objectInputs = objectInputs; - } - - public Set getObjectOutputs() - { - return _objectOutputs; - } - - public void setObjectOutputs(Set objectOutputs) - { - _objectOutputs = objectOutputs; - } - - public void setProps(Map props) - { - _props = props; - } - - public boolean isStart() - { - return _isStart; - } - - public void setStart(boolean start) - { - _isStart = start; - } - - public boolean isEnd() - { - return _isEnd; - } - - public void setEnd(boolean end) - { - _isEnd = end; - } - - public String getComments() - { - return _comments; - } - - public void setComments(String comments) - { - _comments = comments; - } - - public static class ParameterType implements Serializable - { - public static String createUri(String name) - { - return "terms.labkey.org#" + name.replaceAll("\\s",""); - } - - private String _uri; - private String _name; - private PropertyType _type; - - // No-args constructor to support de-serialization in Java 7 - @SuppressWarnings({"UnusedDeclaration"}) - public ParameterType() - { - } - - public ParameterType(String name, PropertyType type) - { - this(name, createUri(name), type); - } - - public ParameterType(String name, String uri, PropertyType type) - { - _name = name; - _uri = uri; - _type = type; - } - - public String getURI() - { - return _uri; - } - - public String getName() - { - return _name; - } - - public PropertyType getType() - { - return _type; - } - } - - public static class DataFile - { - private URI _uri; - private String _role; - private boolean _transient; - private boolean _generated; - - // No-args constructor to support de-serialization in Java 7 - @SuppressWarnings({"UnusedDeclaration"}) - public DataFile() - { - } - - public DataFile(URI uri, String role, boolean transientFile, boolean generated) - { - _uri = uri; - _role = role; - _transient = transientFile; - _generated = generated; - } - - public URI getURI() - { - return _uri; - } - - public String getRole() - { - return _role; - } - - public boolean isTransient() - { - return _transient; - } - - public boolean equals(Object o) - { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - - DataFile that = (DataFile) o; - - if (_role != null ? !_role.equals(that._role) : that._role != null) return false; - return !(_uri != null ? !_uri.equals(that._uri) : that._uri != null); - } - - public int hashCode() - { - int result; - result = (_uri != null ? _uri.hashCode() : 0); - result = 31 * result + (_role != null ? _role.hashCode() : 0); - return result; - } - - public boolean isGenerated() - { - return _generated; - } - } - - public String toString() - { - return _description + " Inputs: " + _inputs + " Outputs: " + _outputs; - } - - public boolean updateForMovedFile(File original, File moved) - { - boolean changed = false; - - if (potentiallySwapFiles(original, moved, _inputs)) - { - changed = true; - } - - if (potentiallySwapFiles(original, moved, _outputs)) - { - changed = true; - } - - return changed; - } - - private boolean potentiallySwapFiles(File original, File moved, Set toInspect) - { - boolean changed = false; - for (DataFile df : toInspect) - { - if (original.toURI().equals(df.getURI())) - { - df._uri = moved.toURI(); - changed = true; - } - } - - return changed; - } - - @Override - public boolean equals(Object o) - { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - - RecordedAction that = (RecordedAction) o; - - if (_description != null ? !_description.equals(that._description) : that._description != null) return false; - if (_inputs != null ? !_inputs.equals(that._inputs) : that._inputs != null) return false; - if (_name != null ? !_name.equals(that._name) : that._name != null) return false; - if (_outputs != null ? !_outputs.equals(that._outputs) : that._outputs != null) return false; - return !(_params != null ? !_params.equals(that._params) : that._params != null); - } - - @Override - public int hashCode() - { - int result = _inputs != null ? _inputs.hashCode() : 0; - result = 31 * result + (_outputs != null ? _outputs.hashCode() : 0); - result = 31 * result + (_params != null ? _params.hashCode() : 0); - result = 31 * result + (_name != null ? _name.hashCode() : 0); - result = 31 * result + (_description != null ? _description.hashCode() : 0); - return result; - } -} +/* + * Copyright (c) 2008-2018 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.api.pipeline; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import org.labkey.api.exp.PropertyDescriptor; +import org.labkey.api.exp.PropertyType; +import org.labkey.api.util.FileUtil; +import org.labkey.api.util.Pair; +import org.labkey.vfs.FileLike; + +import java.io.File; +import java.io.Serializable; +import java.net.URI; +import java.util.Collections; +import java.util.Date; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +/** + * Used to record an action performed by the pipeline. Consumed by XarGeneratorTask, which will create a full + * experiment run to document the steps performed. + * User: jeckels + * Date: Jul 25, 2008 + */ +public class RecordedAction +{ + public static final ParameterType COMMAND_LINE_PARAM = new ParameterType("Command line", "terms.labkey.org#CommandLine", PropertyType.STRING); + + private final Set _inputs = new LinkedHashSet<>(); + private final Set _outputs = new LinkedHashSet<>(); + + @JsonSerialize(keyUsing = ObjectKeySerialization.Serializer.class) + @JsonDeserialize(keyUsing = ObjectKeySerialization.Deserializer.class) + private Map _params = new LinkedHashMap<>(); + @JsonSerialize(keyUsing = ObjectKeySerialization.Serializer.class) + @JsonDeserialize(keyUsing = ObjectKeySerialization.Deserializer.class) + private Map _outputParams = new LinkedHashMap<>(); + + @JsonSerialize(keyUsing = ObjectKeySerialization.Serializer.class) + @JsonDeserialize(keyUsing = ObjectKeySerialization.Deserializer.class) + private Map _props = new LinkedHashMap<>(); + private String _name; + private String _description; + private Date _activityDate; + private Date _startTime; + private Date _endTime; + private Integer _recordCount; + private String _runName; + private String _comments; + + // Provenance map (list of from and to lsid pairs) + private Set> _provenanceMap = new HashSet<>(); + // Set of lsids + private Set _materialInputs = new HashSet<>(); + private Set _materialOutputs = new HashSet<>(); + + // set of lsids + private Set _objectInputs = new HashSet<>(); + private Set _objectOutputs = new HashSet<>(); + + private boolean _isStart; + private boolean _isEnd; + + /** No-args constructor to support de-serialization in Java 7 and beyond */ + @SuppressWarnings({"UnusedDeclaration"}) + public RecordedAction() {} + + public RecordedAction(String name) + { + setName(name); + setDescription(name); + } + + public void addInput(File input, String role) + { + addInput(input.toURI(), role); + } + + public void addInput(FileLike input, String role) + { + addInput(input.toNioPathForRead().toFile(), role); + } + + private boolean uriExists(URI toTest, Set set) + { + for (DataFile df : set) + { + if (toTest.equals(df.getURI())) + { + return true; + } + } + + return false; + } + + public void addInput(URI input, String role) + { + addInput(input, role, true); + } + + public void addInputIfNotPresent(File input, String role) + { + addInput(input.toURI(), role, false); + } + + /** + * Exp.data has a constraint that will only allow a given file + * once per action, so by default this will throw an exception + * if the same file is added twice as an input. Alternately, + * addInputIfNotPresent() which will silently ignore duplicate files. + */ + private void addInput(URI input, String role, boolean throwIfExists) + { + if (!uriExists(input, _inputs)) + { + _inputs.add(new DataFile(input, role, false, false)); + } + else if (throwIfExists) + { + throw new IllegalArgumentException("Already has been added as an input for the action " + getName() + ":" + FileUtil.uriToString(input)); + } + } + + /** + * Exp.data has a constraint that will only allow a given file + * once per action, so by default this will throw an exception + * if the same file is added twice as an output. Alternately, + * addOutputIfNotPresent() which will silently ignore duplicate files. + */ + public void addOutput(File output, String role, boolean transientFile) + { + addOutput(output.toURI(), role, transientFile, false); + } + + public void addOutputIfNotPresent(File output, String role, boolean transientFile) + { + addOutput(output.toURI(), role, transientFile, false, false); + } + + public void addOutput(File output, String role, boolean transientFile, boolean generated) + { + addOutput(output.toURI(), role, transientFile, generated); + } + + public void addOutput(URI output, String role, boolean transientFile) + { + addOutput(output, role, transientFile, false); + } + + public void addOutput(URI output, String role, boolean transientFile, boolean generated) + { + addOutput(output, role, transientFile, generated, true); + } + + private void addOutput(URI output, String role, boolean transientFile, boolean generated, boolean throwIfExists) + { + if (!uriExists(output, _outputs)) + { + _outputs.add(new DataFile(output, role, transientFile, generated)); + } + else if (throwIfExists) + { + throw new IllegalArgumentException("Already has been added as an output for the action " + getName() + ":" + FileUtil.uriToString(output)); + } + } + + public Set getInputs() + { + return Collections.unmodifiableSet(_inputs); + } + + public Set getOutputs() + { + return Collections.unmodifiableSet(_outputs); + } + + public String getName() + { + return _name; + } + + public void setName(String name) + { + _name = name; + } + + public String getRunName() + { + return _runName; + } + + public void setRunName(String runName) + { + _runName = runName; + } + + public String getDescription() + { + return _description; + } + + public void setDescription(String description) + { + _description = description; + } + + public Date getActivityDate() + { + return _activityDate; + } + + public void setActivityDate(Date activityDate) + { + _activityDate = activityDate; + } + + public void setStartTime(Date startTime) + { + _startTime = startTime; + } + + public Date getStartTime() + { + return _startTime; + } + + public void setEndTime(Date endTime) + { + _endTime = endTime; + } + + public Date getEndTime() + { + return _endTime; + } + + public void setRecordCount(Integer recordCount) + { + _recordCount = recordCount; + } + + public Integer getRecordCount() + { + return _recordCount; + } + + public void addParameter(ParameterType type, Object value) + { + _params.put(type, value); + } + + public void addOutputParameter(ParameterType type, Object value) + { + _outputParams.put(type, value); + } + + public void addProperty(PropertyDescriptor pd, Object value ) + { + _props.put(pd,value); + } + + public Map getParams() + { + return Collections.unmodifiableMap(_params); + } + + public Map getOutputParams() + { + return Collections.unmodifiableMap(_outputParams); + } + + public Map getProps() + { + return Collections.unmodifiableMap(_props); + } + + public Set> getProvenanceMap() + { + return _provenanceMap; + } + + public void setProvenanceMap(Set> provenanceMap) + { + _provenanceMap = provenanceMap; + } + + public Set getMaterialInputs() + { + return _materialInputs; + } + + public void setMaterialInputs(Set materialInputs) + { + _materialInputs = materialInputs; + } + + public Set getMaterialOutputs() + { + return _materialOutputs; + } + + public void setMaterialOutputs(Set materialOutputs) + { + _materialOutputs = materialOutputs; + } + + public Set getObjectInputs() + { + return _objectInputs; + } + + public void setObjectInputs(Set objectInputs) + { + _objectInputs = objectInputs; + } + + public Set getObjectOutputs() + { + return _objectOutputs; + } + + public void setObjectOutputs(Set objectOutputs) + { + _objectOutputs = objectOutputs; + } + + public void setProps(Map props) + { + _props = props; + } + + public boolean isStart() + { + return _isStart; + } + + public void setStart(boolean start) + { + _isStart = start; + } + + public boolean isEnd() + { + return _isEnd; + } + + public void setEnd(boolean end) + { + _isEnd = end; + } + + public String getComments() + { + return _comments; + } + + public void setComments(String comments) + { + _comments = comments; + } + + public static class ParameterType implements Serializable + { + public static String createUri(String name) + { + return "terms.labkey.org#" + name.replaceAll("\\s",""); + } + + private String _uri; + private String _name; + private PropertyType _type; + + // No-args constructor to support de-serialization in Java 7 + @SuppressWarnings({"UnusedDeclaration"}) + public ParameterType() + { + } + + public ParameterType(String name, PropertyType type) + { + this(name, createUri(name), type); + } + + public ParameterType(String name, String uri, PropertyType type) + { + _name = name; + _uri = uri; + _type = type; + } + + public String getURI() + { + return _uri; + } + + public String getName() + { + return _name; + } + + public PropertyType getType() + { + return _type; + } + } + + public static class DataFile + { + private URI _uri; + private String _role; + private boolean _transient; + private boolean _generated; + + // No-args constructor to support de-serialization in Java 7 + @SuppressWarnings({"UnusedDeclaration"}) + public DataFile() + { + } + + public DataFile(URI uri, String role, boolean transientFile, boolean generated) + { + _uri = uri; + _role = role; + _transient = transientFile; + _generated = generated; + } + + public URI getURI() + { + return _uri; + } + + public String getRole() + { + return _role; + } + + public boolean isTransient() + { + return _transient; + } + + public boolean equals(Object o) + { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + DataFile that = (DataFile) o; + + if (_role != null ? !_role.equals(that._role) : that._role != null) return false; + return !(_uri != null ? !_uri.equals(that._uri) : that._uri != null); + } + + public int hashCode() + { + int result; + result = (_uri != null ? _uri.hashCode() : 0); + result = 31 * result + (_role != null ? _role.hashCode() : 0); + return result; + } + + public boolean isGenerated() + { + return _generated; + } + } + + public String toString() + { + return _description + " Inputs: " + _inputs + " Outputs: " + _outputs; + } + + public boolean updateForMovedFile(File original, File moved) + { + boolean changed = false; + + if (potentiallySwapFiles(original, moved, _inputs)) + { + changed = true; + } + + if (potentiallySwapFiles(original, moved, _outputs)) + { + changed = true; + } + + return changed; + } + + private boolean potentiallySwapFiles(File original, File moved, Set toInspect) + { + boolean changed = false; + for (DataFile df : toInspect) + { + if (original.toURI().equals(df.getURI())) + { + df._uri = moved.toURI(); + changed = true; + } + } + + return changed; + } + + @Override + public boolean equals(Object o) + { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + RecordedAction that = (RecordedAction) o; + + if (_description != null ? !_description.equals(that._description) : that._description != null) return false; + if (_inputs != null ? !_inputs.equals(that._inputs) : that._inputs != null) return false; + if (_name != null ? !_name.equals(that._name) : that._name != null) return false; + if (_outputs != null ? !_outputs.equals(that._outputs) : that._outputs != null) return false; + return !(_params != null ? !_params.equals(that._params) : that._params != null); + } + + @Override + public int hashCode() + { + int result = _inputs != null ? _inputs.hashCode() : 0; + result = 31 * result + (_outputs != null ? _outputs.hashCode() : 0); + result = 31 * result + (_params != null ? _params.hashCode() : 0); + result = 31 * result + (_name != null ? _name.hashCode() : 0); + result = 31 * result + (_description != null ? _description.hashCode() : 0); + return result; + } +} diff --git a/api/src/org/labkey/api/pipeline/RecordedActionSet.java b/api/src/org/labkey/api/pipeline/RecordedActionSet.java index cc03d80a71f..589f133922d 100644 --- a/api/src/org/labkey/api/pipeline/RecordedActionSet.java +++ b/api/src/org/labkey/api/pipeline/RecordedActionSet.java @@ -1,102 +1,102 @@ -/* - * Copyright (c) 2008-2018 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.api.pipeline; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.fasterxml.jackson.databind.annotation.JsonSerialize; - -import java.net.URI; -import java.nio.file.Path; -import java.util.Arrays; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.Map; -import java.util.Set; - -/** - * A collection of all the recorded actions performed in the context of a single pipeline job. - * User: jeckels - * Date: Aug 8, 2008 - */ -public class RecordedActionSet -{ - private final Set _actions; - @JsonSerialize(keyUsing = StringKeySerialization.Serializer.class) - @JsonDeserialize(keyUsing = StringKeySerialization.URIDeserializer.class) - private final Map _otherInputs; - - @JsonCreator - private RecordedActionSet(@JsonProperty("_actions") Set actions) - { - _actions = actions; - _otherInputs = new LinkedHashMap<>(); - } - - public RecordedActionSet() - { - this(Collections.emptyList()); - } - - public RecordedActionSet(RecordedAction... actions) - { - this(Arrays.asList(actions)); - } - - public RecordedActionSet(Iterable actions) - { - _actions = new LinkedHashSet<>(); - for (RecordedAction action : actions) - { - _actions.add(action); - } - _otherInputs = new LinkedHashMap<>(); - } - - public RecordedActionSet(RecordedActionSet actionSet) - { - this(); - add(actionSet); - } - - public Set getActions() - { - return _actions; - } - - public Map getOtherInputs() - { - return _otherInputs; - } - - public void add(Path inputFile, String inputRole) - { - _otherInputs.put(inputFile.toUri(), inputRole); - } - - public void add(RecordedActionSet set) - { - _actions.addAll(set.getActions()); - _otherInputs.putAll(set.getOtherInputs()); - } - - public void add(RecordedAction action) - { - _actions.add(action); - } -} +/* + * Copyright (c) 2008-2018 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.api.pipeline; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import org.labkey.vfs.FileLike; + +import java.net.URI; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +/** + * A collection of all the recorded actions performed in the context of a single pipeline job. + * User: jeckels + * Date: Aug 8, 2008 + */ +public class RecordedActionSet +{ + private final Set _actions; + @JsonSerialize(keyUsing = StringKeySerialization.Serializer.class) + @JsonDeserialize(keyUsing = StringKeySerialization.URIDeserializer.class) + private final Map _otherInputs; + + @JsonCreator + private RecordedActionSet(@JsonProperty("_actions") Set actions) + { + _actions = actions; + _otherInputs = new LinkedHashMap<>(); + } + + public RecordedActionSet() + { + this(Collections.emptyList()); + } + + public RecordedActionSet(RecordedAction... actions) + { + this(Arrays.asList(actions)); + } + + public RecordedActionSet(Iterable actions) + { + _actions = new LinkedHashSet<>(); + for (RecordedAction action : actions) + { + _actions.add(action); + } + _otherInputs = new LinkedHashMap<>(); + } + + public RecordedActionSet(RecordedActionSet actionSet) + { + this(); + add(actionSet); + } + + public Set getActions() + { + return _actions; + } + + public Map getOtherInputs() + { + return _otherInputs; + } + + public void add(FileLike inputFile, String inputRole) + { + _otherInputs.put(inputFile.toNioPathForRead().toUri(), inputRole); + } + + public void add(RecordedActionSet set) + { + _actions.addAll(set.getActions()); + _otherInputs.putAll(set.getOtherInputs()); + } + + public void add(RecordedAction action) + { + _actions.add(action); + } +} diff --git a/api/src/org/labkey/api/pipeline/WorkDirectory.java b/api/src/org/labkey/api/pipeline/WorkDirectory.java index be806a421e9..a9645975fe3 100644 --- a/api/src/org/labkey/api/pipeline/WorkDirectory.java +++ b/api/src/org/labkey/api/pipeline/WorkDirectory.java @@ -1,138 +1,145 @@ -/* - * Copyright (c) 2008-2015 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.api.pipeline; - -import org.labkey.api.pipeline.cmd.TaskPath; -import org.labkey.api.util.FileType; - -import java.io.File; -import java.io.IOException; -import java.util.List; -import java.util.Map; - -/** - * Represents a working directory in which files are made available to pipeline tasks. Typically, output files - * are stored in this working directory and moved to their desired permanent location after the task has completed - * successfully. Additionally, file inputs may be copied to the directory to ensure they are local, providing better - * IO performance. - * - * @author brendanx - */ -public interface WorkDirectory -{ - enum Function { - /** File is an input into the job. */ - input, - /** File is an output of the job. */ - output, - /** File is a relative path from the root of the {@link TaskPipeline#getDeclaringModule()}. */ - module - } - - /** - * @return the directory where the input files live and where the output files will end up - */ - File getDir(); - - /** Informs the WorkDirectory that a new file is being created. It is treated as a Function.output */ - File newFile(String name); - - /** Informs the WorkDirectory that a new file is being created. */ - File newFile(Function f, String name); - - /** Informs the WorkDirectory that a new file is being created. It is treated as a Function.output */ - File newFile(FileType type); - - /** Informs the WorkDirectory that a new file is being created. */ - File newFile(Function f, FileType type); - - /** - * Indicates that a file is to be used as input. The implementation can choose whether it needs to be copied, unless - * forceCopy is true (in which case it will always be copied to the work directory - * @return the full path to the file where it is available for use - */ - File inputFile(File fileInput, boolean forceCopy) throws IOException; - - /** - * Indicates that a file is to be used as input. The implementation can choose whether it needs to be copied, unless - * forceCopy is true (in which case it will always be copied to the work directory. This version of the method allows the caller - * to manually specify the destination file, which allows callers to place files into subdirectories of the work directory - * @return the full path to the file where it is available for use - */ - File inputFile(File fileInput, File fileWork, boolean forceCopy) throws IOException; - - /** @return the relative path of the file relative to the work directory itself. The file is presumed to be under the work directory. */ - String getRelativePath(File fileWork) throws IOException; - - /** - * @return the final location for file after it's copied out of the work directory - */ - File outputFile(File fileWork) throws IOException; - - /** - * @return the final location for file after it's copied out of the work directory - */ - File outputFile(File fileWork, String nameDest) throws IOException; - - /** - * @return copies the file to the specified location - */ - File outputFile(File fileWork, File dest) throws IOException; - - /** - * Delete a file from the working directory - */ - void discardFile(File fileWork) throws IOException; - - /** Deletes any inputs that were copied into this working directory */ - void discardCopiedInputs() throws IOException; - - /** - * Associates all of the output files now in the work directory (including those that were explicitly declared as - * expected outputs and any other files that might be present) with the RecordedAction - */ - void acceptFilesAsOutputs(Map expectedOutputs, RecordedAction action) throws IOException; - - /** - * Cleans up any lingering inputs and deletes the working directory - * @param success whether or not the task completed successfully. If so, it's fair to complain about unexpected - * files that are still left. If not, don't add additional errors to the log. - */ - void remove(boolean success) throws IOException; - - /** - * Pipeline inputs are copied to the working directory. If the passed file was already copied to the work directory, this will - * return the local copy. - */ - File getWorkingCopyForInput(File f); - - /** - * Ensures that we have a lock, if needed. The lock must be released by the caller. Locks can be configured so that - * we do not have too many separate network file operations in place across multiple machines. - */ - CopyingResource ensureCopyingLock() throws IOException; - - List getWorkFiles(Function f, TaskPath tp); - - File newWorkFile(Function output, TaskPath taskPath, String baseName); - - /** A lock for copying files over a network share, for convenient use with try-with-resources */ - interface CopyingResource extends AutoCloseable - { - @Override - void close(); - } -} +/* + * Copyright (c) 2008-2015 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.api.pipeline; + +import org.labkey.api.pipeline.cmd.TaskPath; +import org.labkey.api.util.FileType; +import org.labkey.vfs.FileLike; + +import java.io.File; +import java.io.IOException; +import java.util.List; +import java.util.Map; + +/** + * Represents a working directory in which files are made available to pipeline tasks. Typically, output files + * are stored in this working directory and moved to their desired permanent location after the task has completed + * successfully. Additionally, file inputs may be copied to the directory to ensure they are local, providing better + * IO performance. + * + * @author brendanx + */ +public interface WorkDirectory +{ + enum Function { + /** File is an input into the job. */ + input, + /** File is an output of the job. */ + output, + /** File is a relative path from the root of the {@link TaskPipeline#getDeclaringModule()}. */ + module + } + + /** + * @return the directory where the input files live and where the output files will end up + */ + File getDir(); + + /** Informs the WorkDirectory that a new file is being created. It is treated as a Function.output */ + File newFile(String name); + + /** Informs the WorkDirectory that a new file is being created. */ + File newFile(Function f, String name); + + /** Informs the WorkDirectory that a new file is being created. It is treated as a Function.output */ + File newFile(FileType type); + + /** Informs the WorkDirectory that a new file is being created. */ + File newFile(Function f, FileType type); + + /** + * Indicates that a file is to be used as input. The implementation can choose whether it needs to be copied, unless + * forceCopy is true (in which case it will always be copied to the work directory + * @return the full path to the file where it is available for use + */ + File inputFile(File fileInput, boolean forceCopy) throws IOException; + + default File inputFile(FileLike fileInput, boolean forceCopy) throws IOException + { + return inputFile(fileInput.toNioPathForRead().toFile(), forceCopy); + } + + + /** + * Indicates that a file is to be used as input. The implementation can choose whether it needs to be copied, unless + * forceCopy is true (in which case it will always be copied to the work directory. This version of the method allows the caller + * to manually specify the destination file, which allows callers to place files into subdirectories of the work directory + * @return the full path to the file where it is available for use + */ + File inputFile(File fileInput, File fileWork, boolean forceCopy) throws IOException; + + /** @return the relative path of the file relative to the work directory itself. The file is presumed to be under the work directory. */ + String getRelativePath(File fileWork) throws IOException; + + /** + * @return the final location for file after it's copied out of the work directory + */ + File outputFile(File fileWork) throws IOException; + + /** + * @return the final location for file after it's copied out of the work directory + */ + File outputFile(File fileWork, String nameDest) throws IOException; + + /** + * @return copies the file to the specified location + */ + File outputFile(File fileWork, File dest) throws IOException; + + /** + * Delete a file from the working directory + */ + void discardFile(File fileWork) throws IOException; + + /** Deletes any inputs that were copied into this working directory */ + void discardCopiedInputs() throws IOException; + + /** + * Associates all of the output files now in the work directory (including those that were explicitly declared as + * expected outputs and any other files that might be present) with the RecordedAction + */ + void acceptFilesAsOutputs(Map expectedOutputs, RecordedAction action) throws IOException; + + /** + * Cleans up any lingering inputs and deletes the working directory + * @param success whether or not the task completed successfully. If so, it's fair to complain about unexpected + * files that are still left. If not, don't add additional errors to the log. + */ + void remove(boolean success) throws IOException; + + /** + * Pipeline inputs are copied to the working directory. If the passed file was already copied to the work directory, this will + * return the local copy. + */ + File getWorkingCopyForInput(File f); + + /** + * Ensures that we have a lock, if needed. The lock must be released by the caller. Locks can be configured so that + * we do not have too many separate network file operations in place across multiple machines. + */ + CopyingResource ensureCopyingLock() throws IOException; + + List getWorkFiles(Function f, TaskPath tp); + + File newWorkFile(Function output, TaskPath taskPath, String baseName); + + /** A lock for copying files over a network share, for convenient use with try-with-resources */ + interface CopyingResource extends AutoCloseable + { + @Override + void close(); + } +} diff --git a/api/src/org/labkey/api/pipeline/browse/PipelinePathForm.java b/api/src/org/labkey/api/pipeline/browse/PipelinePathForm.java index b037848edf1..e19ac52483a 100644 --- a/api/src/org/labkey/api/pipeline/browse/PipelinePathForm.java +++ b/api/src/org/labkey/api/pipeline/browse/PipelinePathForm.java @@ -1,230 +1,193 @@ -/* - * Copyright (c) 2008-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.api.pipeline.browse; - -import org.labkey.api.data.Container; -import org.labkey.api.exp.api.ExpData; -import org.labkey.api.exp.api.ExperimentService; -import org.labkey.api.pipeline.PipeRoot; -import org.labkey.api.pipeline.PipelineService; -import org.labkey.api.security.permissions.ReadPermission; -import org.labkey.api.util.FileUtil; -import org.labkey.api.util.NetworkDrive; -import org.labkey.api.view.NotFoundException; -import org.labkey.api.view.ViewForm; - -import java.io.File; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; - -/** - * PipelinePathForm - * - * Form bean class for pipeline root navigation. - */ -public class PipelinePathForm extends ViewForm -{ - private String _path; - private String[] _file = new String[0]; - private int[] _fileIds = new int[0]; - - public String getPath() - { - return _path; - } - - public void setPath(String path) - { - _path = path; - } - - public String[] getFile() - { - return _file; - } - - public void setFile(String[] file) - { - if (null == file) - return; - for (String s : file) - { - if (s != null) - { - if (s.contains("..") || s.contains("/") || s.contains("\\")) - { - throw new IllegalArgumentException("File names should not include any path information"); - } - } - } - _file = file; - } - - public int[] getFileIds() - { - return _fileIds; - } - - public void setFileIds(int[] fileIds) - { - _fileIds = fileIds; - } - - /** - * For the string filesnames provided, ensures that the files are all in the same directory, which is under the container's pipeline root, - * and that they all exist on disk, though they could be directories, not files. - * For ExpData IDs provided, ensures the files exists and the user has read permission on the associated container. The files do not need to be located in the same directory. - * Throws NotFoundException if no files are specified, invalid files are specified, there's no pipeline root, etc. - */ - public List getValidatedFiles(Container c) - { - return getValidatedFiles(c, false); - } - - public List getValidatedFiles(Container c, boolean allowNonExistentFiles) - { - PipeRoot pr = getPipeRoot(c); - - File dir = pr.resolvePath(getPath()); - if (dir == null || !dir.exists()) - throw new NotFoundException("Could not find path " + getPath()); - - if ((getFile() == null || getFile().length == 0) && (getFileIds() == null || getFileIds().length == 0)) - { - throw new NotFoundException("No files specified"); - } - - List result = new ArrayList<>(); - for (String fileName : _file) - { - File f = pr.resolvePath(getPath() + "/" + fileName); - if (!allowNonExistentFiles && !NetworkDrive.exists(f)) - { - throw new NotFoundException("Could not find file '" + fileName + "' in '" + getPath() + "'"); - } - result.add(f); - } - - ExperimentService es = ExperimentService.get(); - if (_fileIds != null) - { - for (int fileId : _fileIds) - { - ExpData data = es.getExpData(fileId); - if(data == null) - { - throw new NotFoundException("Could not find file associated with Data Id: '" + fileId); - } - - if (!data.getContainer().hasPermission(getUser(), ReadPermission.class)) - { - throw new NotFoundException("Insufficient permissions for file '" + data.getFile()); - } - - File file = data.getFile(); - if (!allowNonExistentFiles && !NetworkDrive.exists(file)) - { - throw new NotFoundException("Could not find file '" + file + "'"); - } - result.add(file); - } - } - - return result; - } - - public List getValidatedPaths(Container c, boolean allowNonExistentFiles) - { - PipeRoot pr = getPipeRoot(c); - - Path dir = pr.resolveToNioPath(getPath()); - if (dir == null || !Files.exists(dir)) - throw new NotFoundException("Could not find path " + getPath()); - - if ((getFile() == null || getFile().length == 0) && (getFileIds() == null || getFileIds().length == 0)) - { - throw new NotFoundException("No files specified"); - } - - List result = new ArrayList<>(); - for (String fileName : _file) - { - Path path = pr.resolveToNioPath(getPath() + "/" + fileName); - if (!allowNonExistentFiles && (null == path || !Files.exists(path))) - { - throw new NotFoundException("Could not find file '" + fileName + "' in '" + getPath() + "'"); - } - if (null != path) - result.add(path); - } - - ExperimentService es = ExperimentService.get(); - if (_fileIds != null) - { - for (int fileId : _fileIds) - { - ExpData data = es.getExpData(fileId); - if(data == null) - { - throw new NotFoundException("Could not find file associated with Data Id: '" + fileId); - } - - if (!data.getContainer().hasPermission(getUser(), ReadPermission.class)) - { - throw new NotFoundException("Insufficient permissions for file '" + data.getFile()); - } - - Path path = pr.resolveToNioPath(data.getDataFileURI().getPath()); - if (!allowNonExistentFiles && (null == path || !Files.exists(path))) - { - throw new NotFoundException("Could not find file '" + FileUtil.getFileName(path) + "'"); - } - if (null != path) - result.add(path); - } - } - - return result; - } - - public PipeRoot getPipeRoot(Container c) - { - PipeRoot pr = PipelineService.get().findPipelineRoot(c); - if (pr == null) - throw new NotFoundException("Could not find a pipeline root for " + c.getPath()); - return pr; - } - - /** Verifies that only a single file was selected and returns it, throwing an exception if there isn't exactly one */ - @Deprecated //prefer the nio.Path version: getValidatedSinglePath - public File getValidatedSingleFile(Container c) - { - return getValidatedSinglePath(c).toFile(); - } - - /** Verifies that only a single file was selected and returns it, throwing an exception if there isn't exactly one */ - public Path getValidatedSinglePath(Container c) - { - List files = getValidatedPaths(c, false); - if (files.size() != 1) - { - throw new IllegalArgumentException("Expected a single file but got " + files.size()); - } - return files.get(0); - } -} +/* + * Copyright (c) 2008-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.api.pipeline.browse; + +import org.labkey.api.data.Container; +import org.labkey.api.exp.api.ExpData; +import org.labkey.api.exp.api.ExperimentService; +import org.labkey.api.pipeline.PipeRoot; +import org.labkey.api.pipeline.PipelineService; +import org.labkey.api.security.permissions.ReadPermission; +import org.labkey.api.util.FileUtil; +import org.labkey.api.util.NetworkDrive; +import org.labkey.api.view.NotFoundException; +import org.labkey.api.view.ViewForm; +import org.labkey.vfs.FileLike; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** + * PipelinePathForm + * + * Form bean class for pipeline root navigation. + */ +public class PipelinePathForm extends ViewForm +{ + private String _path; + private String[] _file = new String[0]; + private int[] _fileIds = new int[0]; + + public String getPath() + { + return _path; + } + + public void setPath(String path) + { + _path = path; + } + + public String[] getFile() + { + return _file; + } + + public void setFile(String[] file) + { + if (null == file) + return; + for (String s : file) + { + if (s != null) + { + if (s.contains("..") || s.contains("/") || s.contains("\\")) + { + throw new IllegalArgumentException("File names should not include any path information"); + } + } + } + _file = file; + } + + public int[] getFileIds() + { + return _fileIds; + } + + public void setFileIds(int[] fileIds) + { + _fileIds = fileIds; + } + + /** + * For the string filesnames provided, ensures that the files are all in the same directory, which is under the container's pipeline root, + * and that they all exist on disk, though they could be directories, not files. + * For ExpData IDs provided, ensures the files exists and the user has read permission on the associated container. The files do not need to be located in the same directory. + * Throws NotFoundException if no files are specified, invalid files are specified, there's no pipeline root, etc. + */ + public List getValidatedFiles(Container c) + { + return getValidatedFiles(c, false); + } + + public List getValidatedFiles(Container c, boolean allowNonExistentFiles) + { + PipeRoot pr = getPipeRoot(c); + + FileLike dir = pr.resolvePathToFileLike(getPath()); + if (dir == null || !dir.exists()) + throw new NotFoundException("Could not find path " + getPath()); + + if ((getFile() == null || getFile().length == 0) && (getFileIds() == null || getFileIds().length == 0)) + { + throw new NotFoundException("No files specified"); + } + + List result = new ArrayList<>(); + for (String fileName : _file) + { + FileLike f = pr.resolvePathToFileLike(getPath() + "/" + fileName); + if (!allowNonExistentFiles && !NetworkDrive.exists(f)) + { + throw new NotFoundException("Could not find file '" + fileName + "' in '" + getPath() + "'"); + } + result.add(f); + } + + ExperimentService es = ExperimentService.get(); + if (_fileIds != null) + { + for (int fileId : _fileIds) + { + ExpData data = es.getExpData(fileId); + if(data == null) + { + throw new NotFoundException("Could not find file associated with Data Id: '" + fileId); + } + + if (!data.getContainer().hasPermission(getUser(), ReadPermission.class)) + { + throw new NotFoundException("Insufficient permissions for file '" + data.getFile()); + } + + FileLike file = data.getFileLike(); + if (!allowNonExistentFiles && !NetworkDrive.exists(file)) + { + throw new NotFoundException("Could not find file '" + file + "'"); + } + result.add(file); + } + } + + return result; + } + + public List getValidatedPaths(Container c, boolean allowNonExistentFiles) + { + List files = getValidatedFiles(c, allowNonExistentFiles); + List result = new ArrayList<>(); + for (FileLike file : files) + { + result.add(file.toNioPathForRead()); + } + return result; + } + + public PipeRoot getPipeRoot(Container c) + { + PipeRoot pr = PipelineService.get().findPipelineRoot(c); + if (pr == null) + throw new NotFoundException("Could not find a pipeline root for " + c.getPath()); + return pr; + } + + /** Verifies that only a single file was selected and returns it, throwing an exception if there isn't exactly one */ + public FileLike getValidatedSingleFile(Container c) + { + List files = getValidatedFiles(c); + if (files.size() != 1) + { + throw new IllegalArgumentException("Expected a single file but got " + files.size()); + } + return files.get(0); + } + + /** Verifies that only a single file was selected and returns it, throwing an exception if there isn't exactly one */ + @Deprecated // use the FileLike version + public Path getValidatedSinglePath(Container c) + { + List files = getValidatedPaths(c, false); + if (files.size() != 1) + { + throw new IllegalArgumentException("Expected a single file but got " + files.size()); + } + return files.get(0); + } +} diff --git a/api/src/org/labkey/api/pipeline/file/AbstractFileAnalysisJob.java b/api/src/org/labkey/api/pipeline/file/AbstractFileAnalysisJob.java index 766e2421a95..7b4b9d58ec7 100644 --- a/api/src/org/labkey/api/pipeline/file/AbstractFileAnalysisJob.java +++ b/api/src/org/labkey/api/pipeline/file/AbstractFileAnalysisJob.java @@ -1,517 +1,475 @@ -/* - * Copyright (c) 2008-2018 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.api.pipeline.file; - -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.labkey.api.exp.PropertyType; -import org.labkey.api.exp.api.ExpRun; -import org.labkey.api.exp.api.ExperimentService; -import org.labkey.api.exp.api.ExperimentUrls; -import org.labkey.api.pipeline.ParamParser; -import org.labkey.api.pipeline.PipeRoot; -import org.labkey.api.pipeline.PipelineJob; -import org.labkey.api.pipeline.PipelineJobService; -import org.labkey.api.pipeline.RecordedAction; -import org.labkey.api.pipeline.TaskId; -import org.labkey.api.pipeline.TaskPipeline; -import org.labkey.api.util.FileType; -import org.labkey.api.util.FileUtil; -import org.labkey.api.util.NetworkDrive; -import org.labkey.api.util.PageFlowUtil; -import org.labkey.api.view.ActionURL; -import org.labkey.api.view.ViewBackgroundInfo; - -import java.io.File; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.TreeMap; -import java.util.stream.Collectors; - -/** - * AbstractFileAnalysisJob - */ -abstract public class AbstractFileAnalysisJob extends PipelineJob implements FileAnalysisJobSupport -{ - private static final Logger _log = LogManager.getLogger(AbstractFileAnalysisJob.class); - - protected Long _experimentRunRowId; - private String _protocolName; - private String _joinedBaseName; - private String _baseName; - private Path _dirData; - private Path _dirAnalysis; - private Path _fileParameters; - private Path _fileJobInfo; - private List _filesInput; - private List _inputTypes; - private boolean _splittable = true; - - private Map _parametersDefaults; - private Map _parametersOverrides; - - public static final String ANALYSIS_PARAMETERS_ROLE_NAME = "AnalysisParameters"; - - // For serialization - protected AbstractFileAnalysisJob() {} - - @Deprecated //Prefer the Path version, retained for backwards compatbility - public AbstractFileAnalysisJob(AbstractFileAnalysisProtocol protocol, - String providerName, - ViewBackgroundInfo info, - PipeRoot root, - String protocolName, - File fileParameters, - List filesInput, - boolean splittable, - boolean writeJobInfoFile) throws IOException - { - this( - protocol, - providerName, - info, - root, - protocolName, - fileParameters.toPath(), - filesInput.stream().map(File::toPath).collect(Collectors.toList()), - splittable, - writeJobInfoFile - ); - } - - public AbstractFileAnalysisJob(@NotNull AbstractFileAnalysisProtocol protocol, - String providerName, - ViewBackgroundInfo info, - PipeRoot root, - String protocolName, - Path fileParameters, - List filesInput, - boolean splittable, - boolean writeJobInfoFile) throws IOException - { - super(providerName, info, root); - - _filesInput = filesInput; - _inputTypes = FileType.findTypes(protocol.getInputTypes(), _filesInput); - _dirData = filesInput.get(0).getParent(); - _protocolName = protocolName; - - _fileParameters = fileParameters; - getActionSet().add(_fileParameters, ANALYSIS_PARAMETERS_ROLE_NAME); // input - _dirAnalysis = _fileParameters.getParent(); - - // Load parameter files - _parametersOverrides = getInputParameters().getInputParameters(); - - // Check for explicitly set default parameters. Otherwise use the default. - String paramDefaults = _parametersOverrides.get("list path, default parameters"); - Path fileDefaults; - if (paramDefaults != null) - fileDefaults = getPipeRoot().resolveToNioPath(paramDefaults); - else - fileDefaults = protocol.getFactory().getDefaultParametersFile(root); - - _parametersDefaults = fileDefaults != null && Files.exists(fileDefaults) ? - getInputParameters(fileDefaults).getInputParameters() : - Collections.emptyMap(); - - if (_log.isDebugEnabled()) - { - logParameters("Defaults", fileDefaults, _parametersDefaults); - logParameters("Overrides", fileParameters, _parametersOverrides); - } - - _splittable = splittable; - _joinedBaseName = protocol.getJoinedBaseName(); - if (_filesInput.size() > 1) - { - _baseName = _joinedBaseName; - } - else - { - _baseName = protocol.getBaseName(_filesInput.get(0)); - } - - String logFile = protocol.timestampLog() ? FileUtil.makeFileNameWithTimestamp(_baseName) : _baseName; - setupLocalDirectoryAndJobLog(getPipeRoot(), "FileAnalysis", logFile); - } - - /** - * @return Path String for a local working directory, temporary if root is cloud based - */ - @Override - protected Path getWorkingDirectoryString() - { - return _dirAnalysis.toAbsolutePath(); - } - - public AbstractFileAnalysisJob(AbstractFileAnalysisJob job, File fileInput) - { - this(job, Collections.singletonList(fileInput)); - } - - public AbstractFileAnalysisJob(AbstractFileAnalysisJob job, List filesInput) - { - super(job); - - // Copy some parameters from the parent job. - _experimentRunRowId = job._experimentRunRowId; - _protocolName = job._protocolName; - _dirData = job._dirData; - _dirAnalysis = job._dirAnalysis; - _fileParameters = job._fileParameters; - _parametersDefaults = job._parametersDefaults; - _parametersOverrides = job._parametersOverrides; - _splittable = job._splittable; - _joinedBaseName = job._joinedBaseName; - - // Change parameters which are specific to the fraction job. - _filesInput = filesInput.stream().map(File::toPath).collect(Collectors.toList()); - _inputTypes = FileType.findTypes(job._inputTypes, _filesInput); - _baseName = (_inputTypes.isEmpty() ? filesInput.get(0).getName() : _inputTypes.get(0).getBaseName(filesInput.get(0))); - - setupLocalDirectoryAndJobLog(getPipeRoot(), "FileAnalysis", _baseName); - } - - @Override - public void clearActionSet(ExpRun run) - { - super.clearActionSet(run); - getActionSet().add(_fileParameters, ANALYSIS_PARAMETERS_ROLE_NAME); - - _experimentRunRowId = run.getRowId(); - } - - public void setSplittable(boolean splittable) - { - _splittable = splittable; - } - - @Override - public boolean isSplittable() - { - return _splittable && getInputFilePaths().size() > 1; - } - - @Override - public List createSplitJobs() - { - if (getInputFiles().size() == 1) - return super.createSplitJobs(); - - ArrayList jobs = new ArrayList<>(); - for (File file : getInputFiles()) - jobs.add(createSingleFileJob(file)); - return Collections.unmodifiableList(jobs); - } - - @Override - public TaskPipeline getTaskPipeline() - { - return PipelineJobService.get().getTaskPipeline(getTaskPipelineId()); - } - - abstract public TaskId getTaskPipelineId(); - - abstract public AbstractFileAnalysisJob createSingleFileJob(File file); - - @Override - public String getProtocolName() - { - return _protocolName; - } - - @Override - public String getBaseName() - { - return _baseName; - } - - @Override - public String getJoinedBaseName() - { - return _joinedBaseName; - } - - @Override - public List getSplitBaseNames() - { - ArrayList baseNames = new ArrayList<>(); - for (Path fileInput : _filesInput) - { - for (FileType ft : _inputTypes) - { - if (ft.isType(fileInput)) - { - baseNames.add(ft.getBaseName(fileInput)); - break; - } - } - } - return baseNames; - } - - @Override - public String getBaseNameForFileType(FileType fileType) - { - if (fileType != null) - { - for (Path fileInput : _filesInput) - { - if (fileType.isType(fileInput)) - return fileType.getBaseName(fileInput); - } - } - - return getBaseName(); - } - - @Override - public File getDataDirectory() - { - return _dirData.toFile(); - } - - @Override - public Path getDataDirectoryPath() - { - return _dirData; - } - - @Override - public File getAnalysisDirectory() - { - return _dirAnalysis.toFile(); - } - - @Override - public Path getAnalysisDirectoryPath() - { - return _dirAnalysis; - } - - @Override - public File findOutputFile(@NotNull String outputDir, @NotNull String fileName) - { - return getOutputFile(outputDir, fileName, getPipeRoot(), getLogger(), getAnalysisDirectory()); - } - - public static File getOutputFile(@NotNull String outputDir, @NotNull String fileName, PipeRoot root, Logger log, File analysisDirectory) - { - File dir; - if (outputDir.startsWith("/")) - { - dir = root.resolvePath(outputDir); - if (dir == null) - throw new RuntimeException("Output directory not under pipeline root: " + outputDir); - - if (!NetworkDrive.exists(dir)) - { - log.info("Creating output directory under pipeline root: " + dir); - if (!dir.mkdirs()) - throw new RuntimeException("Failed to create output directory under pipeline root: " + outputDir); - } - } - else - { - dir = new File(analysisDirectory, outputDir); - if (!NetworkDrive.exists(dir)) - { - log.info("Creating output directory under pipeline analysis dir: " + dir); - if (!dir.mkdirs()) - throw new RuntimeException("Failed to create output directory under analysis dir: " + outputDir); - } - } - - return new File(dir, fileName); - } - - @Override - public List getInputFiles() - { - return getInputFilePaths().stream().map(Path::toFile).collect(Collectors.toList()); - } - - @Override - public List getInputFilePaths() - { - return _filesInput; - } - - @Override - @Nullable - public File getJobInfoFile() - { - return _fileJobInfo.toFile(); - } - - - @Override - @Nullable - public Path getJobInfoFilePath() - { - return _fileJobInfo; - } - - @Override - public File getParametersFile() - { - return _fileParameters.toFile(); - } - - @Override - public Map getParameters() - { - HashMap params = new HashMap<>(_parametersDefaults); - params.putAll(_parametersOverrides); - - // Add previous output parameters to the current set - for (RecordedAction action : getActionSet().getActions()) - { - for (Map.Entry entry : action.getOutputParams().entrySet()) - { - RecordedAction.ParameterType p = entry.getKey(); - Object value = entry.getValue(); - if (p.getType() != PropertyType.ATTACHMENT) - params.put(p.getName(), Objects.toString(value, null)); - } - } - - return Collections.unmodifiableMap(params); - } - - public ParamParser getInputParameters() throws IOException - { - return getInputParameters(_fileParameters); - } - - public ParamParser getInputParameters(Path parametersFile) throws IOException - { - ParamParser parser = createParamParser(); - parser.parse(Files.newInputStream(parametersFile)); - if (parser.getErrors() != null) - { - ParamParser.Error err = parser.getErrors()[0]; - if (err.getLine() == 0) - { - throw new IOException("Failed parsing input xml '" + parametersFile + "'.\n" + - err.getMessage()); - } - else - { - throw new IOException("Failed parsing input xml '" + parametersFile + "'.\n" + - "Line " + err.getLine() + ": " + err.getMessage()); - } - } - return parser; - } - - private void logParameters(String description, Path file, Map parameters) - { - _log.debug(description + " " + parameters.size() + " parameters (" + file + "):"); - for (Map.Entry entry : new TreeMap<>(parameters).entrySet()) - _log.debug(entry.getKey() + " = " + entry.getValue()); - _log.debug(""); - } - - @Override - public ParamParser createParamParser() - { - return PipelineJobService.get().createParamParser(); - } - - @Override - public String getDescription() - { - return getDataDescription(getDataDirectoryPath(), getBaseName(), getJoinedBaseName(), getProtocolName(), getInputFilePaths()); - } - - @Override - public ActionURL getStatusHref() - { - if (_experimentRunRowId != null) - { - ExpRun run = ExperimentService.get().getExpRun(_experimentRunRowId.intValue()); - if (run != null) - return PageFlowUtil.urlProvider(ExperimentUrls.class).getRunGraphURL(run); - } - return null; - } - - @Deprecated //prefer Path version - public static String getDataDescription(File dirData, String baseName, String joinedBaseName, String protocolName) - { - return getDataDescription(dirData.toPath(), baseName, joinedBaseName, protocolName, Collections.emptyList()); - } - - public static String getDataDescription(Path dirData, String baseName, String joinedBaseName, String protocolName, List inputFiles) - { - String dataName = ""; - if (dirData != null) - { - dataName = dirData.getFileName().toString(); - // Can't remember why we would ever need the "xml" check. We may get an extra "." in the path, - // so check for that and remove it. - if (".".equals(dataName) || "xml".equals(dataName)) - { - dirData = dirData.getParent(); - if (dirData != null) - dataName = dirData.getFileName().toString(); - } - } - - StringBuilder description = new StringBuilder(dataName); - if (baseName != null && !baseName.equals(dataName) && - !(AbstractFileAnalysisProtocol.LEGACY_JOINED_BASENAME.equals(baseName) || baseName.equals(joinedBaseName))) // For cluster - { - if (!description.isEmpty()) - description.append("/"); - description.append(baseName); - } - description.append(" (").append(protocolName).append(")"); - - // input files - if (!inputFiles.isEmpty()) - { - description.append(" ("); - //p.getFileName returns the full S3 path -- S3fs bug? - description.append(inputFiles.stream().map(FileUtil::getFileName).collect(Collectors.joining(","))); - description.append(")"); - } - return description.toString(); - } - - /** - * returns support level for .xml.gz handling - * we always read .xml.gz, but may also have a - * preference for producing it in the pipeline - */ - @Override - public FileType.gzSupportLevel getGZPreference() - { - String doGZ = getParameters().get("pipeline, gzip outputs"); - return "yes".equalsIgnoreCase(doGZ)?FileType.gzSupportLevel.PREFER_GZ:FileType.gzSupportLevel.SUPPORT_GZ; - } -} +/* + * Copyright (c) 2008-2018 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.api.pipeline.file; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.NotNull; +import org.labkey.api.exp.PropertyType; +import org.labkey.api.exp.api.ExpRun; +import org.labkey.api.exp.api.ExperimentService; +import org.labkey.api.exp.api.ExperimentUrls; +import org.labkey.api.pipeline.ParamParser; +import org.labkey.api.pipeline.PipeRoot; +import org.labkey.api.pipeline.PipelineJob; +import org.labkey.api.pipeline.PipelineJobService; +import org.labkey.api.pipeline.RecordedAction; +import org.labkey.api.pipeline.TaskId; +import org.labkey.api.pipeline.TaskPipeline; +import org.labkey.api.util.FileType; +import org.labkey.api.util.FileUtil; +import org.labkey.api.util.NetworkDrive; +import org.labkey.api.util.PageFlowUtil; +import org.labkey.api.view.ActionURL; +import org.labkey.api.view.ViewBackgroundInfo; +import org.labkey.vfs.FileLike; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; +import java.util.stream.Collectors; + +/** + * AbstractFileAnalysisJob + */ +abstract public class AbstractFileAnalysisJob extends PipelineJob implements FileAnalysisJobSupport +{ + private static final Logger _log = LogManager.getLogger(AbstractFileAnalysisJob.class); + + protected Long _experimentRunRowId; + private String _protocolName; + private String _joinedBaseName; + private String _baseName; + private FileLike _dirData; + private FileLike _dirAnalysis; + private FileLike _fileParameters; + private List _filesInput; + private List _inputTypes; + private boolean _splittable = true; + + private Map _parametersDefaults; + private Map _parametersOverrides; + + public static final String ANALYSIS_PARAMETERS_ROLE_NAME = "AnalysisParameters"; + + // For serialization + protected AbstractFileAnalysisJob() {} + + public AbstractFileAnalysisJob(@NotNull AbstractFileAnalysisProtocol protocol, + String providerName, + ViewBackgroundInfo info, + PipeRoot root, + String protocolName, + FileLike fileParameters, + List filesInput, + boolean splittable) throws IOException + { + super(providerName, info, root); + + _filesInput = filesInput; + _inputTypes = FileType.findTypes(protocol.getInputTypes(), _filesInput); + _dirData = filesInput.get(0).getParent(); + _protocolName = protocolName; + + _fileParameters = fileParameters; + getActionSet().add(_fileParameters, ANALYSIS_PARAMETERS_ROLE_NAME); // input + _dirAnalysis = _fileParameters.getParent(); + + // Load parameter files + _parametersOverrides = getInputParameters().getInputParameters(); + + // Check for explicitly set default parameters. Otherwise use the default. + String paramDefaults = _parametersOverrides.get("list path, default parameters"); + FileLike fileDefaults; + if (paramDefaults != null) + fileDefaults = getPipeRoot().resolvePathToFileLike(paramDefaults); + else + fileDefaults = protocol.getFactory().getDefaultParametersFile(root); + + _parametersDefaults = fileDefaults != null && fileDefaults.exists() ? + getInputParameters(fileDefaults).getInputParameters() : + Collections.emptyMap(); + + if (_log.isDebugEnabled()) + { + logParameters("Defaults", fileDefaults, _parametersDefaults); + logParameters("Overrides", fileParameters, _parametersOverrides); + } + + _splittable = splittable; + _joinedBaseName = protocol.getJoinedBaseName(); + if (_filesInput.size() > 1) + { + _baseName = _joinedBaseName; + } + else + { + _baseName = protocol.getBaseName(_filesInput.get(0)); + } + + String logFile = protocol.timestampLog() ? FileUtil.makeFileNameWithTimestamp(_baseName) : _baseName; + setupLocalDirectoryAndJobLog(getPipeRoot(), "FileAnalysis", logFile); + } + + /** + * @return Path String for a local working directory, temporary if root is cloud based + */ + @Override + protected Path getWorkingDirectoryString() + { + return _dirAnalysis.toNioPathForWrite().toAbsolutePath(); + } + + public AbstractFileAnalysisJob(AbstractFileAnalysisJob job, FileLike fileInput) + { + this(job, Collections.singletonList(fileInput)); + } + + public AbstractFileAnalysisJob(AbstractFileAnalysisJob job, List filesInput) + { + super(job); + + // Copy some parameters from the parent job. + _experimentRunRowId = job._experimentRunRowId; + _protocolName = job._protocolName; + _dirData = job._dirData; + _dirAnalysis = job._dirAnalysis; + _fileParameters = job._fileParameters; + _parametersDefaults = job._parametersDefaults; + _parametersOverrides = job._parametersOverrides; + _splittable = job._splittable; + _joinedBaseName = job._joinedBaseName; + + // Change parameters which are specific to the fraction job. + _filesInput = new ArrayList<>(filesInput); + _inputTypes = FileType.findTypes(job._inputTypes, _filesInput); + _baseName = (_inputTypes.isEmpty() ? filesInput.get(0).getName() : _inputTypes.get(0).getBaseName(filesInput.get(0))); + + setupLocalDirectoryAndJobLog(getPipeRoot(), "FileAnalysis", _baseName); + } + + @Override + public void clearActionSet(ExpRun run) + { + super.clearActionSet(run); + getActionSet().add(_fileParameters, ANALYSIS_PARAMETERS_ROLE_NAME); + + _experimentRunRowId = run.getRowId(); + } + + public void setSplittable(boolean splittable) + { + _splittable = splittable; + } + + @Override + public boolean isSplittable() + { + return _splittable && getInputFilePaths().size() > 1; + } + + @Override + public List createSplitJobs() + { + if (getInputFiles().size() == 1) + return super.createSplitJobs(); + + ArrayList jobs = new ArrayList<>(); + for (FileLike file : _filesInput) + jobs.add(createSingleFileJob(file)); + return Collections.unmodifiableList(jobs); + } + + @Override + public TaskPipeline getTaskPipeline() + { + return PipelineJobService.get().getTaskPipeline(getTaskPipelineId()); + } + + abstract public TaskId getTaskPipelineId(); + + abstract public AbstractFileAnalysisJob createSingleFileJob(FileLike file); + + @Override + public String getProtocolName() + { + return _protocolName; + } + + @Override + public String getBaseName() + { + return _baseName; + } + + @Override + public String getJoinedBaseName() + { + return _joinedBaseName; + } + + @Override + public List getSplitBaseNames() + { + ArrayList baseNames = new ArrayList<>(); + for (FileLike fileInput : _filesInput) + { + for (FileType ft : _inputTypes) + { + if (ft.isType(fileInput)) + { + baseNames.add(ft.getBaseName(fileInput)); + break; + } + } + } + return baseNames; + } + + @Override + public String getBaseNameForFileType(FileType fileType) + { + if (fileType != null) + { + for (FileLike fileInput : _filesInput) + { + if (fileType.isType(fileInput)) + return fileType.getBaseName(fileInput); + } + } + + return getBaseName(); + } + + @Override + public File getDataDirectory() + { + return _dirData.toNioPathForRead().toFile(); + } + + @Override + public Path getDataDirectoryPath() + { + return _dirData.toNioPathForRead(); + } + + @Override + public File getAnalysisDirectory() + { + return _dirAnalysis.toNioPathForWrite().toFile(); + } + + @Override + public Path getAnalysisDirectoryPath() + { + return _dirAnalysis.toNioPathForWrite(); + } + + @Override + public File findOutputFile(@NotNull String outputDir, @NotNull String fileName) + { + return getOutputFile(outputDir, fileName, getPipeRoot(), getLogger(), getAnalysisDirectory()); + } + + public static File getOutputFile(@NotNull String outputDir, @NotNull String fileName, PipeRoot root, Logger log, File analysisDirectory) + { + File dir; + if (outputDir.startsWith("/")) + { + dir = root.resolvePath(outputDir); + if (dir == null) + throw new RuntimeException("Output directory not under pipeline root: " + outputDir); + + if (!NetworkDrive.exists(dir)) + { + log.info("Creating output directory under pipeline root: " + dir); + if (!dir.mkdirs()) + throw new RuntimeException("Failed to create output directory under pipeline root: " + outputDir); + } + } + else + { + dir = new File(analysisDirectory, outputDir); + if (!NetworkDrive.exists(dir)) + { + log.info("Creating output directory under pipeline analysis dir: " + dir); + if (!dir.mkdirs()) + throw new RuntimeException("Failed to create output directory under analysis dir: " + outputDir); + } + } + + return new File(dir, fileName); + } + + @Override + public List getInputFiles() + { + return getInputFilePaths().stream().map(Path::toFile).collect(Collectors.toList()); + } + + @Override + public List getInputFilePaths() + { + return _filesInput.stream().map(FileLike::toNioPathForRead).toList(); + } + + @Override + public File getParametersFile() + { + return _fileParameters.toNioPathForRead().toFile(); + } + + @Override + public Map getParameters() + { + HashMap params = new HashMap<>(_parametersDefaults); + params.putAll(_parametersOverrides); + + // Add previous output parameters to the current set + for (RecordedAction action : getActionSet().getActions()) + { + for (Map.Entry entry : action.getOutputParams().entrySet()) + { + RecordedAction.ParameterType p = entry.getKey(); + Object value = entry.getValue(); + if (p.getType() != PropertyType.ATTACHMENT) + params.put(p.getName(), Objects.toString(value, null)); + } + } + + return Collections.unmodifiableMap(params); + } + + public ParamParser getInputParameters() throws IOException + { + return getInputParameters(_fileParameters); + } + + public ParamParser getInputParameters(FileLike parametersFile) throws IOException + { + ParamParser parser = createParamParser(); + parser.parse(parametersFile.openInputStream()); + if (parser.getErrors() != null) + { + ParamParser.Error err = parser.getErrors()[0]; + if (err.getLine() == 0) + { + throw new IOException("Failed parsing input xml '" + parametersFile + "'.\n" + + err.getMessage()); + } + else + { + throw new IOException("Failed parsing input xml '" + parametersFile + "'.\n" + + "Line " + err.getLine() + ": " + err.getMessage()); + } + } + return parser; + } + + private void logParameters(String description, FileLike file, Map parameters) + { + _log.debug(description + " " + parameters.size() + " parameters (" + file + "):"); + for (Map.Entry entry : new TreeMap<>(parameters).entrySet()) + _log.debug(entry.getKey() + " = " + entry.getValue()); + _log.debug(""); + } + + @Override + public ParamParser createParamParser() + { + return PipelineJobService.get().createParamParser(); + } + + @Override + public String getDescription() + { + return getDataDescription(getDataDirectoryPath(), getBaseName(), getJoinedBaseName(), getProtocolName(), getInputFilePaths()); + } + + @Override + public ActionURL getStatusHref() + { + if (_experimentRunRowId != null) + { + ExpRun run = ExperimentService.get().getExpRun(_experimentRunRowId.intValue()); + if (run != null) + return PageFlowUtil.urlProvider(ExperimentUrls.class).getRunGraphURL(run); + } + return null; + } + + @Deprecated //prefer Path version + public static String getDataDescription(File dirData, String baseName, String joinedBaseName, String protocolName) + { + return getDataDescription(dirData.toPath(), baseName, joinedBaseName, protocolName, Collections.emptyList()); + } + + public static String getDataDescription(Path dirData, String baseName, String joinedBaseName, String protocolName, List inputFiles) + { + String dataName = ""; + if (dirData != null) + { + dataName = dirData.getFileName().toString(); + // Can't remember why we would ever need the "xml" check. We may get an extra "." in the path, + // so check for that and remove it. + if (".".equals(dataName) || "xml".equals(dataName)) + { + dirData = dirData.getParent(); + if (dirData != null) + dataName = dirData.getFileName().toString(); + } + } + + StringBuilder description = new StringBuilder(dataName); + if (baseName != null && !baseName.equals(dataName) && + !(AbstractFileAnalysisProtocol.LEGACY_JOINED_BASENAME.equals(baseName) || baseName.equals(joinedBaseName))) // For cluster + { + if (!description.isEmpty()) + description.append("/"); + description.append(baseName); + } + description.append(" (").append(protocolName).append(")"); + + // input files + if (!inputFiles.isEmpty()) + { + description.append(" ("); + //p.getFileName returns the full S3 path -- S3fs bug? + description.append(inputFiles.stream().map(FileUtil::getFileName).collect(Collectors.joining(","))); + description.append(")"); + } + return description.toString(); + } + + /** + * returns support level for .xml.gz handling + * we always read .xml.gz, but may also have a + * preference for producing it in the pipeline + */ + @Override + public FileType.gzSupportLevel getGZPreference() + { + String doGZ = getParameters().get("pipeline, gzip outputs"); + return "yes".equalsIgnoreCase(doGZ)?FileType.gzSupportLevel.PREFER_GZ:FileType.gzSupportLevel.SUPPORT_GZ; + } +} diff --git a/api/src/org/labkey/api/pipeline/file/AbstractFileAnalysisProtocol.java b/api/src/org/labkey/api/pipeline/file/AbstractFileAnalysisProtocol.java index a75a2b01925..07a591701f9 100644 --- a/api/src/org/labkey/api/pipeline/file/AbstractFileAnalysisProtocol.java +++ b/api/src/org/labkey/api/pipeline/file/AbstractFileAnalysisProtocol.java @@ -1,304 +1,281 @@ -/* - * Copyright (c) 2008-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.api.pipeline.file; - -import org.apache.commons.io.input.ReaderInputStream; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.labkey.api.action.ApiUsageException; -import org.labkey.api.data.Container; -import org.labkey.api.pipeline.ParamParser; -import org.labkey.api.pipeline.PipeRoot; -import org.labkey.api.pipeline.PipelineJob; -import org.labkey.api.pipeline.PipelineProtocol; -import org.labkey.api.util.FileType; -import org.labkey.api.util.FileUtil; -import org.labkey.api.util.UnexpectedException; -import org.labkey.api.util.XmlBeansUtil; -import org.labkey.api.view.ViewBackgroundInfo; -import org.labkey.api.writer.PrintWriters; -import org.xml.sax.InputSource; - -import javax.xml.parsers.DocumentBuilder; -import javax.xml.transform.OutputKeys; -import javax.xml.transform.Transformer; -import javax.xml.transform.TransformerFactory; -import javax.xml.transform.dom.DOMSource; -import javax.xml.transform.stream.StreamResult; -import java.io.BufferedReader; -import java.io.File; -import java.io.IOException; -import java.io.PrintWriter; -import java.io.StringReader; -import java.io.StringWriter; -import java.nio.charset.Charset; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -/** - * AbstractFileAnalysisProtocol - */ -public abstract class AbstractFileAnalysisProtocol - extends PipelineProtocol -{ - private static final Logger _log = LogManager.getLogger(AbstractFileAnalysisProtocol.class); - - public static final String LEGACY_JOINED_BASENAME = "all"; - - protected String description; - protected String xml; - - protected String email; - protected boolean timestampLog; - - public AbstractFileAnalysisProtocol(String name, String description, String xml) - { - super(name); - - this.description = description; - setXml(xml); - } - - public String getDescription() - { - return description; - } - - public void setDescription(String description) - { - this.description = description; - } - - public String getXml() - { - return xml; - } - - /** - * The xml string has bad formatting and extra whitespace from having had some parameters stripped after being read from file. - * Fix it for redisplay. - * @param xml The raw xml read from file - */ - public void setXml(String xml) - { - try - { - BufferedReader reader = new BufferedReader(new StringReader(xml)); - StringBuilder stripped = new StringBuilder(); - String line; - while ((line = reader.readLine()) != null) - stripped.append(line.trim()); - DocumentBuilder db = XmlBeansUtil.DOCUMENT_BUILDER_FACTORY.newDocumentBuilder(); - DOMSource xmlInput = new DOMSource(db.parse(new InputSource(new StringReader(stripped.toString())))); - StreamResult xmlOutput = new StreamResult(new StringWriter()); - Transformer transformer = TransformerFactory.newInstance().newTransformer(); - transformer.setOutputProperty(OutputKeys.INDENT, "yes"); - transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "2"); - transformer.transform(xmlInput, xmlOutput); - this.xml = xmlOutput.getWriter().toString(); - } - catch (Exception e) - { - // This shouldn't happen; bad input xml would have been detected upstream of here. - throw new ApiUsageException("Invalid xml format", e); - } - } - - public String getEmail() - { - return email; - } - - public void setEmail(String email) - { - this.email = email; - } - - /** - * Get the base name used to construct files names for multi-file inputs and outputs. - * The default base name is the protocol's name except for AbstractMS2SearchProtocol which defaults to "all". - */ - public String getJoinedBaseName() - { - return getName(); - } - - public String getBaseName(File file) - { - return getBaseName(file.toPath()); - } - - public String getBaseName(Path file) - { - FileType ft = findInputType(file); - if (ft == null) - return file.getFileName().toString(); - - return ft.getBaseName(file); - } - - public File getAnalysisDir(File dirData, PipeRoot root) - { - return getFactory().getAnalysisDir(dirData.toPath(), getName(), root).toFile(); - } - - @Deprecated //Prefer Path version - public File getParametersFile(File dirData, PipeRoot root) - { - return getParametersFile(dirData.toPath(), root).toFile(); - } - - public Path getParametersFile(Path dirData, PipeRoot root) - { - return getFactory().getParametersFile(dirData, getName(), root); - } - - @Override - public void saveDefinition(PipeRoot root) throws IOException - { - save(getFactory().getProtocolFile(root, getName(), false), null, null); - } - - @Deprecated //Prefer Path version - public void saveInstance(File file, Container c) throws IOException - { - saveInstance(file.toPath(), c); - } - - public void saveInstance(Path file, Container c) throws IOException - { - Map addParams = new HashMap<>(); - addParams.put(PipelineJob.PIPELINE_EMAIL_ADDRESS_PARAM, email); - save(file, null, addParams); - } - - protected void save(Path file, Map addParams, Map instanceParams) throws IOException - { - if (xml == null || xml.isEmpty()) - { - xml = """ - - - """; - } - - ParamParser parser = parse(); - if (parser.getErrors() != null) - { - ParamParser.Error err = parser.getErrors()[0]; - if (err.getLine() == 0) - throw new IllegalArgumentException(err.getMessage()); - else - throw new IllegalArgumentException("Line " + err.getLine() + ": " + err.getMessage()); - } - - Path dir = file.getParent(); - if (!Files.exists(dir)) - { - try - { - FileUtil.createDirectories(dir); - } - catch (IOException e) - { - throw new IOException("Failed to create directory '" + dir + "'."); - } - } - - parser.setInputParameter(PipelineJob.PIPELINE_PROTOCOL_NAME_PARAM, getName()); - parser.setInputParameter(PipelineJob.PIPELINE_PROTOCOL_DESCRIPTION_PARAM, getDescription()); - - if (addParams != null) - { - for (Map.Entry entry : addParams.entrySet()) - parser.setInputParameter(entry.getKey(), entry.getValue()); - } - if (instanceParams != null) - { - for (Map.Entry entry : instanceParams.entrySet()) - parser.setInputParameter(entry.getKey(), entry.getValue()); - } - - try (PrintWriter writer = PrintWriters.getPrintWriter(file)) - { - xml = parser.getXML(); - if (xml == null) - throw new IOException("Error writing input XML."); - writer.write(xml, 0, xml.length()); - } - catch (IOException eio) - { - _log.error("Error writing input XML.", eio); - throw eio; - } - } - - @NotNull - protected ParamParser parse() - { - ParamParser parser = getFactory().createParamParser(); - try - { - parser.parse(new ReaderInputStream.Builder().setReader(new StringReader(xml)).setCharset(Charset.defaultCharset()).get()); - } - catch (IOException e) - { - // Shouldn't happen since we already had the content in-memory as a String - throw UnexpectedException.wrap(e); - } - return parser; - } - - @Deprecated //Prefer the Path version - public FileType findInputType(File file) - { - return findInputType(file.toPath()); - } - - public FileType findInputType(Path file) - { - for (FileType type : getInputTypes()) - { - if (type.isType(file)) - return type; - } - return null; - } - - public abstract List getInputTypes(); - - @Override - public abstract AbstractFileAnalysisProtocolFactory getFactory(); - - public abstract JOB createPipelineJob(ViewBackgroundInfo info, - PipeRoot root, List filesInput, - Path fileParameters, @Nullable Map variableMap) throws IOException; - - public boolean timestampLog() - { - return timestampLog; - } - - public void setTimestampLog(boolean timestampLog) - { - this.timestampLog = timestampLog; - } -} +/* + * Copyright (c) 2008-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.api.pipeline.file; + +import org.apache.commons.io.input.ReaderInputStream; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.labkey.api.action.ApiUsageException; +import org.labkey.api.data.Container; +import org.labkey.api.pipeline.ParamParser; +import org.labkey.api.pipeline.PipeRoot; +import org.labkey.api.pipeline.PipelineJob; +import org.labkey.api.pipeline.PipelineProtocol; +import org.labkey.api.util.FileType; +import org.labkey.api.util.FileUtil; +import org.labkey.api.util.UnexpectedException; +import org.labkey.api.util.XmlBeansUtil; +import org.labkey.api.view.ViewBackgroundInfo; +import org.labkey.api.writer.PrintWriters; +import org.labkey.vfs.FileLike; +import org.xml.sax.InputSource; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.transform.OutputKeys; +import javax.xml.transform.Transformer; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringReader; +import java.io.StringWriter; +import java.nio.charset.Charset; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * AbstractFileAnalysisProtocol + */ +public abstract class AbstractFileAnalysisProtocol + extends PipelineProtocol +{ + private static final Logger _log = LogManager.getLogger(AbstractFileAnalysisProtocol.class); + + public static final String LEGACY_JOINED_BASENAME = "all"; + + protected String description; + protected String xml; + + protected String email; + protected boolean timestampLog; + + public AbstractFileAnalysisProtocol(String name, String description, String xml) + { + super(name); + + this.description = description; + setXml(xml); + } + + public String getDescription() + { + return description; + } + + public void setDescription(String description) + { + this.description = description; + } + + public String getXml() + { + return xml; + } + + /** + * The xml string has bad formatting and extra whitespace from having had some parameters stripped after being read from file. + * Fix it for redisplay. + * @param xml The raw xml read from file + */ + public void setXml(String xml) + { + try + { + BufferedReader reader = new BufferedReader(new StringReader(xml)); + StringBuilder stripped = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) + stripped.append(line.trim()); + DocumentBuilder db = XmlBeansUtil.DOCUMENT_BUILDER_FACTORY.newDocumentBuilder(); + DOMSource xmlInput = new DOMSource(db.parse(new InputSource(new StringReader(stripped.toString())))); + StreamResult xmlOutput = new StreamResult(new StringWriter()); + Transformer transformer = TransformerFactory.newInstance().newTransformer(); + transformer.setOutputProperty(OutputKeys.INDENT, "yes"); + transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "2"); + transformer.transform(xmlInput, xmlOutput); + this.xml = xmlOutput.getWriter().toString(); + } + catch (Exception e) + { + // This shouldn't happen; bad input xml would have been detected upstream of here. + throw new ApiUsageException("Invalid xml format", e); + } + } + + public String getEmail() + { + return email; + } + + public void setEmail(String email) + { + this.email = email; + } + + /** + * Get the base name used to construct files names for multi-file inputs and outputs. + * The default base name is the protocol's name except for AbstractMS2SearchProtocol which defaults to "all". + */ + public String getJoinedBaseName() + { + return getName(); + } + + public String getBaseName(FileLike file) + { + FileType ft = findInputType(file); + if (ft == null) + return file.getName(); + + return ft.getBaseName(file); + } + + public FileLike getAnalysisDir(FileLike dirData, PipeRoot root) + { + return getFactory().getAnalysisDir(dirData, getName(), root); + } + + public FileLike getParametersFile(FileLike dirData, PipeRoot root) + { + return getFactory().getParametersFile(dirData, getName(), root); + } + + @Override + public void saveDefinition(PipeRoot root) throws IOException + { + save(getFactory().getProtocolFile(root, getName(), false), null, null); + } + + public void saveInstance(FileLike file, Container c) throws IOException + { + Map addParams = new HashMap<>(); + addParams.put(PipelineJob.PIPELINE_EMAIL_ADDRESS_PARAM, email); + save(file, null, addParams); + } + + protected void save(FileLike file, Map addParams, Map instanceParams) throws IOException + { + if (xml == null || xml.isEmpty()) + { + xml = """ + + + """; + } + + ParamParser parser = parse(); + if (parser.getErrors() != null) + { + ParamParser.Error err = parser.getErrors()[0]; + if (err.getLine() == 0) + throw new IllegalArgumentException(err.getMessage()); + else + throw new IllegalArgumentException("Line " + err.getLine() + ": " + err.getMessage()); + } + + FileLike dir = file.getParent(); + if (!dir.exists()) + { + try + { + FileUtil.createDirectories(dir); + } + catch (IOException e) + { + throw new IOException("Failed to create directory '" + dir + "'."); + } + } + + parser.setInputParameter(PipelineJob.PIPELINE_PROTOCOL_NAME_PARAM, getName()); + parser.setInputParameter(PipelineJob.PIPELINE_PROTOCOL_DESCRIPTION_PARAM, getDescription()); + + if (addParams != null) + { + for (Map.Entry entry : addParams.entrySet()) + parser.setInputParameter(entry.getKey(), entry.getValue()); + } + if (instanceParams != null) + { + for (Map.Entry entry : instanceParams.entrySet()) + parser.setInputParameter(entry.getKey(), entry.getValue()); + } + + try (PrintWriter writer = PrintWriters.getPrintWriter(file.openOutputStream())) + { + xml = parser.getXML(); + if (xml == null) + throw new IOException("Error writing input XML."); + writer.write(xml, 0, xml.length()); + } + catch (IOException eio) + { + _log.error("Error writing input XML.", eio); + throw eio; + } + } + + @NotNull + protected ParamParser parse() + { + ParamParser parser = getFactory().createParamParser(); + try + { + parser.parse(new ReaderInputStream.Builder().setReader(new StringReader(xml)).setCharset(Charset.defaultCharset()).get()); + } + catch (IOException e) + { + // Shouldn't happen since we already had the content in-memory as a String + throw UnexpectedException.wrap(e); + } + return parser; + } + + public FileType findInputType(FileLike file) + { + for (FileType type : getInputTypes()) + { + if (type.isType(file)) + return type; + } + return null; + } + + public abstract List getInputTypes(); + + @Override + public abstract AbstractFileAnalysisProtocolFactory getFactory(); + + public abstract JOB createPipelineJob(ViewBackgroundInfo info, + PipeRoot root, List filesInput, + FileLike fileParameters, @Nullable Map variableMap) throws IOException; + + public boolean timestampLog() + { + return timestampLog; + } + + public void setTimestampLog(boolean timestampLog) + { + this.timestampLog = timestampLog; + } +} diff --git a/api/src/org/labkey/api/pipeline/file/AbstractFileAnalysisProtocolFactory.java b/api/src/org/labkey/api/pipeline/file/AbstractFileAnalysisProtocolFactory.java index 28fe1bf96e7..c5e2c640a14 100644 --- a/api/src/org/labkey/api/pipeline/file/AbstractFileAnalysisProtocolFactory.java +++ b/api/src/org/labkey/api/pipeline/file/AbstractFileAnalysisProtocolFactory.java @@ -1,380 +1,374 @@ -/* - * Copyright (c) 2008-2017 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.api.pipeline.file; - -import org.apache.commons.io.input.ReaderInputStream; -import org.apache.commons.lang3.ArrayUtils; -import org.apache.logging.log4j.Logger; -import org.jetbrains.annotations.Nullable; -import org.labkey.api.data.Container; -import org.labkey.api.pipeline.ParamParser; -import org.labkey.api.pipeline.PipeRoot; -import org.labkey.api.pipeline.PipelineJob; -import org.labkey.api.pipeline.PipelineJobService; -import org.labkey.api.pipeline.PipelineProtocolFactory; -import org.labkey.api.pipeline.PipelineProvider; -import org.labkey.api.pipeline.PipelineService; -import org.labkey.api.pipeline.TaskPipeline; -import org.labkey.api.util.FileUtil; -import org.labkey.api.util.NetworkDrive; -import org.labkey.api.util.PageFlowUtil; -import org.labkey.api.util.logging.LogHelper; - -import java.io.BufferedReader; -import java.io.BufferedWriter; -import java.io.File; -import java.io.FileNotFoundException; -import java.io.IOException; -import java.io.InputStream; -import java.io.Reader; -import java.io.StringReader; -import java.nio.charset.Charset; -import java.nio.file.Files; -import java.nio.file.InvalidPathException; -import java.nio.file.Path; -import java.nio.file.StandardOpenOption; -import java.util.List; - -/** - * Base class for protocol factories that are primarily focused on analyzing data files (as opposed to other types of resources) - */ -abstract public class AbstractFileAnalysisProtocolFactory> extends PipelineProtocolFactory -{ - private static final Logger _log = LogHelper.getLogger(AbstractFileAnalysisProtocolFactory.class, "Pipeline protocol and parameter errors"); - - public static final String DEFAULT_PARAMETERS_NAME = "default"; - - /** - * Get the file name used for parameter files in analysis directories. - * - * @return file name - */ - public String getParametersFileName() - { - return getName() + ".xml"; - } - - /** - * Get the file name for the default parameters for all protocols of this type. - * - * @return file name - */ - public String getDefaultParametersFileName() - { - return DEFAULT_PARAMETERS_NAME + ".xml"; - } - - /** - * Get the file name for the old default parameters for all protocols of this type, - * back when these files were stored in the root. - * - * @return file name - */ - public String getLegacyDefaultParametersFileName() - { - return getName() + "_default_input.xml"; - } - - /** - * Get the analysis directory location, given a directory containing the mass spec data. - * - * @param dirData mass spec data directory - * @param protocolName name of protocol for analysis - * @param root pipeline root under which the files are stored - * @return analysis directory - */ - public Path getAnalysisDir(Path dirData, String protocolName, PipeRoot root) - { - Path defaultFile = dirData.resolve(getName()).resolve(protocolName); - // Check if the pipeline root wants us to write somewhere else, because the source file might be in a read-only - // pipeline location - String relativePath = root.relativePath(defaultFile); - return root.resolveToNioPath(relativePath); - } - - /** - * Returns true if the file uses the type of protocol created by this factory. - */ - public boolean isProtocolTypeFile(File file) - { - return NetworkDrive.exists(new File(file.getParent(), getParametersFileName())); - } - - @Deprecated - public File getParametersFile(@Nullable File dirData, String protocolName, PipeRoot root) - { - Path result = getParametersFile(dirData == null? null : dirData.toPath(), protocolName, root); - return result != null ? result.toFile() : null; - } - /** - * Get the parameters file location, given a directory containing the mass spec data. - * - * @param dirData mass spec data directory - * @param protocolName name of protocol for analysis - * @param root pipeline root under which the files are stored - * @return parameters file - */ - @Nullable - public Path getParametersFile(@Nullable Path dirData, String protocolName, PipeRoot root) - { - if (dirData == null) - { - return null; - } - Path defaultFile = getAnalysisDir(dirData, protocolName, root).resolve(getParametersFileName()); - // Check if the pipeline root wants us to write somewhere else, because the source file might be in a read-only - // pipeline location - String relativePath = root.relativePath(defaultFile); - return root.resolveToNioPath(relativePath); - } - - /** - * Get the default parameters file, given the pipeline root directory. - * - * @param root pipeline root directory - * @return default parameters file - */ - public Path getDefaultParametersFile(PipeRoot root) - { - return getProtocolDir(root, false).resolve(getDefaultParametersFileName()); - } - - /** - * Make sure default parameters for this protocol type exist. - * - * @param root pipeline root - */ - public void ensureDefaultParameters(PipeRoot root) throws IOException - { - if (!NetworkDrive.exists(getDefaultParametersFile(root))) - setDefaultParametersXML(root, getDefaultParametersXML(root)); - } - - @Override - public String[] getProtocolNames(PipeRoot root, Path dirData, boolean archived) - { - String[] protocolNames = super.getProtocolNames(root, dirData, archived); - - // The default parameters file is not really a protocol so remove it from the list. - return ArrayUtils.removeElement(protocolNames, DEFAULT_PARAMETERS_NAME); - } - - public void initSystemDirectory(File rootDir, File systemDir) - { - // Make sure the root protocol directory is in the right place. - File protocolRootDir = locateProtocolRootDir(rootDir, systemDir); - - // Make sure the defaults for this particular protocol are in the right place. - File fileLegacyDefaults = FileUtil.appendName(rootDir, getLegacyDefaultParametersFileName()); - if (NetworkDrive.exists(fileLegacyDefaults)) - { - File protocolDir = FileUtil.appendName(protocolRootDir, getName()); - fileLegacyDefaults.renameTo(FileUtil.appendName(protocolDir, getDefaultParametersFileName())); - } - } - - /** - * Override to set a custom validator. - * - * @return a parser for working with a parameter stream - */ - public ParamParser createParamParser() - { - return PipelineJobService.get().createParamParser(); - } - - public abstract T createProtocolInstance(String name, String description, String xml, Container container); - - protected T createProtocolInstance(ParamParser parser, Container container) - { - // Remove the pipeline specific parameters. - String name = parser.removeInputParameter(PipelineJob.PIPELINE_PROTOCOL_NAME_PARAM); - String description = parser.removeInputParameter(PipelineJob.PIPELINE_PROTOCOL_DESCRIPTION_PARAM); - String folder = parser.removeInputParameter(PipelineJob.PIPELINE_LOAD_FOLDER_PARAM); - String email = parser.removeInputParameter(PipelineJob.PIPELINE_EMAIL_ADDRESS_PARAM); - - T instance = createProtocolInstance(name, description, parser.getXML(), container); - - instance.setEmail(email); - - return instance; - } - - @Override - public T load(PipeRoot root, String name, boolean archived) throws IOException - { - T instance = loadInstance(getProtocolFile(root, name, archived), root.getContainer()); - - // Don't allow the XML to override the name passed in. This - // can be extremely confusing. - instance.setName(name); - return instance; - } - - public T loadInstance(Path file, Container container) throws IOException - { - ParamParser parser = createParamParser(); - try (InputStream is = Files.newInputStream(file)) - { - parser.parse(is); - if (parser.getErrors() != null) - { - ParamParser.Error err = parser.getErrors()[0]; - if (err.getLine() == 0) - { - throw new IOException("Failed parsing input parameters '" + file + "'.\n" + - err.getMessage()); - } - else - { - throw new IOException("Failed parsing input parameters '" + file + "'.\n" + - "Line " + err.getLine() + ": " + err.getMessage()); - } - } - - return createProtocolInstance(parser, container); - } - } - - public String getDefaultParametersXML(PipeRoot root) throws IOException - { - Path fileDefault = getDefaultParametersFile(root); - if (!Files.exists(fileDefault)) - return null; - - return new FileDefaultsReader(fileDefault).readXML(); - } - - protected static class FileDefaultsReader extends DefaultsReader - { - private final Path _fileDefaults; - - public FileDefaultsReader(Path fileDefaults) - { - _fileDefaults = fileDefaults; - } - - @Override - public Reader createReader() throws IOException - { - return Files.newBufferedReader(_fileDefaults, Charset.defaultCharset()); - } - } - - abstract protected static class DefaultsReader - { - abstract public Reader createReader() throws IOException; - - public String readXML() throws IOException - { - try (BufferedReader reader = new BufferedReader(createReader())) - { - return PageFlowUtil.getReaderContentsAsString(reader); - } - catch (FileNotFoundException enf) - { - _log.error("Default parameters file missing. Check product setup.", enf); - throw enf; - } - catch (IOException eio) - { - _log.error("Error reading default parameters file.", eio); - throw eio; - } - } - } - - public void setDefaultParametersXML(PipeRoot root, String xml) throws IOException - { - if (xml == null || xml.isEmpty()) - throw new IllegalArgumentException("You must supply default parameters for " + getName() + "."); - - ParamParser parser = createParamParser(); - parser.parse(new ReaderInputStream(new StringReader(xml))); - if (parser.getErrors() != null) - { - ParamParser.Error err = parser.getErrors()[0]; - if (err.getLine() == 0) - throw new IllegalArgumentException(err.getMessage()); - else - throw new IllegalArgumentException("Line " + err.getLine() + ": " + err.getMessage()); - } - - Path fileDefault = getDefaultParametersFile(root); - FileUtil.createDirectories(fileDefault.getParent()); - - try (BufferedWriter writer = Files.newBufferedWriter(fileDefault, Charset.defaultCharset(), StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) - { - writer.write(xml, 0, xml.length()); - } - catch (IOException eio) - { - _log.error("Error writing default parameters file.", eio); - throw eio; - } - } - - public static >, F extends AbstractFileAnalysisProtocolFactory> - F fromFile(Class clazz, File file) - { - List providers = PipelineService.get().getPipelineProviders(); - for (PipelineProvider provider : providers) - { - if (!(clazz.isInstance(provider))) - continue; - - T mprovider = (T) provider; - F factory = mprovider.getProtocolFactory(file); - if (factory != null) - return factory; - } - - // TODO: Return some default? - return null; - } - - @Nullable - public AbstractFileAnalysisProtocol getProtocol(PipeRoot root, Path dirData, String protocolName, boolean archived) - { - try - { - Path protocolFile = getParametersFile(dirData, protocolName, root); - AbstractFileAnalysisProtocol result; - if (NetworkDrive.exists(protocolFile)) - { - result = loadInstance(protocolFile, root.getContainer()); - - // Don't allow the instance file to override the protocol name. - result.setName(protocolName); - } - else - { - protocolFile = getProtocolFile(root, protocolName, archived); - if (protocolFile == null || !Files.exists(protocolFile)) - return null; - - result = load(root, protocolName, archived); - } - return result; - } - catch (IOException|InvalidPathException e) - { - _log.warn("Error loading protocol file.", e); - return null; - } - } - -} +/* + * Copyright (c) 2008-2017 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.api.pipeline.file; + +import org.apache.commons.io.input.ReaderInputStream; +import org.apache.commons.lang3.ArrayUtils; +import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.Nullable; +import org.labkey.api.data.Container; +import org.labkey.api.pipeline.ParamParser; +import org.labkey.api.pipeline.PipeRoot; +import org.labkey.api.pipeline.PipelineJob; +import org.labkey.api.pipeline.PipelineJobService; +import org.labkey.api.pipeline.PipelineProtocolFactory; +import org.labkey.api.pipeline.PipelineProvider; +import org.labkey.api.pipeline.PipelineService; +import org.labkey.api.pipeline.TaskPipeline; +import org.labkey.api.reader.Readers; +import org.labkey.api.util.FileUtil; +import org.labkey.api.util.NetworkDrive; +import org.labkey.api.util.PageFlowUtil; +import org.labkey.api.util.logging.LogHelper; +import org.labkey.api.writer.PrintWriters; +import org.labkey.vfs.FileLike; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.PrintWriter; +import java.io.Reader; +import java.io.StringReader; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.util.List; + +/** + * Base class for protocol factories that are primarily focused on analyzing data files (as opposed to other types of resources) + */ +abstract public class AbstractFileAnalysisProtocolFactory> extends PipelineProtocolFactory +{ + private static final Logger _log = LogHelper.getLogger(AbstractFileAnalysisProtocolFactory.class, "Pipeline protocol and parameter errors"); + + public static final String DEFAULT_PARAMETERS_NAME = "default"; + + /** + * Get the file name used for parameter files in analysis directories. + * + * @return file name + */ + public String getParametersFileName() + { + return getName() + ".xml"; + } + + /** + * Get the file name for the default parameters for all protocols of this type. + * + * @return file name + */ + public String getDefaultParametersFileName() + { + return DEFAULT_PARAMETERS_NAME + ".xml"; + } + + /** + * Get the file name for the old default parameters for all protocols of this type, + * back when these files were stored in the root. + * + * @return file name + */ + public String getLegacyDefaultParametersFileName() + { + return getName() + "_default_input.xml"; + } + + /** + * Get the analysis directory location, given a directory containing the mass spec data. + * + * @param dirData mass spec data directory + * @param protocolName name of protocol for analysis + * @param root pipeline root under which the files are stored + * @return analysis directory + */ + public FileLike getAnalysisDir(FileLike dirData, String protocolName, PipeRoot root) + { + FileLike defaultFile = dirData.resolveChild(getName()).resolveChild(protocolName); + // Check if the pipeline root wants us to write somewhere else, because the source file might be in a read-only + // pipeline location + String relativePath = root.relativePath(defaultFile); + return root.resolvePathToFileLike(relativePath); + } + + /** + * Returns true if the file uses the type of protocol created by this factory. + */ + public boolean isProtocolTypeFile(File file) + { + return NetworkDrive.exists(new File(file.getParent(), getParametersFileName())); + } + + /** + * Get the parameters file location, given a directory containing the mass spec data. + * + * @param dirData mass spec data directory + * @param protocolName name of protocol for analysis + * @param root pipeline root under which the files are stored + * @return parameters file + */ + @Nullable + public FileLike getParametersFile(@Nullable FileLike dirData, String protocolName, PipeRoot root) + { + if (dirData == null) + { + return null; + } + FileLike defaultFile = getAnalysisDir(dirData, protocolName, root).resolveChild(getParametersFileName()); + // Check if the pipeline root wants us to write somewhere else, because the source file might be in a read-only + // pipeline location + String relativePath = root.relativePath(defaultFile); + return root.resolvePathToFileLike(relativePath); + } + + /** + * Get the default parameters file, given the pipeline root directory. + * + * @param root pipeline root directory + * @return default parameters file + */ + public FileLike getDefaultParametersFile(PipeRoot root) + { + return getProtocolDir(root, false).resolveChild(getDefaultParametersFileName()); + } + + /** + * Make sure default parameters for this protocol type exist. + * + * @param root pipeline root + */ + public void ensureDefaultParameters(PipeRoot root) throws IOException + { + if (!NetworkDrive.exists(getDefaultParametersFile(root))) + setDefaultParametersXML(root, getDefaultParametersXML(root)); + } + + @Override + public String[] getProtocolNames(PipeRoot root, FileLike dirData, boolean archived) + { + String[] protocolNames = super.getProtocolNames(root, dirData, archived); + + // The default parameters file is not really a protocol so remove it from the list. + return ArrayUtils.removeElement(protocolNames, DEFAULT_PARAMETERS_NAME); + } + + public void initSystemDirectory(File rootDir, File systemDir) + { + // Make sure the root protocol directory is in the right place. + File protocolRootDir = locateProtocolRootDir(rootDir, systemDir); + + // Make sure the defaults for this particular protocol are in the right place. + File fileLegacyDefaults = FileUtil.appendName(rootDir, getLegacyDefaultParametersFileName()); + if (NetworkDrive.exists(fileLegacyDefaults)) + { + File protocolDir = FileUtil.appendName(protocolRootDir, getName()); + fileLegacyDefaults.renameTo(FileUtil.appendName(protocolDir, getDefaultParametersFileName())); + } + } + + /** + * Override to set a custom validator. + * + * @return a parser for working with a parameter stream + */ + public ParamParser createParamParser() + { + return PipelineJobService.get().createParamParser(); + } + + public abstract T createProtocolInstance(String name, String description, String xml, Container container); + + protected T createProtocolInstance(ParamParser parser, Container container) + { + // Remove the pipeline specific parameters. + String name = parser.removeInputParameter(PipelineJob.PIPELINE_PROTOCOL_NAME_PARAM); + String description = parser.removeInputParameter(PipelineJob.PIPELINE_PROTOCOL_DESCRIPTION_PARAM); + String folder = parser.removeInputParameter(PipelineJob.PIPELINE_LOAD_FOLDER_PARAM); + String email = parser.removeInputParameter(PipelineJob.PIPELINE_EMAIL_ADDRESS_PARAM); + + T instance = createProtocolInstance(name, description, parser.getXML(), container); + + instance.setEmail(email); + + return instance; + } + + @Override + public T load(PipeRoot root, String name, boolean archived) throws IOException + { + T instance = loadInstance(getProtocolFile(root, name, archived), root.getContainer()); + + // Don't allow the XML to override the name passed in. This + // can be extremely confusing. + instance.setName(name); + return instance; + } + + public T loadInstance(FileLike file, Container container) throws IOException + { + ParamParser parser = createParamParser(); + try (InputStream is = file.openInputStream()) + { + parser.parse(is); + if (parser.getErrors() != null) + { + ParamParser.Error err = parser.getErrors()[0]; + if (err.getLine() == 0) + { + throw new IOException("Failed parsing input parameters '" + file + "'.\n" + + err.getMessage()); + } + else + { + throw new IOException("Failed parsing input parameters '" + file + "'.\n" + + "Line " + err.getLine() + ": " + err.getMessage()); + } + } + + return createProtocolInstance(parser, container); + } + } + + public String getDefaultParametersXML(PipeRoot root) throws IOException + { + FileLike fileDefault = getDefaultParametersFile(root); + if (!fileDefault.exists()) + return null; + + return new FileDefaultsReader(fileDefault).readXML(); + } + + protected static class FileDefaultsReader extends DefaultsReader + { + private final FileLike _fileDefaults; + + public FileDefaultsReader(FileLike fileDefaults) + { + _fileDefaults = fileDefaults; + } + + @Override + public Reader createReader() throws IOException + { + return Readers.getReader(_fileDefaults.openInputStream()); + } + } + + abstract protected static class DefaultsReader + { + abstract public Reader createReader() throws IOException; + + public String readXML() throws IOException + { + try (BufferedReader reader = new BufferedReader(createReader())) + { + return PageFlowUtil.getReaderContentsAsString(reader); + } + catch (FileNotFoundException enf) + { + _log.error("Default parameters file missing. Check product setup.", enf); + throw enf; + } + catch (IOException eio) + { + _log.error("Error reading default parameters file.", eio); + throw eio; + } + } + } + + public void setDefaultParametersXML(PipeRoot root, String xml) throws IOException + { + if (xml == null || xml.isEmpty()) + throw new IllegalArgumentException("You must supply default parameters for " + getName() + "."); + + ParamParser parser = createParamParser(); + parser.parse(new ReaderInputStream(new StringReader(xml))); + if (parser.getErrors() != null) + { + ParamParser.Error err = parser.getErrors()[0]; + if (err.getLine() == 0) + throw new IllegalArgumentException(err.getMessage()); + else + throw new IllegalArgumentException("Line " + err.getLine() + ": " + err.getMessage()); + } + + FileLike fileDefault = getDefaultParametersFile(root); + FileUtil.createDirectories(fileDefault.getParent()); + + try (PrintWriter writer = PrintWriters.getPrintWriter(fileDefault.openOutputStream())) + { + writer.write(xml, 0, xml.length()); + } + catch (IOException eio) + { + _log.error("Error writing default parameters file.", eio); + throw eio; + } + } + + public static >, F extends AbstractFileAnalysisProtocolFactory> + F fromFile(Class clazz, File file) + { + List providers = PipelineService.get().getPipelineProviders(); + for (PipelineProvider provider : providers) + { + if (!(clazz.isInstance(provider))) + continue; + + T mprovider = (T) provider; + F factory = mprovider.getProtocolFactory(file); + if (factory != null) + return factory; + } + + // TODO: Return some default? + return null; + } + + @Nullable + public AbstractFileAnalysisProtocol getProtocol(PipeRoot root, FileLike dirData, String protocolName, boolean archived) + { + try + { + FileLike protocolFile = getParametersFile(dirData, protocolName, root); + AbstractFileAnalysisProtocol result; + if (NetworkDrive.exists(protocolFile)) + { + result = loadInstance(protocolFile, root.getContainer()); + + // Don't allow the instance file to override the protocol name. + result.setName(protocolName); + } + else + { + protocolFile = getProtocolFile(root, protocolName, archived); + if (protocolFile == null || !protocolFile.exists()) + return null; + + result = load(root, protocolName, archived); + } + return result; + } + catch (IOException|InvalidPathException e) + { + _log.warn("Error loading protocol file.", e); + return null; + } + } + +} diff --git a/api/src/org/labkey/api/pipeline/file/FileAnalysisJobSupport.java b/api/src/org/labkey/api/pipeline/file/FileAnalysisJobSupport.java index 52c9752230a..82d9d9bfddd 100644 --- a/api/src/org/labkey/api/pipeline/file/FileAnalysisJobSupport.java +++ b/api/src/org/labkey/api/pipeline/file/FileAnalysisJobSupport.java @@ -1,193 +1,174 @@ -/* - * Copyright (c) 2008-2017 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.api.pipeline.file; - -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.labkey.api.pipeline.ParamParser; -import org.labkey.api.util.FileType; - -import java.io.File; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -/** - * FileAnalysisJobSupport - * - * @author brendanx - */ -public interface FileAnalysisJobSupport -{ - /** - * @return protocol name of the current protocol. - */ - String getProtocolName(); - - /** - * @return the base name for the full set of files. - */ - String getJoinedBaseName(); - - /** - * @return the base names for all the split input files, or just this - * job's single base name in an array, if this is a split job. - */ - List getSplitBaseNames(); - - /** - * @return base name of the original input file. - */ - String getBaseName(); - - /** - * @param fileType The file type to compare - * @return base name for the specified FileType - */ - String getBaseNameForFileType(FileType fileType); - - /** - * @return the directory in which the original input file resides. - */ - @Deprecated //Prefer the getDataDirectoryPath version as File return type doesn't support full URIs very well - File getDataDirectory(); - default Path getDataDirectoryPath() - { - // TODO This needs implementation in derived classes... - // This is typically safe but may cause an error if FileSystem provider isn't configured - return getDataDirectory().toPath(); - } - - /** - * @return the directory where the input files reside, and where the - * final analysis should end up. - */ - @Deprecated // Please use getAnalysisDirectoryPath instead, as File objects may have issues with full URIs - File getAnalysisDirectory(); - default Path getAnalysisDirectoryPath() - { - // TODO This needs implementation in derived classes... - // This is typically safe but may cause an error if FileSystem provider isn't configured - return getAnalysisDirectory().toPath(); - } - - /** - * Returns a file for use as input in the pipeline, given its name. - * This allows the task definitions to name files they require as input, - * and the pipeline definition to specify where those files should come from. - */ - @Deprecated // Please use findInputPath instead, as File objects may have issues with full URIs - File findInputFile(String name); - default Path findInputPath(String filepath) - { - // TODO This needs implementation in derived classes... - // This is typically safe but may cause an error if FileSystem provider isn't configured - return findInputFile(filepath).toPath(); - } - - /** - * Returns a file for use as output in the pipeline, given its name. - * This allows the task definitions to name files they create as output, - * and the pipeline definition to specify where those files should end up. - */ - @Deprecated //Please switch to use findOutputPath - File findOutputFile(String name); //TODO update implementations to return nio.Path directly - default Path findOutputPath(String name) - { - //This is generally safe, but may fail if the appropriate filesystem providers are not registered. - return findOutputFile(name).toPath(); - } - - /** - * Returns a file for the output dir and file name. - * The output dir is a directory path relative to the analysis directory, - * or, if the path starts with "/", relative to the pipeline root. - */ - @Deprecated //Please switch to use findOutputPath - File findOutputFile(@NotNull String outputDir, @NotNull String fileName); - default Path findOutputPath(@NotNull String outputDir, @NotNull String filename) - { - //This is generally safe, but may fail if the appropriate filesystem providers are not registered. - return findOutputFile(outputDir, filename).toPath(); - } - - /** - * @return a parameter parser object for writing parameters to a file. - */ - ParamParser createParamParser(); - - /** - * @return name-value map of the BioML parameters. - */ - Map getParameters(); - - /** - * @return the parameters input file used to drive the pipeline. - */ - @Nullable - @Deprecated //Use Path based versions - File getParametersFile(); - - /** - * @return the job info file used to provide the external executable or script task with input file context. - */ - @Nullable - @Deprecated //Use Path based versions - File getJobInfoFile(); - - /** - * @return a list of all input files analyzed. - */ - @Deprecated - List getInputFiles(); - - - /** - * @return the parameters input file used to drive the pipeline. - */ - @Nullable - default Path getParametersFilePath() - { - //Implemented as such for backwards compatibility - return getParametersFile() == null ? null : getParametersFile().toPath(); - } - - /** - * @return the job info file used to provide the external executable or script task with input file context. - */ - @Nullable - default Path getJobInfoFilePath() - { - //Implemented as such for backwards compatibility - return getJobInfoFile() == null? null : getJobInfoFile().toPath(); - } - - default List getInputFilePaths() - { - //Implemented as such for backwards compatibility - return getInputFiles().stream().map(File::toPath).collect(Collectors.toList()); - } - - /** - * returns support level for .xml.gz handling: - * SUPPORT_GZ or PREFER_GZ - * we always read .xml.gz, but may also have a - * preference for producing it in the pipeline - */ - FileType.gzSupportLevel getGZPreference(); - -} +/* + * Copyright (c) 2008-2017 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.api.pipeline.file; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.labkey.api.pipeline.ParamParser; +import org.labkey.api.util.FileType; +import org.labkey.vfs.FileLike; +import org.labkey.vfs.FileSystemLike; + +import java.io.File; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * FileAnalysisJobSupport + * + * @author brendanx + */ +public interface FileAnalysisJobSupport +{ + /** + * @return protocol name of the current protocol. + */ + String getProtocolName(); + + /** + * @return the base name for the full set of files. + */ + String getJoinedBaseName(); + + /** + * @return the base names for all the split input files, or just this + * job's single base name in an array, if this is a split job. + */ + List getSplitBaseNames(); + + /** + * @return base name of the original input file. + */ + String getBaseName(); + + /** + * @param fileType The file type to compare + * @return base name for the specified FileType + */ + String getBaseNameForFileType(FileType fileType); + + /** + * @return the directory in which the original input file resides. + */ + @Deprecated //Prefer the getDataDirectoryPath version as File return type doesn't support full URIs very well + File getDataDirectory(); + default Path getDataDirectoryPath() + { + // TODO This needs implementation in derived classes... + // This is typically safe but may cause an error if FileSystem provider isn't configured + return getDataDirectory().toPath(); + } + + /** + * @return the directory where the input files reside, and where the + * final analysis should end up. + */ + @Deprecated // Please use getAnalysisDirectoryPath instead, as File objects may have issues with full URIs + File getAnalysisDirectory(); + default Path getAnalysisDirectoryPath() + { + // TODO This needs implementation in derived classes... + // This is typically safe but may cause an error if FileSystem provider isn't configured + return getAnalysisDirectory().toPath(); + } + + default FileLike getAnalysisDirectoryFileLike() + { + // TODO This needs implementation in derived classes... + // This is typically safe but may cause an error if FileSystem provider isn't configured + return FileSystemLike.wrapFile(getAnalysisDirectory()); + } + + /** + * Returns a file for use as input in the pipeline, given its name. + * This allows the task definitions to name files they require as input, + * and the pipeline definition to specify where those files should come from. + */ + @Deprecated // Please use findInputPath instead, as File objects may have issues with full URIs + File findInputFile(String name); + default Path findInputPath(String filepath) + { + // TODO This needs implementation in derived classes... + // This is typically safe but may cause an error if FileSystem provider isn't configured + return findInputFile(filepath).toPath(); + } + + /** + * Returns a file for use as output in the pipeline, given its name. + * This allows the task definitions to name files they create as output, + * and the pipeline definition to specify where those files should end up. + */ + @Deprecated //Please switch to use findOutputPath + File findOutputFile(String name); //TODO update implementations to return nio.Path directly + default Path findOutputPath(String name) + { + //This is generally safe, but may fail if the appropriate filesystem providers are not registered. + return findOutputFile(name).toPath(); + } + + /** + * Returns a file for the output dir and file name. + * The output dir is a directory path relative to the analysis directory, + * or, if the path starts with "/", relative to the pipeline root. + */ + @Deprecated //Please switch to use findOutputPath + File findOutputFile(@NotNull String outputDir, @NotNull String fileName); + default Path findOutputPath(@NotNull String outputDir, @NotNull String filename) + { + //This is generally safe, but may fail if the appropriate filesystem providers are not registered. + return findOutputFile(outputDir, filename).toPath(); + } + + /** + * @return a parameter parser object for writing parameters to a file. + */ + ParamParser createParamParser(); + + /** + * @return name-value map of the BioML parameters. + */ + Map getParameters(); + + /** + * @return the parameters input file used to drive the pipeline. + */ + @Nullable + @Deprecated //Use Path based versions + File getParametersFile(); + + /** + * @return a list of all input files analyzed. + */ + @Deprecated + List getInputFiles(); + + default List getInputFilePaths() + { + //Implemented as such for backwards compatibility + return getInputFiles().stream().map(File::toPath).collect(Collectors.toList()); + } + + /** + * returns support level for .xml.gz handling: + * SUPPORT_GZ or PREFER_GZ + * we always read .xml.gz, but may also have a + * preference for producing it in the pipeline + */ + FileType.gzSupportLevel getGZPreference(); + +} diff --git a/api/src/org/labkey/api/pipeline/file/FileAnalysisTaskPipeline.java b/api/src/org/labkey/api/pipeline/file/FileAnalysisTaskPipeline.java index 04360c27cba..803d5eabea5 100644 --- a/api/src/org/labkey/api/pipeline/file/FileAnalysisTaskPipeline.java +++ b/api/src/org/labkey/api/pipeline/file/FileAnalysisTaskPipeline.java @@ -1,113 +1,121 @@ -/* - * Copyright (c) 2008-2018 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.api.pipeline.file; - -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.labkey.api.data.Container; -import org.labkey.api.formSchema.FormSchema; -import org.labkey.api.pipeline.PipelineActionConfig; -import org.labkey.api.pipeline.TaskPipeline; -import org.labkey.api.util.FileType; -import org.labkey.api.util.ReturnURLString; -import org.labkey.api.util.URLHelper; - -import java.io.File; -import java.io.FileFilter; -import java.nio.file.DirectoryStream; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; - -/** - * FileAnalysisTaskPipeline - * A filter interface that implements both the io.FileFilter and nio.DirectoryStream.Filter interfaces - */ -public interface FileAnalysisTaskPipeline extends TaskPipeline -{ - interface FilePathFilter extends FileFilter, DirectoryStream.Filter - { - @Override - boolean accept(File file); - - @Override - boolean accept(Path path); - } - - - /** - * Returns the name of the protocol factory for this pipeline, which - * will be used as the root directory name for all analyses of this type - * and the directory name of the saved system protocol XML files. - * - * @return the name of the protocol factory - */ - String getProtocolFactoryName(); - - /** - * Returns the full list of acceptable file types that can be used to - * start this pipeline. - * - * @return list containing acceptable initial file types - */ - @NotNull - List getInitialFileTypes(); - - /** - * Returns a FileFilter for use in creating an input file set. - * - * @return filter for input files - */ - @NotNull - FilePathFilter getInitialFileTypeFilter(); - - @NotNull - URLHelper getAnalyzeURL(Container c, String path, @Nullable ReturnURLString parsedReturnUrl); - - @NotNull - Map> getTypeHierarchy(); - - @Nullable - PipelineActionConfig.displayState getDefaultDisplayState(); - - boolean isAllowForTriggerConfiguration(); - - /** - * Write out the job info as a tsv file similar to the R transformation runProperties format. - * This is a info file for an entire job (or split job) that command line or script tasks may use - * to determine the inputs files and other job related metadata. - * - * @see org.labkey.api.pipeline.file.AbstractFileAnalysisJob#writeJobInfoTSV(java.io.File) - * @see org.labkey.api.qc.TsvDataExchangeHandler - * @link https://www.labkey.org/Documentation/wiki-page.view?name=runProperties - */ - boolean isWriteJobInfoFile(); - - /** - * Allow the job to be split if there are multiple file inputs. - */ - boolean isSplittable(); - - String getHelpText(); - - Boolean isMoveAvailable(); - - Boolean isInitialFileTypesRequired(); - - FormSchema getFormSchema(); - - FormSchema getCustomFieldsFormSchema(); -} +/* + * Copyright (c) 2008-2018 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.api.pipeline.file; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.labkey.api.data.Container; +import org.labkey.api.formSchema.FormSchema; +import org.labkey.api.pipeline.PipelineActionConfig; +import org.labkey.api.pipeline.TaskPipeline; +import org.labkey.api.util.FileType; +import org.labkey.api.util.ReturnURLString; +import org.labkey.api.util.URLHelper; +import org.labkey.vfs.FileLike; + +import java.io.File; +import java.io.FileFilter; +import java.nio.file.DirectoryStream; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.function.Predicate; + +/** + * FileAnalysisTaskPipeline + * A filter interface that implements both the io.FileFilter and nio.DirectoryStream.Filter interfaces + */ +public interface FileAnalysisTaskPipeline extends TaskPipeline +{ + interface FilePathFilter extends FileFilter, DirectoryStream.Filter, Predicate + { + @Override + boolean accept(File file); + + @Override + boolean accept(Path path); + + @Override + default boolean test(FileLike fileLike) + { + return accept(fileLike.toNioPathForRead()); + } + } + + + /** + * Returns the name of the protocol factory for this pipeline, which + * will be used as the root directory name for all analyses of this type + * and the directory name of the saved system protocol XML files. + * + * @return the name of the protocol factory + */ + String getProtocolFactoryName(); + + /** + * Returns the full list of acceptable file types that can be used to + * start this pipeline. + * + * @return list containing acceptable initial file types + */ + @NotNull + List getInitialFileTypes(); + + /** + * Returns a FileFilter for use in creating an input file set. + * + * @return filter for input files + */ + @NotNull + FilePathFilter getInitialFileTypeFilter(); + + @NotNull + URLHelper getAnalyzeURL(Container c, String path, @Nullable ReturnURLString parsedReturnUrl); + + @NotNull + Map> getTypeHierarchy(); + + @Nullable + PipelineActionConfig.displayState getDefaultDisplayState(); + + boolean isAllowForTriggerConfiguration(); + + /** + * Write out the job info as a tsv file similar to the R transformation runProperties format. + * This is a info file for an entire job (or split job) that command line or script tasks may use + * to determine the inputs files and other job related metadata. + * + * @see org.labkey.api.pipeline.file.AbstractFileAnalysisJob#writeJobInfoTSV(java.io.File) + * @see org.labkey.api.qc.TsvDataExchangeHandler + * @link https://www.labkey.org/Documentation/wiki-page.view?name=runProperties + */ + boolean isWriteJobInfoFile(); + + /** + * Allow the job to be split if there are multiple file inputs. + */ + boolean isSplittable(); + + String getHelpText(); + + Boolean isMoveAvailable(); + + Boolean isInitialFileTypesRequired(); + + FormSchema getFormSchema(); + + FormSchema getCustomFieldsFormSchema(); +} diff --git a/api/src/org/labkey/api/study/SpecimenTransform.java b/api/src/org/labkey/api/study/SpecimenTransform.java index d00b7731f19..65531c925ae 100644 --- a/api/src/org/labkey/api/study/SpecimenTransform.java +++ b/api/src/org/labkey/api/study/SpecimenTransform.java @@ -1,86 +1,87 @@ -/* - * Copyright (c) 2013-2016 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.api.study; - -import org.jetbrains.annotations.Nullable; -import org.labkey.api.data.Container; -import org.labkey.api.pipeline.PipelineJob; -import org.labkey.api.pipeline.PipelineJobException; -import org.labkey.api.query.ValidationException; -import org.labkey.api.security.User; -import org.labkey.api.util.FileType; -import org.labkey.api.view.ActionURL; - -import java.io.File; -import java.nio.file.Path; - -/** - * User: klum - * Date: 11/12/13 - */ -public interface SpecimenTransform -{ - /** - * Returns the descriptive name - */ - String getName(); - - /** - * Returns whether module containing transform is present for a container - */ - boolean isValid(Container container); - - /** - * Returns whether transform is active for a container - */ - boolean isActive(Container container); - - /** - * Returns the file type that this transform can accept - */ - FileType getFileType(); - - void transform(@Nullable PipelineJob job, Path input, Path outputArchive) throws PipelineJobException; - - /** - * An optional post transform step. - */ - void postTransform(@Nullable PipelineJob job, File input, File outputArchive) throws PipelineJobException; - - @Nullable - ActionURL getManageAction(Container c, User user); - - /** - * Returns and saved configuration information - */ - ExternalImportConfig getExternalImportConfig(Container c, User user) throws ValidationException; - - /** - * An optional capability to import from an external (API) source, data that can be transformed into - * a LabKey compatible specimen archive - * - * @param importConfig configuration object - * @param inputArchive the file to write the externally sourced data into - */ - void importFromExternalSource(@Nullable PipelineJob job, ExternalImportConfig importConfig, File inputArchive) throws PipelineJobException; - - interface ExternalImportConfig - { - String getBaseServerUrl(); - String getUsername(); - String getPassword(); - } -} +/* + * Copyright (c) 2013-2016 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.api.study; + +import org.jetbrains.annotations.Nullable; +import org.labkey.api.data.Container; +import org.labkey.api.pipeline.PipelineJob; +import org.labkey.api.pipeline.PipelineJobException; +import org.labkey.api.query.ValidationException; +import org.labkey.api.security.User; +import org.labkey.api.util.FileType; +import org.labkey.api.view.ActionURL; +import org.labkey.vfs.FileLike; + +import java.io.File; +import java.nio.file.Path; + +/** + * User: klum + * Date: 11/12/13 + */ +public interface SpecimenTransform +{ + /** + * Returns the descriptive name + */ + String getName(); + + /** + * Returns whether module containing transform is present for a container + */ + boolean isValid(Container container); + + /** + * Returns whether transform is active for a container + */ + boolean isActive(Container container); + + /** + * Returns the file type that this transform can accept + */ + FileType getFileType(); + + void transform(@Nullable PipelineJob job, Path input, Path outputArchive) throws PipelineJobException; + + /** + * An optional post transform step. + */ + void postTransform(@Nullable PipelineJob job, File input, File outputArchive) throws PipelineJobException; + + @Nullable + ActionURL getManageAction(Container c, User user); + + /** + * Returns and saved configuration information + */ + ExternalImportConfig getExternalImportConfig(Container c, User user) throws ValidationException; + + /** + * An optional capability to import from an external (API) source, data that can be transformed into + * a LabKey compatible specimen archive + * + * @param importConfig configuration object + * @param inputArchive the file to write the externally sourced data into + */ + void importFromExternalSource(@Nullable PipelineJob job, ExternalImportConfig importConfig, FileLike inputArchive) throws PipelineJobException; + + interface ExternalImportConfig + { + String getBaseServerUrl(); + String getUsername(); + String getPassword(); + } +} diff --git a/api/src/org/labkey/api/util/FileType.java b/api/src/org/labkey/api/util/FileType.java index 680795aafaa..584a9deb082 100644 --- a/api/src/org/labkey/api/util/FileType.java +++ b/api/src/org/labkey/api/util/FileType.java @@ -1,786 +1,801 @@ -/* - * Copyright (c) 2008-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.api.util; - -import org.apache.commons.io.IOCase; -import org.apache.tika.detect.DefaultDetector; -import org.apache.tika.detect.Detector; -import org.apache.tika.io.TikaInputStream; -import org.apache.tika.metadata.Metadata; -import org.apache.tika.mime.MediaType; -import org.apache.tika.mime.MimeTypes; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.junit.Assert; -import org.junit.Test; -import org.labkey.api.pipeline.file.FileAnalysisJobSupport; -import org.labkey.vfs.FileLike; - -import java.io.File; -import java.io.IOException; -import java.io.Serializable; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; - -/** - * FileType - * - * @author brendanx - */ -public class FileType implements Serializable -{ - private static final Detector DETECTOR = new DefaultDetector(MimeTypes.getDefaultMimeTypes()); - - // For serialization - protected FileType() {} - - public File findInputFile(FileAnalysisJobSupport support, String baseName) - { - if (_suffixes.size() > 1) - { - for (String suffix : _suffixes) - { - File f = support.findInputFile(baseName + suffix); - if (f != null && NetworkDrive.exists(f)) - { - return f; - } - } - } - - return support.findInputFile(getDefaultName(baseName)); - } - - /** handle TPP's native use of .xml.gz **/ - public enum gzSupportLevel - { - NO_GZ, // we don't support gzip for this filetype - SUPPORT_GZ, // we support gzip for this filetype, but it's not the norm - PREFER_GZ // we support gzip for this filetype, and it's the default for new files - } - - /** A list of possible suffixes in priority order. Later suffixes may also match earlier suffixes */ - private List _suffixes; - /** a list of filetypes to reject - handles the scenario where old pepxml files are "foo.xml" and - * we have to avoid grabbing "foo.pep-prot.xml" - */ - private List _antiTypes; - /** The canonical suffix, will be used when creating new files from scratch */ - private String _defaultSuffix; - - /** Mime content type. */ - private List _contentTypes; - - private Boolean _dir; - /** If _preferGZ is true, assume suffix.gz for new files to support TPP's transparent .xml.gz useage. - * When dealing with existing files, non-gz version is still assumed to be the target if found **/ - private Boolean _preferGZ; - /** If _supportGZ is true, accept .suffix.gz as the equivalent of .suffix **/ - private Boolean _supportGZ; - private boolean _caseSensitiveOnCaseSensitiveFileSystems = false; - - /** - * true if the different file extensions are just transformed versions of the same data (such as .raw and .mzXML) - * and therefore if multiple are present only the first should be considered for actions in the UI. - * false if they are independent and should all be considered actionable - */ - private boolean _extensionsMutuallyExclusive = true; - - /** - * Constructor to use when type is assumed to be a file, but a call to isDirectory() - * is not necessary. - * - * @param supportGZ for handling of TPP's transparent use of .xml.gz - * @param suffix usually the file extension, but may be some other suffix to - * uniquely identify a file type - * - */ - public FileType(String suffix, gzSupportLevel supportGZ) - { - this(Arrays.asList(suffix), suffix, supportGZ); - } - - /** - * Constructor to use when type is assumed to be a file, but a call to isDirectory() - * is not necessary. - * - * @param suffix usually the file extension, but may be some other suffix to - * uniquely identify a file type - * - */ - public FileType(String suffix) - { - this(Arrays.asList(suffix), suffix); - } - - /** - * Constructor to use when a call to isDirectory() is necessary to differentiate this - * file type. - * - * @param suffix usually the file extension, but may be some other suffix to - * uniquely identify a file type - * @param dir true when the type must be a directory - */ - public FileType(String suffix, boolean dir) - { - this(Arrays.asList(suffix), suffix, dir, gzSupportLevel.NO_GZ); - } - - /** - * @param suffixes list of what are usually the file extensions (but may be some other suffix to - * uniquely identify a file type), in priority order. The first suffix that matches a file will be used - * and files that match the rest of the suffixes will be ignored - * @param defaultSuffix the canonical suffix, will be used when creating new files from scratch - */ - public FileType(List suffixes, String defaultSuffix) - { - this(suffixes, defaultSuffix, false, gzSupportLevel.NO_GZ); - } - - /** - * @param suffixes list of what are usually the file extensions (but may be some other suffix to - * uniquely identify a file type), in priority order. The first suffix that matches a file will be used - * and files that match the rest of the suffixes will be ignored - * @param defaultSuffix the canonical suffix, will be used when creating new files from scratch - * @param contentTypes Content types for this file type. If null, a content type will be guessed based on the extension. - */ - public FileType(List suffixes, String defaultSuffix, List contentTypes) - { - this(suffixes, defaultSuffix, false, gzSupportLevel.NO_GZ, contentTypes); - } - - /** - * @param suffixes list of what are usually the file extensions (but may be some other suffix to - * uniquely identify a file type), in priority order. The first suffix that matches a file will be used - * and files that match the rest of the suffixes will be ignored - * @param defaultSuffix the canonical suffix, will be used when creating new files from scratch - * @param dir true when the type must be a directory - */ - public FileType(List suffixes, String defaultSuffix, boolean dir) - { - this(suffixes, defaultSuffix, dir, gzSupportLevel.NO_GZ); - } - - /** - * @param suffixes list of what are usually the file extensions (but may be some other suffix to - * uniquely identify a file type), in priority order. The first suffix that matches a file will be used - * and files that match the rest of the suffixes will be ignored - * @param defaultSuffix the canonical suffix, will be used when creating new files from scratch - * @param dir true when the type must be a directory - * @param supportGZ for handling TPP's transparent use of .xml.gz - */ - public FileType(List suffixes, String defaultSuffix, boolean dir, gzSupportLevel supportGZ) - { - this(suffixes, defaultSuffix, dir, supportGZ, null); - } - - - /** - * @param suffixes list of what are usually the file extensions (but may be some other suffix to - * uniquely identify a file type), in priority order. The first suffix that matches a file will be used - * and files that match the rest of the suffixes will be ignored - * @param defaultSuffix the canonical suffix, will be used when creating new files from scratch - * @param doSupportGZ for handling TPP's transparent use of .xml.gz - */ - public FileType(List suffixes, String defaultSuffix, gzSupportLevel doSupportGZ) - { - this(suffixes, defaultSuffix, false, doSupportGZ, null); - } - - /** - * @param suffixes list of what are usually the file extensions (but may be some other suffix to - * uniquely identify a file type), in priority order. The first suffix that matches a file will be used - * and files that match the rest of the suffixes will be ignored - * @param defaultSuffix the canonical suffix, will be used when creating new files from scratch - * @param doSupportGZ for handling TPP's transparent use of .xml.gz - * @param contentTypes Content types for this file type. If null, a content type will be guessed based on the extension. - */ - public FileType(List suffixes, String defaultSuffix, boolean dir, gzSupportLevel doSupportGZ, List contentTypes) - { - _suffixes = suffixes; - supportGZ(doSupportGZ); - _defaultSuffix = defaultSuffix; - _dir = Boolean.valueOf(dir); - _antiTypes = new ArrayList<>(0); - if (!suffixes.contains(defaultSuffix)) - { - throw new IllegalArgumentException("List of suffixes " + _suffixes + " does not contain the preferred suffix:" + _defaultSuffix); - } - - if (contentTypes == null) - { - MimeMap mm = new MimeMap(); - String contentType = mm.getContentType(defaultSuffix); - if (contentType != null) - _contentTypes = Collections.singletonList(contentType); - else - _contentTypes = Collections.emptyList(); - } - else - { - _contentTypes = Collections.unmodifiableList(new ArrayList<>(contentTypes)); - } - } - - /** helper for supporting TPP's use of .xml.gz */ - private String tryName(Path parentDir, String name) - { - if (_supportGZ.booleanValue()) // TPP treats xml.gz as a native format - { - FileUtil.legalPathPartThrow(name); - // in the case of existing files, non-gz copy wins if present - Path f = parentDir!=null ? FileUtil.appendName(parentDir, name) : Path.of(name); - if (!NetworkDrive.exists(f)) - { // non-gz copy doesn't exist - how about .gz version? - String gzname = name + ".gz"; - if (_preferGZ.booleanValue()) - { // we like .gz for new filenames, so don't care if exists - return gzname; - } - f = parentDir!=null ? FileUtil.appendName(parentDir, gzname) : Path.of(gzname); - if (NetworkDrive.exists(f)) - { // we don't prefer .gz, but we support it if it exists - return gzname; - } - } - } - return name; - } - - /** Uses the preferred suffix, useful when there's not a directory of existing files to reference */ - /** if _preferGZ is set, will use preferred suffix.gz since TPP treats .gz as native format, - * unless non-gz file exists */ - public String getDefaultName(String basename) - { - return tryName(null, basename + _defaultSuffix); - } - - /** - * turn support for gzipped files on and off - */ - public boolean supportGZ(gzSupportLevel doSupportGZ) - { - _supportGZ = Boolean.valueOf(doSupportGZ != gzSupportLevel.NO_GZ); - _preferGZ = Boolean.valueOf(doSupportGZ == gzSupportLevel.PREFER_GZ); - return _supportGZ.booleanValue(); - } - - /** - * add a new supported suffix, return new list length - */ - public int addSuffix(String newsuffix) - { - List s = new ArrayList<>(_suffixes.size()+1); - for (String suffix : _suffixes) - { - s.add(suffix); - } - s.add(newsuffix); - _suffixes = s; - return _suffixes.size(); - } - - /** - * add a new filetype to reject, return new list length - */ - public int addAntiFileType(FileType anti) - { - List s = new ArrayList<>(_antiTypes.size()+1); - for (FileType a : _antiTypes) - { - s.add(a); - } - s.add(anti); - _antiTypes = s; - return _antiTypes.size(); - } - - // used to avoid, for example, mistaking protxml ".pep-prot.xml" for pepxml ".xml" file - private boolean isAntiFileType(String name, byte[] header) - { - for (FileType a : _antiTypes) - { - if (a.isType(name)) - { - return true; - } - } - return false; - } - - /** - * Looks for a file in the parentDir that matches, in priority order. If one is found, returns its file name. - * If nothing matches, uses the defaultSuffix to build a file name. - */ - public String getName(File parentDir, String basename) - { - return getName(parentDir.toPath(), basename); - } - - public String getName(Path parentDir, String basename) - { - if (_suffixes.size() > 1) - { - // Only bother checking if we have more than one possible suffix - for (String suffix : _suffixes) - { - String name = tryName(parentDir, basename + suffix); - Path f = FileUtil.appendName(parentDir, name); - if (NetworkDrive.exists(f)) - { - // avoid, for example, mistaking protxml ".pep-prot.xml" for pepxml ".xml" file - if (!isAntiFileType(name, null)) - { - return name; - } - } - } - } - return tryName(parentDir, basename + _defaultSuffix); - } - - public String getName(String parentDirName, String basename) - { - File parentDir = new File(parentDirName); - return getName(parentDir,basename); - } - - /** - * Looks for a file in the parentDir that matches, in priority order. If one is found, returns its file name. - * If nothing matches, uses the defaultSuffix to build a file name. - */ - @Deprecated //please switch to using the nio.Path version as the File class can have issues using full URIs - public File getFile(File parentDir, String basename) - { - return new File(parentDir, getName(parentDir, basename)); - } - - public Path getPath(Path parentDir, String basename) - { - return FileUtil.appendName(parentDir, getName(parentDir, basename)); - } - - /** - * @return the index of the first suffix that matches. Useful when looking through a directory of files and - * determining which is the preferred file for this FileType. - */ - public int getIndexMatch(Path file) - { - return getIndexMatch(file.getFileName().toString(), file.toString()); - } - - private int getIndexMatch(String filename, String filePath) - { - if (!isAntiFileType(filename, null)) // avoid, for example, mistaking .pep-prot.xml for .xml - { - for (int i = 0; i < _suffixes.size(); i++) - { - String s = toLowerIfCaseInsensitive(_suffixes.get(i)); - if (toLowerIfCaseInsensitive(filename).endsWith(s)) - { - return i; - } - // TPP treats .xml.gz as a native format - if (_supportGZ.booleanValue() && toLowerIfCaseInsensitive(filename).endsWith(s + ".gz")) - { - return i; - } - } - } - - throw new IllegalArgumentException("No match found for " + filePath + " with " + this); - } - - private String toLowerIfCaseInsensitive(String s) - { - if (s == null) - { - return null; - } - if (_caseSensitiveOnCaseSensitiveFileSystems && IOCase.SYSTEM.isCaseSensitive()) - { - return s; - } - return s.toLowerCase(); - } - - /** - * Finds the best suffix based on priority order, strips it off, and returns the remainder. If there is no matching - * suffix, returns the original file name. - */ - public String getBaseName(File file) - { - return getBaseName(file.toPath()); - } - - public String getBaseName(@NotNull java.nio.file.Path file) - { - String fileName = file.getFileName().toString(); - if (isAntiFileType(fileName, null) || !isType(file)) - return fileName; - - String suffix = null; - for (String s : _suffixes) - { - // run the entire list in order to assure strongest match - // consider .msprefix.mzxml vs .mzxml for example - if (toLowerIfCaseInsensitive(fileName).endsWith(toLowerIfCaseInsensitive(s))) - { - if ((null==suffix) || (s.length()>suffix.length())) - { - suffix = s; - } - } - else if (_supportGZ.booleanValue()) // TPP treats .xml.gz as a native read format - { - String sgz = s+".gz"; - if (fileName.endsWith(sgz)) - { - if ((null==suffix) || (sgz.length()>suffix.length())) - { - suffix = sgz; - } - } - } - } - assert suffix != null : "Could not find matching suffix even though types match"; - return fileName.substring(0, fileName.length() - suffix.length()); - } - - public File newFile(File parent, String basename) - { - return FileUtil.appendName(parent, getName(parent, basename)); - } - - public Path newFile(Path parent, String basename) - { - return FileUtil.appendName(parent, getName(parent, basename)); - } - - public boolean isType(File file) - { - return isType(file, null, null); - } - - public boolean isType(FileLike file) - { - return isType(file.toNioPathForRead(), null, null); - } - - public boolean isType(java.nio.file.Path path) - { - return isType(path, null, null); - } - - public boolean isType(java.nio.file.Path path, String contentType, byte[] header) - { - if ((path == null) || (_dir != null && _dir.booleanValue() != Files.isDirectory(path))) - return false; - - return isType(path.getFileName().toString(), contentType, header); - } - - - public boolean isType(File file, String contentType, byte[] header) - { - if ((file == null) || (_dir != null && _dir.booleanValue() != file.isDirectory())) - return false; - - return isType(file.getName(), contentType, header); - } - - /** - * Checks if the path matches any of the suffixes - */ - public boolean isType(String filePath) - { - return isType(filePath, null, null); - } - - /** - * Checks if the path matches any of the suffixes and the file header if provided. - */ - public boolean isType(@Nullable String filePath, @Nullable String contentType, @Nullable byte[] header) - { - String providedContentType = contentType; // Save it for later - - // avoid, for example, mistaking protxml ".pep-prot.xml" for pepxml ".xml" - if (isAntiFileType(filePath, header)) - { - return false; - } - - // Attempt to match by content type. - if (_contentTypes != null) - { - // Use Tika to determine the content type - if (contentType == null && header != null) - contentType = detectContentType(filePath, header); - - if (contentType != null) - { - contentType = contentType.toLowerCase().trim(); - if (_contentTypes.contains(contentType)) - return true; - } - } - - // Attempt to match by suffix and header. - if (filePath != null) - { - filePath = toLowerIfCaseInsensitive(filePath); - for (String suffix : _suffixes) - { - suffix = toLowerIfCaseInsensitive(suffix); - if (filePath.endsWith(suffix)) - { - if (header == null || isHeaderMatch(header)) - return true; - } - // TPP treats .xml.gz as a native format - if (_supportGZ.booleanValue() && filePath.endsWith(suffix + ".gz")) - { - if (header == null || isHeaderMatch(header)) - return true; - } - } - } - - // Attempt to match using just the header, but only if the original content type was null, Issue 47814 - return null == providedContentType && header != null && isHeaderMatch(header); - } - - protected static String detectContentType(String fileName, byte[] header) - { - final Metadata metadata = new Metadata(); - metadata.set("resourceName", fileName); - try (TikaInputStream is = TikaInputStream.get(header, metadata)) - { - MediaType mediaType = DETECTOR.detect(is, metadata); - if (mediaType != null) - return mediaType.toString(); - - return null; - } - catch (IOException e) - { - throw new RuntimeException(e); - } - } - - public boolean isMatch(String name, String basename) - { - for (String suffix : _suffixes) - { - if (name.equalsIgnoreCase(basename + suffix)) - { - return true; - } - // TPP treats .xml.gz as a native format - if (_supportGZ.booleanValue() && name.equals(basename + suffix+".gz")) - { - return true; - } - } - return false; - } - - /** - * Checks if the file header matches. This is useful for FileTypes that share an - * extension, e.g. "txt" or "xml", or when the filename or extension isn't available. - * - * @param header First few K of the file. - * @return True if the header matches, false otherwise. - */ - public boolean isHeaderMatch(@NotNull byte[] header) - { - return false; - } - - public boolean equals(Object o) - { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - - FileType fileType = (FileType) o; - - if (_supportGZ != null ? !_supportGZ.equals(fileType._supportGZ) : fileType._supportGZ != null) return false; - if (_preferGZ != null ? !_preferGZ.equals(fileType._preferGZ) : fileType._preferGZ != null) return false; - if (_dir != null ? !_dir.equals(fileType._dir) : fileType._dir != null) return false; - if (_defaultSuffix != null ? !_defaultSuffix.equals(fileType._defaultSuffix) : fileType._defaultSuffix != null) - return false; - if (_antiTypes != null ? !_antiTypes.equals(fileType._antiTypes) : fileType._antiTypes != null) return false; - return !(_suffixes != null ? !_suffixes.equals(fileType._suffixes) : fileType._suffixes != null); - } - - public String getDefaultSuffix() - { - return _defaultSuffix; - } - - public List getSuffixes() - { - return Collections.unmodifiableList(_suffixes); - } - - public int hashCode() - { - int result; - result = (_suffixes != null ? _suffixes.hashCode() : 0); - result = 31 * result + (_defaultSuffix != null ? _defaultSuffix.hashCode() : 0); - result = 31 * result + (_dir != null ? _dir.hashCode() : 0); - result = 31 * result + (_supportGZ != null ? _supportGZ.hashCode() : 0); - result = 31 * result + (_preferGZ != null ? _preferGZ.hashCode() : 0); - return result; - } - - public String toString() - { - return (_dir == null || !_dir.booleanValue() ? _suffixes.toString() : _suffixes + "/"); - } - - @NotNull - public static List findTypes(@NotNull List types, @NotNull List files) - { - ArrayList foundTypes = new ArrayList<>(); - // This O(n*m), but these are usually very short lists. - for (FileType type : types) - { - for (Path file : files) - { - if (type.isType(file.getFileName().toString())) - { - foundTypes.add(type); - break; - } - } - } - return foundTypes; - } - - /** - * true if the different file extensions are just transformed versions of the same data (such as .raw and .mzXML) - * and therefore if multiple are present only the first should be considered for actions in the UI. - * false if they are independent and should all be considered actionable - */ - public boolean isExtensionsMutuallyExclusive() - { - return _extensionsMutuallyExclusive; - } - - /** - * @param extensionsMutuallyExclusive true if the different file extensions are just transformed versions of the - * same data (such as .raw and .mzXML) and therefore if multiple are present only the first should be - * considered for actions in the UI. - * false if they are independent and should all be considered actionable - */ - public void setExtensionsMutuallyExclusive(boolean extensionsMutuallyExclusive) - { - _extensionsMutuallyExclusive = extensionsMutuallyExclusive; - } - - /** - * @return a FileType that will only match on the default suffix for this FileType - */ - public FileType getDefaultFileType() - { - if (!_suffixes.isEmpty()) - { - FileType ft = new FileType(_defaultSuffix); - ft._dir = _dir; - ft._supportGZ = _supportGZ.booleanValue(); - ft._preferGZ = _preferGZ.booleanValue(); - return ft; - } - else - { - return this; - } - } - - public String getDefaultRole() - { - if (_defaultSuffix.contains(".")) - { - return _defaultSuffix.substring(_defaultSuffix.indexOf(".") + 1); - } - return _defaultSuffix; - } - - public boolean isCaseSensitiveOnCaseSensitiveFileSystems() - { - return _caseSensitiveOnCaseSensitiveFileSystems; - } - - public void setCaseSensitiveOnCaseSensitiveFileSystems(boolean caseSensitiveOnCaseSensitiveFileSystems) - { - _caseSensitiveOnCaseSensitiveFileSystems = caseSensitiveOnCaseSensitiveFileSystems; - } - - public List getContentTypes() - { - return _contentTypes; - } - - public static class TestCase extends Assert - { - @Test - public void test() - { - // simple case - FileType ft = new FileType(".foo"); - assertTrue(ft.isType("test.foo")); - assertTrue(!ft.isType("test.foo.gz")); - assertEquals("test.foo",ft.getDefaultName("test")); - - // support for .gz - FileType ftgz = new FileType(".foo",gzSupportLevel.SUPPORT_GZ); - assertTrue(ftgz.isType("test.foo")); - assertTrue(ftgz.isType("test.foo.gz")); - assertEquals("test.foo",ftgz.getDefaultName("test")); - - // preference for .gz - FileType ftgzgz = new FileType(".foo",gzSupportLevel.PREFER_GZ); - assertTrue(ftgzgz.isType("test.foo")); - assertTrue(ftgzgz.isType("test.foo.gz")); - assertEquals("test.foo.gz",ftgzgz.getDefaultName("test")); - - // multiple extensions - ArrayList foobar = new ArrayList<>(); - foobar.add(".foo"); - foobar.add(".bar"); - FileType ftt = new FileType(foobar,".foo",false,gzSupportLevel.SUPPORT_GZ); - assertTrue(ftt.isType("test.foo")); - assertTrue(ftt.isType("test.bar")); - assertTrue(ftt.isType("test.foo.gz")); - assertTrue(ftt.isType("test.bar.gz")); - assertTrue(ftt.isType("test.bAr.gZ")); // extensions are case insensitive - assertEquals("test.foo",ftt.getDefaultName("test")); - - // antitypes - for example avoid mistaking protxml ".pep-prot.xml" for pepxml ".xml" - assertTrue(ftt.isType("test.foo.bar")); - ftt.addAntiFileType(new FileType(".foo.bar")); - assertTrue(!ftt.isType("test.foo.bar")); - assertTrue(ftt.isType("test.foo")); - assertTrue(ftt.isType("test.bar")); - - } - } -} +/* + * Copyright (c) 2008-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.api.util; + +import org.apache.commons.io.IOCase; +import org.apache.tika.detect.DefaultDetector; +import org.apache.tika.detect.Detector; +import org.apache.tika.io.TikaInputStream; +import org.apache.tika.metadata.Metadata; +import org.apache.tika.mime.MediaType; +import org.apache.tika.mime.MimeTypes; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.junit.Assert; +import org.junit.Test; +import org.labkey.api.pipeline.file.FileAnalysisJobSupport; +import org.labkey.vfs.FileLike; + +import java.io.File; +import java.io.IOException; +import java.io.Serializable; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * FileType + * + * @author brendanx + */ +public class FileType implements Serializable +{ + private static final Detector DETECTOR = new DefaultDetector(MimeTypes.getDefaultMimeTypes()); + + // For serialization + protected FileType() {} + + public File findInputFile(FileAnalysisJobSupport support, String baseName) + { + if (_suffixes.size() > 1) + { + for (String suffix : _suffixes) + { + File f = support.findInputFile(baseName + suffix); + if (f != null && NetworkDrive.exists(f)) + { + return f; + } + } + } + + return support.findInputFile(getDefaultName(baseName)); + } + + /** handle TPP's native use of .xml.gz **/ + public enum gzSupportLevel + { + NO_GZ, // we don't support gzip for this filetype + SUPPORT_GZ, // we support gzip for this filetype, but it's not the norm + PREFER_GZ // we support gzip for this filetype, and it's the default for new files + } + + /** A list of possible suffixes in priority order. Later suffixes may also match earlier suffixes */ + private List _suffixes; + /** a list of filetypes to reject - handles the scenario where old pepxml files are "foo.xml" and + * we have to avoid grabbing "foo.pep-prot.xml" + */ + private List _antiTypes; + /** The canonical suffix, will be used when creating new files from scratch */ + private String _defaultSuffix; + + /** Mime content type. */ + private List _contentTypes; + + private Boolean _dir; + /** If _preferGZ is true, assume suffix.gz for new files to support TPP's transparent .xml.gz useage. + * When dealing with existing files, non-gz version is still assumed to be the target if found **/ + private Boolean _preferGZ; + /** If _supportGZ is true, accept .suffix.gz as the equivalent of .suffix **/ + private Boolean _supportGZ; + private boolean _caseSensitiveOnCaseSensitiveFileSystems = false; + + /** + * true if the different file extensions are just transformed versions of the same data (such as .raw and .mzXML) + * and therefore if multiple are present only the first should be considered for actions in the UI. + * false if they are independent and should all be considered actionable + */ + private boolean _extensionsMutuallyExclusive = true; + + /** + * Constructor to use when type is assumed to be a file, but a call to isDirectory() + * is not necessary. + * + * @param supportGZ for handling of TPP's transparent use of .xml.gz + * @param suffix usually the file extension, but may be some other suffix to + * uniquely identify a file type + * + */ + public FileType(String suffix, gzSupportLevel supportGZ) + { + this(Arrays.asList(suffix), suffix, supportGZ); + } + + /** + * Constructor to use when type is assumed to be a file, but a call to isDirectory() + * is not necessary. + * + * @param suffix usually the file extension, but may be some other suffix to + * uniquely identify a file type + * + */ + public FileType(String suffix) + { + this(Arrays.asList(suffix), suffix); + } + + /** + * Constructor to use when a call to isDirectory() is necessary to differentiate this + * file type. + * + * @param suffix usually the file extension, but may be some other suffix to + * uniquely identify a file type + * @param dir true when the type must be a directory + */ + public FileType(String suffix, boolean dir) + { + this(Arrays.asList(suffix), suffix, dir, gzSupportLevel.NO_GZ); + } + + /** + * @param suffixes list of what are usually the file extensions (but may be some other suffix to + * uniquely identify a file type), in priority order. The first suffix that matches a file will be used + * and files that match the rest of the suffixes will be ignored + * @param defaultSuffix the canonical suffix, will be used when creating new files from scratch + */ + public FileType(List suffixes, String defaultSuffix) + { + this(suffixes, defaultSuffix, false, gzSupportLevel.NO_GZ); + } + + /** + * @param suffixes list of what are usually the file extensions (but may be some other suffix to + * uniquely identify a file type), in priority order. The first suffix that matches a file will be used + * and files that match the rest of the suffixes will be ignored + * @param defaultSuffix the canonical suffix, will be used when creating new files from scratch + * @param contentTypes Content types for this file type. If null, a content type will be guessed based on the extension. + */ + public FileType(List suffixes, String defaultSuffix, List contentTypes) + { + this(suffixes, defaultSuffix, false, gzSupportLevel.NO_GZ, contentTypes); + } + + /** + * @param suffixes list of what are usually the file extensions (but may be some other suffix to + * uniquely identify a file type), in priority order. The first suffix that matches a file will be used + * and files that match the rest of the suffixes will be ignored + * @param defaultSuffix the canonical suffix, will be used when creating new files from scratch + * @param dir true when the type must be a directory + */ + public FileType(List suffixes, String defaultSuffix, boolean dir) + { + this(suffixes, defaultSuffix, dir, gzSupportLevel.NO_GZ); + } + + /** + * @param suffixes list of what are usually the file extensions (but may be some other suffix to + * uniquely identify a file type), in priority order. The first suffix that matches a file will be used + * and files that match the rest of the suffixes will be ignored + * @param defaultSuffix the canonical suffix, will be used when creating new files from scratch + * @param dir true when the type must be a directory + * @param supportGZ for handling TPP's transparent use of .xml.gz + */ + public FileType(List suffixes, String defaultSuffix, boolean dir, gzSupportLevel supportGZ) + { + this(suffixes, defaultSuffix, dir, supportGZ, null); + } + + + /** + * @param suffixes list of what are usually the file extensions (but may be some other suffix to + * uniquely identify a file type), in priority order. The first suffix that matches a file will be used + * and files that match the rest of the suffixes will be ignored + * @param defaultSuffix the canonical suffix, will be used when creating new files from scratch + * @param doSupportGZ for handling TPP's transparent use of .xml.gz + */ + public FileType(List suffixes, String defaultSuffix, gzSupportLevel doSupportGZ) + { + this(suffixes, defaultSuffix, false, doSupportGZ, null); + } + + /** + * @param suffixes list of what are usually the file extensions (but may be some other suffix to + * uniquely identify a file type), in priority order. The first suffix that matches a file will be used + * and files that match the rest of the suffixes will be ignored + * @param defaultSuffix the canonical suffix, will be used when creating new files from scratch + * @param doSupportGZ for handling TPP's transparent use of .xml.gz + * @param contentTypes Content types for this file type. If null, a content type will be guessed based on the extension. + */ + public FileType(List suffixes, String defaultSuffix, boolean dir, gzSupportLevel doSupportGZ, List contentTypes) + { + _suffixes = suffixes; + supportGZ(doSupportGZ); + _defaultSuffix = defaultSuffix; + _dir = Boolean.valueOf(dir); + _antiTypes = new ArrayList<>(0); + if (!suffixes.contains(defaultSuffix)) + { + throw new IllegalArgumentException("List of suffixes " + _suffixes + " does not contain the preferred suffix:" + _defaultSuffix); + } + + if (contentTypes == null) + { + MimeMap mm = new MimeMap(); + String contentType = mm.getContentType(defaultSuffix); + if (contentType != null) + _contentTypes = Collections.singletonList(contentType); + else + _contentTypes = Collections.emptyList(); + } + else + { + _contentTypes = Collections.unmodifiableList(new ArrayList<>(contentTypes)); + } + } + + /** helper for supporting TPP's use of .xml.gz */ + private String tryName(Path parentDir, String name) + { + if (_supportGZ.booleanValue()) // TPP treats xml.gz as a native format + { + FileUtil.legalPathPartThrow(name); + // in the case of existing files, non-gz copy wins if present + Path f = parentDir!=null ? FileUtil.appendName(parentDir, name) : Path.of(name); + if (!NetworkDrive.exists(f)) + { // non-gz copy doesn't exist - how about .gz version? + String gzname = name + ".gz"; + if (_preferGZ.booleanValue()) + { // we like .gz for new filenames, so don't care if exists + return gzname; + } + f = parentDir!=null ? FileUtil.appendName(parentDir, gzname) : Path.of(gzname); + if (NetworkDrive.exists(f)) + { // we don't prefer .gz, but we support it if it exists + return gzname; + } + } + } + return name; + } + + /** Uses the preferred suffix, useful when there's not a directory of existing files to reference */ + /** if _preferGZ is set, will use preferred suffix.gz since TPP treats .gz as native format, + * unless non-gz file exists */ + public String getDefaultName(String basename) + { + return tryName(null, basename + _defaultSuffix); + } + + /** + * turn support for gzipped files on and off + */ + public boolean supportGZ(gzSupportLevel doSupportGZ) + { + _supportGZ = Boolean.valueOf(doSupportGZ != gzSupportLevel.NO_GZ); + _preferGZ = Boolean.valueOf(doSupportGZ == gzSupportLevel.PREFER_GZ); + return _supportGZ.booleanValue(); + } + + /** + * add a new supported suffix, return new list length + */ + public int addSuffix(String newsuffix) + { + List s = new ArrayList<>(_suffixes.size()+1); + for (String suffix : _suffixes) + { + s.add(suffix); + } + s.add(newsuffix); + _suffixes = s; + return _suffixes.size(); + } + + /** + * add a new filetype to reject, return new list length + */ + public int addAntiFileType(FileType anti) + { + List s = new ArrayList<>(_antiTypes.size()+1); + for (FileType a : _antiTypes) + { + s.add(a); + } + s.add(anti); + _antiTypes = s; + return _antiTypes.size(); + } + + // used to avoid, for example, mistaking protxml ".pep-prot.xml" for pepxml ".xml" file + private boolean isAntiFileType(String name, byte[] header) + { + for (FileType a : _antiTypes) + { + if (a.isType(name)) + { + return true; + } + } + return false; + } + + /** + * Looks for a file in the parentDir that matches, in priority order. If one is found, returns its file name. + * If nothing matches, uses the defaultSuffix to build a file name. + */ + public String getName(File parentDir, String basename) + { + return getName(parentDir.toPath(), basename); + } + + public String getName(FileLike parentDir, String basename) + { + return getName(parentDir.toNioPathForRead(), basename); + } + + public String getName(Path parentDir, String basename) + { + if (_suffixes.size() > 1) + { + // Only bother checking if we have more than one possible suffix + for (String suffix : _suffixes) + { + String name = tryName(parentDir, basename + suffix); + Path f = FileUtil.appendName(parentDir, name); + if (NetworkDrive.exists(f)) + { + // avoid, for example, mistaking protxml ".pep-prot.xml" for pepxml ".xml" file + if (!isAntiFileType(name, null)) + { + return name; + } + } + } + } + return tryName(parentDir, basename + _defaultSuffix); + } + + public String getName(String parentDirName, String basename) + { + File parentDir = new File(parentDirName); + return getName(parentDir,basename); + } + + /** + * Looks for a file in the parentDir that matches, in priority order. If one is found, returns its file name. + * If nothing matches, uses the defaultSuffix to build a file name. + */ + @Deprecated //please switch to using the nio.Path version as the File class can have issues using full URIs + public File getFile(File parentDir, String basename) + { + return new File(parentDir, getName(parentDir, basename)); + } + + public Path getPath(Path parentDir, String basename) + { + return FileUtil.appendName(parentDir, getName(parentDir, basename)); + } + + /** + * @return the index of the first suffix that matches. Useful when looking through a directory of files and + * determining which is the preferred file for this FileType. + */ + public int getIndexMatch(Path file) + { + return getIndexMatch(file.getFileName().toString(), file.toString()); + } + + private int getIndexMatch(String filename, String filePath) + { + if (!isAntiFileType(filename, null)) // avoid, for example, mistaking .pep-prot.xml for .xml + { + for (int i = 0; i < _suffixes.size(); i++) + { + String s = toLowerIfCaseInsensitive(_suffixes.get(i)); + if (toLowerIfCaseInsensitive(filename).endsWith(s)) + { + return i; + } + // TPP treats .xml.gz as a native format + if (_supportGZ.booleanValue() && toLowerIfCaseInsensitive(filename).endsWith(s + ".gz")) + { + return i; + } + } + } + + throw new IllegalArgumentException("No match found for " + filePath + " with " + this); + } + + private String toLowerIfCaseInsensitive(String s) + { + if (s == null) + { + return null; + } + if (_caseSensitiveOnCaseSensitiveFileSystems && IOCase.SYSTEM.isCaseSensitive()) + { + return s; + } + return s.toLowerCase(); + } + + /** + * Finds the best suffix based on priority order, strips it off, and returns the remainder. If there is no matching + * suffix, returns the original file name. + */ + public String getBaseName(File file) + { + return getBaseName(file.toPath()); + } + + public String getBaseName(FileLike file) + { + return getBaseName(file.toNioPathForRead()); + } + + public String getBaseName(@NotNull java.nio.file.Path file) + { + String fileName = file.getFileName().toString(); + if (isAntiFileType(fileName, null) || !isType(file)) + return fileName; + + String suffix = null; + for (String s : _suffixes) + { + // run the entire list in order to assure strongest match + // consider .msprefix.mzxml vs .mzxml for example + if (toLowerIfCaseInsensitive(fileName).endsWith(toLowerIfCaseInsensitive(s))) + { + if ((null==suffix) || (s.length()>suffix.length())) + { + suffix = s; + } + } + else if (_supportGZ.booleanValue()) // TPP treats .xml.gz as a native read format + { + String sgz = s+".gz"; + if (fileName.endsWith(sgz)) + { + if ((null==suffix) || (sgz.length()>suffix.length())) + { + suffix = sgz; + } + } + } + } + assert suffix != null : "Could not find matching suffix even though types match"; + return fileName.substring(0, fileName.length() - suffix.length()); + } + + public File newFile(File parent, String basename) + { + return FileUtil.appendName(parent, getName(parent, basename)); + } + + public FileLike newFile(FileLike parent, String basename) + { + return parent.resolveChild(getName(parent, basename)); + } + + public Path newFile(Path parent, String basename) + { + return FileUtil.appendName(parent, getName(parent, basename)); + } + + public boolean isType(File file) + { + return isType(file, null, null); + } + + public boolean isType(FileLike file) + { + return isType(file.toNioPathForRead(), null, null); + } + + public boolean isType(java.nio.file.Path path) + { + return isType(path, null, null); + } + + public boolean isType(java.nio.file.Path path, String contentType, byte[] header) + { + if ((path == null) || (_dir != null && _dir.booleanValue() != Files.isDirectory(path))) + return false; + + return isType(path.getFileName().toString(), contentType, header); + } + + + public boolean isType(File file, String contentType, byte[] header) + { + if ((file == null) || (_dir != null && _dir.booleanValue() != file.isDirectory())) + return false; + + return isType(file.getName(), contentType, header); + } + + /** + * Checks if the path matches any of the suffixes + */ + public boolean isType(String filePath) + { + return isType(filePath, null, null); + } + + /** + * Checks if the path matches any of the suffixes and the file header if provided. + */ + public boolean isType(@Nullable String filePath, @Nullable String contentType, @Nullable byte[] header) + { + String providedContentType = contentType; // Save it for later + + // avoid, for example, mistaking protxml ".pep-prot.xml" for pepxml ".xml" + if (isAntiFileType(filePath, header)) + { + return false; + } + + // Attempt to match by content type. + if (_contentTypes != null) + { + // Use Tika to determine the content type + if (contentType == null && header != null) + contentType = detectContentType(filePath, header); + + if (contentType != null) + { + contentType = contentType.toLowerCase().trim(); + if (_contentTypes.contains(contentType)) + return true; + } + } + + // Attempt to match by suffix and header. + if (filePath != null) + { + filePath = toLowerIfCaseInsensitive(filePath); + for (String suffix : _suffixes) + { + suffix = toLowerIfCaseInsensitive(suffix); + if (filePath.endsWith(suffix)) + { + if (header == null || isHeaderMatch(header)) + return true; + } + // TPP treats .xml.gz as a native format + if (_supportGZ.booleanValue() && filePath.endsWith(suffix + ".gz")) + { + if (header == null || isHeaderMatch(header)) + return true; + } + } + } + + // Attempt to match using just the header, but only if the original content type was null, Issue 47814 + return null == providedContentType && header != null && isHeaderMatch(header); + } + + protected static String detectContentType(String fileName, byte[] header) + { + final Metadata metadata = new Metadata(); + metadata.set("resourceName", fileName); + try (TikaInputStream is = TikaInputStream.get(header, metadata)) + { + MediaType mediaType = DETECTOR.detect(is, metadata); + if (mediaType != null) + return mediaType.toString(); + + return null; + } + catch (IOException e) + { + throw new RuntimeException(e); + } + } + + public boolean isMatch(String name, String basename) + { + for (String suffix : _suffixes) + { + if (name.equalsIgnoreCase(basename + suffix)) + { + return true; + } + // TPP treats .xml.gz as a native format + if (_supportGZ.booleanValue() && name.equals(basename + suffix+".gz")) + { + return true; + } + } + return false; + } + + /** + * Checks if the file header matches. This is useful for FileTypes that share an + * extension, e.g. "txt" or "xml", or when the filename or extension isn't available. + * + * @param header First few K of the file. + * @return True if the header matches, false otherwise. + */ + public boolean isHeaderMatch(@NotNull byte[] header) + { + return false; + } + + public boolean equals(Object o) + { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + FileType fileType = (FileType) o; + + if (_supportGZ != null ? !_supportGZ.equals(fileType._supportGZ) : fileType._supportGZ != null) return false; + if (_preferGZ != null ? !_preferGZ.equals(fileType._preferGZ) : fileType._preferGZ != null) return false; + if (_dir != null ? !_dir.equals(fileType._dir) : fileType._dir != null) return false; + if (_defaultSuffix != null ? !_defaultSuffix.equals(fileType._defaultSuffix) : fileType._defaultSuffix != null) + return false; + if (_antiTypes != null ? !_antiTypes.equals(fileType._antiTypes) : fileType._antiTypes != null) return false; + return !(_suffixes != null ? !_suffixes.equals(fileType._suffixes) : fileType._suffixes != null); + } + + public String getDefaultSuffix() + { + return _defaultSuffix; + } + + public List getSuffixes() + { + return Collections.unmodifiableList(_suffixes); + } + + public int hashCode() + { + int result; + result = (_suffixes != null ? _suffixes.hashCode() : 0); + result = 31 * result + (_defaultSuffix != null ? _defaultSuffix.hashCode() : 0); + result = 31 * result + (_dir != null ? _dir.hashCode() : 0); + result = 31 * result + (_supportGZ != null ? _supportGZ.hashCode() : 0); + result = 31 * result + (_preferGZ != null ? _preferGZ.hashCode() : 0); + return result; + } + + public String toString() + { + return (_dir == null || !_dir.booleanValue() ? _suffixes.toString() : _suffixes + "/"); + } + + @NotNull + public static List findTypes(@NotNull List types, @NotNull List files) + { + ArrayList foundTypes = new ArrayList<>(); + // This O(n*m), but these are usually very short lists. + for (FileType type : types) + { + for (FileLike file : files) + { + if (type.isType(file.getName())) + { + foundTypes.add(type); + break; + } + } + } + return foundTypes; + } + + /** + * true if the different file extensions are just transformed versions of the same data (such as .raw and .mzXML) + * and therefore if multiple are present only the first should be considered for actions in the UI. + * false if they are independent and should all be considered actionable + */ + public boolean isExtensionsMutuallyExclusive() + { + return _extensionsMutuallyExclusive; + } + + /** + * @param extensionsMutuallyExclusive true if the different file extensions are just transformed versions of the + * same data (such as .raw and .mzXML) and therefore if multiple are present only the first should be + * considered for actions in the UI. + * false if they are independent and should all be considered actionable + */ + public void setExtensionsMutuallyExclusive(boolean extensionsMutuallyExclusive) + { + _extensionsMutuallyExclusive = extensionsMutuallyExclusive; + } + + /** + * @return a FileType that will only match on the default suffix for this FileType + */ + public FileType getDefaultFileType() + { + if (!_suffixes.isEmpty()) + { + FileType ft = new FileType(_defaultSuffix); + ft._dir = _dir; + ft._supportGZ = _supportGZ.booleanValue(); + ft._preferGZ = _preferGZ.booleanValue(); + return ft; + } + else + { + return this; + } + } + + public String getDefaultRole() + { + if (_defaultSuffix.contains(".")) + { + return _defaultSuffix.substring(_defaultSuffix.indexOf(".") + 1); + } + return _defaultSuffix; + } + + public boolean isCaseSensitiveOnCaseSensitiveFileSystems() + { + return _caseSensitiveOnCaseSensitiveFileSystems; + } + + public void setCaseSensitiveOnCaseSensitiveFileSystems(boolean caseSensitiveOnCaseSensitiveFileSystems) + { + _caseSensitiveOnCaseSensitiveFileSystems = caseSensitiveOnCaseSensitiveFileSystems; + } + + public List getContentTypes() + { + return _contentTypes; + } + + public static class TestCase extends Assert + { + @Test + public void test() + { + // simple case + FileType ft = new FileType(".foo"); + assertTrue(ft.isType("test.foo")); + assertTrue(!ft.isType("test.foo.gz")); + assertEquals("test.foo",ft.getDefaultName("test")); + + // support for .gz + FileType ftgz = new FileType(".foo",gzSupportLevel.SUPPORT_GZ); + assertTrue(ftgz.isType("test.foo")); + assertTrue(ftgz.isType("test.foo.gz")); + assertEquals("test.foo",ftgz.getDefaultName("test")); + + // preference for .gz + FileType ftgzgz = new FileType(".foo",gzSupportLevel.PREFER_GZ); + assertTrue(ftgzgz.isType("test.foo")); + assertTrue(ftgzgz.isType("test.foo.gz")); + assertEquals("test.foo.gz",ftgzgz.getDefaultName("test")); + + // multiple extensions + ArrayList foobar = new ArrayList<>(); + foobar.add(".foo"); + foobar.add(".bar"); + FileType ftt = new FileType(foobar,".foo",false,gzSupportLevel.SUPPORT_GZ); + assertTrue(ftt.isType("test.foo")); + assertTrue(ftt.isType("test.bar")); + assertTrue(ftt.isType("test.foo.gz")); + assertTrue(ftt.isType("test.bar.gz")); + assertTrue(ftt.isType("test.bAr.gZ")); // extensions are case insensitive + assertEquals("test.foo",ftt.getDefaultName("test")); + + // antitypes - for example avoid mistaking protxml ".pep-prot.xml" for pepxml ".xml" + assertTrue(ftt.isType("test.foo.bar")); + ftt.addAntiFileType(new FileType(".foo.bar")); + assertTrue(!ftt.isType("test.foo.bar")); + assertTrue(ftt.isType("test.foo")); + assertTrue(ftt.isType("test.bar")); + + } + } +} diff --git a/api/src/org/labkey/api/util/FileUtil.java b/api/src/org/labkey/api/util/FileUtil.java index d7675c3092d..4b473470677 100644 --- a/api/src/org/labkey/api/util/FileUtil.java +++ b/api/src/org/labkey/api/util/FileUtil.java @@ -1,2405 +1,2435 @@ -/* - * Copyright (c) 2008-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.labkey.api.util; - -import org.apache.commons.io.FileUtils; -import org.apache.commons.io.FilenameUtils; -import org.apache.commons.io.IOUtils; -import org.apache.commons.io.file.SimplePathVisitor; -import org.apache.commons.io.input.LabKeyByteBufferCleaner; -import org.apache.commons.lang3.StringUtils; -import org.apache.logging.log4j.Logger; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.jmock.Expectations; -import org.jmock.Mockery; -import org.jmock.lib.legacy.ClassImposteriser; -import org.junit.Assert; -import org.junit.Test; -import org.labkey.api.cloud.CloudStoreService; -import org.labkey.api.data.Container; -import org.labkey.api.files.FileContentService; -import org.labkey.api.security.Crypt; -import org.labkey.api.settings.AppProps; -import org.labkey.api.util.logging.LogHelper; -import org.labkey.api.view.UnauthorizedException; -import org.labkey.vfs.FileLike; -import org.labkey.vfs.FileSystemLike; - -import java.io.BufferedInputStream; -import java.io.BufferedReader; -import java.io.ByteArrayInputStream; -import java.io.Closeable; -import java.io.DataOutput; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.FileReader; -import java.io.FileWriter; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.io.Reader; -import java.io.Writer; -import java.net.URI; -import java.net.URISyntaxException; -import java.nio.ByteBuffer; -import java.nio.CharBuffer; -import java.nio.channels.FileChannel; -import java.nio.channels.FileLock; -import java.nio.channels.ReadableByteChannel; -import java.nio.file.FileSystems; -import java.nio.file.FileVisitResult; -import java.nio.file.Files; -import java.nio.file.InvalidPathException; -import java.nio.file.Path; -import java.nio.file.StandardCopyOption; -import java.nio.file.attribute.BasicFileAttributes; -import java.nio.file.attribute.FileAttribute; -import java.nio.file.attribute.PosixFilePermission; -import java.nio.file.attribute.PosixFilePermissions; -import java.security.DigestInputStream; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.Comparator; -import java.util.HashSet; -import java.util.LinkedList; -import java.util.List; -import java.util.Objects; -import java.util.Set; -import java.util.regex.Pattern; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -public class FileUtil -{ - public static final String FILE_SCHEME = "file"; // url scheme for local file system - - private static final Logger LOG = LogHelper.getLogger(FileUtil.class, "FileUtil.java logger"); - - private static File _tempDir = null; - private static FileLike _tempDirFileLike = null; - - static private final String windowsRestricted = "\\/:*?\"<>|`"; - // and ` seems like a bad idea for linux? - static private final String linuxRestricted = "`"; - static private final String restrictedPrintable = windowsRestricted + linuxRestricted; - - private static final ThreadLocal> tempPaths = ThreadLocal.withInitial(HashSet::new); - - private static Pattern extensionChecker; - - public static void startRequest() - { - tempPaths.get().clear(); - } - - @SuppressWarnings("RedundantOperationOnEmptyContainer") - public static void stopRequest() - { - var paths = tempPaths.get(); - assert paths.isEmpty(); - for (Path p : paths) - { - try - { - Files.deleteIfExists(p); - } - catch (IOException x) - { - p.toFile().deleteOnExit(); - } - } - paths.clear(); - } - - - @Deprecated - public static boolean deleteDirectoryContents(File dir) - { - try - { - return deleteDirectoryContents(dir.toPath()); - } - catch (IOException e) - { - return false; // could there be more done here to log the error? - } - } - - - public static boolean deleteDirectoryContents(Path dir) throws IOException - { - return deleteDirectoryContents(dir, null); - } - - - public static boolean deleteDirectoryContents(FileLike dir) throws IOException - { - if (!dir.getFileSystem().canWriteFiles()) - throw new UnauthorizedException(); - return deleteDirectoryContents(toFileForWrite(dir).toPath(), null); - } - - - public static boolean deleteDirectoryContents(Path dir, @Nullable Logger log) throws IOException - { - if (Files.isDirectory(dir)) - { - File dirFile = dir.toFile(); //TODO this method should be converted to use Path and Files.walkFileTree - String[] children = dirFile.list(); - - if (null == children) // 17562 - return true; - - for (String aChildren : children) - { - boolean success = deleteDir(FileUtil.appendName(dirFile, aChildren), log); - if (!success) - { - return false; - } - } - } - return true; - } - - - public static boolean deleteSubDirs(File dir) - { - if (dir.isDirectory()) - { - File[] children = dir.listFiles(); - if (null != children) - { - for (File child : children) - { - boolean success = true; - if (child.isDirectory()) - success = deleteDir(child); - if (!success) - { - return false; - } - } - } - } - return true; - } - - - /** File.delete() will only delete a directory if it's empty, but this will - * delete all the contents and the directory */ - public static boolean deleteDir(File dir) - { - return deleteDir(dir, null); - } - - - @Deprecated - public static boolean deleteDir(@NotNull File dir, Logger log) - { - return deleteDir(dir.toPath(), log); - } - - - public static boolean deleteDir(Path dir, Logger log) - { - //TODO seems like this could be reworked to use Files.walkFileTree - log = log == null ? LOG : log; - - // Issue 22336: See note in FileUtils.isSymLink() about windows-specific bugs for symlinks: - // http://commons.apache.org/proper/commons-io/apidocs/org/apache/commons/io/FileUtils.html - if (!Files.isSymbolicLink(dir)) - { - try - { - // this returns true if !dir.isDirectory() - boolean success = deleteDirectoryContents(dir, log); - if (!success) - return false; - } - catch (IOException e) - { - log.debug(String.format("Unable to clean dir [%1$s]", dir), e); - return false; - } - } - - IOException lastException = null; - - // The directory is now either a sym-link or empty, so delete it - for (int i = 0; i < 5 ; i++) - { - try - { - Files.deleteIfExists(dir); - return true; - } - catch (IOException e) - { - lastException = e; - // Issue 39579: Folder import sometimes fails to delete temp directory - // wait a little then try again - log.warn("Failed to delete file. Sleep and try to delete again. " + e.getMessage()); - try {Thread.sleep(1000);} catch (InterruptedException x) {/* pass */} - } - } - log.warn("Failed to delete file after 5 attempts: " + FileUtil.getAbsoluteCaseSensitiveFile(dir.toFile()), lastException); - return false; - } - - - public static boolean deleteDir(@NotNull Path dir) throws IOException - { - if (Files.exists(dir)) - { - if (hasCloudScheme(dir)) - { - // TODO: On Windows, collect is yielding AccessDenied Exception, so only do this for cloud - try (Stream paths = Files.walk(dir)) - { - boolean success = true; - for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) - { - success = Files.deleteIfExists(path) && success; - } - return success; - } - } - else - { - return deleteDir(dir.toFile()); // Note: we maintain existing behavior from before Path work, which is to ignore any error - } - } - - return true; - } - - - public static void copyDirectory(Path srcPath, Path destPath) throws IOException - { - // Will replace existing files - if (!Files.exists(destPath)) - FileUtil.createDirectory(destPath); - try (Stream list = Files.list(srcPath)) - { - for (Path srcChild : list.toList()) - { - Path destChild = destPath.resolve(getFileName(srcChild)); - if (Files.isDirectory(srcChild)) - copyDirectory(srcChild, destChild); - else - Files.copy(srcChild, destChild, StandardCopyOption.REPLACE_EXISTING); - } - } - } - - public static String isAllowedFileName(String s, boolean checkFileExtension) - { - return isAllowedFileName(s, checkFileExtension, AppProps.getInstance()); - } - - static String isAllowedFileName(String s, boolean checkFileExtension, AppProps appProps) - { - if (appProps.isInvalidFilenameBlocked()) - { - String msg = validateFileName(s); - if (msg != null) - return msg; - } - - if (checkFileExtension) - { - String badExtension = checkExtension(s, AppProps.getInstance()); - if (badExtension != null) - return "This file type [" + badExtension + "] is not allowed. Accepted file extensions: " + AppProps.getInstance().getAllowedExtensions(); - } - return null; - } - - public static @Nullable String validateFileName(String s) - { - return StringUtilsLabKey.validateLegalNames(s, restrictedPrintable, "Filename"); - } - - private static String checkExtension(String filename, AppProps appProps) - { - // If the allow list is empty, allow any extension - if (appProps.getAllowedExtensions().isEmpty()) - return null; - - if (extensionChecker == null) - setExtensionChecker(appProps); - - String extension = FilenameUtils.getExtension(filename); - return extensionChecker.matcher(filename).matches() ? null : extension; - } - - private static void setExtensionChecker(AppProps appProps) - { - // Regex encode the allowed extensions (escape periods and add '|' optional matcher) - String allowedExtensions = appProps.getAllowedExtensions().stream().map(Pattern::quote).collect(Collectors.joining("|")); - // Allow any extension in the list unless it is preceded by a '.' which we use as a proxy for double/multi extensions - extensionChecker = Pattern.compile(String.format("^[^\\.]*(%1$s)$", allowedExtensions), Pattern.CASE_INSENSITIVE); - } - - public static void clearExtensionChecker() - { - extensionChecker = null; - } - - public static void checkAllowedFileName(String s, boolean checkFileExtension) throws IOException - { - String msg = isAllowedFileName(s, checkFileExtension); - if (null == msg) - return; - throw new IOException(s + ": " + msg); - } - - public static boolean mkdir(File file) throws IOException - { - return mkdir(file, AppProps.getInstance().isInvalidFilenameBlocked()); - } - - public static File toFileForRead(FileLike file) - { - if (null == file) - return null; - return file.toNioPathForRead().toFile(); - } - - public static File toFileForWrite(FileLike file) - { - if (null == file) - return null; - return file.toNioPathForWrite().toFile(); - } - - public static boolean mkdir(FileLike file) throws IOException - { - return mkdir(toFileForWrite(file), AppProps.getInstance().isInvalidFilenameBlocked()); - } - - public static boolean mkdir(File file, boolean checkFileName) throws IOException - { - if (checkFileName) - checkAllowedFileName(file.getName(), false); - //noinspection SSBasedInspection - return file.mkdir(); - } - - - public static boolean mkdirs(File file) throws IOException - { - return mkdirs(file, AppProps.getInstance().isInvalidFilenameBlocked()); - } - - public static boolean mkdirs(FileLike file) throws IOException - { - if (!file.getFileSystem().canWriteFiles()) - throw new UnauthorizedException(); - var ret = mkdirs(toFileForWrite(file), AppProps.getInstance().isInvalidFilenameBlocked()); - file.refresh(); - return ret; - } - - public static boolean mkdirs(File file, boolean checkFileName) throws IOException - { - File parent = file; - while (!Files.exists(parent.toPath())) - { - if (checkFileName) - checkAllowedFileName(parent.getName(), false); - parent = parent.getParentFile(); - } - //noinspection SSBasedInspection - return file.mkdirs(); - } - - public static boolean mkdirs(FileLike file, boolean checkFileName) throws IOException - { - FileLike parent = file; - var ret = false; - while (!Files.exists(parent.toNioPathForWrite())) - { - ret = true; - if (checkFileName) - checkAllowedFileName(parent.getName(), false); - parent = parent.getParent(); - } - file.mkdirs(); - return ret; - } - - - public static Path createDirectory(Path path) throws IOException - { - return createDirectory(path, AppProps.getInstance().isInvalidFilenameBlocked()); - } - - - public static Path createDirectory(Path path, boolean checkFileName) throws IOException - { - if (checkFileName) - checkAllowedFileName(getFileName(path), false); - if (!Files.exists(path)) - //noinspection SSBasedInspection - return Files.createDirectory(path); - return path; - } - - - public static Path createDirectories(Path path) throws IOException - { - return createDirectories(path, AppProps.getInstance().isInvalidFilenameBlocked()); - } - - - public static void createDirectories(FileLike file) throws IOException - { - if (!file.getFileSystem().canWriteFiles()) - throw new UnauthorizedException(); - File target = toFileForWrite(file); - createDirectories(target.toPath(), AppProps.getInstance().isInvalidFilenameBlocked()); - } - - - public static Path createDirectories(Path path, boolean checkFileName) throws IOException - { - Path parent = path; - while (!Files.exists(parent)) - { - if (checkFileName) - checkAllowedFileName(getFileName(parent), false); - parent = parent.getParent(); - } - //noinspection SSBasedInspection - return Files.createDirectories(path); - } - - - public static boolean renameTo(FileLike from, FileLike to) - { - // TODO FileLike.renameTo() - return toFileForRead(from).renameTo(toFileForWrite(to)); - } - - - public static boolean createNewFile(File file) throws IOException - { - return createNewFile(file, AppProps.getInstance().isInvalidFilenameBlocked()); - } - - - public static boolean createNewFile(File file, boolean checkFileName) throws IOException - { - if (checkFileName) - checkAllowedFileName(file.getName(), true); - //noinspection SSBasedInspection - return file.createNewFile(); - } - - - public static boolean createNewFile(FileLike file, boolean checkFileName) throws IOException - { - if (checkFileName) - checkAllowedFileName(file.getName(), true); - var ret = !file.exists(); - file.createFile(); - return ret; - } - - - public static Path createFile(Path path, FileAttribute... attrs) throws IOException - { - return createFile(path, AppProps.getInstance().isInvalidFilenameBlocked(), attrs); - } - - - public static Path createFile(Path path, boolean checkFileName, FileAttribute... attrs) throws IOException - { - if (checkFileName) - checkAllowedFileName(getFileName(path), true); - return Files.createFile(path, attrs); - } - - - // return true if file exists and is not a directory - public static boolean isFileAndExists(@Nullable Path path) - { - try - { - // One call to cloud rather than two (exists && !isDirectory) - return (null != path && !Files.readAttributes(path, BasicFileAttributes.class).isDirectory()); - } - catch (IOException e) - { - return false; - } - } - - - /** - * Remove text right of a specific number of periods, including the periods, from a file's name. - *
    - *
  • C:\dir\name.ext, 1 => name
  • - *
  • C:\dir\name.ext1.ext2, 2 => name
  • - *
  • C:\dir\name.ext1.ext2, 1 => name.ext1
  • - *
- * - * @param fileName name of the file - * @param dots number of dots to remove - * @return base name - */ - public static String getBaseName(String fileName, int dots) - { - String baseName = fileName; - while (dots-- > 0 && baseName.indexOf('.') != -1) - baseName = baseName.substring(0, baseName.lastIndexOf('.')); - return baseName; - } - - - /** - * Remove text right of and including the last period in a file's name. - * @param fileName name of the file - * @return base name - */ - public static String getBaseName(String fileName) - { - return getBaseName(fileName, 1); - } - - - /** - * Remove text right of a specific number of periods, including the periods, from a file's name. - *
    - *
  • C:\dir\name.ext, 1 => name
  • - *
  • C:\dir\name.ext1.ext2, 2 => name
  • - *
  • C:\dir\name.ext1.ext2, 1 => name.ext1
  • - *
- * - * @param file file from which to get the name - * @param dots number of dots to remove - * @return base name - */ - public static String getBaseName(File file, int dots) - { - return getBaseName(file.getName(), dots); - } - - - /** - * Remove text right of and including the last period in a file's name. - * @param file file from which to get the name - * @return base name - */ - public static String getBaseName(File file) - { - return getBaseName(file, 1); - } - - - /** - * Returns the file name extension without the dot, null if there - * isn't one. - */ - @Nullable - public static String getExtension(File file) - { - return getExtension(file.getName()); - } - - - /** - * Returns the file name extension without the dot, null if there - * isn't one. - */ - @Nullable - public static String getExtension(String name) - { - if (name != null && name.lastIndexOf('.') != -1) - { - return name.substring(name.lastIndexOf('.') + 1); - } - return null; - } - - - public static boolean hasCloudScheme(Path path) - { - try - { - return hasCloudScheme(path.toUri()); - } - catch (Exception e) - { - return false; - } - } - - - public static boolean hasCloudScheme(URI uri) - { - return "s3".equalsIgnoreCase(uri.getScheme()); - } - - - public static boolean hasCloudScheme(String url) - { - return url.toLowerCase().startsWith("s3://"); - } - - - public static boolean hasCloudScheme(FileLike filelike) - { - return "s3".equals(filelike.getFileSystem().getScheme()); - } - - - public static String getAbsolutePath(Path path) - { - if (!FileUtil.hasCloudScheme(path)) - return path.toFile().getAbsolutePath(); - else - return getPathStringWithoutAccessId(path.toAbsolutePath().toUri()); - - } - - - @Nullable - public static String getAbsolutePath(Container container, Path path) - { // Returned string is NOT necessarily a URI (i.e. it is not encoded) - return getAbsolutePath(container, path.toUri()); - } - - - @Nullable - public static String getAbsolutePath(Container container, URI uri) - { - if (!uri.isAbsolute()) - return null; - else if (!FileUtil.hasCloudScheme(uri)) - return new File(uri).getAbsolutePath(); - else - return getAbsolutePathWithoutAccessIdFromCloudUrl(container, uri); - } - - - @Nullable - public static String getAbsoluteCaseSensitivePathString(Container container, URI uri) - { - if (!uri.isAbsolute()) - return null; - else if (!FileUtil.hasCloudScheme(uri)) - return getAbsoluteCaseSensitiveFile(new File(uri)).toPath().toUri().toString(); // Was: return getAbsoluteCaseSensitiveFile(new File(uri)).toURI().toString(); // #36352 - else - return getAbsolutePathWithoutAccessIdFromCloudUrl(container, uri); - } - - - @Nullable - public static Path getAbsoluteCaseSensitivePath(Container container, URI uri) - { - if (!uri.isAbsolute()) - return null; - else if (!FileUtil.hasCloudScheme(uri)) - return getAbsoluteCaseSensitiveFile(new File(uri)).toPath(); - else - return getAbsolutePathFromCloudUrl(container, uri); - } - - - @Nullable - private static String getAbsolutePathWithoutAccessIdFromCloudUrl(Container container, URI uri) - { - Path path = getAbsolutePathFromCloudUrl(container, uri); - return null != path ? getPathStringWithoutAccessId(path.toAbsolutePath().toUri()) : null; - } - - - @Nullable - private static Path getAbsolutePathFromCloudUrl(Container container, URI uri) - { - Path path = Objects.requireNonNull(CloudStoreService.get()).getPathFromUrl(container, uri.toString()); - return null != path ? path.toAbsolutePath() : null; - } - - - public static Path getAbsoluteCaseSensitivePath(Container container, Path path) - { - if (!FileUtil.hasCloudScheme(path)) - return getAbsoluteCaseSensitiveFile(path.toFile()).toPath(); - else - return path.toAbsolutePath(); - } - - - @Nullable - public static Path getPath(Container container, URI uri) - { - if (!uri.isAbsolute()) - return null; - else if (!FileUtil.hasCloudScheme(uri)) - return new File(uri).toPath(); - else - return Objects.requireNonNull(CloudStoreService.get()).getPathFromUrl(container, uri.toString()); - } - - - public static URI createUri(String str) - { - return createUri(str, true); - } - - - public static URI createUri(String str, boolean isEncoded) - { - str = str.replace("\\", "/"); - // Assume that Windows-style drive-letter paths like c:/myfile.txt should be treated as file:/ URIs - if (str.matches("^[A-Za-z]:/.*")) - return new File(str).toURI(); - - String str2 = str; - if (str2.startsWith("/")) - str2 = "file://" + str; - - // Creating stack traces is expensive so only bother if we're really going to log it - if (LOG.isDebugEnabled()) - { - LOG.debug("CreateUri from: " + str + " [" + Thread.currentThread().getStackTrace()[2].toString() + "]"); - } - if (isEncoded) - str2 = str2.replace(" ", "%20"); // Spaces in paths make URI unhappy - else - str2 = encodeForURL(str2); - try - { - return new URI(str2); - } - catch (URISyntaxException e) - { - // We're handling encoded and unencoded, so this can fail because of certain reserved chars; - if (str.startsWith("/")) - return new File(str).toPath().toUri(); - throw new IllegalArgumentException(e); - } - } - - - @NotNull - public static String getFileName(Path fullPath) - { - // We want unencoded fileName - if (hasCloudScheme(fullPath)) - { - Path path = fullPath.getFileName(); - return path == null ? "" : path.toUri().getPath(); - } - else - { - return fullPath.getFileName().toString(); - } - } - - - /** Only returns a child path */ - public static File appendPath(File dir, org.labkey.api.util.Path originalPath) - { - org.labkey.api.util.Path path = originalPath.normalize(); - if (path == null || (!path.isEmpty() && "..".equals(path.get(0)))) - throw new InvalidPathException(originalPath.toString(), "Path to parent not allowed"); - @SuppressWarnings("SSBasedInspection") - var ret = new File(dir, path.toString()); - if (!ret.toPath().normalize().startsWith(dir.toPath().normalize())) - throw new InvalidPathException(originalPath.toString(), "Path to parent not allowed"); - return ret; - } - - - /** Only returns a child path */ - public static FileLike appendPath(FileLike dir, org.labkey.api.util.Path path) - { - path = path.normalize(); - if (!path.isEmpty() && "..".equals(path.get(0))) - throw new InvalidPathException(path.toString(), "Path to parent not allowed"); - return dir.resolveFile(path); - } - - - /** Resolve a relative path, may not be a descendant. */ - public static FileLike resolveFile(FileLike dir, org.labkey.api.util.Path path) - { - return dir.resolveFile(path); - } - - - /* Only returns an immediate child */ - public static File appendName(File dir, org.labkey.api.util.Path.Part part) - { - return appendName(dir, part.toString()); - } - - - /* Only returns an immediate child */ - public static File appendName(File dir, String name) - { - if (!dir.isAbsolute()) - { - dir = dir.getAbsoluteFile(); - } - legalPathPartThrow(name); - @SuppressWarnings("SSBasedInspection") - var ret = new File(dir, name); - - if (!ret.toPath().normalize().startsWith(dir.toPath().normalize())) - throw new InvalidPathException(name, "Path to parent not allowed"); - return ret; - } - - /* Only returns an immediate child */ - public static Path appendName(Path dir, String name) - { - legalPathPartThrow(name); - var ret = dir.resolve(name); - - if (!ret.normalize().startsWith(dir.normalize())) - throw new InvalidPathException(name, "Path to parent not allowed"); - return ret; - } - - - // narrower check than isLegalName() or isAllowedFileName() - // this check that a name is a valid path part (e.g. filename) and is not path like. - public static void legalPathPartThrow(String name) - { - int invalidCharacterIndex = StringUtils.indexOfAny(name, '/', File.separatorChar); - if (invalidCharacterIndex >= 0) - throw new InvalidPathException(name, "Invalid file or directory name", invalidCharacterIndex); - if (".".equals(name) || "..".equals(name)) - throw new InvalidPathException(name, "Invalid file or directory name"); - } - - - public static String decodeSpaces(@NotNull String str) - { - return str.replace("%20", " "); - } - - - public static String pathToString(Path path) - { // Returns a URI string (encoded) - return getPathStringWithoutAccessId(path.toUri()); - } - - - public static String uriToString(URI uri) - { - return getPathStringWithoutAccessId(uri); - } - - - public static Path stringToPath(Container container, String str) - { - return stringToPath(container, str, true); - } - - - public static Path stringToPath(Container container, String str, boolean isEncoded) - { - if (!FileUtil.hasCloudScheme(str)) - return new File(createUri(str, isEncoded)).toPath(); - else - return Objects.requireNonNull(CloudStoreService.get()).getPathFromUrl(container, PageFlowUtil.decode(str)/*decode everything not just the space*/); - } - - - public static String getCloudRootPathString(String cloudName) - { - return FileContentService.CLOUD_ROOT_PREFIX + "/" + cloudName; - } - - - @Nullable - private static String getPathStringWithoutAccessId(URI uri) - { - if (null != uri) - if (hasCloudScheme(uri)) - return uri.toString().replaceFirst("/\\w+@s3", "/s3"); // Remove accessId portion if exists - else - { - try - { - return Objects.requireNonNull(URIUtil.normalizeUri(uri)).toString(); - } - catch (URISyntaxException e) - { - LOG.debug("Error attempting to conform uri: " + e.getMessage()); - return uri.toString(); - } - } - else - return null; - } - - - /** - * Get relative path of File 'file' with respect to 'home' directory - *

-     * example : home = /a/b/c
-     *           file    = /a/d/e/x.txt
-     *           return = ../../d/e/x.txt
-     * 

- * The path returned has system specific directory separators. - *

- * It is equivalent to:
- *

home.toURI().relativize(f.toURI).toString().replace('/', File.separatorChar)
- * - * @param home base path, should be a directory, not a file, or it doesn't make sense - * @param file file to generate path for - * @param canonicalize whether or not the paths need to be canonicalized - * @return path from home to file as a string - */ - public static String relativize(File home, File file, boolean canonicalize) throws IOException - { - if (canonicalize) - { - home = FileUtil.getAbsoluteCaseSensitiveFile(home); - file = FileUtil.getAbsoluteCaseSensitiveFile(file); - } - else - { - home = resolveFile(home); - file = resolveFile(file); - } - return matchPathLists(getPathList(home), getPathList(file)); - } - - - /** - * Get a relative path of File 'file' with respect to 'home' directory, - * forcing Unix (i.e. URI) forward slashes for directory separators. - *

- * This is a lot like URIUtil.relativize() without requiring - * that the file be a descendant of the base. - *

- * It is equivalent to:
- *

home.toURI().relativize(f.toURI).toString()
- */ - public static String relativizeUnix(File home, File f, boolean canonicalize) throws IOException - { - return relativize(home, f, canonicalize).replace('\\', '/'); - } - - - public static String relativizeUnix(Path home, Path f, boolean canonicalize) throws IOException - { - if (!hasCloudScheme(home) && !hasCloudScheme(f)) - return relativizeUnix(home.toFile(), f.toFile(), canonicalize); - return getPathStringWithoutAccessId(home.toUri().relativize(f.toUri())); - } - - - /** - * Break a path down into individual elements and add to a list. - *

- * example : if a path is /a/b/c/d.txt, the breakdown will be [d.txt,c,b,a] - * - * @param file input file - * @return a List collection with the individual elements of the path in reverse order - */ - private static List getPathList(File file) - { - List parts = new ArrayList<>(); - while (file != null) - { - parts.add(file.getName()); - file = file.getParentFile(); - } - - return parts; - } - - - /** - * Figure out a string representing the relative path of - * 'file' with respect to 'home' - * - * @param home home path - * @param file path of file - * @return relative path from home to file - */ - public static String matchPathLists(List home, List file) - { - // start at the beginning of the lists - // iterate while both lists are equal - StringBuilder path = new StringBuilder(); - int i = home.size() - 1; - int j = file.size() - 1; - - // first eliminate common root - while ((i >= 0) && (j >= 0) && (home.get(i).equals(file.get(j)))) - { - i--; - j--; - } - - // for each remaining level in the home path, add a .. - for (; i >= 0; i--) - path.append("..").append(File.separator); - - // for each level in the file path, add the path - for (; j >= 1; j--) - path.append(file.get(j)).append(File.separator); - - // if nothing left of the file, then it was a directory - // of which home is a subdirectory. - if (j < 0) - { - if (path.isEmpty()) - path.append("."); - else - path.delete(path.length() - 1, path.length()); // remove trailing sep - } - else - path.append(file.get(j)); // add file name - - return path.toString(); - } - - public static void copyFile(File src, File dst) throws IOException - { - try (FileInputStream is = new FileInputStream(src); - FileChannel in = is.getChannel(); - FileLock lockIn = in.lock(0L, Long.MAX_VALUE, true)) - { - copyFile(in, in.size(), dst); - dst.setLastModified(src.lastModified()); - } - } - - - // FileUtil.copyFile() does not use transferTo() or sync() - public static void copyFile(ReadableByteChannel in, long expected, File dst) throws IOException - { - createNewFile(dst); - - boolean success = false; - long actual = 0; - long bytesCopied; - - LOG.debug("Starting to transfer to " + dst + ", expecting " + (expected == -1 ? "an unknown number" : Long.toString(expected)) + " bytes"); - - try (FileOutputStream os = new FileOutputStream(dst); - FileChannel out = os.getChannel(); - FileLock lockOut = out.lock()) - { - do - { - bytesCopied = out.transferFrom(in, actual, Long.MAX_VALUE); - actual += bytesCopied; - if (actual != expected && bytesCopied != 0) - { - LOG.debug("Still transferring to " + dst + ", " + actual + " bytes transferred so far"); - } - } - while (bytesCopied != 0); - success = actual == expected; - os.getFD().sync(); - } - finally - { - if (success) - { - LOG.debug("Finished transferring " + actual + " bytes to " + dst); - } - else - { - LOG.debug("Failed during transfer, but successfully copied at least " + actual + " bytes to " + dst); - } - } - } - - - /** - * Copies an entire file system branch to another location, including the root directory itself - * @param src The source file root - * @param dest The destination file root - * @throws IOException thrown from IO functions - */ - public static void copyBranch(File src, File dest) throws IOException - { - copyBranch(src, dest, false); - } - - - /** - * Copies an entire file system branch to another location - * - * @param src The source file root - * @param dest The destination file root - * @param contentsOnly Pass false to copy the root directory as well as the files within; true to just copy the contents - * @throws IOException Thrown if there's an IO exception - */ - public static void copyBranch(File src, File dest, boolean contentsOnly) throws IOException - { - //if src is just a file, copy it and return - if (src.isFile()) - { - File destFile = FileUtil.appendName(dest, src.getName()); - copyFile(src, destFile); - return; - } - - //if copying the src root directory as well, make that - //within the dest and re-assign dest to the new directory - if (!contentsOnly) - { - dest = FileUtil.appendName(dest, src.getName()); - mkdirs(dest); - if(!dest.isDirectory()) - throw new IOException("Unable to create the directory " + dest + "!"); - } - - File[] children = src.listFiles(); - if (children == null) - { - throw new IOException("Unable to get file listing for directory: " + src); - } - for (File file : children) - { - copyBranch(file, dest, false); - } - } - - - /** - * always returns path starting with /. Tries to leave trailing '/' as is - * (unless ends with /. or /..) - * - * @param path path to normalize - * @return cleaned path or null if path goes outside of 'root' - */ - @Deprecated // use java.util.Path - public static String normalize(String path) - { - if (path == null || equals(path,'/')) - return path; - - String str = path; - if (str.indexOf('\\') >= 0) - str = str.replace('\\', '/'); - if (!startsWith(str,'/')) - str = "/" + str; - int len = str.length(); - - // quick scan, look for /. or // -quickScan: - { - for (int i=0 ; i list = normalizeSplit(str); - if (null == list) - return null; - if (list.isEmpty()) - return "/"; - StringBuilder sb = new StringBuilder(str.length()+2); - for (String name : list) - { - sb.append('/'); - sb.append(name); - } - return sb.toString(); - } - - - @Deprecated // use java.util.Path - public static ArrayList normalizeSplit(String str) - { - int len = str.length(); - ArrayList list = new ArrayList<>(); - int start = 0; - for (int i=0 ; i<=len ; i++) - { - if (i==len || str.charAt(i) == '/') - { - if (start < i) - { - String part = str.substring(start, i); - if (part.isEmpty() || equals(part,'.')) - { - } - else if (part.equals("..")) - { - if (list.isEmpty()) - return null; - list.remove(list.size()-1); - } - else - { - list.add(part); - } - } - start=i+1; - } - } - return list; - } - - public static String encodeForURL(String str) - { - return encodeForURL(str, false); - } - - public static String encodeForURL(String str, boolean checkEncoded) - { - if (checkEncoded && isUrlEncoded(str)) - return str; - - // str is unencoded; we need certain special chars encoded for it to become a URL - // % & # @ ~ {} [] - return StringUtils.replaceEach(str, DECODED, ENCODED); - } - - private static final String[] ENCODED = {"%25", "%23", "%26", "%40", "%7E", "%7B", "%7D", "%5B", "%5D", "%2B", "%20"}; - private static final String[] DECODED = {"%", "#", "&", "@", "~", "{", "}", "[", "]", "+", " "}; - - static public String decodeURL(String str) - { - return StringUtils.replaceEach(str, ENCODED, DECODED); - } - - public static boolean isUrlEncoded(String str) - { - return StringUtils.indexOfAny(str, ENCODED) > -1; - } - - static boolean startsWith(String s, char ch) - { - return !s.isEmpty() && s.charAt(0) == ch; - } - - - static boolean equals(String s, char ch) - { - return s.length() == 1 && s.charAt(0) == ch; - } - - - public static String relativePath(String dir, String filePath) - { - dir = normalize(dir); - filePath = normalize(filePath); - if (dir.endsWith("/")) - dir = dir.substring(0,dir.length()-1); - if (!filePath.toLowerCase().startsWith(dir.toLowerCase())) - return null; - String relPath = filePath.substring(dir.length()); - if (relPath.isEmpty()) - return relPath; - if (relPath.startsWith("/")) - return relPath.substring(1); - return null; - } - - - private static String digest(MessageDigest md, InputStream is) throws IOException - { - try (DigestInputStream dis = new DigestInputStream(is, md)) - { - byte[] buf = new byte[8 * 1024]; - while (-1 != (dis.read(buf))) - { - /* */ - } - return Crypt.encodeHex(md.digest()); - } - } - - - public static String sha1sum(InputStream is) throws IOException - { - try - { - return digest(MessageDigest.getInstance("SHA1"), is); - } - catch (NoSuchAlgorithmException e) - { - LOG.error("unexpected error", e); - return null; - } - finally - { - IOUtils.closeQuietly(is); - } - } - - - public static String sha1sum(byte[] bytes) throws IOException - { - return sha1sum(new ByteArrayInputStream(bytes)); - } - - - public static String md5sum(InputStream is) throws IOException - { - try - { - return digest(MessageDigest.getInstance("MD5"), is); - } - catch (NoSuchAlgorithmException e) - { - LOG.error("unexpected error", e); - return null; - } - finally - { - IOUtils.closeQuietly(is); - } - } - - - public static String md5sum(byte[] bytes) throws IOException - { - return md5sum(new ByteArrayInputStream(bytes)); - } - - - public static byte[] readHeader(@NotNull File f, int len) throws IOException - { - try (InputStream is = new BufferedInputStream(new FileInputStream(f))) - { - return FileUtil.readHeader(is, len); - } - } - - - public static byte[] readHeader(@NotNull InputStream is, int len) throws IOException - { - assert is.markSupported(); - is.mark(len); - try - { - byte[] buf = new byte[len]; - while (0 < len) - { - int r = is.read(buf, buf.length-len, len); - if (r == -1) - { - byte[] ret = new byte[buf.length-len]; - System.arraycopy(buf, 0, ret, 0, buf.length-len); - return ret; - } - len -= r; - } - return buf; - } - finally - { - is.reset(); - } - } - - - // - // NOTE: IOUtil uses fairly small buffers for copy - // - - final static int BUFFERSIZE = 32*1024; - - // Closes input stream - public static long copyData(InputStream is, File file) throws IOException - { - try (InputStream input = is; FileOutputStream fos = new FileOutputStream(file)) - { - return copyData(input, fos); - } - } - - /** Does not close input or output stream */ - public static long copyData(InputStream is, OutputStream os) throws IOException - { - byte[] buf = new byte[BUFFERSIZE]; - long total = 0; - int r; - while (0 <= (r = is.read(buf))) - { - os.write(buf,0,r); - total += r; - } - return total; - } - - - /** Does not close input or output stream */ - public static void copyData(InputStream is, DataOutput os, long len) throws IOException - { - byte[] buf = new byte[BUFFERSIZE]; - long remaining = len; - do - { - int r = (int)Math.min(buf.length, remaining); - r = is.read(buf, 0, r); - os.write(buf,0,r); - remaining -= r; - } while (0 < remaining); - } - - - /** Does not close input or output stream */ - public static void copyData(InputStream is, DataOutput os) throws IOException - { - byte[] buf = new byte[BUFFERSIZE]; - int r; - while (0 < (r = is.read(buf))) - os.write(buf,0,r); - } - - // NOTE: Keep in sync with the copied constants in TestFileUtils - private static final char[] ILLEGAL_CHARS = {'/','\\',':','?','<','>','*','|','"','^', '\n', '\r', '\''}; - public static final String ILLEGAL_CHARS_STRING = new String(ILLEGAL_CHARS); - - public static boolean isLegalName(String name) - { - if (name == null || name.trim().isEmpty()) - return false; - - if (name.length() > 255) - return false; - - return !StringUtils.containsAny(name, ILLEGAL_CHARS); - } - - // NOTE: Keep in sync with the copied implementation in TestFileUtils.makeLegalFileName() - public static String makeLegalName(String name) - { - if (name == null) - { - return "__null__"; - } - - if (name.isEmpty()) - { - return "__empty__"; - } - - //limit to 255 chars (FAT and OS X) - //replace illegal chars - char[] ret = new char[Math.min(255, name.length())]; - for(int idx = 0; idx < ret.length; ++idx) - { - char ch = name.charAt(idx); - // Reject characters that are illegal anywhere - if (StringUtils.contains(ILLEGAL_CHARS_STRING, ch) || - // Or characters that are illegal starts to a file name - (idx == 0 && (ch == '-' || ch == '$'))) - { - ch = '_'; - } - else if (ch == '-' && - idx > 0 && - name.charAt(idx - 1) == ' ') - { - int i = idx + 1; - // Skip through as many consecutive '-' as there might be - while (i < name.length() && name.charAt(i) == '-') - { - i++; - } - // If the next character after the '-' isn't a space, transform the leading '-' in the sequence - if (i < name.length() && name.charAt(i) != ' ') - { - ch = '_'; - } - } - - ret[idx] = ch; - } - - //can't end with space (windows) - //can't end with period (windows) - int lastIndex = ret.length - 1; - char ch = ret[lastIndex]; - if (ch == ' ' || ch == '.') - ret[lastIndex] = '_'; - - return new String(ret); - } - - - /** - * Returns the absolute path to a file. On Windows and Mac, corrects casing in file paths to match the - * canonical path. - */ - @NotNull - public static FileLike getAbsoluteCaseSensitiveFile(@NotNull FileLike file) - { - return FileSystemLike.wrapFile(getAbsoluteCaseSensitiveFile(file.toNioPathForRead().toFile())); - } - - @NotNull - public static File getAbsoluteCaseSensitiveFile(@NotNull File file) - { - file = resolveFile(file.getAbsoluteFile()); - if (isCaseInsensitiveFileSystem()) - { - try - { - @SuppressWarnings("SSBasedInspection") - File canonicalFile = file.getCanonicalFile(); - - if (canonicalFile.getAbsolutePath().equalsIgnoreCase(file.getAbsolutePath())) - { - return canonicalFile; - } - } - catch (IOException e) - { - // Ignore and just use the absolute file - } - } - return file.getAbsoluteFile(); - } - - - public static boolean isCaseInsensitiveFileSystem() - { - // FileSystem case sensitivity cannot be inferred from OS, for example mac os defaults to case-insensitive but can be configured to be case-sensitive - // Additionally, file root can be mounted to location on a different OS, or it can use S3 - String osName = System.getProperty("os.name").toLowerCase(); - return (osName.startsWith("windows") || osName.startsWith("mac os")); - } - - - /** - * Strips out ".." and "." from the path - */ - public static File resolveFile(File file) - { - File parent = file.getParentFile(); - if (parent == null) - { - return file; - } - if (".".equals(file.getName())) - { - return resolveFile(parent); - } - int dotDotCount = 0; - while ("..".equals(file.getName()) || dotDotCount > 0) - { - if ("..".equals(file.getName())) - { - dotDotCount++; - } - else if (!".".equals(file.getName())) - { - dotDotCount--; - } - if (parent.getParentFile() == null) - { - return parent; - } - file = file.getParentFile(); - parent = file.getParentFile(); - } - // we don't need to use FileUtil.appendName() here - //noinspection SSBasedInspection - return new File(resolveFile(parent), file.getName()); - } - - - // use FileLike createTempDirectoryFileLike() - @Deprecated - public static Path createTempDirectory(@Nullable String prefix) throws IOException - { - if (null != prefix) - legalPathPartThrow(prefix); - return Files.createTempDirectory(prefix).toAbsolutePath(); - } - - - public static FileLike createTempDirectoryFileLike(@Nullable String prefix) throws IOException - { - if (null != prefix) - legalPathPartThrow(prefix); - return new FileSystemLike.Builder(Files.createTempDirectory(prefix).toAbsolutePath()).readwrite().root(); - } - - - public static boolean deleteTempDirectoryFileLike(@NotNull FileLike file) throws IOException - { - if (!file.getPath().isEmpty()) - throw new IllegalArgumentException("Method expects a file returned by createTempDirectoryFileObject"); - if (!file.getFileSystem().canWriteFiles()) - throw new UnauthorizedException(); - return FileUtil.deleteDirectoryContents(file); - } - - - // Under Catalina, it seems to pick \tomcat\temp - // On the web server under Tomcat, it seems to pick c:\Documents and Settings\ITOMCAT_EDI\Local Settings\Temp - public static File getTempDirectory() - { - if (null == _tempDir) - { - try - { - File temp = createTempFile("deleteme", null); - _tempDir = temp.getParentFile().getAbsoluteFile(); - temp.delete(); - } - catch (IOException e) - { - throw new ConfigurationException("The temporary directory (likely " + System.getProperty("java.io.tmpdir") + ") on this server is inaccessible. There may be a file permission issue, or the directory may not exist.", e); - } - } - - return _tempDir; - } - - - public static FileLike getTempDirectoryFileLike() - { - if (null == _tempDirFileLike) - { - _tempDirFileLike = new FileSystemLike.Builder(getTempDirectory()).readwrite().noMemCheck().root(); - } - return _tempDirFileLike; - } - - - // Use this instead of File.createTempFile() (see Issue #46794) - public static File createTempFile(@Nullable String prefix, @Nullable String suffix, File directory) throws IOException - { - if (null != prefix) - legalPathPartThrow(prefix); - if (null != suffix) - legalPathPartThrow(suffix); - return Files.createTempFile(directory.toPath(), prefix, suffix).toFile(); - } - - // Use this instead of File.createTempFile() (see Issue #46794) - public static FileLike createTempFile(@Nullable String prefix, @Nullable String suffix, FileLike directory) throws IOException - { - if (null != prefix) - legalPathPartThrow(prefix); - if (null != suffix) - legalPathPartThrow(suffix); - var path = Files.createTempFile(directory.toNioPathForWrite(), prefix, suffix); - return directory.resolveChild(path.getFileName().toString()); - } - - // Use this instead of File.createTempFile() (see Issue #46794) - public static File createTempFile(@Nullable String prefix, @Nullable String suffix) throws IOException - { - return createTempFile(prefix, suffix, false); - } - - // Use this instead of File.createTempFile() (see Issue #46794) - public static FileLike createTempFileLike(@Nullable String prefix, @Nullable String suffix) throws IOException - { - return FileSystemLike.wrapFile(createTempFile(prefix, suffix, false)); - } - - public static File createTempFile(@Nullable String prefix, @Nullable String suffix, boolean threadLocal) throws IOException - { - if (null != prefix) - legalPathPartThrow(prefix); - if (null != suffix) - legalPathPartThrow(suffix); - var path = Files.createTempFile(prefix, suffix).toAbsolutePath(); - if (threadLocal) - tempPaths.get().add(path); - return path.toFile(); - } - - - private static final boolean isPosix = - FileSystems.getDefault().supportedFileAttributeViews().contains("posix"); - final static private FileAttribute[] tempFileAttributes = new FileAttribute[] { PosixFilePermissions.asFileAttribute(Set.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE)) }; - - public static boolean createTempFile(File file) throws IOException - { - if (file.exists()) - return false; - mkdirs(file.getParentFile()); - if (isPosix) - createFile(file.toPath(), tempFileAttributes); - else - createFile(file.toPath()); - return true; - } - - - public static void deleteTempFile(File f) - { - if (null != f && f.isFile()) - { - if(f.delete()) - tempPaths.get().remove(f.toPath()); - } - } - - - // Converts a document name into keywords appropriate for indexing. We want to retrieve a document named "labkey.txt" - // when the user searches for "labkey.txt", "labkey" or "txt". Lucene analyzers tokenize on whitespace, so this method - // returns the original document name plus the document name with common symbols replaced with spaces. - public static String getSearchKeywords(String documentName) - { - return documentName + " " + documentName.replaceAll("[._-]", " "); - } - - - /** - * Creates a legal, cross-platform file name from the component parts (replacing special characters like colons, semi-colons, slashes, etc - * @param prefix the start of the file name to generate, to be appended with a timestamp suffix - * @param extension the extension (not including the dot) for the desired file name - */ - public static String makeFileNameWithTimestamp(String prefix, @Nullable String extension) - { - return makeLegalName(prefix + "_" + getTimestamp() + (extension == null ? "" : ("." + extension))); - } - - - public static String makeFileNameWithTimestamp(String prefix) - { - return makeLegalName(prefix + "_" + getTimestamp()); - } - - - private static long lastTime = 0; - private static final Object timeLock = new Object(); - - // return a unique time, rounded to the nearest second - private static long currentSeconds() - { - synchronized(timeLock) - { - long sec = HeartBeat.currentTimeMillis(); - sec -= sec % 1000; - lastTime = Math.max(sec, lastTime + 1000); - return lastTime; - } - } - - - public static String getTimestamp() - { - String time = DateUtil.toISO(currentSeconds(), false); - time = time.replace(":", "-"); - time = time.replace(" ", "_"); - - return time; - } - - - private static String indent(LinkedList hasMoreFlags) - { - StringBuilder sb = new StringBuilder(); - for (int i = 0, len = hasMoreFlags.size(); i < len; i++) - { - Boolean hasMore = hasMoreFlags.get(i); - if (i == len-1) - sb.append(hasMore ? "├── " : "└── "); - else - sb.append(hasMore ? "│  " : " "); - } - - return sb.toString(); - } - - - private static void printTree(StringBuilder sb, Path node, LinkedList hasMoreFlags) throws IOException - { - Files.walkFileTree(node, new SimplePathVisitor() - { - @Override - public @NotNull FileVisitResult preVisitDirectory(@NotNull Path dir, @NotNull BasicFileAttributes attrs) throws IOException - { - hasMoreFlags.add(true); - return super.preVisitDirectory(dir, attrs); - } - - @Override - public @NotNull FileVisitResult visitFile(@NotNull Path file, @NotNull BasicFileAttributes attrs) throws IOException - { - appendFileLogEntry(sb, file, hasMoreFlags); - return super.visitFile(file, attrs); - } - - - @Override - public @NotNull FileVisitResult postVisitDirectory(@NotNull Path dir, IOException exc) throws IOException - { - hasMoreFlags.removeLast(); - return super.postVisitDirectory(dir, exc); - } - }); - } - - - private static void appendFileLogEntry(StringBuilder sb, Path node, LinkedList hasMoreFlags) throws IOException - { - if (hasMoreFlags.isEmpty()) - sb.append(node.toAbsolutePath()); - else - sb.append(indent(hasMoreFlags)).append(node.getFileName()); - - if (Files.isDirectory(node)) - sb.append("/"); - else - sb.append(" (").append(FileUtils.byteCountToDisplaySize(Files.size(node))).append(")"); - sb.append("\n"); - } - - - public static String printTree(Path root) throws IOException - { - StringBuilder sb = new StringBuilder(); - printTree(sb, root, new LinkedList<>()); - return sb.toString(); - } - - - public static String getUnencodedAbsolutePath(Container container, Path path) - { - if (!path.isAbsolute()) - return null; - else if (!FileUtil.hasCloudScheme(path)) - return path.toFile().getAbsolutePath(); - else - { - return PageFlowUtil.decode( //URI conversion encodes - getPathStringWithoutAccessId( - CloudStoreService.get().getPathFromUrl(container, path.toString()).toUri() - ) - ); - } - } - - public static File findUniqueFileName(String originalFilename, File dir) - { - if (originalFilename == null || originalFilename.isEmpty()) - { - originalFilename = "[unnamed]"; - } - File file; - int uniquifier = 0; - do - { - String fullName = getAppendedFileName(originalFilename, uniquifier); - file = appendName(dir, fullName); - uniquifier++; - } - while (file.exists()); - return file; - } - - public static FileLike findUniqueFileName(String originalFilename, FileLike dir) - { - if (originalFilename == null || originalFilename.isEmpty()) - { - originalFilename = "[unnamed]"; - } - FileLike file; - int uniquifier = 0; - do - { - String fullName = getAppendedFileName(originalFilename, uniquifier); - file = dir.resolveChild(fullName); - uniquifier++; - } - while (file.exists()); - return file; - } - - public static String getAppendedFileName(String originalFilename, int uniquifier) - { - String prefix = originalFilename; - String suffix = ""; - - int index = originalFilename.indexOf('.'); - if (index != -1) - { - prefix = originalFilename.substring(0, index); - suffix = originalFilename.substring(index); - } - - return prefix + (uniquifier == 0 ? "" : "-" + uniquifier) + suffix; - } - - - /* If you have a write once, read once text file/stream, you can use this class. - * It wraps the calls to create and delete a temp file, and also will use - * direct to cache the first portion of the file to avoid hitting the - * file system if the file is smaller. - * - * The caller needs to call close() on this object or the Reader returned - * by getReader(). Calling close on both is OK. - */ - public static class TempTextFileWrapper implements Closeable - { - final int characterLimitInMemory; - final ByteBuffer _byteBuffer; - final CharBuffer _charBuffer; - FileWriter _fileWriter = null; - FileReader _fileReader = null; - File _tmpFile = null; - boolean closed = false; // so we can ignore multiple calls to close - - Writer _writer = null; - Reader _reader = null; - - public TempTextFileWrapper(int characterLimitInMemory) - { - this.characterLimitInMemory = characterLimitInMemory; - this._byteBuffer = ByteBuffer.allocate(characterLimitInMemory * 2); - this._charBuffer = _byteBuffer.asCharBuffer(); - } - - public TempTextFileWrapper(CharBuffer charBuffer) - { - this.characterLimitInMemory = charBuffer.capacity(); - this._byteBuffer = null; - this._charBuffer = charBuffer; - } - - - public Writer getWriter() - { - if (null != _writer || closed) - throw new IllegalStateException(closed ? "TempTextFileWrapper is closed" : "getWriter() called twice"); - - // CONSIDER ByteBuffer.allocateDirect(), for now caller can pass in a direct buffer if desired - _writer = new Writer() - { - boolean closed = false; - - @Override - public void write(char @NotNull [] cbuf, int off, int len) throws IOException - { - if (closed) - throw new IOException("Writer is closed"); - if (_charBuffer.remaining() > 0) - { - var l = Math.min(_charBuffer.remaining(), len); - _charBuffer.put(cbuf, off, l); - if (l == len) - return; - off += l; - len -= l; - } - if (null == _fileWriter) - { - assert null == _tmpFile; - _tmpFile = FileUtil.createTempFile("tika", ".tmp.txt"); - _fileWriter = new FileWriter(_tmpFile, StringUtilsLabKey.DEFAULT_CHARSET); - } - _fileWriter.write(cbuf, off, len); - } - - @Override - public void flush() throws IOException - { - if (null != _fileWriter) - _fileWriter.flush(); - } - - @Override - public void close() throws IOException - { - if (null != _fileWriter) - { - _fileWriter.flush(); - _fileWriter.close(); - } - _fileWriter = null; - closed = true; - } - }; - return _writer; - } - - private void _prepareToRead() - { - if (null != _writer) - { - IOUtils.closeQuietly(_writer); - _writer = null; - _charBuffer.flip(); - } - } - - public Reader getReader() - { - if (null != _reader || closed) - throw new IllegalStateException(closed ? "TempTextFileWrapper is closed" : "getReader() called twice"); - - _reader = new Reader() - { - @Override - public int read(char @NotNull [] cbuf, int off, int len) throws IOException - { - _prepareToRead(); - - if (0 < _charBuffer.remaining()) - { - var l = Math.min(len, _charBuffer.remaining()); - _charBuffer.get(cbuf, off, l); - return l; - } - if (null == _fileReader && null != _tmpFile) - _fileReader = new FileReader(_tmpFile, StringUtilsLabKey.DEFAULT_CHARSET); - if (null == _fileReader) - return -1; - return _fileReader.read(cbuf, off, len); - } - - @Override - public void close() throws IOException - { - TempTextFileWrapper.this.close(); - } - }; - return _reader; - } - - public String getSummary(int length) - { - _prepareToRead(); - var l = Math.min(_charBuffer.limit(), length); - return _charBuffer.slice(0,l).toString(); - } - - @Override - public void close() throws IOException - { - if (!closed) - { - closed = true; - if (null != _fileReader) - IOUtils.closeQuietly(_fileReader); - _fileReader = null; - if (null != _fileWriter) - IOUtils.closeQuietly(_fileWriter); - _fileWriter = null; - if (null != _tmpFile) - FileUtil.deleteTempFile(_tmpFile); - _tmpFile = null; - if (null != _byteBuffer && _byteBuffer.isDirect()) - LabKeyByteBufferCleaner.clean(_byteBuffer); - } - } - } - - - @SuppressWarnings("SSBasedInspection") - public static class TestCase extends Assert - { - private static final File ROOT; - - static - { - File f = new File(".").getAbsoluteFile(); - while (f.getParentFile() != null) - { - f = f.getParentFile(); - } - ROOT = f; - } - - @Test - public void testStandardResolve() - { - assertEquals(new File(ROOT, "test/path/sub"), resolveFile(new File(ROOT, "test/path/sub"))); - assertEquals(new File(ROOT, "test"), resolveFile(new File(ROOT, "test"))); - assertEquals(new File(ROOT, "test/path/file.ext"), resolveFile(new File(ROOT, "test/path/file.ext"))); - } - - @Test - public void testDotResolve() - { - assertEquals(new File(ROOT, "test/path/sub"), resolveFile(new File(ROOT, "test/path/./sub"))); - assertEquals(new File(ROOT, "test"), resolveFile(new File(ROOT, "./test"))); - assertEquals(new File(ROOT, "test/path/file.ext"), resolveFile(new File(ROOT, "test/path/file.ext/."))); - } - - @Test - public void testDotDotResolve() - { - assertEquals(ROOT, resolveFile(new File(ROOT, ".."))); - assertEquals(new File(ROOT, "test/sub"), resolveFile(new File(ROOT, "test/path/../sub"))); - assertEquals(new File(ROOT, "test/sub2"), resolveFile(new File(ROOT, "test/path/../sub/../sub2"))); - assertEquals(new File(ROOT, "test"), resolveFile(new File(ROOT, "test/path/sub/../.."))); - assertEquals(new File(ROOT, "sub"), resolveFile(new File(ROOT, "test/path/../../sub"))); - assertEquals(new File(ROOT, "sub2"), resolveFile(new File(ROOT, "test/path/../../sub/../sub2"))); - assertEquals(new File(ROOT, "sub2"), resolveFile(new File(ROOT, "test/path/.././../sub/../sub2"))); - assertEquals(new File(ROOT, "sub2"), resolveFile(new File(ROOT, "test/path/.././../sub/../../sub2"))); - assertEquals(new File(ROOT, "sub2"), resolveFile(new File(ROOT, "a/test/path/.././../sub/../../sub2"))); - assertEquals(new File(ROOT, "b/sub2"), resolveFile(new File(ROOT, "b/a/test/path/.././../sub/../../sub2"))); - assertEquals(ROOT, resolveFile(new File(ROOT, "test/path/../../../.."))); - assertEquals(new File(ROOT, "test/sub"), resolveFile(new File(ROOT, "../../../../test/sub"))); - assertEquals(new File(ROOT, "test"), resolveFile(new File(ROOT, "../test"))); - assertEquals(new File(ROOT, "test/path"), resolveFile(new File(ROOT, "test/path/file.ext/.."))); - assertEquals(new File(ROOT, "folder"), resolveFile(new File(ROOT, ".././../folder"))); - assertEquals(new File(ROOT, "b"), resolveFile(new File(ROOT, "folder/a/.././../b"))); - } - - @Test - public void testUriToString() - { - assertEquals("converted file:/// URI does not match expected string", "file:///data/myfile.txt", uriToString(URI.create("file:///data/myfile.txt"))); - assertEquals("converted file:/ URI does not match expected string", "file:///data/myfile.txt", uriToString(URI.create("file:/data/myfile.txt"))); - } - - @Test - public void testNormalizeURI() - { - assertEquals("file:/// uri not as expected","file:///my/triple/file/path", uriToString(URI.create("file:///my/triple/file/path"))); - assertEquals("file:/// uri with drive letter not as expected","file:///C:/my/triple/file/path", uriToString(URI.create("file:///C:/my/triple/file/path"))); - assertEquals("file:/ uri not conformed to file:///","file:///my/single/file/path", uriToString(URI.create("file:/my/single/file/path"))); - assertEquals("file:/ with drive letter not conformed to file:///","file:///C:/my/single/file/path", uriToString(URI.create("file:/C:/my/single/file/path"))); - assertEquals("File uri with host not as expected", "file://localhost:8080/my/host/file/path", uriToString(URI.create("file://localhost:8080/my/host/file/path"))); - assertEquals("Schemed URI not as expected","http://localhost:8080/my/triple/file/path?query=abcd#anchor", uriToString(URI.create("http://localhost:8080/my/triple/file/path?query=abcd#anchor"))); - } - - @Test - public void testTempFileWrapper() throws IOException - { - try - { - FileUtil.startRequest(); - var sonnet = """ - From fairest creatures we desire increase, - That thereby beauty's rose might never die, - But as the riper should by time decease, - His tender heir might bear his memory: - But thou contracted to thine own bright eyes, - Feed'st thy light's flame with self-substantial fuel, - Making a famine where abundance lies, - Thy self thy foe, to thy sweet self too cruel: - Thou that art now the world's fresh ornament, - And only herald to the gaudy spring, - Within thine own bud buriest thy content, - And tender churl mak'st waste in niggarding: - Pity the world, or else this glutton be, - To eat the world's due, by the grave and thee. - """; - try (var tf = new TempTextFileWrapper(64)) - { - var w = tf.getWriter(); - for (var l : StringUtils.split(sonnet, '\n')) - w.write(l + "\n"); - var r = new BufferedReader(tf.getReader()); - String l, lines = ""; - while (null != (l = r.readLine())) - lines = lines + l + "\n"; - assertEquals(sonnet.trim(), lines.trim()); - assertEquals(sonnet.substring(0, 64), tf.getSummary(100)); - } - try (var tf = new TempTextFileWrapper(900)) - { - var w = tf.getWriter(); - for (var l : StringUtils.split(sonnet, '\n')) - w.write(l + "\n"); - var r = new BufferedReader(tf.getReader()); - String l, lines = ""; - while (null != (l = r.readLine())) - lines = lines + l + "\n"; - assertEquals(sonnet.trim(), lines.trim()); - assertEquals(sonnet.substring(0, 100), tf.getSummary(100)); - } - } - finally - { - // make sure we did not leave any temp files lying around - FileUtil.stopRequest(); - } - } - - @Test - public void testMakeLegalName() - { - assertEquals("__null__", makeLegalName(null)); - assertEquals("__empty__", makeLegalName("")); - assertEquals("_", makeLegalName(" ")); - assertEquals(" _", makeLegalName(" ")); - assertEquals("_", makeLegalName(".")); - assertEquals("._", makeLegalName("..")); - assertEquals("foo", makeLegalName("foo")); - assertEquals("foo_", makeLegalName("foo ")); - assertEquals("foo_", makeLegalName("foo.")); - assertEquals("foo -", makeLegalName("foo -")); - assertEquals("foo _arg", makeLegalName("foo -arg")); - assertEquals("foo _arg-arg", makeLegalName("foo -arg-arg")); - assertEquals("foo _arg _arg2", makeLegalName("foo -arg -arg2")); - - // These are allowed. Verify they don't get changed - assertEquals("a", makeLegalName("a")); - assertEquals("a-b", makeLegalName("a-b")); - assertEquals("a - b", makeLegalName("a - b")); - assertEquals("a- b", makeLegalName("a- b")); - assertEquals("a--b", makeLegalName("a--b")); - assertEquals("a -- b", makeLegalName("a -- b")); - assertEquals("a-- b", makeLegalName("a-- b")); - - // These aren't allowed. Make sure they get changed - assertEquals("_a", makeLegalName("-a")); - assertEquals(" _a", makeLegalName(" -a")); - assertEquals("a _b", makeLegalName("a -b")); - assertEquals("_-a", makeLegalName("--a")); - assertEquals(" _-a", makeLegalName(" --a")); - assertEquals("a _-b", makeLegalName("a --b")); - assertEquals("a _--b", makeLegalName("a ---b")); - - assertEquals(StringUtils.repeat('_', ILLEGAL_CHARS.length), makeLegalName(new String(ILLEGAL_CHARS))); - assertEquals(StringUtils.repeat('_', 255), makeLegalName(StringUtils.repeat(new String(ILLEGAL_CHARS), 50))); - assertEquals(StringUtils.repeat('.', 254) + "_", makeLegalName(StringUtils.repeat('.', 500))); - assertEquals(StringUtils.repeat(' ', 254) + "_", makeLegalName(StringUtils.repeat(' ', 500))); - } - - @Test - public void testAllowedFileName() - { - //Test Setup - Mockery _context = new Mockery(); - _context.setImposteriser(ClassImposteriser.INSTANCE); - AppProps mockProps = _context.mock(AppProps.class); - _context.checking(new Expectations(){{ - allowing(mockProps).isInvalidFilenameBlocked(); - will(returnValue(true)); - }}); - - assertNull(isAllowedFileName("a", false, mockProps)); - assertNull(isAllowedFileName("a-b", false, mockProps)); - assertNull(isAllowedFileName("a - b", false, mockProps)); - assertNull(isAllowedFileName("a- b", false, mockProps)); - assertNull(isAllowedFileName("a--b", false, mockProps)); - assertNull(isAllowedFileName("a -- b", false, mockProps)); - assertNull(isAllowedFileName("a-- b", false, mockProps)); - assertNull(isAllowedFileName("a b", false, mockProps)); - assertNull(isAllowedFileName("a%b", false, mockProps)); - assertNull(isAllowedFileName("a$b", false, mockProps)); - assertNull(isAllowedFileName("%ab", false, mockProps)); - - assertNotNull(isAllowedFileName(null, false, mockProps)); - assertNotNull(isAllowedFileName("", false, mockProps)); - assertNotNull(isAllowedFileName(" ", false, mockProps)); - assertNotNull(isAllowedFileName("a\tb", false, mockProps)); - assertNotNull(isAllowedFileName("-a", false, mockProps)); - assertNotNull(isAllowedFileName(" -a", false, mockProps)); - assertNotNull(isAllowedFileName("a -b", false, mockProps)); - assertNotNull(isAllowedFileName("--a", false, mockProps)); - assertNotNull(isAllowedFileName(" --a", false, mockProps)); - assertNotNull(isAllowedFileName("a --b", false, mockProps)); - assertNotNull(isAllowedFileName("a ---b", false, mockProps)); - assertNotNull(isAllowedFileName("a/b", false, mockProps)); - assertNotNull(isAllowedFileName("a\b", false, mockProps)); - assertNotNull(isAllowedFileName("a:b", false, mockProps)); - assertNotNull(isAllowedFileName("a*b", false, mockProps)); - assertNotNull(isAllowedFileName("a?b", false, mockProps)); - assertNotNull(isAllowedFileName("ab", false, mockProps)); - assertNotNull(isAllowedFileName("a\"b", false, mockProps)); - assertNotNull(isAllowedFileName("a|b", false, mockProps)); - assertNotNull(isAllowedFileName("a`b", false, mockProps)); - assertNotNull(isAllowedFileName("$ab", false, mockProps)); - assertNotNull(isAllowedFileName("-ab", false, mockProps)); - assertNotNull(isAllowedFileName("a`b", false, mockProps)); - } - - @Test - public void testAcceptableExtensions() - { - List allowedExtensions = Arrays.asList( - ".1", - ".txt", - ".tar", - ".tar.gz", - ".a_v", - ".xlsx", - ".l-()[]{}1☃"); - - //Test Setup - Mockery _context = new Mockery(); - _context.setImposteriser(ClassImposteriser.INSTANCE); - AppProps mockProps = _context.mock(AppProps.class); - _context.checking(new Expectations(){{ - allowing(mockProps).getAllowedExtensions(); - will(returnValue(allowedExtensions)); - }}); - - - assertNull("Extension should be allowed", checkExtension("test.txt", mockProps)); - assertNull("Multiple extension should be allowed", checkExtension("archive.tar.gz", mockProps)); - assertNull("Case-insensitive extension should be allowed", checkExtension("archive.TaR.Gz", mockProps)); - assertNull("Special characters aren't escaped properly", checkExtension("my test.l-()[]{}1☃", mockProps)); - assertNull("Numeric extension should be allowed", checkExtension("test.1", mockProps)); - assertNotNull("Multiple extension matched when it shouldn't", checkExtension("tar.gz", mockProps)); - assertNotNull("Matched unlist extension", checkExtension("my test.notListed", mockProps)); - assertNotNull("Combined multiple extension matched incorrectly", checkExtension("multi.a_v.tar", mockProps)); - assertNotNull("Multi-multi extension matched unexpectedly", checkExtension("multi.not.tar.gz", mockProps)); - assertNotNull("No extension matched unexpectedly", checkExtension("No extension", mockProps)); - } - - @Test - public void testNoAcceptableExtensions() - { - List allowedExtensions = Collections.emptyList(); - - //Test Setup - Mockery _context; - _context = new Mockery(); - _context.setImposteriser(ClassImposteriser.INSTANCE); - AppProps mockProps = _context.mock(AppProps.class); - _context.checking(new Expectations(){{ - allowing(mockProps).getAllowedExtensions(); - will(returnValue(allowedExtensions)); - }}); - - assertNull("Special characters aren't escaped properly", checkExtension("my test.l-()[]{}1☃", mockProps)); - assertNull("Unlisted extension should be allowed, but wasn't", checkExtension("my test.notListed", mockProps)); - assertNull("Combined extension should be allowed, but wasn't", checkExtension("multi.tar.a_v", mockProps)); - assertNull("No extension should be allowed, but wasn't", checkExtension("No extension", mockProps)); - assertNull("Numeric extension should be allowed", checkExtension("test.1", mockProps)); - } - - @Test - public void testGetAppendedFileName() - { - String originalFilename = "test.txt"; - assertEquals("test.txt", getAppendedFileName(originalFilename, 0)); - assertEquals("test-1.txt", getAppendedFileName(originalFilename, 1)); - assertEquals("test-2.txt", getAppendedFileName(originalFilename, 2)); - } - } -} +/* + * Copyright (c) 2008-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.labkey.api.util; + +import org.apache.commons.io.FileUtils; +import org.apache.commons.io.FilenameUtils; +import org.apache.commons.io.IOUtils; +import org.apache.commons.io.file.SimplePathVisitor; +import org.apache.commons.io.input.LabKeyByteBufferCleaner; +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jmock.Expectations; +import org.jmock.Mockery; +import org.jmock.lib.legacy.ClassImposteriser; +import org.junit.Assert; +import org.junit.Test; +import org.labkey.api.cloud.CloudStoreService; +import org.labkey.api.data.Container; +import org.labkey.api.files.FileContentService; +import org.labkey.api.security.Crypt; +import org.labkey.api.settings.AppProps; +import org.labkey.api.util.logging.LogHelper; +import org.labkey.api.view.UnauthorizedException; +import org.labkey.vfs.FileLike; +import org.labkey.vfs.FileSystemLike; + +import java.io.BufferedInputStream; +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.Closeable; +import java.io.DataOutput; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.Reader; +import java.io.Writer; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; +import java.nio.channels.ReadableByteChannel; +import java.nio.file.FileSystems; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.attribute.FileAttribute; +import java.nio.file.attribute.PosixFilePermission; +import java.nio.file.attribute.PosixFilePermissions; +import java.security.DigestInputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public class FileUtil +{ + public static final String FILE_SCHEME = "file"; // url scheme for local file system + + private static final Logger LOG = LogHelper.getLogger(FileUtil.class, "FileUtil.java logger"); + + private static File _tempDir = null; + private static FileLike _tempDirFileLike = null; + + static private final String windowsRestricted = "\\/:*?\"<>|`"; + // and ` seems like a bad idea for linux? + static private final String linuxRestricted = "`"; + static private final String restrictedPrintable = windowsRestricted + linuxRestricted; + + private static final ThreadLocal> tempPaths = ThreadLocal.withInitial(HashSet::new); + + private static Pattern extensionChecker; + + public static void startRequest() + { + tempPaths.get().clear(); + } + + @SuppressWarnings("RedundantOperationOnEmptyContainer") + public static void stopRequest() + { + var paths = tempPaths.get(); + assert paths.isEmpty(); + for (Path p : paths) + { + try + { + Files.deleteIfExists(p); + } + catch (IOException x) + { + p.toFile().deleteOnExit(); + } + } + paths.clear(); + } + + + @Deprecated + public static boolean deleteDirectoryContents(File dir) + { + try + { + return deleteDirectoryContents(dir.toPath()); + } + catch (IOException e) + { + return false; // could there be more done here to log the error? + } + } + + + public static boolean deleteDirectoryContents(Path dir) throws IOException + { + return deleteDirectoryContents(dir, null); + } + + + public static boolean deleteDirectoryContents(FileLike dir) throws IOException + { + if (!dir.getFileSystem().canWriteFiles()) + throw new UnauthorizedException(); + return deleteDirectoryContents(toFileForWrite(dir).toPath(), null); + } + + + public static boolean deleteDirectoryContents(Path dir, @Nullable Logger log) throws IOException + { + if (Files.isDirectory(dir)) + { + File dirFile = dir.toFile(); //TODO this method should be converted to use Path and Files.walkFileTree + String[] children = dirFile.list(); + + if (null == children) // 17562 + return true; + + for (String aChildren : children) + { + boolean success = deleteDir(FileUtil.appendName(dirFile, aChildren), log); + if (!success) + { + return false; + } + } + } + return true; + } + + + public static boolean deleteSubDirs(File dir) + { + if (dir.isDirectory()) + { + File[] children = dir.listFiles(); + if (null != children) + { + for (File child : children) + { + boolean success = true; + if (child.isDirectory()) + success = deleteDir(child); + if (!success) + { + return false; + } + } + } + } + return true; + } + + + /** File.delete() will only delete a directory if it's empty, but this will + * delete all the contents and the directory */ + public static boolean deleteDir(File dir) + { + return deleteDir(dir, null); + } + + public static boolean deleteDir(FileLike dir) + { + return deleteDir(dir.toNioPathForWrite(), null); + } + + @Deprecated + public static boolean deleteDir(@NotNull File dir, Logger log) + { + return deleteDir(dir.toPath(), log); + } + + + public static boolean deleteDir(Path dir, Logger log) + { + //TODO seems like this could be reworked to use Files.walkFileTree + log = log == null ? LOG : log; + + // Issue 22336: See note in FileUtils.isSymLink() about windows-specific bugs for symlinks: + // http://commons.apache.org/proper/commons-io/apidocs/org/apache/commons/io/FileUtils.html + if (!Files.isSymbolicLink(dir)) + { + try + { + // this returns true if !dir.isDirectory() + boolean success = deleteDirectoryContents(dir, log); + if (!success) + return false; + } + catch (IOException e) + { + log.debug(String.format("Unable to clean dir [%1$s]", dir), e); + return false; + } + } + + IOException lastException = null; + + // The directory is now either a sym-link or empty, so delete it + for (int i = 0; i < 5 ; i++) + { + try + { + Files.deleteIfExists(dir); + return true; + } + catch (IOException e) + { + lastException = e; + // Issue 39579: Folder import sometimes fails to delete temp directory + // wait a little then try again + log.warn("Failed to delete file. Sleep and try to delete again. " + e.getMessage()); + try {Thread.sleep(1000);} catch (InterruptedException x) {/* pass */} + } + } + log.warn("Failed to delete file after 5 attempts: " + FileUtil.getAbsoluteCaseSensitiveFile(dir.toFile()), lastException); + return false; + } + + + public static boolean deleteDir(@NotNull Path dir) throws IOException + { + if (Files.exists(dir)) + { + if (hasCloudScheme(dir)) + { + // TODO: On Windows, collect is yielding AccessDenied Exception, so only do this for cloud + try (Stream paths = Files.walk(dir)) + { + boolean success = true; + for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) + { + success = Files.deleteIfExists(path) && success; + } + return success; + } + } + else + { + return deleteDir(dir.toFile()); // Note: we maintain existing behavior from before Path work, which is to ignore any error + } + } + + return true; + } + + + public static void copyDirectory(Path srcPath, Path destPath) throws IOException + { + // Will replace existing files + if (!Files.exists(destPath)) + FileUtil.createDirectory(destPath); + try (Stream list = Files.list(srcPath)) + { + for (Path srcChild : list.toList()) + { + Path destChild = destPath.resolve(getFileName(srcChild)); + if (Files.isDirectory(srcChild)) + copyDirectory(srcChild, destChild); + else + Files.copy(srcChild, destChild, StandardCopyOption.REPLACE_EXISTING); + } + } + } + + public static String isAllowedFileName(String s, boolean checkFileExtension) + { + return isAllowedFileName(s, checkFileExtension, AppProps.getInstance()); + } + + static String isAllowedFileName(String s, boolean checkFileExtension, AppProps appProps) + { + if (appProps.isInvalidFilenameBlocked()) + { + String msg = validateFileName(s); + if (msg != null) + return msg; + } + + if (checkFileExtension) + { + String badExtension = checkExtension(s, AppProps.getInstance()); + if (badExtension != null) + return "This file type [" + badExtension + "] is not allowed. Accepted file extensions: " + AppProps.getInstance().getAllowedExtensions(); + } + return null; + } + + public static @Nullable String validateFileName(String s) + { + return StringUtilsLabKey.validateLegalNames(s, restrictedPrintable, "Filename"); + } + + private static String checkExtension(String filename, AppProps appProps) + { + // If the allow list is empty, allow any extension + if (appProps.getAllowedExtensions().isEmpty()) + return null; + + if (extensionChecker == null) + setExtensionChecker(appProps); + + String extension = FilenameUtils.getExtension(filename); + return extensionChecker.matcher(filename).matches() ? null : extension; + } + + private static void setExtensionChecker(AppProps appProps) + { + // Regex encode the allowed extensions (escape periods and add '|' optional matcher) + String allowedExtensions = appProps.getAllowedExtensions().stream().map(Pattern::quote).collect(Collectors.joining("|")); + // Allow any extension in the list unless it is preceded by a '.' which we use as a proxy for double/multi extensions + extensionChecker = Pattern.compile(String.format("^[^\\.]*(%1$s)$", allowedExtensions), Pattern.CASE_INSENSITIVE); + } + + public static void clearExtensionChecker() + { + extensionChecker = null; + } + + public static void checkAllowedFileName(String s, boolean checkFileExtension) throws IOException + { + String msg = isAllowedFileName(s, checkFileExtension); + if (null == msg) + return; + throw new IOException(s + ": " + msg); + } + + public static boolean mkdir(File file) throws IOException + { + return mkdir(file, AppProps.getInstance().isInvalidFilenameBlocked()); + } + + public static File toFileForRead(FileLike file) + { + if (null == file) + return null; + return file.toNioPathForRead().toFile(); + } + + public static File toFileForWrite(FileLike file) + { + if (null == file) + return null; + return file.toNioPathForWrite().toFile(); + } + + public static boolean mkdir(FileLike file) throws IOException + { + return mkdir(toFileForWrite(file), AppProps.getInstance().isInvalidFilenameBlocked()); + } + + public static boolean mkdir(File file, boolean checkFileName) throws IOException + { + if (checkFileName) + checkAllowedFileName(file.getName(), false); + //noinspection SSBasedInspection + return file.mkdir(); + } + + + public static boolean mkdirs(File file) throws IOException + { + return mkdirs(file, AppProps.getInstance().isInvalidFilenameBlocked()); + } + + public static boolean mkdirs(FileLike file) throws IOException + { + if (!file.getFileSystem().canWriteFiles()) + throw new UnauthorizedException(); + var ret = mkdirs(toFileForWrite(file), AppProps.getInstance().isInvalidFilenameBlocked()); + file.refresh(); + return ret; + } + + public static boolean mkdirs(File file, boolean checkFileName) throws IOException + { + File parent = file; + while (!Files.exists(parent.toPath())) + { + if (checkFileName) + checkAllowedFileName(parent.getName(), false); + parent = parent.getParentFile(); + } + //noinspection SSBasedInspection + return file.mkdirs(); + } + + public static boolean mkdirs(FileLike file, boolean checkFileName) throws IOException + { + FileLike parent = file; + var ret = false; + while (!Files.exists(parent.toNioPathForWrite())) + { + ret = true; + if (checkFileName) + checkAllowedFileName(parent.getName(), false); + parent = parent.getParent(); + } + file.mkdirs(); + return ret; + } + + + public static FileLike createDirectory(FileLike path) throws IOException + { + createDirectory(path.toNioPathForWrite(), AppProps.getInstance().isInvalidFilenameBlocked()); + return path; + } + + public static Path createDirectory(Path path) throws IOException + { + return createDirectory(path, AppProps.getInstance().isInvalidFilenameBlocked()); + } + + + public static Path createDirectory(Path path, boolean checkFileName) throws IOException + { + if (checkFileName) + checkAllowedFileName(getFileName(path), false); + if (!Files.exists(path)) + //noinspection SSBasedInspection + return Files.createDirectory(path); + return path; + } + + + public static Path createDirectories(Path path) throws IOException + { + return createDirectories(path, AppProps.getInstance().isInvalidFilenameBlocked()); + } + + + public static void createDirectories(FileLike file) throws IOException + { + if (!file.getFileSystem().canWriteFiles()) + throw new UnauthorizedException(); + File target = toFileForWrite(file); + createDirectories(target.toPath(), AppProps.getInstance().isInvalidFilenameBlocked()); + } + + + public static Path createDirectories(Path path, boolean checkFileName) throws IOException + { + Path parent = path; + while (!Files.exists(parent)) + { + if (checkFileName) + checkAllowedFileName(getFileName(parent), false); + parent = parent.getParent(); + } + //noinspection SSBasedInspection + return Files.createDirectories(path); + } + + + public static boolean renameTo(FileLike from, FileLike to) + { + // TODO FileLike.renameTo() + return toFileForRead(from).renameTo(toFileForWrite(to)); + } + + + public static boolean createNewFile(File file) throws IOException + { + return createNewFile(file, AppProps.getInstance().isInvalidFilenameBlocked()); + } + + + public static boolean createNewFile(File file, boolean checkFileName) throws IOException + { + if (checkFileName) + checkAllowedFileName(file.getName(), true); + //noinspection SSBasedInspection + return file.createNewFile(); + } + + + public static boolean createNewFile(FileLike file, boolean checkFileName) throws IOException + { + if (checkFileName) + checkAllowedFileName(file.getName(), true); + var ret = !file.exists(); + file.createFile(); + return ret; + } + + + public static Path createFile(Path path, FileAttribute... attrs) throws IOException + { + return createFile(path, AppProps.getInstance().isInvalidFilenameBlocked(), attrs); + } + + + public static Path createFile(Path path, boolean checkFileName, FileAttribute... attrs) throws IOException + { + if (checkFileName) + checkAllowedFileName(getFileName(path), true); + return Files.createFile(path, attrs); + } + + + // return true if file exists and is not a directory + public static boolean isFileAndExists(@Nullable Path path) + { + try + { + // One call to cloud rather than two (exists && !isDirectory) + return (null != path && !Files.readAttributes(path, BasicFileAttributes.class).isDirectory()); + } + catch (IOException e) + { + return false; + } + } + + + /** + * Remove text right of a specific number of periods, including the periods, from a file's name. + *

    + *
  • C:\dir\name.ext, 1 => name
  • + *
  • C:\dir\name.ext1.ext2, 2 => name
  • + *
  • C:\dir\name.ext1.ext2, 1 => name.ext1
  • + *
+ * + * @param fileName name of the file + * @param dots number of dots to remove + * @return base name + */ + public static String getBaseName(String fileName, int dots) + { + String baseName = fileName; + while (dots-- > 0 && baseName.indexOf('.') != -1) + baseName = baseName.substring(0, baseName.lastIndexOf('.')); + return baseName; + } + + + /** + * Remove text right of and including the last period in a file's name. + * @param fileName name of the file + * @return base name + */ + public static String getBaseName(String fileName) + { + return getBaseName(fileName, 1); + } + + + /** + * Remove text right of a specific number of periods, including the periods, from a file's name. + *
    + *
  • C:\dir\name.ext, 1 => name
  • + *
  • C:\dir\name.ext1.ext2, 2 => name
  • + *
  • C:\dir\name.ext1.ext2, 1 => name.ext1
  • + *
+ * + * @param file file from which to get the name + * @param dots number of dots to remove + * @return base name + */ + public static String getBaseName(File file, int dots) + { + return getBaseName(file.getName(), dots); + } + + public static String getBaseName(FileLike file, int dots) + { + return getBaseName(file.toNioPathForRead().toFile(), dots); + } + + + /** + * Remove text right of and including the last period in a file's name. + * @param file file from which to get the name + * @return base name + */ + public static String getBaseName(File file) + { + return getBaseName(file, 1); + } + + public static String getBaseName(FileLike file) + { + return getBaseName(file, 1); + } + + + /** + * Returns the file name extension without the dot, null if there + * isn't one. + */ + @Nullable + public static String getExtension(File file) + { + return getExtension(file.getName()); + } + + + /** + * Returns the file name extension without the dot, null if there + * isn't one. + */ + @Nullable + public static String getExtension(String name) + { + if (name != null && name.lastIndexOf('.') != -1) + { + return name.substring(name.lastIndexOf('.') + 1); + } + return null; + } + + + public static boolean hasCloudScheme(Path path) + { + try + { + return hasCloudScheme(path.toUri()); + } + catch (Exception e) + { + return false; + } + } + + + public static boolean hasCloudScheme(URI uri) + { + return "s3".equalsIgnoreCase(uri.getScheme()); + } + + + public static boolean hasCloudScheme(String url) + { + return url.toLowerCase().startsWith("s3://"); + } + + + public static boolean hasCloudScheme(FileLike filelike) + { + return "s3".equals(filelike.getFileSystem().getScheme()); + } + + + public static String getAbsolutePath(Path path) + { + if (!FileUtil.hasCloudScheme(path)) + return path.toFile().getAbsolutePath(); + else + return getPathStringWithoutAccessId(path.toAbsolutePath().toUri()); + + } + + + @Nullable + public static String getAbsolutePath(Container container, Path path) + { // Returned string is NOT necessarily a URI (i.e. it is not encoded) + return getAbsolutePath(container, path.toUri()); + } + + + @Nullable + public static String getAbsolutePath(Container container, URI uri) + { + if (!uri.isAbsolute()) + return null; + else if (!FileUtil.hasCloudScheme(uri)) + return new File(uri).getAbsolutePath(); + else + return getAbsolutePathWithoutAccessIdFromCloudUrl(container, uri); + } + + + @Nullable + public static String getAbsoluteCaseSensitivePathString(Container container, URI uri) + { + if (!uri.isAbsolute()) + return null; + else if (!FileUtil.hasCloudScheme(uri)) + return getAbsoluteCaseSensitiveFile(new File(uri)).toPath().toUri().toString(); // Was: return getAbsoluteCaseSensitiveFile(new File(uri)).toURI().toString(); // #36352 + else + return getAbsolutePathWithoutAccessIdFromCloudUrl(container, uri); + } + + + @Nullable + public static Path getAbsoluteCaseSensitivePath(Container container, URI uri) + { + if (!uri.isAbsolute()) + return null; + else if (!FileUtil.hasCloudScheme(uri)) + return getAbsoluteCaseSensitiveFile(new File(uri)).toPath(); + else + return getAbsolutePathFromCloudUrl(container, uri); + } + + + @Nullable + private static String getAbsolutePathWithoutAccessIdFromCloudUrl(Container container, URI uri) + { + Path path = getAbsolutePathFromCloudUrl(container, uri); + return null != path ? getPathStringWithoutAccessId(path.toAbsolutePath().toUri()) : null; + } + + + @Nullable + private static Path getAbsolutePathFromCloudUrl(Container container, URI uri) + { + Path path = Objects.requireNonNull(CloudStoreService.get()).getPathFromUrl(container, uri.toString()); + return null != path ? path.toAbsolutePath() : null; + } + + + public static Path getAbsoluteCaseSensitivePath(Container container, Path path) + { + if (!FileUtil.hasCloudScheme(path)) + return getAbsoluteCaseSensitiveFile(path.toFile()).toPath(); + else + return path.toAbsolutePath(); + } + + + @Nullable + public static Path getPath(Container container, URI uri) + { + if (!uri.isAbsolute()) + return null; + else if (!FileUtil.hasCloudScheme(uri)) + return new File(uri).toPath(); + else + return Objects.requireNonNull(CloudStoreService.get()).getPathFromUrl(container, uri.toString()); + } + + + public static URI createUri(String str) + { + return createUri(str, true); + } + + + public static URI createUri(String str, boolean isEncoded) + { + str = str.replace("\\", "/"); + // Assume that Windows-style drive-letter paths like c:/myfile.txt should be treated as file:/ URIs + if (str.matches("^[A-Za-z]:/.*")) + return new File(str).toURI(); + + String str2 = str; + if (str2.startsWith("/")) + str2 = "file://" + str; + + // Creating stack traces is expensive so only bother if we're really going to log it + if (LOG.isDebugEnabled()) + { + LOG.debug("CreateUri from: " + str + " [" + Thread.currentThread().getStackTrace()[2].toString() + "]"); + } + if (isEncoded) + str2 = str2.replace(" ", "%20"); // Spaces in paths make URI unhappy + else + str2 = encodeForURL(str2); + try + { + return new URI(str2); + } + catch (URISyntaxException e) + { + // We're handling encoded and unencoded, so this can fail because of certain reserved chars; + if (str.startsWith("/")) + return new File(str).toPath().toUri(); + throw new IllegalArgumentException(e); + } + } + + + @NotNull + public static String getFileName(Path fullPath) + { + // We want unencoded fileName + if (hasCloudScheme(fullPath)) + { + Path path = fullPath.getFileName(); + return path == null ? "" : path.toUri().getPath(); + } + else + { + return fullPath.getFileName().toString(); + } + } + + + /** Only returns a child path */ + public static File appendPath(File dir, org.labkey.api.util.Path originalPath) + { + org.labkey.api.util.Path path = originalPath.normalize(); + if (path == null || (!path.isEmpty() && "..".equals(path.get(0)))) + throw new InvalidPathException(originalPath.toString(), "Path to parent not allowed"); + @SuppressWarnings("SSBasedInspection") + var ret = new File(dir, path.toString()); + if (!ret.toPath().normalize().startsWith(dir.toPath().normalize())) + throw new InvalidPathException(originalPath.toString(), "Path to parent not allowed"); + return ret; + } + + + /** Only returns a child path */ + public static FileLike appendPath(FileLike dir, org.labkey.api.util.Path path) + { + path = path.normalize(); + if (!path.isEmpty() && "..".equals(path.get(0))) + throw new InvalidPathException(path.toString(), "Path to parent not allowed"); + return dir.resolveFile(path); + } + + + /** Resolve a relative path, may not be a descendant. */ + public static FileLike resolveFile(FileLike dir, org.labkey.api.util.Path path) + { + return dir.resolveFile(path); + } + + + /* Only returns an immediate child */ + public static File appendName(File dir, org.labkey.api.util.Path.Part part) + { + return appendName(dir, part.toString()); + } + + + /* Only returns an immediate child */ + public static File appendName(File dir, String name) + { + if (!dir.isAbsolute()) + { + dir = dir.getAbsoluteFile(); + } + legalPathPartThrow(name); + @SuppressWarnings("SSBasedInspection") + var ret = new File(dir, name); + + if (!ret.toPath().normalize().startsWith(dir.toPath().normalize())) + throw new InvalidPathException(name, "Path to parent not allowed"); + return ret; + } + + /* Only returns an immediate child */ + public static Path appendName(Path dir, String name) + { + legalPathPartThrow(name); + var ret = dir.resolve(name); + + if (!ret.normalize().startsWith(dir.normalize())) + throw new InvalidPathException(name, "Path to parent not allowed"); + return ret; + } + + + // narrower check than isLegalName() or isAllowedFileName() + // this check that a name is a valid path part (e.g. filename) and is not path like. + public static void legalPathPartThrow(String name) + { + int invalidCharacterIndex = StringUtils.indexOfAny(name, '/', File.separatorChar); + if (invalidCharacterIndex >= 0) + throw new InvalidPathException(name, "Invalid file or directory name", invalidCharacterIndex); + if (".".equals(name) || "..".equals(name)) + throw new InvalidPathException(name, "Invalid file or directory name"); + } + + + public static String decodeSpaces(@NotNull String str) + { + return str.replace("%20", " "); + } + + + public static String pathToString(Path path) + { // Returns a URI string (encoded) + return getPathStringWithoutAccessId(path.toUri()); + } + + + public static String uriToString(URI uri) + { + return getPathStringWithoutAccessId(uri); + } + + + public static Path stringToPath(Container container, String str) + { + return stringToPath(container, str, true); + } + + + public static Path stringToPath(Container container, String str, boolean isEncoded) + { + if (!FileUtil.hasCloudScheme(str)) + return new File(createUri(str, isEncoded)).toPath(); + else + return Objects.requireNonNull(CloudStoreService.get()).getPathFromUrl(container, PageFlowUtil.decode(str)/*decode everything not just the space*/); + } + + + public static String getCloudRootPathString(String cloudName) + { + return FileContentService.CLOUD_ROOT_PREFIX + "/" + cloudName; + } + + + @Nullable + private static String getPathStringWithoutAccessId(URI uri) + { + if (null != uri) + if (hasCloudScheme(uri)) + return uri.toString().replaceFirst("/\\w+@s3", "/s3"); // Remove accessId portion if exists + else + { + try + { + return Objects.requireNonNull(URIUtil.normalizeUri(uri)).toString(); + } + catch (URISyntaxException e) + { + LOG.debug("Error attempting to conform uri: " + e.getMessage()); + return uri.toString(); + } + } + else + return null; + } + + + /** + * Get relative path of File 'file' with respect to 'home' directory + *

+     * example : home = /a/b/c
+     *           file    = /a/d/e/x.txt
+     *           return = ../../d/e/x.txt
+     * 

+ * The path returned has system specific directory separators. + *

+ * It is equivalent to:
+ *

home.toURI().relativize(f.toURI).toString().replace('/', File.separatorChar)
+ * + * @param home base path, should be a directory, not a file, or it doesn't make sense + * @param file file to generate path for + * @param canonicalize whether or not the paths need to be canonicalized + * @return path from home to file as a string + */ + public static String relativize(File home, File file, boolean canonicalize) throws IOException + { + if (canonicalize) + { + home = FileUtil.getAbsoluteCaseSensitiveFile(home); + file = FileUtil.getAbsoluteCaseSensitiveFile(file); + } + else + { + home = resolveFile(home); + file = resolveFile(file); + } + return matchPathLists(getPathList(home), getPathList(file)); + } + + + /** + * Get a relative path of File 'file' with respect to 'home' directory, + * forcing Unix (i.e. URI) forward slashes for directory separators. + *

+ * This is a lot like URIUtil.relativize() without requiring + * that the file be a descendant of the base. + *

+ * It is equivalent to:
+ *

home.toURI().relativize(f.toURI).toString()
+ */ + public static String relativizeUnix(File home, File f, boolean canonicalize) throws IOException + { + return relativize(home, f, canonicalize).replace('\\', '/'); + } + + + public static String relativizeUnix(Path home, Path f, boolean canonicalize) throws IOException + { + if (!hasCloudScheme(home) && !hasCloudScheme(f)) + return relativizeUnix(home.toFile(), f.toFile(), canonicalize); + return getPathStringWithoutAccessId(home.toUri().relativize(f.toUri())); + } + + + /** + * Break a path down into individual elements and add to a list. + *

+ * example : if a path is /a/b/c/d.txt, the breakdown will be [d.txt,c,b,a] + * + * @param file input file + * @return a List collection with the individual elements of the path in reverse order + */ + private static List getPathList(File file) + { + List parts = new ArrayList<>(); + while (file != null) + { + parts.add(file.getName()); + file = file.getParentFile(); + } + + return parts; + } + + + /** + * Figure out a string representing the relative path of + * 'file' with respect to 'home' + * + * @param home home path + * @param file path of file + * @return relative path from home to file + */ + public static String matchPathLists(List home, List file) + { + // start at the beginning of the lists + // iterate while both lists are equal + StringBuilder path = new StringBuilder(); + int i = home.size() - 1; + int j = file.size() - 1; + + // first eliminate common root + while ((i >= 0) && (j >= 0) && (home.get(i).equals(file.get(j)))) + { + i--; + j--; + } + + // for each remaining level in the home path, add a .. + for (; i >= 0; i--) + path.append("..").append(File.separator); + + // for each level in the file path, add the path + for (; j >= 1; j--) + path.append(file.get(j)).append(File.separator); + + // if nothing left of the file, then it was a directory + // of which home is a subdirectory. + if (j < 0) + { + if (path.isEmpty()) + path.append("."); + else + path.delete(path.length() - 1, path.length()); // remove trailing sep + } + else + path.append(file.get(j)); // add file name + + return path.toString(); + } + + public static void copyFile(FileLike src, FileLike dst) throws IOException + { + try (InputStream in = src.openInputStream(); + OutputStream out = dst.openOutputStream()) + { + copyData(in, out); + } + } + + + public static void copyFile(File src, File dst) throws IOException + { + try (FileInputStream is = new FileInputStream(src); + FileChannel in = is.getChannel(); + FileLock lockIn = in.lock(0L, Long.MAX_VALUE, true)) + { + copyFile(in, in.size(), dst); + dst.setLastModified(src.lastModified()); + } + } + + + // FileUtil.copyFile() does not use transferTo() or sync() + public static void copyFile(ReadableByteChannel in, long expected, File dst) throws IOException + { + createNewFile(dst); + + boolean success = false; + long actual = 0; + long bytesCopied; + + LOG.debug("Starting to transfer to " + dst + ", expecting " + (expected == -1 ? "an unknown number" : Long.toString(expected)) + " bytes"); + + try (FileOutputStream os = new FileOutputStream(dst); + FileChannel out = os.getChannel(); + FileLock lockOut = out.lock()) + { + do + { + bytesCopied = out.transferFrom(in, actual, Long.MAX_VALUE); + actual += bytesCopied; + if (actual != expected && bytesCopied != 0) + { + LOG.debug("Still transferring to " + dst + ", " + actual + " bytes transferred so far"); + } + } + while (bytesCopied != 0); + success = actual == expected; + os.getFD().sync(); + } + finally + { + if (success) + { + LOG.debug("Finished transferring " + actual + " bytes to " + dst); + } + else + { + LOG.debug("Failed during transfer, but successfully copied at least " + actual + " bytes to " + dst); + } + } + } + + + /** + * Copies an entire file system branch to another location, including the root directory itself + * @param src The source file root + * @param dest The destination file root + * @throws IOException thrown from IO functions + */ + public static void copyBranch(File src, File dest) throws IOException + { + copyBranch(src, dest, false); + } + + + /** + * Copies an entire file system branch to another location + * + * @param src The source file root + * @param dest The destination file root + * @param contentsOnly Pass false to copy the root directory as well as the files within; true to just copy the contents + * @throws IOException Thrown if there's an IO exception + */ + public static void copyBranch(File src, File dest, boolean contentsOnly) throws IOException + { + //if src is just a file, copy it and return + if (src.isFile()) + { + File destFile = FileUtil.appendName(dest, src.getName()); + copyFile(src, destFile); + return; + } + + //if copying the src root directory as well, make that + //within the dest and re-assign dest to the new directory + if (!contentsOnly) + { + dest = FileUtil.appendName(dest, src.getName()); + mkdirs(dest); + if(!dest.isDirectory()) + throw new IOException("Unable to create the directory " + dest + "!"); + } + + File[] children = src.listFiles(); + if (children == null) + { + throw new IOException("Unable to get file listing for directory: " + src); + } + for (File file : children) + { + copyBranch(file, dest, false); + } + } + + + /** + * always returns path starting with /. Tries to leave trailing '/' as is + * (unless ends with /. or /..) + * + * @param path path to normalize + * @return cleaned path or null if path goes outside of 'root' + */ + @Deprecated // use java.util.Path + public static String normalize(String path) + { + if (path == null || equals(path,'/')) + return path; + + String str = path; + if (str.indexOf('\\') >= 0) + str = str.replace('\\', '/'); + if (!startsWith(str,'/')) + str = "/" + str; + int len = str.length(); + + // quick scan, look for /. or // +quickScan: + { + for (int i=0 ; i list = normalizeSplit(str); + if (null == list) + return null; + if (list.isEmpty()) + return "/"; + StringBuilder sb = new StringBuilder(str.length()+2); + for (String name : list) + { + sb.append('/'); + sb.append(name); + } + return sb.toString(); + } + + + @Deprecated // use java.util.Path + public static ArrayList normalizeSplit(String str) + { + int len = str.length(); + ArrayList list = new ArrayList<>(); + int start = 0; + for (int i=0 ; i<=len ; i++) + { + if (i==len || str.charAt(i) == '/') + { + if (start < i) + { + String part = str.substring(start, i); + if (part.isEmpty() || equals(part,'.')) + { + } + else if (part.equals("..")) + { + if (list.isEmpty()) + return null; + list.remove(list.size()-1); + } + else + { + list.add(part); + } + } + start=i+1; + } + } + return list; + } + + public static String encodeForURL(String str) + { + return encodeForURL(str, false); + } + + public static String encodeForURL(String str, boolean checkEncoded) + { + if (checkEncoded && isUrlEncoded(str)) + return str; + + // str is unencoded; we need certain special chars encoded for it to become a URL + // % & # @ ~ {} [] + return StringUtils.replaceEach(str, DECODED, ENCODED); + } + + private static final String[] ENCODED = {"%25", "%23", "%26", "%40", "%7E", "%7B", "%7D", "%5B", "%5D", "%2B", "%20"}; + private static final String[] DECODED = {"%", "#", "&", "@", "~", "{", "}", "[", "]", "+", " "}; + + static public String decodeURL(String str) + { + return StringUtils.replaceEach(str, ENCODED, DECODED); + } + + public static boolean isUrlEncoded(String str) + { + return StringUtils.indexOfAny(str, ENCODED) > -1; + } + + static boolean startsWith(String s, char ch) + { + return !s.isEmpty() && s.charAt(0) == ch; + } + + + static boolean equals(String s, char ch) + { + return s.length() == 1 && s.charAt(0) == ch; + } + + + public static String relativePath(String dir, String filePath) + { + dir = normalize(dir); + filePath = normalize(filePath); + if (dir.endsWith("/")) + dir = dir.substring(0,dir.length()-1); + if (!filePath.toLowerCase().startsWith(dir.toLowerCase())) + return null; + String relPath = filePath.substring(dir.length()); + if (relPath.isEmpty()) + return relPath; + if (relPath.startsWith("/")) + return relPath.substring(1); + return null; + } + + + private static String digest(MessageDigest md, InputStream is) throws IOException + { + try (DigestInputStream dis = new DigestInputStream(is, md)) + { + byte[] buf = new byte[8 * 1024]; + while (-1 != (dis.read(buf))) + { + /* */ + } + return Crypt.encodeHex(md.digest()); + } + } + + + public static String sha1sum(InputStream is) throws IOException + { + try + { + return digest(MessageDigest.getInstance("SHA1"), is); + } + catch (NoSuchAlgorithmException e) + { + LOG.error("unexpected error", e); + return null; + } + finally + { + IOUtils.closeQuietly(is); + } + } + + + public static String sha1sum(byte[] bytes) throws IOException + { + return sha1sum(new ByteArrayInputStream(bytes)); + } + + + public static String md5sum(InputStream is) throws IOException + { + try + { + return digest(MessageDigest.getInstance("MD5"), is); + } + catch (NoSuchAlgorithmException e) + { + LOG.error("unexpected error", e); + return null; + } + finally + { + IOUtils.closeQuietly(is); + } + } + + + public static String md5sum(byte[] bytes) throws IOException + { + return md5sum(new ByteArrayInputStream(bytes)); + } + + + public static byte[] readHeader(@NotNull File f, int len) throws IOException + { + try (InputStream is = new BufferedInputStream(new FileInputStream(f))) + { + return FileUtil.readHeader(is, len); + } + } + + + public static byte[] readHeader(@NotNull InputStream is, int len) throws IOException + { + assert is.markSupported(); + is.mark(len); + try + { + byte[] buf = new byte[len]; + while (0 < len) + { + int r = is.read(buf, buf.length-len, len); + if (r == -1) + { + byte[] ret = new byte[buf.length-len]; + System.arraycopy(buf, 0, ret, 0, buf.length-len); + return ret; + } + len -= r; + } + return buf; + } + finally + { + is.reset(); + } + } + + + // + // NOTE: IOUtil uses fairly small buffers for copy + // + + final static int BUFFERSIZE = 32*1024; + + // Closes input stream + public static long copyData(InputStream is, File file) throws IOException + { + try (InputStream input = is; FileOutputStream fos = new FileOutputStream(file)) + { + return copyData(input, fos); + } + } + + /** Does not close input or output stream */ + public static long copyData(InputStream is, OutputStream os) throws IOException + { + byte[] buf = new byte[BUFFERSIZE]; + long total = 0; + int r; + while (0 <= (r = is.read(buf))) + { + os.write(buf,0,r); + total += r; + } + return total; + } + + + /** Does not close input or output stream */ + public static void copyData(InputStream is, DataOutput os, long len) throws IOException + { + byte[] buf = new byte[BUFFERSIZE]; + long remaining = len; + do + { + int r = (int)Math.min(buf.length, remaining); + r = is.read(buf, 0, r); + os.write(buf,0,r); + remaining -= r; + } while (0 < remaining); + } + + + /** Does not close input or output stream */ + public static void copyData(InputStream is, DataOutput os) throws IOException + { + byte[] buf = new byte[BUFFERSIZE]; + int r; + while (0 < (r = is.read(buf))) + os.write(buf,0,r); + } + + // NOTE: Keep in sync with the copied constants in TestFileUtils + private static final char[] ILLEGAL_CHARS = {'/','\\',':','?','<','>','*','|','"','^', '\n', '\r', '\''}; + public static final String ILLEGAL_CHARS_STRING = new String(ILLEGAL_CHARS); + + public static boolean isLegalName(String name) + { + if (name == null || name.trim().isEmpty()) + return false; + + if (name.length() > 255) + return false; + + return !StringUtils.containsAny(name, ILLEGAL_CHARS); + } + + // NOTE: Keep in sync with the copied implementation in TestFileUtils.makeLegalFileName() + public static String makeLegalName(String name) + { + if (name == null) + { + return "__null__"; + } + + if (name.isEmpty()) + { + return "__empty__"; + } + + //limit to 255 chars (FAT and OS X) + //replace illegal chars + char[] ret = new char[Math.min(255, name.length())]; + for(int idx = 0; idx < ret.length; ++idx) + { + char ch = name.charAt(idx); + // Reject characters that are illegal anywhere + if (StringUtils.contains(ILLEGAL_CHARS_STRING, ch) || + // Or characters that are illegal starts to a file name + (idx == 0 && (ch == '-' || ch == '$'))) + { + ch = '_'; + } + else if (ch == '-' && + idx > 0 && + name.charAt(idx - 1) == ' ') + { + int i = idx + 1; + // Skip through as many consecutive '-' as there might be + while (i < name.length() && name.charAt(i) == '-') + { + i++; + } + // If the next character after the '-' isn't a space, transform the leading '-' in the sequence + if (i < name.length() && name.charAt(i) != ' ') + { + ch = '_'; + } + } + + ret[idx] = ch; + } + + //can't end with space (windows) + //can't end with period (windows) + int lastIndex = ret.length - 1; + char ch = ret[lastIndex]; + if (ch == ' ' || ch == '.') + ret[lastIndex] = '_'; + + return new String(ret); + } + + + /** + * Returns the absolute path to a file. On Windows and Mac, corrects casing in file paths to match the + * canonical path. + */ + @NotNull + public static FileLike getAbsoluteCaseSensitiveFile(@NotNull FileLike file) + { + return FileSystemLike.wrapFile(getAbsoluteCaseSensitiveFile(file.toNioPathForRead().toFile())); + } + + @NotNull + public static File getAbsoluteCaseSensitiveFile(@NotNull File file) + { + file = resolveFile(file.getAbsoluteFile()); + if (isCaseInsensitiveFileSystem()) + { + try + { + @SuppressWarnings("SSBasedInspection") + File canonicalFile = file.getCanonicalFile(); + + if (canonicalFile.getAbsolutePath().equalsIgnoreCase(file.getAbsolutePath())) + { + return canonicalFile; + } + } + catch (IOException e) + { + // Ignore and just use the absolute file + } + } + return file.getAbsoluteFile(); + } + + + public static boolean isCaseInsensitiveFileSystem() + { + // FileSystem case sensitivity cannot be inferred from OS, for example mac os defaults to case-insensitive but can be configured to be case-sensitive + // Additionally, file root can be mounted to location on a different OS, or it can use S3 + String osName = System.getProperty("os.name").toLowerCase(); + return (osName.startsWith("windows") || osName.startsWith("mac os")); + } + + + /** + * Strips out ".." and "." from the path + */ + public static File resolveFile(File file) + { + File parent = file.getParentFile(); + if (parent == null) + { + return file; + } + if (".".equals(file.getName())) + { + return resolveFile(parent); + } + int dotDotCount = 0; + while ("..".equals(file.getName()) || dotDotCount > 0) + { + if ("..".equals(file.getName())) + { + dotDotCount++; + } + else if (!".".equals(file.getName())) + { + dotDotCount--; + } + if (parent.getParentFile() == null) + { + return parent; + } + file = file.getParentFile(); + parent = file.getParentFile(); + } + // we don't need to use FileUtil.appendName() here + //noinspection SSBasedInspection + return new File(resolveFile(parent), file.getName()); + } + + + // use FileLike createTempDirectoryFileLike() + @Deprecated + public static Path createTempDirectory(@Nullable String prefix) throws IOException + { + if (null != prefix) + legalPathPartThrow(prefix); + return Files.createTempDirectory(prefix).toAbsolutePath(); + } + + + public static FileLike createTempDirectoryFileLike(@Nullable String prefix) throws IOException + { + if (null != prefix) + legalPathPartThrow(prefix); + return new FileSystemLike.Builder(Files.createTempDirectory(prefix).toAbsolutePath()).readwrite().root(); + } + + + public static boolean deleteTempDirectoryFileLike(@NotNull FileLike file) throws IOException + { + if (!file.getPath().isEmpty()) + throw new IllegalArgumentException("Method expects a file returned by createTempDirectoryFileObject"); + if (!file.getFileSystem().canWriteFiles()) + throw new UnauthorizedException(); + return FileUtil.deleteDirectoryContents(file); + } + + + // Under Catalina, it seems to pick \tomcat\temp + // On the web server under Tomcat, it seems to pick c:\Documents and Settings\ITOMCAT_EDI\Local Settings\Temp + public static File getTempDirectory() + { + if (null == _tempDir) + { + try + { + File temp = createTempFile("deleteme", null); + _tempDir = temp.getParentFile().getAbsoluteFile(); + temp.delete(); + } + catch (IOException e) + { + throw new ConfigurationException("The temporary directory (likely " + System.getProperty("java.io.tmpdir") + ") on this server is inaccessible. There may be a file permission issue, or the directory may not exist.", e); + } + } + + return _tempDir; + } + + + public static FileLike getTempDirectoryFileLike() + { + if (null == _tempDirFileLike) + { + _tempDirFileLike = new FileSystemLike.Builder(getTempDirectory()).readwrite().noMemCheck().root(); + } + return _tempDirFileLike; + } + + + // Use this instead of File.createTempFile() (see Issue #46794) + public static File createTempFile(@Nullable String prefix, @Nullable String suffix, File directory) throws IOException + { + if (null != prefix) + legalPathPartThrow(prefix); + if (null != suffix) + legalPathPartThrow(suffix); + return Files.createTempFile(directory.toPath(), prefix, suffix).toFile(); + } + + // Use this instead of File.createTempFile() (see Issue #46794) + public static FileLike createTempFile(@Nullable String prefix, @Nullable String suffix, FileLike directory) throws IOException + { + if (null != prefix) + legalPathPartThrow(prefix); + if (null != suffix) + legalPathPartThrow(suffix); + var path = Files.createTempFile(directory.toNioPathForWrite(), prefix, suffix); + return directory.resolveChild(path.getFileName().toString()); + } + + // Use this instead of File.createTempFile() (see Issue #46794) + public static File createTempFile(@Nullable String prefix, @Nullable String suffix) throws IOException + { + return createTempFile(prefix, suffix, false); + } + + // Use this instead of File.createTempFile() (see Issue #46794) + public static FileLike createTempFileLike(@Nullable String prefix, @Nullable String suffix) throws IOException + { + return FileSystemLike.wrapFile(createTempFile(prefix, suffix, false)); + } + + public static File createTempFile(@Nullable String prefix, @Nullable String suffix, boolean threadLocal) throws IOException + { + if (null != prefix) + legalPathPartThrow(prefix); + if (null != suffix) + legalPathPartThrow(suffix); + var path = Files.createTempFile(prefix, suffix).toAbsolutePath(); + if (threadLocal) + tempPaths.get().add(path); + return path.toFile(); + } + + + private static final boolean isPosix = + FileSystems.getDefault().supportedFileAttributeViews().contains("posix"); + final static private FileAttribute[] tempFileAttributes = new FileAttribute[] { PosixFilePermissions.asFileAttribute(Set.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE)) }; + + public static boolean createTempFile(File file) throws IOException + { + if (file.exists()) + return false; + mkdirs(file.getParentFile()); + if (isPosix) + createFile(file.toPath(), tempFileAttributes); + else + createFile(file.toPath()); + return true; + } + + + public static void deleteTempFile(File f) + { + if (null != f && f.isFile()) + { + if(f.delete()) + tempPaths.get().remove(f.toPath()); + } + } + + + // Converts a document name into keywords appropriate for indexing. We want to retrieve a document named "labkey.txt" + // when the user searches for "labkey.txt", "labkey" or "txt". Lucene analyzers tokenize on whitespace, so this method + // returns the original document name plus the document name with common symbols replaced with spaces. + public static String getSearchKeywords(String documentName) + { + return documentName + " " + documentName.replaceAll("[._-]", " "); + } + + + /** + * Creates a legal, cross-platform file name from the component parts (replacing special characters like colons, semi-colons, slashes, etc + * @param prefix the start of the file name to generate, to be appended with a timestamp suffix + * @param extension the extension (not including the dot) for the desired file name + */ + public static String makeFileNameWithTimestamp(String prefix, @Nullable String extension) + { + return makeLegalName(prefix + "_" + getTimestamp() + (extension == null ? "" : ("." + extension))); + } + + + public static String makeFileNameWithTimestamp(String prefix) + { + return makeLegalName(prefix + "_" + getTimestamp()); + } + + + private static long lastTime = 0; + private static final Object timeLock = new Object(); + + // return a unique time, rounded to the nearest second + private static long currentSeconds() + { + synchronized(timeLock) + { + long sec = HeartBeat.currentTimeMillis(); + sec -= sec % 1000; + lastTime = Math.max(sec, lastTime + 1000); + return lastTime; + } + } + + + public static String getTimestamp() + { + String time = DateUtil.toISO(currentSeconds(), false); + time = time.replace(":", "-"); + time = time.replace(" ", "_"); + + return time; + } + + + private static String indent(LinkedList hasMoreFlags) + { + StringBuilder sb = new StringBuilder(); + for (int i = 0, len = hasMoreFlags.size(); i < len; i++) + { + Boolean hasMore = hasMoreFlags.get(i); + if (i == len-1) + sb.append(hasMore ? "├── " : "└── "); + else + sb.append(hasMore ? "│  " : " "); + } + + return sb.toString(); + } + + + private static void printTree(StringBuilder sb, Path node, LinkedList hasMoreFlags) throws IOException + { + Files.walkFileTree(node, new SimplePathVisitor() + { + @Override + public @NotNull FileVisitResult preVisitDirectory(@NotNull Path dir, @NotNull BasicFileAttributes attrs) throws IOException + { + hasMoreFlags.add(true); + return super.preVisitDirectory(dir, attrs); + } + + @Override + public @NotNull FileVisitResult visitFile(@NotNull Path file, @NotNull BasicFileAttributes attrs) throws IOException + { + appendFileLogEntry(sb, file, hasMoreFlags); + return super.visitFile(file, attrs); + } + + + @Override + public @NotNull FileVisitResult postVisitDirectory(@NotNull Path dir, IOException exc) throws IOException + { + hasMoreFlags.removeLast(); + return super.postVisitDirectory(dir, exc); + } + }); + } + + + private static void appendFileLogEntry(StringBuilder sb, Path node, LinkedList hasMoreFlags) throws IOException + { + if (hasMoreFlags.isEmpty()) + sb.append(node.toAbsolutePath()); + else + sb.append(indent(hasMoreFlags)).append(node.getFileName()); + + if (Files.isDirectory(node)) + sb.append("/"); + else + sb.append(" (").append(FileUtils.byteCountToDisplaySize(Files.size(node))).append(")"); + sb.append("\n"); + } + + + public static String printTree(Path root) throws IOException + { + StringBuilder sb = new StringBuilder(); + printTree(sb, root, new LinkedList<>()); + return sb.toString(); + } + + + public static String getUnencodedAbsolutePath(Container container, Path path) + { + if (!path.isAbsolute()) + return null; + else if (!FileUtil.hasCloudScheme(path)) + return path.toFile().getAbsolutePath(); + else + { + return PageFlowUtil.decode( //URI conversion encodes + getPathStringWithoutAccessId( + CloudStoreService.get().getPathFromUrl(container, path.toString()).toUri() + ) + ); + } + } + + public static File findUniqueFileName(String originalFilename, File dir) + { + if (originalFilename == null || originalFilename.isEmpty()) + { + originalFilename = "[unnamed]"; + } + File file; + int uniquifier = 0; + do + { + String fullName = getAppendedFileName(originalFilename, uniquifier); + file = appendName(dir, fullName); + uniquifier++; + } + while (file.exists()); + return file; + } + + public static FileLike findUniqueFileName(String originalFilename, FileLike dir) + { + if (originalFilename == null || originalFilename.isEmpty()) + { + originalFilename = "[unnamed]"; + } + FileLike file; + int uniquifier = 0; + do + { + String fullName = getAppendedFileName(originalFilename, uniquifier); + file = dir.resolveChild(fullName); + uniquifier++; + } + while (file.exists()); + return file; + } + + public static String getAppendedFileName(String originalFilename, int uniquifier) + { + String prefix = originalFilename; + String suffix = ""; + + int index = originalFilename.indexOf('.'); + if (index != -1) + { + prefix = originalFilename.substring(0, index); + suffix = originalFilename.substring(index); + } + + return prefix + (uniquifier == 0 ? "" : "-" + uniquifier) + suffix; + } + + + /* If you have a write once, read once text file/stream, you can use this class. + * It wraps the calls to create and delete a temp file, and also will use + * direct to cache the first portion of the file to avoid hitting the + * file system if the file is smaller. + * + * The caller needs to call close() on this object or the Reader returned + * by getReader(). Calling close on both is OK. + */ + public static class TempTextFileWrapper implements Closeable + { + final int characterLimitInMemory; + final ByteBuffer _byteBuffer; + final CharBuffer _charBuffer; + FileWriter _fileWriter = null; + FileReader _fileReader = null; + File _tmpFile = null; + boolean closed = false; // so we can ignore multiple calls to close + + Writer _writer = null; + Reader _reader = null; + + public TempTextFileWrapper(int characterLimitInMemory) + { + this.characterLimitInMemory = characterLimitInMemory; + this._byteBuffer = ByteBuffer.allocate(characterLimitInMemory * 2); + this._charBuffer = _byteBuffer.asCharBuffer(); + } + + public TempTextFileWrapper(CharBuffer charBuffer) + { + this.characterLimitInMemory = charBuffer.capacity(); + this._byteBuffer = null; + this._charBuffer = charBuffer; + } + + + public Writer getWriter() + { + if (null != _writer || closed) + throw new IllegalStateException(closed ? "TempTextFileWrapper is closed" : "getWriter() called twice"); + + // CONSIDER ByteBuffer.allocateDirect(), for now caller can pass in a direct buffer if desired + _writer = new Writer() + { + boolean closed = false; + + @Override + public void write(char @NotNull [] cbuf, int off, int len) throws IOException + { + if (closed) + throw new IOException("Writer is closed"); + if (_charBuffer.remaining() > 0) + { + var l = Math.min(_charBuffer.remaining(), len); + _charBuffer.put(cbuf, off, l); + if (l == len) + return; + off += l; + len -= l; + } + if (null == _fileWriter) + { + assert null == _tmpFile; + _tmpFile = FileUtil.createTempFile("tika", ".tmp.txt"); + _fileWriter = new FileWriter(_tmpFile, StringUtilsLabKey.DEFAULT_CHARSET); + } + _fileWriter.write(cbuf, off, len); + } + + @Override + public void flush() throws IOException + { + if (null != _fileWriter) + _fileWriter.flush(); + } + + @Override + public void close() throws IOException + { + if (null != _fileWriter) + { + _fileWriter.flush(); + _fileWriter.close(); + } + _fileWriter = null; + closed = true; + } + }; + return _writer; + } + + private void _prepareToRead() + { + if (null != _writer) + { + IOUtils.closeQuietly(_writer); + _writer = null; + _charBuffer.flip(); + } + } + + public Reader getReader() + { + if (null != _reader || closed) + throw new IllegalStateException(closed ? "TempTextFileWrapper is closed" : "getReader() called twice"); + + _reader = new Reader() + { + @Override + public int read(char @NotNull [] cbuf, int off, int len) throws IOException + { + _prepareToRead(); + + if (0 < _charBuffer.remaining()) + { + var l = Math.min(len, _charBuffer.remaining()); + _charBuffer.get(cbuf, off, l); + return l; + } + if (null == _fileReader && null != _tmpFile) + _fileReader = new FileReader(_tmpFile, StringUtilsLabKey.DEFAULT_CHARSET); + if (null == _fileReader) + return -1; + return _fileReader.read(cbuf, off, len); + } + + @Override + public void close() throws IOException + { + TempTextFileWrapper.this.close(); + } + }; + return _reader; + } + + public String getSummary(int length) + { + _prepareToRead(); + var l = Math.min(_charBuffer.limit(), length); + return _charBuffer.slice(0,l).toString(); + } + + @Override + public void close() throws IOException + { + if (!closed) + { + closed = true; + if (null != _fileReader) + IOUtils.closeQuietly(_fileReader); + _fileReader = null; + if (null != _fileWriter) + IOUtils.closeQuietly(_fileWriter); + _fileWriter = null; + if (null != _tmpFile) + FileUtil.deleteTempFile(_tmpFile); + _tmpFile = null; + if (null != _byteBuffer && _byteBuffer.isDirect()) + LabKeyByteBufferCleaner.clean(_byteBuffer); + } + } + } + + + @SuppressWarnings("SSBasedInspection") + public static class TestCase extends Assert + { + private static final File ROOT; + + static + { + File f = new File(".").getAbsoluteFile(); + while (f.getParentFile() != null) + { + f = f.getParentFile(); + } + ROOT = f; + } + + @Test + public void testStandardResolve() + { + assertEquals(new File(ROOT, "test/path/sub"), resolveFile(new File(ROOT, "test/path/sub"))); + assertEquals(new File(ROOT, "test"), resolveFile(new File(ROOT, "test"))); + assertEquals(new File(ROOT, "test/path/file.ext"), resolveFile(new File(ROOT, "test/path/file.ext"))); + } + + @Test + public void testDotResolve() + { + assertEquals(new File(ROOT, "test/path/sub"), resolveFile(new File(ROOT, "test/path/./sub"))); + assertEquals(new File(ROOT, "test"), resolveFile(new File(ROOT, "./test"))); + assertEquals(new File(ROOT, "test/path/file.ext"), resolveFile(new File(ROOT, "test/path/file.ext/."))); + } + + @Test + public void testDotDotResolve() + { + assertEquals(ROOT, resolveFile(new File(ROOT, ".."))); + assertEquals(new File(ROOT, "test/sub"), resolveFile(new File(ROOT, "test/path/../sub"))); + assertEquals(new File(ROOT, "test/sub2"), resolveFile(new File(ROOT, "test/path/../sub/../sub2"))); + assertEquals(new File(ROOT, "test"), resolveFile(new File(ROOT, "test/path/sub/../.."))); + assertEquals(new File(ROOT, "sub"), resolveFile(new File(ROOT, "test/path/../../sub"))); + assertEquals(new File(ROOT, "sub2"), resolveFile(new File(ROOT, "test/path/../../sub/../sub2"))); + assertEquals(new File(ROOT, "sub2"), resolveFile(new File(ROOT, "test/path/.././../sub/../sub2"))); + assertEquals(new File(ROOT, "sub2"), resolveFile(new File(ROOT, "test/path/.././../sub/../../sub2"))); + assertEquals(new File(ROOT, "sub2"), resolveFile(new File(ROOT, "a/test/path/.././../sub/../../sub2"))); + assertEquals(new File(ROOT, "b/sub2"), resolveFile(new File(ROOT, "b/a/test/path/.././../sub/../../sub2"))); + assertEquals(ROOT, resolveFile(new File(ROOT, "test/path/../../../.."))); + assertEquals(new File(ROOT, "test/sub"), resolveFile(new File(ROOT, "../../../../test/sub"))); + assertEquals(new File(ROOT, "test"), resolveFile(new File(ROOT, "../test"))); + assertEquals(new File(ROOT, "test/path"), resolveFile(new File(ROOT, "test/path/file.ext/.."))); + assertEquals(new File(ROOT, "folder"), resolveFile(new File(ROOT, ".././../folder"))); + assertEquals(new File(ROOT, "b"), resolveFile(new File(ROOT, "folder/a/.././../b"))); + } + + @Test + public void testUriToString() + { + assertEquals("converted file:/// URI does not match expected string", "file:///data/myfile.txt", uriToString(URI.create("file:///data/myfile.txt"))); + assertEquals("converted file:/ URI does not match expected string", "file:///data/myfile.txt", uriToString(URI.create("file:/data/myfile.txt"))); + } + + @Test + public void testNormalizeURI() + { + assertEquals("file:/// uri not as expected","file:///my/triple/file/path", uriToString(URI.create("file:///my/triple/file/path"))); + assertEquals("file:/// uri with drive letter not as expected","file:///C:/my/triple/file/path", uriToString(URI.create("file:///C:/my/triple/file/path"))); + assertEquals("file:/ uri not conformed to file:///","file:///my/single/file/path", uriToString(URI.create("file:/my/single/file/path"))); + assertEquals("file:/ with drive letter not conformed to file:///","file:///C:/my/single/file/path", uriToString(URI.create("file:/C:/my/single/file/path"))); + assertEquals("File uri with host not as expected", "file://localhost:8080/my/host/file/path", uriToString(URI.create("file://localhost:8080/my/host/file/path"))); + assertEquals("Schemed URI not as expected","http://localhost:8080/my/triple/file/path?query=abcd#anchor", uriToString(URI.create("http://localhost:8080/my/triple/file/path?query=abcd#anchor"))); + } + + @Test + public void testTempFileWrapper() throws IOException + { + try + { + FileUtil.startRequest(); + var sonnet = """ + From fairest creatures we desire increase, + That thereby beauty's rose might never die, + But as the riper should by time decease, + His tender heir might bear his memory: + But thou contracted to thine own bright eyes, + Feed'st thy light's flame with self-substantial fuel, + Making a famine where abundance lies, + Thy self thy foe, to thy sweet self too cruel: + Thou that art now the world's fresh ornament, + And only herald to the gaudy spring, + Within thine own bud buriest thy content, + And tender churl mak'st waste in niggarding: + Pity the world, or else this glutton be, + To eat the world's due, by the grave and thee. + """; + try (var tf = new TempTextFileWrapper(64)) + { + var w = tf.getWriter(); + for (var l : StringUtils.split(sonnet, '\n')) + w.write(l + "\n"); + var r = new BufferedReader(tf.getReader()); + String l, lines = ""; + while (null != (l = r.readLine())) + lines = lines + l + "\n"; + assertEquals(sonnet.trim(), lines.trim()); + assertEquals(sonnet.substring(0, 64), tf.getSummary(100)); + } + try (var tf = new TempTextFileWrapper(900)) + { + var w = tf.getWriter(); + for (var l : StringUtils.split(sonnet, '\n')) + w.write(l + "\n"); + var r = new BufferedReader(tf.getReader()); + String l, lines = ""; + while (null != (l = r.readLine())) + lines = lines + l + "\n"; + assertEquals(sonnet.trim(), lines.trim()); + assertEquals(sonnet.substring(0, 100), tf.getSummary(100)); + } + } + finally + { + // make sure we did not leave any temp files lying around + FileUtil.stopRequest(); + } + } + + @Test + public void testMakeLegalName() + { + assertEquals("__null__", makeLegalName(null)); + assertEquals("__empty__", makeLegalName("")); + assertEquals("_", makeLegalName(" ")); + assertEquals(" _", makeLegalName(" ")); + assertEquals("_", makeLegalName(".")); + assertEquals("._", makeLegalName("..")); + assertEquals("foo", makeLegalName("foo")); + assertEquals("foo_", makeLegalName("foo ")); + assertEquals("foo_", makeLegalName("foo.")); + assertEquals("foo -", makeLegalName("foo -")); + assertEquals("foo _arg", makeLegalName("foo -arg")); + assertEquals("foo _arg-arg", makeLegalName("foo -arg-arg")); + assertEquals("foo _arg _arg2", makeLegalName("foo -arg -arg2")); + + // These are allowed. Verify they don't get changed + assertEquals("a", makeLegalName("a")); + assertEquals("a-b", makeLegalName("a-b")); + assertEquals("a - b", makeLegalName("a - b")); + assertEquals("a- b", makeLegalName("a- b")); + assertEquals("a--b", makeLegalName("a--b")); + assertEquals("a -- b", makeLegalName("a -- b")); + assertEquals("a-- b", makeLegalName("a-- b")); + + // These aren't allowed. Make sure they get changed + assertEquals("_a", makeLegalName("-a")); + assertEquals(" _a", makeLegalName(" -a")); + assertEquals("a _b", makeLegalName("a -b")); + assertEquals("_-a", makeLegalName("--a")); + assertEquals(" _-a", makeLegalName(" --a")); + assertEquals("a _-b", makeLegalName("a --b")); + assertEquals("a _--b", makeLegalName("a ---b")); + + assertEquals(StringUtils.repeat('_', ILLEGAL_CHARS.length), makeLegalName(new String(ILLEGAL_CHARS))); + assertEquals(StringUtils.repeat('_', 255), makeLegalName(StringUtils.repeat(new String(ILLEGAL_CHARS), 50))); + assertEquals(StringUtils.repeat('.', 254) + "_", makeLegalName(StringUtils.repeat('.', 500))); + assertEquals(StringUtils.repeat(' ', 254) + "_", makeLegalName(StringUtils.repeat(' ', 500))); + } + + @Test + public void testAllowedFileName() + { + //Test Setup + Mockery _context = new Mockery(); + _context.setImposteriser(ClassImposteriser.INSTANCE); + AppProps mockProps = _context.mock(AppProps.class); + _context.checking(new Expectations(){{ + allowing(mockProps).isInvalidFilenameBlocked(); + will(returnValue(true)); + }}); + + assertNull(isAllowedFileName("a", false, mockProps)); + assertNull(isAllowedFileName("a-b", false, mockProps)); + assertNull(isAllowedFileName("a - b", false, mockProps)); + assertNull(isAllowedFileName("a- b", false, mockProps)); + assertNull(isAllowedFileName("a--b", false, mockProps)); + assertNull(isAllowedFileName("a -- b", false, mockProps)); + assertNull(isAllowedFileName("a-- b", false, mockProps)); + assertNull(isAllowedFileName("a b", false, mockProps)); + assertNull(isAllowedFileName("a%b", false, mockProps)); + assertNull(isAllowedFileName("a$b", false, mockProps)); + assertNull(isAllowedFileName("%ab", false, mockProps)); + + assertNotNull(isAllowedFileName(null, false, mockProps)); + assertNotNull(isAllowedFileName("", false, mockProps)); + assertNotNull(isAllowedFileName(" ", false, mockProps)); + assertNotNull(isAllowedFileName("a\tb", false, mockProps)); + assertNotNull(isAllowedFileName("-a", false, mockProps)); + assertNotNull(isAllowedFileName(" -a", false, mockProps)); + assertNotNull(isAllowedFileName("a -b", false, mockProps)); + assertNotNull(isAllowedFileName("--a", false, mockProps)); + assertNotNull(isAllowedFileName(" --a", false, mockProps)); + assertNotNull(isAllowedFileName("a --b", false, mockProps)); + assertNotNull(isAllowedFileName("a ---b", false, mockProps)); + assertNotNull(isAllowedFileName("a/b", false, mockProps)); + assertNotNull(isAllowedFileName("a\b", false, mockProps)); + assertNotNull(isAllowedFileName("a:b", false, mockProps)); + assertNotNull(isAllowedFileName("a*b", false, mockProps)); + assertNotNull(isAllowedFileName("a?b", false, mockProps)); + assertNotNull(isAllowedFileName("ab", false, mockProps)); + assertNotNull(isAllowedFileName("a\"b", false, mockProps)); + assertNotNull(isAllowedFileName("a|b", false, mockProps)); + assertNotNull(isAllowedFileName("a`b", false, mockProps)); + assertNotNull(isAllowedFileName("$ab", false, mockProps)); + assertNotNull(isAllowedFileName("-ab", false, mockProps)); + assertNotNull(isAllowedFileName("a`b", false, mockProps)); + } + + @Test + public void testAcceptableExtensions() + { + List allowedExtensions = Arrays.asList( + ".1", + ".txt", + ".tar", + ".tar.gz", + ".a_v", + ".xlsx", + ".l-()[]{}1☃"); + + //Test Setup + Mockery _context = new Mockery(); + _context.setImposteriser(ClassImposteriser.INSTANCE); + AppProps mockProps = _context.mock(AppProps.class); + _context.checking(new Expectations(){{ + allowing(mockProps).getAllowedExtensions(); + will(returnValue(allowedExtensions)); + }}); + + + assertNull("Extension should be allowed", checkExtension("test.txt", mockProps)); + assertNull("Multiple extension should be allowed", checkExtension("archive.tar.gz", mockProps)); + assertNull("Case-insensitive extension should be allowed", checkExtension("archive.TaR.Gz", mockProps)); + assertNull("Special characters aren't escaped properly", checkExtension("my test.l-()[]{}1☃", mockProps)); + assertNull("Numeric extension should be allowed", checkExtension("test.1", mockProps)); + assertNotNull("Multiple extension matched when it shouldn't", checkExtension("tar.gz", mockProps)); + assertNotNull("Matched unlist extension", checkExtension("my test.notListed", mockProps)); + assertNotNull("Combined multiple extension matched incorrectly", checkExtension("multi.a_v.tar", mockProps)); + assertNotNull("Multi-multi extension matched unexpectedly", checkExtension("multi.not.tar.gz", mockProps)); + assertNotNull("No extension matched unexpectedly", checkExtension("No extension", mockProps)); + } + + @Test + public void testNoAcceptableExtensions() + { + List allowedExtensions = Collections.emptyList(); + + //Test Setup + Mockery _context; + _context = new Mockery(); + _context.setImposteriser(ClassImposteriser.INSTANCE); + AppProps mockProps = _context.mock(AppProps.class); + _context.checking(new Expectations(){{ + allowing(mockProps).getAllowedExtensions(); + will(returnValue(allowedExtensions)); + }}); + + assertNull("Special characters aren't escaped properly", checkExtension("my test.l-()[]{}1☃", mockProps)); + assertNull("Unlisted extension should be allowed, but wasn't", checkExtension("my test.notListed", mockProps)); + assertNull("Combined extension should be allowed, but wasn't", checkExtension("multi.tar.a_v", mockProps)); + assertNull("No extension should be allowed, but wasn't", checkExtension("No extension", mockProps)); + assertNull("Numeric extension should be allowed", checkExtension("test.1", mockProps)); + } + + @Test + public void testGetAppendedFileName() + { + String originalFilename = "test.txt"; + assertEquals("test.txt", getAppendedFileName(originalFilename, 0)); + assertEquals("test-1.txt", getAppendedFileName(originalFilename, 1)); + assertEquals("test-2.txt", getAppendedFileName(originalFilename, 2)); + } + } +} diff --git a/api/src/org/labkey/api/util/PossiblyGZIPpedFileInputStreamFactory.java b/api/src/org/labkey/api/util/PossiblyGZIPpedFileInputStreamFactory.java index f43864a88dc..cf292b3d998 100644 --- a/api/src/org/labkey/api/util/PossiblyGZIPpedFileInputStreamFactory.java +++ b/api/src/org/labkey/api/util/PossiblyGZIPpedFileInputStreamFactory.java @@ -1,60 +1,60 @@ -/* - * Copyright (c) 2010-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.api.util; - -import java.io.File; -import java.io.FileInputStream; -import java.io.FileNotFoundException; -import java.io.InputStream; -import java.util.zip.GZIPInputStream; - - -/** - * examine a file and access as a gzip file if appropriate - * - * note this would be tidier as "public class PossiblyGZIPpedFileInputStream extends InputStream" but - * I worry about performance since the read() call gets hit a lot and would rather not insert - * another deeply loop nested function call PossiblyGZIPpedFileInputStream.read->_iStream.read() - * - * bpratt, Insilicos - * - */ -abstract public class PossiblyGZIPpedFileInputStreamFactory -{ - private static final int STREAM_BUFFER_SIZE = 128 * 1024; - - static public InputStream getStream(File f) throws FileNotFoundException - { - FileInputStream fis = new FileInputStream(f); - try - { - return new GZIPInputStream(fis, STREAM_BUFFER_SIZE); - } - catch (java.io.IOException e) - { - // not a gzip file - reopen since we ate a couple of bytes - try - { - fis.close(); - } - catch (java.io.IOException ee) - { - // seems unlikely at this point - } - return new FileInputStream(f); - } - } -} +/* + * Copyright (c) 2010-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.api.util; + +import org.labkey.vfs.FileLike; + +import java.io.IOException; +import java.io.InputStream; +import java.util.zip.GZIPInputStream; + + +/** + * examine a file and access as a gzip file if appropriate + * + * note this would be tidier as "public class PossiblyGZIPpedFileInputStream extends InputStream" but + * I worry about performance since the read() call gets hit a lot and would rather not insert + * another deeply loop nested function call PossiblyGZIPpedFileInputStream.read->_iStream.read() + * + * bpratt, Insilicos + * + */ +abstract public class PossiblyGZIPpedFileInputStreamFactory +{ + private static final int STREAM_BUFFER_SIZE = 128 * 1024; + + static public InputStream getStream(FileLike f) throws IOException + { + InputStream fis = f.openInputStream(); + try + { + return new GZIPInputStream(fis, STREAM_BUFFER_SIZE); + } + catch (java.io.IOException e) + { + // not a gzip file - reopen since we ate a couple of bytes + try + { + fis.close(); + } + catch (java.io.IOException ee) + { + // seems unlikely at this point + } + return f.openInputStream(); + } + } +} diff --git a/api/src/org/labkey/api/writer/ZipUtil.java b/api/src/org/labkey/api/writer/ZipUtil.java index f486ff24e3d..9af28cd2610 100644 --- a/api/src/org/labkey/api/writer/ZipUtil.java +++ b/api/src/org/labkey/api/writer/ZipUtil.java @@ -1,202 +1,204 @@ -/* - * Copyright (c) 2009-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.api.writer; - -import org.apache.logging.log4j.Logger; -import org.jetbrains.annotations.Nullable; -import org.labkey.api.util.CheckedInputStream; -import org.labkey.api.util.FileUtil; -import org.labkey.api.util.PageFlowUtil; -import org.labkey.api.util.ResponseHelper; - -import jakarta.servlet.http.HttpServletResponse; -import java.io.BufferedInputStream; -import java.io.BufferedOutputStream; -import java.io.File; -import java.io.FileInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.nio.file.FileAlreadyExistsException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; -import java.util.stream.Collectors; -import java.util.zip.ZipEntry; -import java.util.zip.ZipInputStream; -import java.util.zip.ZipOutputStream; - -/** - * User: adam - * Date: Apr 28, 2009 - * Time: 2:00:23 PM - */ -public class ZipUtil -{ - // Unzip a zipped file archive to the specified directory - @Deprecated - public static List unzipToDirectory(File zipFile, File unzipDir) throws IOException - { - return unzipToDirectory(zipFile.toPath(), unzipDir.toPath()).stream().map(Path::toFile).collect(Collectors.toList()); - } - - public static List unzipToDirectory(Path zipFile, Path unzipDir) throws IOException - { - return unzipToDirectory(zipFile, unzipDir, null); - } - - @Deprecated - public static List unzipToDirectory(File zipFile, File unzipDir, @Nullable Logger log) throws IOException - { - return unzipToDirectory(zipFile.toPath(), unzipDir.toPath(), log).stream().map(Path::toFile).collect(Collectors.toList()); - } - - public static List unzipToDirectory(Path zipFile, Path unzipDir, @Nullable Logger log) throws IOException - { - return unzipToDirectory(zipFile, unzipDir, log, false); - } - - @Deprecated - public static List unzipToDirectory(File zipFile, File unzipDir, @Nullable Logger log, boolean includeFolder) throws IOException - { - return unzipToDirectory(zipFile.toPath(), unzipDir.toPath(), log, includeFolder).stream().map(Path::toFile).collect(Collectors.toList()); - } - - // Unzip an archive to the specified directory; log each file if Logger is non-null - public static List unzipToDirectory(Path zipFile, Path unzipDir, @Nullable Logger log, boolean includeFolder) throws IOException - { - try (InputStream is = Files.newInputStream(zipFile)) - { - return unzipToDirectory(is, unzipDir, log, includeFolder); - } - } - - // Unzip a zipped input stream to the specified directory - public static List unzipToDirectory(InputStream is, Path unzipDir) throws IOException - { - return unzipToDirectory(is, unzipDir, null); - } - - public static List unzipToDirectory(InputStream is, Path unzipDir, @Nullable Logger log) throws IOException - { - return unzipToDirectory(is, unzipDir, log, false); - } - - // Unzips an input stream to the specified directory; logs each file if Logger is non-null. - public static List unzipToDirectory(InputStream is, Path unzipDir, @Nullable Logger log, boolean includeFolder) throws IOException - { - List files = new ArrayList<>(); - - // ZipInputStream.close() should close InputStream is. Use a CheckedInputStream to be sure. - try (ZipInputStream zis = new ZipInputStream(new CheckedInputStream(is))) - { - ZipEntry entry; - - while (null != (entry = zis.getNextEntry())) - { - Path destFile = unzipDir.resolve(entry.getName()); - - //Verify that the entry target doesn't attempt to push data outside the unzipDir by resolving '..' - if (!destFile.toAbsolutePath().normalize().startsWith(unzipDir.toAbsolutePath().normalize().toString())) { - throw new IOException("Zip entry is outside of the target dir. \nDest file: " + destFile + " \nUnzip dir: " + unzipDir + " \nZip entry: " + entry.getName()); - } - - if (entry.isDirectory()) - { - FileUtil.createDirectories(destFile); - if (!Files.isDirectory(destFile)) - { - throw new IOException("Failed to create directory: " + destFile.getFileName().toString()); - } - if (includeFolder) - files.add(destFile); - continue; - } - - if (null != log) - log.info("Expanding " + entry.getName()); - - FileUtil.createDirectories(destFile.getParent()); - if (Files.exists(destFile)) - { - throw new IOException("File already exists: " + destFile.getFileName().toString()); - } - - try - { - FileUtil.createFile(destFile); - } - catch (FileAlreadyExistsException e) - { - throw new IOException("Failed to extract file: " + destFile.getFileName(), e); - } - - // We can't close() this, otherwise zis will get closed - BufferedInputStream bis = new BufferedInputStream(zis); - - try (BufferedOutputStream os = new BufferedOutputStream(Files.newOutputStream(destFile))) - { - FileUtil.copyData(bis, os); - } - - files.add(destFile); - zis.closeEntry(); - } - } - - return files; - } - - - public static void zipToStream(HttpServletResponse response, File file, boolean preZipped) throws IOException - { - response.setContentType("application/zip"); - ResponseHelper.setContentDisposition(response, ResponseHelper.ContentDispositionType.attachment, file.getName() + (preZipped ? "" : ".zip")); - - if (preZipped) - { - PageFlowUtil.streamFile(response, file, true); - return; - } - - try (ZipOutputStream zos = new ZipOutputStream(response.getOutputStream())) - { - addResource(file, zos); - } - } - - - private static void addResource(File file, ZipOutputStream out) throws IOException - { - if (file.listFiles() != null) - { - for (File f : file.listFiles()) - { - addResource(f, out); - } - } - else - { - ZipEntry entry = new ZipEntry(file.getName()); - out.putNextEntry(entry); - - try (InputStream in = new FileInputStream(file)) - { - FileUtil.copyData(in, out); - } - } - } -} +/* + * Copyright (c) 2009-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.api.writer; + +import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.Nullable; +import org.labkey.api.util.CheckedInputStream; +import org.labkey.api.util.FileUtil; +import org.labkey.api.util.PageFlowUtil; +import org.labkey.api.util.ResponseHelper; + +import jakarta.servlet.http.HttpServletResponse; +import org.labkey.vfs.FileLike; +import org.labkey.vfs.FileSystemLike; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; +import java.util.zip.ZipOutputStream; + +/** + * User: adam + * Date: Apr 28, 2009 + * Time: 2:00:23 PM + */ +public class ZipUtil +{ + public static List unzipToDirectory(Path zipFile, Path unzipDir) throws IOException + { + return unzipToDirectory(zipFile, unzipDir, null); + } + + public static List unzipToDirectory(FileLike zipFile, FileLike unzipDir) throws IOException + { + List paths = unzipToDirectory(zipFile.toNioPathForRead(), unzipDir.toNioPathForWrite(), null); + File rootFile = unzipDir.toNioPathForRead().toFile(); + List result = new ArrayList<>(); + for (Path path : paths) + { + result.add(FileSystemLike.wrapFile(rootFile, path.toFile())); + } + return result; + } + + @Deprecated + public static List unzipToDirectory(File zipFile, File unzipDir, @Nullable Logger log) throws IOException + { + return unzipToDirectory(zipFile.toPath(), unzipDir.toPath(), log).stream().map(Path::toFile).collect(Collectors.toList()); + } + + public static List unzipToDirectory(Path zipFile, Path unzipDir, @Nullable Logger log) throws IOException + { + return unzipToDirectory(zipFile, unzipDir, log, false); + } + + // Unzip an archive to the specified directory; log each file if Logger is non-null + public static List unzipToDirectory(Path zipFile, Path unzipDir, @Nullable Logger log, boolean includeFolder) throws IOException + { + try (InputStream is = Files.newInputStream(zipFile)) + { + return unzipToDirectory(is, unzipDir, log, includeFolder); + } + } + + // Unzip a zipped input stream to the specified directory + public static List unzipToDirectory(InputStream is, Path unzipDir) throws IOException + { + return unzipToDirectory(is, unzipDir, null); + } + + public static List unzipToDirectory(InputStream is, Path unzipDir, @Nullable Logger log) throws IOException + { + return unzipToDirectory(is, unzipDir, log, false); + } + + // Unzips an input stream to the specified directory; logs each file if Logger is non-null. + public static List unzipToDirectory(InputStream is, Path unzipDir, @Nullable Logger log, boolean includeFolder) throws IOException + { + List files = new ArrayList<>(); + + // ZipInputStream.close() should close InputStream is. Use a CheckedInputStream to be sure. + try (ZipInputStream zis = new ZipInputStream(new CheckedInputStream(is))) + { + ZipEntry entry; + + while (null != (entry = zis.getNextEntry())) + { + Path destFile = unzipDir.resolve(entry.getName()); + + //Verify that the entry target doesn't attempt to push data outside the unzipDir by resolving '..' + if (!destFile.toAbsolutePath().normalize().startsWith(unzipDir.toAbsolutePath().normalize().toString())) { + throw new IOException("Zip entry is outside of the target dir. \nDest file: " + destFile + " \nUnzip dir: " + unzipDir + " \nZip entry: " + entry.getName()); + } + + if (entry.isDirectory()) + { + FileUtil.createDirectories(destFile); + if (!Files.isDirectory(destFile)) + { + throw new IOException("Failed to create directory: " + destFile.getFileName().toString()); + } + if (includeFolder) + files.add(destFile); + continue; + } + + if (null != log) + log.info("Expanding " + entry.getName()); + + FileUtil.createDirectories(destFile.getParent()); + if (Files.exists(destFile)) + { + throw new IOException("File already exists: " + destFile.getFileName().toString()); + } + + try + { + FileUtil.createFile(destFile); + } + catch (FileAlreadyExistsException e) + { + throw new IOException("Failed to extract file: " + destFile.getFileName(), e); + } + + // We can't close() this, otherwise zis will get closed + BufferedInputStream bis = new BufferedInputStream(zis); + + try (BufferedOutputStream os = new BufferedOutputStream(Files.newOutputStream(destFile))) + { + FileUtil.copyData(bis, os); + } + + files.add(destFile); + zis.closeEntry(); + } + } + + return files; + } + + + public static void zipToStream(HttpServletResponse response, File file, boolean preZipped) throws IOException + { + response.setContentType("application/zip"); + ResponseHelper.setContentDisposition(response, ResponseHelper.ContentDispositionType.attachment, file.getName() + (preZipped ? "" : ".zip")); + + if (preZipped) + { + PageFlowUtil.streamFile(response, file, true); + return; + } + + try (ZipOutputStream zos = new ZipOutputStream(response.getOutputStream())) + { + addResource(file, zos); + } + } + + + private static void addResource(File file, ZipOutputStream out) throws IOException + { + if (file.listFiles() != null) + { + for (File f : file.listFiles()) + { + addResource(f, out); + } + } + else + { + ZipEntry entry = new ZipEntry(file.getName()); + out.putNextEntry(entry); + + try (InputStream in = new FileInputStream(file)) + { + FileUtil.copyData(in, out); + } + } + } +} diff --git a/api/src/org/labkey/vfs/FileLike.java b/api/src/org/labkey/vfs/FileLike.java index 0466976cfd7..63d79f8d883 100644 --- a/api/src/org/labkey/vfs/FileLike.java +++ b/api/src/org/labkey/vfs/FileLike.java @@ -21,6 +21,7 @@ import java.io.OutputStream; import java.net.URI; import java.util.List; +import java.util.function.Predicate; @JsonSerialize(using = FileLike.FileLikeSerializer.class) @JsonDeserialize(using = FileLike.FileLikeDeserializer.class) @@ -87,6 +88,12 @@ default FileLike resolveChild(String name) @NotNull List getChildren(); + @NotNull + default List getChildren(Predicate filter) + { + return getChildren().stream().filter(filter).toList(); + } + /** * Does not create parent directories */ diff --git a/experiment/src/org/labkey/experiment/CompressedInputStreamXarSource.java b/experiment/src/org/labkey/experiment/CompressedInputStreamXarSource.java index 485247f6930..58b2572578e 100644 --- a/experiment/src/org/labkey/experiment/CompressedInputStreamXarSource.java +++ b/experiment/src/org/labkey/experiment/CompressedInputStreamXarSource.java @@ -10,6 +10,7 @@ import org.labkey.api.security.User; import org.labkey.api.util.FileUtil; import org.labkey.api.util.XmlBeansUtil; +import org.labkey.vfs.FileLike; import java.io.BufferedInputStream; import java.io.BufferedOutputStream; @@ -19,7 +20,6 @@ import java.io.InputStream; import java.io.OutputStream; import java.nio.charset.StandardCharsets; -import java.nio.file.Path; import java.util.Map; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; @@ -32,10 +32,10 @@ public class CompressedInputStreamXarSource extends AbstractFileXarSource { private final InputStream _xarInputStream; - private final Path _logFile; + private final FileLike _logFile; private String _xml; - public CompressedInputStreamXarSource(InputStream xarInputStream, Path xarFile, Path logFile, @Nullable PipelineJob job, User user, Container container, @Nullable Map substitutions) + public CompressedInputStreamXarSource(InputStream xarInputStream, FileLike xarFile, FileLike logFile, @Nullable PipelineJob job, User user, Container container, @Nullable Map substitutions) { super(job == null ? null : job.getDescription(), container, user, job, substitutions); _xarInputStream = xarInputStream; @@ -86,7 +86,7 @@ public ExperimentArchiveDocument getDocument() throws XmlException, IOException } @Override - public Path getLogFilePath() + public FileLike getLogFilePath() { return _logFile; } diff --git a/experiment/src/org/labkey/experiment/api/ExperimentServiceImpl.java b/experiment/src/org/labkey/experiment/api/ExperimentServiceImpl.java index 23ba91d58dc..768675f0b00 100644 --- a/experiment/src/org/labkey/experiment/api/ExperimentServiceImpl.java +++ b/experiment/src/org/labkey/experiment/api/ExperimentServiceImpl.java @@ -8164,9 +8164,9 @@ public List getExpProtocolsWithParameterValue( } @Override - public PipelineJob importXarAsync(ViewBackgroundInfo info, File file, String description, PipeRoot root) throws IOException + public PipelineJob importXarAsync(ViewBackgroundInfo info, FileLike file, String description, PipeRoot root) throws IOException { - ExperimentPipelineJob job = new ExperimentPipelineJob(info, file.toPath(), description, false, root); + ExperimentPipelineJob job = new ExperimentPipelineJob(info, file, description, false, root); try { PipelineService.get().queueJob(job); diff --git a/experiment/src/org/labkey/experiment/controllers/exp/ExperimentController.java b/experiment/src/org/labkey/experiment/controllers/exp/ExperimentController.java index a2506f4a50b..af9b2588188 100644 --- a/experiment/src/org/labkey/experiment/controllers/exp/ExperimentController.java +++ b/experiment/src/org/labkey/experiment/controllers/exp/ExperimentController.java @@ -1,8371 +1,8371 @@ -/* - * Copyright (c) 2008-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.labkey.experiment.controllers.exp; - -import au.com.bytecode.opencsv.CSVWriter; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import jakarta.servlet.http.HttpSession; -import org.apache.commons.collections4.MapUtils; -import org.apache.commons.lang3.StringUtils; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.apache.poi.openxml4j.exceptions.InvalidFormatException; -import org.apache.poi.ss.usermodel.Workbook; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; -import org.labkey.api.action.ApiJsonWriter; -import org.labkey.api.action.ApiResponse; -import org.labkey.api.action.ApiSimpleResponse; -import org.labkey.api.action.ApiUsageException; -import org.labkey.api.action.ExportAction; -import org.labkey.api.action.FormHandlerAction; -import org.labkey.api.action.FormViewAction; -import org.labkey.api.action.HasViewContext; -import org.labkey.api.action.Marshal; -import org.labkey.api.action.Marshaller; -import org.labkey.api.action.MutatingApiAction; -import org.labkey.api.action.QueryViewAction; -import org.labkey.api.action.ReadOnlyApiAction; -import org.labkey.api.action.ReturnUrlForm; -import org.labkey.api.action.SimpleApiJsonForm; -import org.labkey.api.action.SimpleErrorView; -import org.labkey.api.action.SimpleResponse; -import org.labkey.api.action.SimpleViewAction; -import org.labkey.api.action.SpringActionController; -import org.labkey.api.assay.AssayFileWriter; -import org.labkey.api.assay.AssayProtocolSchema; -import org.labkey.api.assay.AssayProvider; -import org.labkey.api.assay.AssayService; -import org.labkey.api.assay.actions.UploadWizardAction; -import org.labkey.api.assay.security.DesignAssayPermission; -import org.labkey.api.attachments.AttachmentParent; -import org.labkey.api.attachments.AttachmentService; -import org.labkey.api.attachments.BaseDownloadAction; -import org.labkey.api.audit.AbstractAuditTypeProvider; -import org.labkey.api.audit.AuditLogService; -import org.labkey.api.audit.DetailedAuditTypeEvent; -import org.labkey.api.audit.SampleTimelineAuditEvent; -import org.labkey.api.audit.TransactionAuditProvider; -import org.labkey.api.collections.CaseInsensitiveHashMap; -import org.labkey.api.collections.CaseInsensitiveHashSet; -import org.labkey.api.collections.LongHashMap; -import org.labkey.api.data.ActionButton; -import org.labkey.api.data.BaseColumnInfo; -import org.labkey.api.data.ButtonBar; -import org.labkey.api.data.ColumnInfo; -import org.labkey.api.data.CompareType; -import org.labkey.api.data.Container; -import org.labkey.api.data.ContainerFilter; -import org.labkey.api.data.ContainerManager; -import org.labkey.api.data.DataRegion; -import org.labkey.api.data.DataRegionSelection; -import org.labkey.api.data.DbSchema; -import org.labkey.api.data.DbScope; -import org.labkey.api.data.DisplayColumn; -import org.labkey.api.data.ExcelWriter; -import org.labkey.api.data.MenuButton; -import org.labkey.api.data.NameGenerator; -import org.labkey.api.data.SQLFragment; -import org.labkey.api.data.ShowRows; -import org.labkey.api.data.SimpleDisplayColumn; -import org.labkey.api.data.SimpleFilter; -import org.labkey.api.data.Sort; -import org.labkey.api.data.SqlSelector; -import org.labkey.api.data.TSVWriter; -import org.labkey.api.data.TableInfo; -import org.labkey.api.data.TableSelector; -import org.labkey.api.dataiterator.DataIteratorContext; -import org.labkey.api.exp.AbstractParameter; -import org.labkey.api.exp.DeleteForm; -import org.labkey.api.exp.DuplicateMaterialException; -import org.labkey.api.exp.ExperimentDataHandler; -import org.labkey.api.exp.ExperimentException; -import org.labkey.api.exp.ExperimentRunForm; -import org.labkey.api.exp.ExperimentRunListView; -import org.labkey.api.exp.ExperimentRunType; -import org.labkey.api.exp.Identifiable; -import org.labkey.api.exp.Lsid; -import org.labkey.api.exp.LsidManager; -import org.labkey.api.exp.LsidType; -import org.labkey.api.exp.ObjectProperty; -import org.labkey.api.exp.OntologyManager; -import org.labkey.api.exp.ProtocolApplicationParameter; -import org.labkey.api.exp.XarContext; -import org.labkey.api.exp.api.DataClassDomainKindProperties; -import org.labkey.api.exp.api.ExpData; -import org.labkey.api.exp.api.ExpDataClass; -import org.labkey.api.exp.api.ExpExperiment; -import org.labkey.api.exp.api.ExpLineageOptions; -import org.labkey.api.exp.api.ExpMaterial; -import org.labkey.api.exp.api.ExpMaterialRunInput; -import org.labkey.api.exp.api.ExpObject; -import org.labkey.api.exp.api.ExpProtocol; -import org.labkey.api.exp.api.ExpProtocolApplication; -import org.labkey.api.exp.api.ExpRun; -import org.labkey.api.exp.api.ExpRunAttachmentParent; -import org.labkey.api.exp.api.ExpRunEditor; -import org.labkey.api.exp.api.ExpRunItem; -import org.labkey.api.exp.api.ExpSampleType; -import org.labkey.api.exp.api.ExperimentJSONConverter; -import org.labkey.api.exp.api.ExperimentService; -import org.labkey.api.exp.api.ExperimentUrls; -import org.labkey.api.exp.api.NameExpressionOptionService; -import org.labkey.api.exp.api.ResolveLsidsForm; -import org.labkey.api.exp.api.SampleTypeDomainKind; -import org.labkey.api.exp.api.SampleTypeService; -import org.labkey.api.exp.list.ListService; -import org.labkey.api.exp.property.Domain; -import org.labkey.api.exp.property.DomainAuditProvider; -import org.labkey.api.exp.property.DomainKind; -import org.labkey.api.exp.property.DomainProperty; -import org.labkey.api.exp.property.DomainTemplate; -import org.labkey.api.exp.property.DomainTemplateGroup; -import org.labkey.api.exp.property.DomainUtil; -import org.labkey.api.exp.property.PropertyService; -import org.labkey.api.exp.query.ExpDataProtocolInputTable; -import org.labkey.api.exp.query.ExpInputTable; -import org.labkey.api.exp.query.ExpMaterialProtocolInputTable; -import org.labkey.api.exp.query.ExpSchema; -import org.labkey.api.exp.query.SamplesSchema; -import org.labkey.api.exp.xar.LSIDRelativizer; -import org.labkey.api.exp.xar.LsidUtils; -import org.labkey.api.files.FileContentService; -import org.labkey.api.gwt.client.AuditBehaviorType; -import org.labkey.api.inventory.InventoryService; -import org.labkey.api.module.ModuleHtmlView; -import org.labkey.api.module.ModuleLoader; -import org.labkey.api.pipeline.PipeRoot; -import org.labkey.api.pipeline.PipelineService; -import org.labkey.api.pipeline.PipelineStatusFile; -import org.labkey.api.pipeline.PipelineUrls; -import org.labkey.api.pipeline.PipelineValidationException; -import org.labkey.api.qc.SampleStatusService; -import org.labkey.api.query.AbstractQueryImportAction; -import org.labkey.api.query.BatchValidationException; -import org.labkey.api.query.DetailsURL; -import org.labkey.api.query.DuplicateKeyException; -import org.labkey.api.query.FieldKey; -import org.labkey.api.query.QueryAction; -import org.labkey.api.query.QueryDefinition; -import org.labkey.api.query.QueryException; -import org.labkey.api.query.QueryForm; -import org.labkey.api.query.QueryParam; -import org.labkey.api.query.QueryService; -import org.labkey.api.query.QuerySettings; -import org.labkey.api.query.QueryUpdateForm; -import org.labkey.api.query.QueryUpdateService; -import org.labkey.api.query.QueryUpdateServiceException; -import org.labkey.api.query.QueryView; -import org.labkey.api.query.SchemaKey; -import org.labkey.api.query.UserSchema; -import org.labkey.api.query.UserSchemaAction; -import org.labkey.api.reader.ColumnDescriptor; -import org.labkey.api.reader.DataLoader; -import org.labkey.api.reader.DataLoaderFactory; -import org.labkey.api.reader.ExcelFactory; -import org.labkey.api.search.SearchService; -import org.labkey.api.security.ActionNames; -import org.labkey.api.security.RequiresAnyOf; -import org.labkey.api.security.RequiresNoPermission; -import org.labkey.api.security.RequiresPermission; -import org.labkey.api.security.SecurableResource; -import org.labkey.api.security.User; -import org.labkey.api.security.permissions.AdminPermission; -import org.labkey.api.security.permissions.DeletePermission; -import org.labkey.api.security.permissions.DesignDataClassPermission; -import org.labkey.api.security.permissions.DesignSampleTypePermission; -import org.labkey.api.security.permissions.InsertPermission; -import org.labkey.api.security.permissions.Permission; -import org.labkey.api.security.permissions.ReadPermission; -import org.labkey.api.security.permissions.SampleWorkflowDeletePermission; -import org.labkey.api.security.permissions.SiteAdminPermission; -import org.labkey.api.security.permissions.TroubleshooterPermission; -import org.labkey.api.security.permissions.UpdatePermission; -import org.labkey.api.settings.AppProps; -import org.labkey.api.settings.ConceptURIProperties; -import org.labkey.api.sql.LabKeySql; -import org.labkey.api.study.Dataset; -import org.labkey.api.study.StudyService; -import org.labkey.api.study.StudyUrls; -import org.labkey.api.study.publish.StudyPublishService; -import org.labkey.api.usageMetrics.SimpleMetricsService; -import org.labkey.api.util.DOM; -import org.labkey.api.util.DOM.LK; -import org.labkey.api.util.ErrorRenderer; -import org.labkey.api.util.ExceptionUtil; -import org.labkey.api.util.FileStream; -import org.labkey.api.util.FileUtil; -import org.labkey.api.util.HtmlString; -import org.labkey.api.util.HtmlStringBuilder; -import org.labkey.api.util.ImageUtil; -import org.labkey.api.util.JSoupUtil; -import org.labkey.api.util.LinkBuilder; -import org.labkey.api.util.NetworkDrive; -import org.labkey.api.util.PageFlowUtil; -import org.labkey.api.util.Pair; -import org.labkey.api.util.ResponseHelper; -import org.labkey.api.util.SafeToRender; -import org.labkey.api.util.SessionHelper; -import org.labkey.api.util.StringExpression; -import org.labkey.api.util.URLHelper; -import org.labkey.api.util.UniqueID; -import org.labkey.api.util.CsrfInput; -import org.labkey.api.view.ActionURL; -import org.labkey.api.view.BadRequestException; -import org.labkey.api.view.DataView; -import org.labkey.api.view.DataViewSnapshotSelectionForm; -import org.labkey.api.view.DetailsView; -import org.labkey.api.view.HBox; -import org.labkey.api.view.HtmlView; -import org.labkey.api.view.HttpView; -import org.labkey.api.view.InsertView; -import org.labkey.api.view.JspView; -import org.labkey.api.view.NavTree; -import org.labkey.api.view.NotFoundException; -import org.labkey.api.view.RedirectException; -import org.labkey.api.view.UnauthorizedException; -import org.labkey.api.view.UpdateView; -import org.labkey.api.view.VBox; -import org.labkey.api.view.ViewBackgroundInfo; -import org.labkey.api.view.ViewContext; -import org.labkey.api.view.ViewServlet; -import org.labkey.api.view.WebPartView; -import org.labkey.api.view.template.ClientDependency; -import org.labkey.api.view.template.PageConfig; -import org.labkey.experiment.ChooseExperimentTypeBean; -import org.labkey.experiment.ConfirmDeleteView; -import org.labkey.experiment.CustomPropertiesView; -import org.labkey.experiment.DataClassWebPart; -import org.labkey.experiment.DerivedSamplePropertyHelper; -import org.labkey.experiment.DotGraph; -import org.labkey.experiment.ExpDataFileListener; -import org.labkey.experiment.ExperimentRunDisplayColumn; -import org.labkey.experiment.ExperimentRunGraph; -import org.labkey.experiment.LineageGraphDisplayColumn; -import org.labkey.experiment.MissingFilesCheckInfo; -import org.labkey.experiment.MoveRunsBean; -import org.labkey.experiment.ParentChildView; -import org.labkey.experiment.ProtocolApplicationDisplayColumn; -import org.labkey.experiment.ProtocolDisplayColumn; -import org.labkey.experiment.ProtocolWebPart; -import org.labkey.experiment.RunGroupWebPart; -import org.labkey.experiment.SampleTypeDisplayColumn; -import org.labkey.experiment.SampleTypeWebPart; -import org.labkey.experiment.StandardAndCustomPropertiesView; -import org.labkey.experiment.XarExportPipelineJob; -import org.labkey.experiment.XarExportType; -import org.labkey.experiment.XarExporter; -import org.labkey.experiment.api.ClosureQueryHelper; -import org.labkey.experiment.api.DataClass; -import org.labkey.experiment.api.DataClassDomainKind; -import org.labkey.experiment.api.ExpDataClassAttachmentParent; -import org.labkey.experiment.api.ExpDataClassImpl; -import org.labkey.experiment.api.ExpDataImpl; -import org.labkey.experiment.api.ExpExperimentImpl; -import org.labkey.experiment.api.ExpMaterialImpl; -import org.labkey.experiment.api.ExpProtocolApplicationImpl; -import org.labkey.experiment.api.ExpProtocolImpl; -import org.labkey.experiment.api.ExpRunImpl; -import org.labkey.experiment.api.ExpSampleTypeImpl; -import org.labkey.experiment.api.Experiment; -import org.labkey.experiment.api.ExperimentServiceImpl; -import org.labkey.experiment.api.GraphAlgorithms; -import org.labkey.experiment.api.ProtocolActionStepDetail; -import org.labkey.experiment.api.SampleTypeServiceImpl; -import org.labkey.experiment.api.SampleTypeUpdateServiceDI; -import org.labkey.experiment.controllers.property.PropertyController; -import org.labkey.experiment.lineage.ExpLineageServiceImpl; -import org.labkey.experiment.pipeline.ExperimentPipelineJob; -import org.labkey.experiment.types.TypesController; -import org.labkey.experiment.xar.XarExportSelection; -import org.labkey.vfs.FileLike; -import org.labkey.vfs.FileSystemLike; -import org.springframework.beans.PropertyValue; -import org.springframework.beans.PropertyValues; -import org.springframework.validation.BindException; -import org.springframework.validation.Errors; -import org.springframework.validation.ObjectError; -import org.springframework.web.multipart.MultipartFile; -import org.springframework.web.multipart.MultipartHttpServletRequest; -import org.springframework.web.servlet.ModelAndView; - -import javax.imageio.ImageIO; -import java.awt.*; -import java.awt.image.BufferedImage; -import java.io.BufferedOutputStream; -import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.FileNotFoundException; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.net.URI; -import java.nio.file.Files; -import java.nio.file.Path; -import java.sql.SQLException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.Comparator; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Iterator; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.Set; -import java.util.TreeSet; -import java.util.function.Function; -import java.util.stream.Collectors; - -import static java.util.stream.Collectors.toList; -import static org.labkey.api.data.DbScope.CommitTaskOption.POSTCOMMIT; -import static org.labkey.api.exp.query.ExpSchema.TableType.DataInputs; -import static org.labkey.api.query.QueryImportPipelineJob.QUERY_IMPORT_NOTIFICATION_PROVIDER_PARAM; -import static org.labkey.api.query.QueryImportPipelineJob.QUERY_IMPORT_PIPELINE_DESCRIPTION_PARAM; -import static org.labkey.api.query.QueryImportPipelineJob.QUERY_IMPORT_PIPELINE_PROVIDER_PARAM; -import static org.labkey.api.util.DOM.A; -import static org.labkey.api.util.DOM.Attribute.action; -import static org.labkey.api.util.DOM.Attribute.href; -import static org.labkey.api.util.DOM.Attribute.id; -import static org.labkey.api.util.DOM.Attribute.method; -import static org.labkey.api.util.DOM.Attribute.name; -import static org.labkey.api.util.DOM.Attribute.size; -import static org.labkey.api.util.DOM.Attribute.src; -import static org.labkey.api.util.DOM.Attribute.target; -import static org.labkey.api.util.DOM.Attribute.type; -import static org.labkey.api.util.DOM.Attribute.value; -import static org.labkey.api.util.DOM.Attribute.width; -import static org.labkey.api.util.DOM.DIV; -import static org.labkey.api.util.DOM.IMG; -import static org.labkey.api.util.DOM.INPUT; -import static org.labkey.api.util.DOM.LI; -import static org.labkey.api.util.DOM.TABLE; -import static org.labkey.api.util.DOM.TD; -import static org.labkey.api.util.DOM.TR; -import static org.labkey.api.util.DOM.UL; -import static org.labkey.api.util.DOM.at; -import static org.labkey.api.util.DOM.cl; -import static org.labkey.experiment.ExpDataIterators.setContainerFilterForImport; -import static org.labkey.experiment.api.SampleTypeServiceImpl.SampleChangeType.update; - -public class ExperimentController extends SpringActionController -{ - private static final Logger _log = LogManager.getLogger(ExperimentController.class); - private static final DefaultActionResolver _actionResolver = new DefaultActionResolver( - ExperimentController.class - ); - private static final String GUEST_DIRECTORY_NAME = "guest"; - - public ExperimentController() - { - setActionResolver(_actionResolver); - } - - public static void ensureCorrectContainer(Container requestContainer, ExpObject object, ViewContext viewContext) - { - Container objectContainer = object.getContainer(); - if (!requestContainer.equals(objectContainer)) - { - ActionURL url = viewContext.cloneActionURL(); - url.setContainer(objectContainer); - throw new RedirectException(url); - } - } - - // Complete no-op, but leave in place in case we decide to adjust the base nav trail - private void addRootNavTrail(NavTree root) - { - // Intentionally don't add an "Experiment" node to the list because it's too overloaded. All content on the - // default action can be added to a portal page if desired. - } - - @Override - public PageConfig defaultPageConfig() - { - // set default help topic for controller - PageConfig config = super.defaultPageConfig(); - config.setHelpTopic("experiment"); - return config; - } - - @ActionNames("begin,gridView") - @RequiresPermission(ReadPermission.class) - public class BeginAction extends SimpleViewAction - { - @Override - public VBox getView(Object o, BindException errors) - { - VBox result = new VBox(); - - VBox runListView = createRunListView(20); - result.addView(runListView); - - RunGroupWebPart runGroups = new RunGroupWebPart(getViewContext(), false); - runGroups.showHeader(); - result.addView(runGroups); - - result.addView(new ProtocolWebPart(false, getViewContext())); - result.addView(new SampleTypeWebPart(false, getViewContext())); - result.addView(new DataClassWebPart(false, getViewContext(), null)); - - return result; - } - - @Override - public void addNavTrail(NavTree root) - { - addRootNavTrail(root); - root.addChild("Experiment"); - } - } - - @RequiresPermission(ReadPermission.class) - public class ShowRunsAction extends SimpleViewAction - { - @Override - public VBox getView(Object o, BindException errors) - { - return createRunListView(100); - } - - @Override - public void addNavTrail(NavTree root) - { - addRootNavTrail(root); - root.addChild("Experiment Runs"); - } - } - - private VBox createRunListView(int defaultMaxRows) - { - Set types = ExperimentService.get().getExperimentRunTypes(getContainer()); - ChooseExperimentTypeBean bean = new ChooseExperimentTypeBean(types, ExperimentRunType.getSelectedFilter(types, getViewContext().getRequest().getParameter("experimentRunFilter")), getViewContext().getActionURL().clone(), Collections.emptyList()); - JspView chooserView = new JspView<>("/org/labkey/experiment/experimentRunQueryHeader.jsp", bean); - - ExperimentRunListView view = ExperimentService.get().createExperimentRunWebPart(getViewContext(), bean.getSelectedFilter()); - view.setFrame(WebPartView.FrameType.NONE); - - // When paginated and the user hasn't explicitly set a maxRows, use the default maxRows size. - QuerySettings settings = view.getSettings(); - if (!settings.isMaxRowsSet() && settings.getShowRows() == ShowRows.PAGINATED) - { - settings.setMaxRows(defaultMaxRows); - } - - VBox result = new VBox(chooserView, view); - result.setFrame(WebPartView.FrameType.PORTAL); - return result; - } - - @RequiresPermission(ReadPermission.class) - @ActionNames("showRunGroups, showExperiments") - public class ShowRunGroupsAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object o, BindException errors) - { - RunGroupWebPart webPart = new RunGroupWebPart(getViewContext(), false); - webPart.setFrame(WebPartView.FrameType.NONE); - return webPart; - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("runGroups"); - addRootNavTrail(root); - root.addChild("Run Groups"); - } - } - - public record Field(String domainURI, String domainName, String name, Container container) {} - public record MiniExpObject(Object rowId, String name) {} - public record TimelineSummary(MiniExpObject miniExpObject, String mostRecentValue) {} - public record ProblemType(String tableName, String fieldName, String pkName) { - public Object toHtml(List summaries) - { - return DOM.DIV( - DOM.H4(tableName), - DOM.TABLE(at(cl("table-condensed", "labkey-data-region", "table-bordered")), - DOM.THEAD(DOM.TH(pkName), DOM.TH(fieldName)), - summaries.stream().map(summary -> - DOM.TR(DOM.TD(summary.miniExpObject.name), DOM.TD(summary.mostRecentValue))) - )); - } - } - - @RequiresPermission(SiteAdminPermission.class) - public static class ReportLostFieldValuesAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object o, BindException errors) - { - // Find all the fields that could have lost data due to issue 52666 - TableInfo t = new ExpSchema(getUser(), ContainerManager.getRoot()).getTable(ExpSchema.TableType.Fields.name(), ContainerFilter.getUnsafeEverythingFilter()); - List fields = new TableSelector(t, - new SimpleFilter(FieldKey.fromParts("StorageColumnNameMatch"), false). - addCondition(FieldKey.fromParts("DomainURI"), ":AssayDomain-Data.", CompareType.DOES_NOT_CONTAIN), - null). - getArrayList(Field.class); - - // Prep audit table for querying - UserSchema auditSchema = AuditLogService.get().createSchema(getUser(), ContainerManager.getRoot()); - - Map> sampleTypeSummaries = new HashMap<>(); - Map> dataClassSummaries = new HashMap<>(); - Map> listSummaries = new HashMap<>(); - - Map> problematicFields = new LinkedHashMap<>(); - - for (Field field : fields) - { - String domainURI = field.domainURI; - String fieldName = field.name; - Container container = field.container; - Domain domain = PropertyService.get().getDomain(container, domainURI); - if (domain != null && domain.getDomainKind() != null) - { - TableInfo table = domain.getDomainKind().getTableInfo(getUser(), container, domain, ContainerFilter.getUnsafeEverythingFilter()); - - if (table != null) - { - // Drill into sample types - if (domain.getDomainKind().getClass().equals(SampleTypeDomainKind.class)) - { - // rows that currently have no value for the field with potential for data loss - List rowsWithNull = new TableSelector(table, - new HashSet<>(List.of("RowId", "Name")), - new SimpleFilter(new CompareType.CompareClause(FieldKey.fromParts(fieldName), CompareType.ISBLANK, null)), - null). - getArrayList(MiniExpObject.class); - - List fixupsNeeded = checkData( - rowsWithNull, - fieldName, - obj -> new SimpleFilter(FieldKey.fromParts("SampleId"), obj.rowId), - auditSchema.getTable(SampleTimelineAuditEvent.EVENT_TYPE, ContainerFilter.getUnsafeEverythingFilter())); - if (!fixupsNeeded.isEmpty()) - { - sampleTypeSummaries.put(new ProblemType(table.getName(), fieldName, "SampleID"), fixupsNeeded); - } - } - // and data classes/sample sources - if (domain.getDomainKind().getClass().equals(DataClassDomainKind.class)) - { - // rows samples that current have no value for the field with potential for data loss - List rowsWithNull = new TableSelector(table, - new HashSet<>(List.of("RowId", "Name")), - new SimpleFilter(new CompareType.CompareClause(FieldKey.fromParts(fieldName), CompareType.ISBLANK, null)), - null). - getArrayList(MiniExpObject.class); - - List fixupsNeeded = checkData( - rowsWithNull, - fieldName, - obj -> new SimpleFilter(FieldKey.fromParts("RowPk"), Objects.toString(obj.rowId)). - addCondition(FieldKey.fromParts("SchemaName"), "exp.data"). - addCondition(FieldKey.fromParts("QueryName"), domain.getName()), - auditSchema.getTable("QueryUpdateAuditEvent", ContainerFilter.getUnsafeEverythingFilter())); - - if (!fixupsNeeded.isEmpty()) - { - dataClassSummaries.put(new ProblemType(table.getName(), fieldName, "SourceID"), fixupsNeeded); - } - } - // and lists - if ("lists".equals(table.getUserSchema().getName())) - { - // rows samples that current have no value for the field with potential for data loss - List rowsWithNull = new ArrayList<>(); - - ColumnInfo entityIdCol = table.getColumn("EntityId"); - ColumnInfo pkCol = table.getPkColumns().get(0); - - new TableSelector(table, - List.of(entityIdCol, pkCol), - new SimpleFilter(new CompareType.CompareClause(FieldKey.fromParts(fieldName), CompareType.ISBLANK, null)), - null). - forEachResults(r -> - { - Object entityId = entityIdCol.getValue(r); - Object pk = pkCol.getValue(r); - rowsWithNull.add(new MiniExpObject(entityId, pk.toString())); - }); - - - List fixupsNeeded = checkData( - rowsWithNull, - fieldName, - obj -> new SimpleFilter(FieldKey.fromParts("ListItemEntityId"), obj.rowId), - auditSchema.getTable("ListAuditEvent", ContainerFilter.getUnsafeEverythingFilter())); - - if (!fixupsNeeded.isEmpty()) - { - listSummaries.put(new ProblemType(table.getName(), fieldName, table.getPkColumnNames().get(0)), fixupsNeeded); - } - } - - long totalRows = new TableSelector(table).getRowCount(); - long emptyRows = new TableSelector(table, new SimpleFilter(new CompareType.CompareClause(FieldKey.fromParts(fieldName), CompareType.ISBLANK, null)), null).getRowCount(); - problematicFields.put(field, Pair.of(totalRows, emptyRows)); - } - else - { - problematicFields.put(field, Pair.of(null, null)); - } - } - } - - return new HtmlView("Fixups Needed", - DOM.createHtmlFragment( - DOM.H2("Potentially Problematic Fields"), - problematicFields.isEmpty() ? "No problematic fields detected!" : - DOM.TABLE(at(cl("table-condensed", "labkey-data-region", "table-bordered")), - DOM.THEAD(DOM.TH("Domain Name"), DOM.TH("Domain URI"), DOM.TH("Field Name"), DOM.TH("Container"), DOM.TH("Total Rows"), DOM.TH("Rows with Nulls")), - problematicFields.entrySet().stream().map(e -> { - Field f = e.getKey(); - Pair counts = e.getValue(); - return DOM.TR( - DOM.TD(f.domainName), - DOM.TD(f.domainURI), - DOM.TD(f.name), - DOM.TD(f.container.getPath()), - DOM.TD(counts.first), - DOM.TD(counts.second) - ); - } - )), - - DOM.H2("Sample Types"), - sampleTypeSummaries.isEmpty() ? "No problems detected!" : - sampleTypeSummaries.entrySet().stream().map(e -> - e.getKey().toHtml(e.getValue())), - - DOM.H2("Data Classes"), - dataClassSummaries.isEmpty() ? "No problems detected!" : - dataClassSummaries.entrySet().stream().map(e -> - e.getKey().toHtml(e.getValue())), - - DOM.H2("Lists"), - listSummaries.isEmpty() ? "No problems detected!" : - listSummaries.entrySet().stream().map(e -> - e.getKey().toHtml(e.getValue())) - )); - } - - @NotNull - private List checkData(List rowsWithNull, String fieldName, Function filterGenerator, TableInfo auditTable) - { - List fixupsNeeded = new ArrayList<>(); - - // For each sample without a value today, check the audit history - for (MiniExpObject row : rowsWithNull) - { - // Order by RowId to get them in the sequence they happened in - var events = new TableSelector(auditTable, filterGenerator.apply(row), new Sort("RowId")).getArrayList(DetailedAuditTypeEvent.class); - // Remember the most recently set value - String mostRecentValue = null; - for (DetailedAuditTypeEvent event : events) - { - Map newValues = new CaseInsensitiveHashMap<>(AbstractAuditTypeProvider.decodeFromDataMap(event.getNewRecordMap())); - if (newValues.containsKey(fieldName)) - { - // Will be the empty string if the value was intentionally set to blank - mostRecentValue = newValues.get(fieldName); - } - } - // If the value had been set before, and its most recent insert/update wasn't setting it blank, - // it's most likely a lost value - if (mostRecentValue != null && !mostRecentValue.isEmpty()) - { - fixupsNeeded.add(new TimelineSummary(row, mostRecentValue)); - } - } - return fixupsNeeded; - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild("Accidentally Nulled Field Report"); - } - } - - @RequiresPermission(ReadPermission.class) - public static class CreateHiddenRunGroupAction extends MutatingApiAction - { - @Override - public ApiResponse execute(SimpleApiJsonForm form, BindException errors) throws Exception - { - JSONObject json = form.getJsonObject(); - String selectionKey = json.optString("selectionKey", null); - List runs = new ArrayList<>(); - - // Accept either an explicit list of run IDs - if (json.has("runIds")) - { - JSONArray runIds = json.getJSONArray("runIds"); - for (int i = 0; i < runIds.length(); i++) - { - ExpRunImpl run = ExperimentServiceImpl.get().getExpRun(runIds.getInt(i)); - if (run != null) - { - runs.add(run); - } - } - } - // Or a reference to a DataRegion selection key - else if (selectionKey != null) - { - Set ids = DataRegionSelection.getSelectedIntegers(getViewContext(), selectionKey, false); - for (Long id : ids) - { - ExpRunImpl run = ExperimentServiceImpl.get().getExpRun(id); - if (run != null) - { - runs.add(run); - } - } - } - if (runs.isEmpty()) - { - throw new NotFoundException(); - } - - ExpExperiment group = ExperimentService.get().createHiddenRunGroup(getContainer(), getUser(), runs.toArray(new ExpRun[0])); - if (selectionKey != null) - DataRegionSelection.clearAll(getViewContext(), selectionKey); - - ApiSimpleResponse response = new ApiSimpleResponse(); - response.putBean(group, "rowId", "LSID", "name", "hidden"); - return response; - } - } - - - @RequiresPermission(ReadPermission.class) - public class DetailsAction extends QueryViewAction - { - private ExpExperimentImpl _experiment; - - public DetailsAction() - { - super(ExpObjectForm.class); - } - - private Pair> createViews(ExpObjectForm form, BindException errors) - { - _experiment = ExperimentServiceImpl.get().getExpExperiment(form.getRowId()); - if (_experiment == null) - { - throw new NotFoundException("Could not find an experiment with RowId " + form.getRowId()); - } - - if (!_experiment.getContainer().equals(getContainer())) - { - throw new RedirectException(getViewContext().cloneActionURL().setContainer(_experiment.getContainer())); - } - - List protocols = _experiment.getAllProtocols(); - - Set types = new TreeSet<>(ExperimentService.get().getExperimentRunTypes(getContainer())); - ExperimentRunType selectedType = ExperimentRunType.getSelectedFilter(types, getViewContext().getRequest().getParameter("experimentRunFilter")); - - ChooseExperimentTypeBean bean = new ChooseExperimentTypeBean(types, selectedType, getViewContext().getActionURL().clone(), protocols); - JspView chooserView = new JspView<>("/org/labkey/experiment/experimentRunQueryHeader.jsp", bean, errors); - - ExperimentRunListView runListView = ExperimentRunListView.createView(getViewContext(), bean.getSelectedFilter(), true); - runListView.getRunTable().setExperiment(_experiment); - runListView.setShowRemoveFromExperimentButton(true); - runListView.setShowDeleteButton(true); - runListView.setShowAddToRunGroupButton(true); - runListView.setShowExportButtons(true); - runListView.setShowMoveRunsButton(true); - return new Pair<>(runListView, chooserView); - } - - @Override - protected ModelAndView getHtmlView(ExpObjectForm form, BindException errors) throws Exception - { - Pair> views = createViews(form, errors); - - CustomPropertiesView customPropertiesView = new CustomPropertiesView(_experiment.getLSID(), getContainer()); - - TableInfo runGroupsTable = new ExpSchema(getUser(), getContainer()).getTable(ExpSchema.TableType.RunGroups); - - DetailsView detailsView = new DetailsView(new DataRegion(), _experiment.getRowId()); - detailsView.getDataRegion().setTable(runGroupsTable); - detailsView.getDataRegion().addColumns(runGroupsTable, "RowId,Name,Created,Modified,Contact,ExperimentDescriptionURL,Hypothesis,Comments"); - detailsView.getDataRegion().getDisplayColumn(0).setVisible(false); - detailsView.getDataRegion().getDisplayColumn(2).setWidth("60%"); - - ButtonBar bb = new ButtonBar(); - bb.setStyle(ButtonBar.Style.separateButtons); - ActionButton b = new ActionButton(ExperimentUrlsImpl.get().getShowUpdateURL(_experiment), "Edit"); - b.setDisplayPermission(UpdatePermission.class); - bb.add(b); - detailsView.getDataRegion().setButtonBar(bb); - if (_experiment.getBatchProtocol() != null) - { - detailsView.setTitle("Batch Details"); - detailsView.getDataRegion().addColumns(runGroupsTable, "BatchProtocolId"); - } - else - { - detailsView.setTitle("Run Group Details"); - } - - VBox runsVBox = new VBox(views.second, createInitializedQueryView(form, errors, false, null)); - runsVBox.setTitle("Experiment Runs"); - runsVBox.setFrame(WebPartView.FrameType.PORTAL); - - return new VBox(new StandardAndCustomPropertiesView(detailsView, customPropertiesView), runsVBox); - } - - @Override - protected ExperimentRunListView createQueryView(ExpObjectForm form, BindException errors, boolean forExport, String dataRegion) - { - return createViews(form, errors).first; - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("runGroups"); - addRootNavTrail(root); - root.addChild("Run Groups", ExperimentUrlsImpl.get().getShowExperimentsURL(getContainer())); - root.addChild(_experiment.getName()); - } - } - - @RequiresPermission(ReadPermission.class) - public class ListSampleTypesAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object o, BindException errors) - { - SampleTypeWebPart view = new SampleTypeWebPart(false, getViewContext()); - view.setFrame(WebPartView.FrameType.NONE); - view.setErrorMessage(getViewContext().getRequest().getParameter("errorMessage")); - - return view; - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("sampleSets"); - addRootNavTrail(root); - root.addChild("Sample Types"); - } - } - - @RequiresPermission(ReadPermission.class) - public class ShowSampleTypeAction extends SimpleViewAction - { - private ExpSampleTypeImpl _sampleType; - - @Override - public ModelAndView getView(ExpObjectForm form, BindException errors) - { - _sampleType = SampleTypeServiceImpl.get().getSampleType(getContainer(), getUser(), form.getRowId()); - if (_sampleType == null && form.getLsid() != null) - { - if (form.getLsid().equalsIgnoreCase("Material") || form.getLsid().equalsIgnoreCase("Sample")) - { - // Not a real sample type - just show all the materials instead - throw new RedirectException(new ActionURL(ShowAllMaterialsAction.class, getContainer())); - } - // Check if the URL specifies the LSID, and stick the bean back into the form - _sampleType = SampleTypeServiceImpl.get().getSampleType(form.getLsid()); - } - - if (_sampleType == null) - { - throw new NotFoundException("No matching sample type found"); - } - - List allScopedSampleTypes = (List) SampleTypeService.get().getSampleTypes(getContainer(), getUser(), true); - if (!allScopedSampleTypes.contains(_sampleType)) - { - ensureCorrectContainer(getContainer(), _sampleType, getViewContext()); - } - - SamplesSchema schema = new SamplesSchema(getUser(), getContainer()); - QuerySettings settings = schema.getSettings(getViewContext(), "Material", _sampleType.getName()); - QueryView queryView = new SampleTypeContentsView(_sampleType, schema, settings, errors); - - DetailsView detailsView = new DetailsView(getSampleTypeRegion(getViewContext()), _sampleType.getRowId()); - detailsView.getDataRegion().getDisplayColumn("Name").setURL((ActionURL)null); - detailsView.getDataRegion().getDisplayColumn("LSID").setVisible(false); - detailsView.getDataRegion().getDisplayColumn("MaterialLSIDPrefix").setVisible(false); - detailsView.getDataRegion().getDisplayColumn("LabelColor").setVisible(false); - detailsView.getDataRegion().getDisplayColumn("MetricUnit").setVisible(false); - detailsView.getDataRegion().getDisplayColumn("Category").setVisible(false); - - detailsView.setTitle("Sample Type Properties"); - detailsView.getDataRegion().getButtonBar(DataRegion.MODE_DETAILS).setStyle(ButtonBar.Style.separateButtons); - - Container autoLinkContainer = _sampleType.getAutoLinkTargetContainer(); - if (null != autoLinkContainer) - { - DisplayColumn autoLinkTargetColumn = detailsView.getDataRegion().getDisplayColumn("autoLinkTargetContainer"); - autoLinkTargetColumn.setVisible(false); - - SimpleDisplayColumn displayAutoLinkTargetColumn = new SimpleDisplayColumn(); - displayAutoLinkTargetColumn.setCaption("Auto Link Target Container:"); - String path = autoLinkContainer.getPath(); - displayAutoLinkTargetColumn.setDisplayHtml(path.equals("/") ? "" : path); - detailsView.getDataRegion().addDisplayColumn(displayAutoLinkTargetColumn); - } - - DisplayColumn autoLinkCategoryColumn = detailsView.getDataRegion().getDisplayColumn("autoLinkCategory"); - autoLinkCategoryColumn.setVisible(false); - SimpleDisplayColumn displayAutoLinkCategoryColumn = new SimpleDisplayColumn(); - displayAutoLinkCategoryColumn.setCaption("Auto Link Category:"); - displayAutoLinkCategoryColumn.setDisplayHtml(_sampleType.getAutoLinkCategory()); - detailsView.getDataRegion().addDisplayColumn(displayAutoLinkCategoryColumn); - - if (_sampleType.hasNameAsIdCol()) - { - SimpleDisplayColumn nameIdCol = new SimpleDisplayColumn(); - nameIdCol.setCaption("Has Name Id Column:"); - nameIdCol.setDisplayHtml("true"); - detailsView.getDataRegion().addDisplayColumn(nameIdCol); - } - - if (_sampleType.hasIdColumns()) - { - SimpleDisplayColumn idCols = new SimpleDisplayColumn(); - idCols.setCaption("Id Column(s):"); - String names = _sampleType.getIdCols().stream() - .filter(Objects::nonNull) - .map(DomainProperty::getName) - .collect(Collectors.joining(", ")); - if (!names.isEmpty()) - { - idCols.setDisplayHtml(PageFlowUtil.filter(names)); - detailsView.getDataRegion().addDisplayColumn(idCols); - } - } - - if (_sampleType.getParentCol() != null) - { - SimpleDisplayColumn parentCol = new SimpleDisplayColumn(PageFlowUtil.filter(_sampleType.getParentCol().getName())); - parentCol.setCaption("Parent Column:"); - detailsView.getDataRegion().addDisplayColumn(parentCol); - } - - try - { - SimpleDisplayColumn importAliasCol = new SimpleDisplayColumn(); - importAliasCol.setCaption("Parent Import Alias(es):"); - if (!_sampleType.getImportAliases().isEmpty()) - importAliasCol.setDisplayHtml(PageFlowUtil.filter(StringUtils.join(_sampleType.getImportAliases().keySet(), ", "))); - detailsView.getDataRegion().addDisplayColumn(importAliasCol); - } - catch (IOException e) - { - // unable to parse import alias map from JSON - } - - if (!getContainer().equals(_sampleType.getContainer())) - { - ActionURL definitionURL = urlProvider(ExperimentUrls.class).getShowSampleTypeURL(_sampleType); - SimpleDisplayColumn definedInCol = new SimpleDisplayColumn("" + - PageFlowUtil.filter(_sampleType.getContainer().getPath()) + - ""); - definedInCol.setCaption("Defined In:"); - detailsView.getDataRegion().addDisplayColumn(definedInCol); - } - - // Not all sample types can be edited - DomainKind domainKind = _sampleType.getDomain().getDomainKind(); - if (domainKind != null && domainKind.canEditDefinition(getUser(), _sampleType.getDomain())) - { - if (domainKind instanceof SampleTypeDomainKind) - { - ActionURL updateURL = new ActionURL(EditSampleTypeAction.class, _sampleType.getContainer()); - updateURL.addParameter("RowId", _sampleType.getRowId()); - updateURL.addReturnUrl(getViewContext().getActionURL()); - - if (!getContainer().equals(_sampleType.getContainer())) - { - String editLink = updateURL.toString(); - ActionButton updateButton = new ActionButton("Edit Type"); - updateButton.setActionType(ActionButton.Action.SCRIPT); - updateButton.setScript("if (window.confirm('This sample type is defined in the " + _sampleType.getContainer().getPath() + " folder. Would you still like to edit it?')) { window.location = '" + editLink + "' }"); - detailsView.getDataRegion().getButtonBar(DataRegion.MODE_DETAILS).add(updateButton); - } - else - { - ActionButton updateButton = new ActionButton(updateURL, "Edit Type", ActionButton.Action.LINK); - updateButton.setDisplayPermission(DesignSampleTypePermission.class); - updateButton.setPrimary(true); - detailsView.getDataRegion().getButtonBar(DataRegion.MODE_DETAILS).add(updateButton); - } - - ActionURL deleteURL = new ActionURL(DeleteSampleTypesAction.class, _sampleType.getContainer()); - deleteURL.addParameter("singleObjectRowId", _sampleType.getRowId()); - deleteURL.addReturnUrl(ExperimentUrlsImpl.get().getShowSampleTypeListURL(getContainer())); - ActionButton deleteButton = new ActionButton(deleteURL, "Delete Type", ActionButton.Action.LINK); - deleteButton.setDisplayPermission(DesignSampleTypePermission.class); - detailsView.getDataRegion().getButtonBar(DataRegion.MODE_DETAILS).add(deleteButton); - } - else - { - ActionURL editURL = domainKind.urlEditDefinition(_sampleType.getDomain(), new ViewBackgroundInfo(_sampleType.getContainer(), getUser(), getViewContext().getActionURL())); - if (editURL != null) - { - editURL.addReturnUrl(getViewContext().getActionURL()); - ActionButton editTypeButton = new ActionButton(editURL, "Edit Fields"); - editTypeButton.setDisplayPermission(UpdatePermission.class); - detailsView.getDataRegion().getButtonBar(DataRegion.MODE_DETAILS).add(editTypeButton); - } - } - } - - if (_sampleType.canImportMoreSamples()) - { - TableInfo table = queryView.getTable(); - if (table != null) - { - ActionURL importURL = table.getImportDataURL(getContainer()); - if (importURL != null) - { - importURL = importURL.clone(); - importURL.addReturnUrl(getViewContext().getActionURL()); - ActionButton uploadButton = new ActionButton(importURL, "Import More Samples", ActionButton.Action.LINK); - uploadButton.setDisplayPermission(UpdatePermission.class); - detailsView.getDataRegion().getButtonBar(DataRegion.MODE_DETAILS).add(uploadButton); - } - } - } - - var publish = StudyPublishService.get(); - if (AuditLogService.get().isViewable() && publish != null) - { - ContainerFilter cf = ContainerFilter.Type.CurrentAndSubfolders.create(getContainer(), getUser()); - ActionURL linkToStudyHistoryURL = publish.getPublishHistory(getContainer(), Dataset.PublishSource.SampleType, _sampleType.getRowId(), cf); - ActionButton linkToStudyHistoryButton = new ActionButton(linkToStudyHistoryURL, "Link to Study History", ActionButton.Action.LINK); - linkToStudyHistoryButton.setDisplayPermission(InsertPermission.class); - detailsView.getDataRegion().getButtonBar(DataRegion.MODE_DETAILS).add(linkToStudyHistoryButton); - } - - return new VBox(detailsView, queryView); - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("sampleSets"); - ActionURL url = ExperimentUrls.get().getShowSampleTypeListURL(getContainer()); - addRootNavTrail(root); - root.addChild("Sample Types", url); - root.addChild("Sample Type " + _sampleType.getName()); - } - } - - @RequiresPermission(ReadPermission.class) - public class ShowAllMaterialsAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object o, BindException errors) - { - ExpSchema schema = new ExpSchema(getUser(), getContainer()); - QuerySettings settings = schema.getSettings(getViewContext(), "Materials", ExpSchema.TableType.Materials.toString()); - QueryView view = new QueryView(schema, settings, errors) - { - @Override - protected void populateButtonBar(DataView view, ButtonBar bar) - { - super.populateButtonBar(view, bar); - bar.add(SampleTypeContentsView.getDeriveSamplesButton(getContainer(),null)); - } - }; - view.setShowDetailsColumn(false); - return view; - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("sampleSets"); - addRootNavTrail(root); - root.addChild("All Materials"); - } - } - - /** - * Only shows standard and custom properties, not parent and child samples. Used for indexing - */ - @RequiresPermission(ReadPermission.class) - public class ShowMaterialSimpleAction extends SimpleViewAction - { - protected ExpMaterialImpl _material; - - @Override - public VBox getView(ExpObjectForm form, BindException errors) throws Exception - { - Container c = getContainer(); - _material = ExperimentServiceImpl.get().getExpMaterial(form.getRowId()); - if (_material == null && form.getLsid() != null) - { - _material = ExperimentServiceImpl.get().getExpMaterial(form.getLsid()); - } - if (_material == null) - { - throw new NotFoundException("Could not find a material with RowId " + form.getRowId()); - } - - ensureCorrectContainer(getContainer(), _material, getViewContext()); - - ExpRunImpl run = _material.getRun(); - ExpProtocol sourceProtocol = _material.getSourceProtocol(); - ExpProtocolApplication sourceProtocolApplication = _material.getSourceApplication(); - - DataRegion dr = new DataRegion(); - dr.addColumns(ExperimentServiceImpl.get().getTinfoMaterial().getUserEditableColumns()); - dr.removeColumns("RowId", "RunId", "LastIndexed", "LSID", "SourceApplicationId", "CpasType"); - - //dr.addColumns(extraProps); - dr.addDisplayColumn(new ExperimentRunDisplayColumn(run, "Source Experiment Run")); - dr.addDisplayColumn(new ProtocolDisplayColumn(sourceProtocol, "Source Protocol")); - dr.addDisplayColumn(new ProtocolApplicationDisplayColumn(sourceProtocolApplication, "Source Protocol Application")); - dr.addDisplayColumn(new LineageGraphDisplayColumn(_material, run)); - dr.addDisplayColumn(new SampleTypeDisplayColumn(_material)); - - //TODO: Can't yet edit materials uploaded from a material source - dr.setButtonBar(new ButtonBar()); - DetailsView detailsView = new DetailsView(dr, _material.getRowId()); - detailsView.setTitle("Standard Properties"); - detailsView.setFrame(WebPartView.FrameType.PORTAL); - - CustomPropertiesView cpv = new CustomPropertiesView(_material, c, getUser()); - - return new VBox(new StandardAndCustomPropertiesView(detailsView, cpv)); - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("sampleSets"); - addRootNavTrail(root); - root.addChild("Sample Types", ExperimentUrlsImpl.get().getShowSampleTypeListURL(getContainer())); - ExpSampleType sampleType = _material.getSampleType(); - if (sampleType != null) - { - root.addChild(sampleType.getName(), ExperimentUrlsImpl.get().getShowSampleTypeURL(sampleType)); - } - root.addChild("Sample " + _material.getName()); - } - } - - @RequiresPermission(ReadPermission.class) - public class ShowMaterialAction extends ShowMaterialSimpleAction - { - @Override - public VBox getView(ExpObjectForm form, BindException errors) throws Exception - { - VBox vbox = super.getView(form, errors); - - List materialsToInvestigate = new ArrayList<>(); - final Set successorRuns = new HashSet<>(); - materialsToInvestigate.add(_material); - Set investigatedMaterials = new HashSet<>(); - do - { - // Query for all the next tier of materials at once - issue 45402 - List followupRuns = ExperimentService.get().getRunsUsingMaterials(materialsToInvestigate); - - // Mark this set as investigated and reset for the next cycle - investigatedMaterials.addAll(materialsToInvestigate); - materialsToInvestigate = new ArrayList<>(); - - for (ExpRun r : followupRuns) - { - // Only expand the material outputs of the run if it's our first time visiting it - if (successorRuns.add(r)) - { - materialsToInvestigate.addAll(r.getMaterialOutputs()); - } - } - - if (successorRuns.size() > 1000) - { - // Give up - there may be a cycle or other problematic data - break; - } - - // Cull the ones we've already looked up - materialsToInvestigate.removeAll(investigatedMaterials); - } - while (!materialsToInvestigate.isEmpty()); - - HtmlStringBuilder updateLinks = HtmlStringBuilder.of(); - ExpSampleType st = _material.getSampleType(); - if (st != null && st.getContainer() != null && st.getContainer().hasPermission(getUser(), UpdatePermission.class)) - { - // XXX: ridiculous amount of work to get a update url expression for the sample type's table. - UserSchema samplesSchema = QueryService.get().getUserSchema(getUser(), st.getContainer(), "Samples"); - QueryDefinition queryDef = samplesSchema.getQueryDefForTable(st.getName()); - StringExpression expr = queryDef.urlExpr(QueryAction.updateQueryRow, null); - if (expr != null) - { - // Since we're building a detailsURL outside the context of a "row" need to set the correct - // container context on the generated expr. - ((DetailsURL) expr).setContainerContext(st.getContainer()); - String url = expr.eval(Collections.singletonMap(new FieldKey(null, "RowId"), _material.getRowId())); - updateLinks.append(LinkBuilder.labkeyLink("edit", url)).append(" "); - } - } - - if (getContainer().hasPermission(getUser(), InsertPermission.class)) - { - ActionURL deriveURL = new ActionURL(DeriveSamplesChooseTargetAction.class, getContainer()); - deriveURL.addParameter("rowIds", _material.getRowId()); - if (st != null) - deriveURL.addParameter("targetSampleTypeId", st.getRowId()); - - updateLinks.append(LinkBuilder.labkeyLink("derive samples from this sample", deriveURL)).append(" "); - } - - vbox.addView(new HtmlView(updateLinks)); - - ExperimentRunListView runListView = ExperimentRunListView.createView(getViewContext(), ExperimentRunType.ALL_RUNS_TYPE, true); - runListView.setShowRecordSelectors(false); - runListView.getRunTable().setRuns(successorRuns); - runListView.getRunTable().setContainerFilter(new ContainerFilter.AllFolders(getUser())); - runListView.setAllowableContainerFilterTypes(ContainerFilter.Type.Current, ContainerFilter.Type.CurrentAndSubfolders, ContainerFilter.Type.AllFolders); - runListView.setTitle("Runs associated with this material or a derived material"); - - ParentChildView pv = new ParentChildView(_material, getViewContext()); - vbox.addView(pv); - vbox.addView(runListView); - - return vbox; - } - } - - - // - // DataClass - // - - @RequiresPermission(ReadPermission.class) - public class ListDataClassAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object o, BindException errors) - { - DataClassWebPart view = new DataClassWebPart(false, getViewContext(), null); - view.setFrame(WebPartView.FrameType.NONE); - view.setErrorMessage(getViewContext().getRequest().getParameter("errorMessage")); - - return view; - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("dataClass"); - addRootNavTrail(root); - root.addChild("Data Classes"); - } - } - - public static class DataClassForm extends ExpObjectForm - { - private String _name; - - public String getName() - { - return _name; - } - - public void setName(String name) - { - _name = name; - } - - public ExpDataClassImpl getDataClass(@Nullable Container container) - { - ExpDataClassImpl dataClass = null; - - if (getName() != null) - { - dataClass = ExperimentServiceImpl.get().getDataClass(getContainer(), getUser(), getName()); - if (dataClass == null) - throw new NotFoundException("No data class found for name '" + getName() + "'."); - } - else if (getRowId() > 0) - { - dataClass = ExperimentServiceImpl.get().getDataClass(getContainer(), getUser(), getRowId()); - } - - if (dataClass == null) - throw new NotFoundException("No data class found."); - else if (container != null && !container.equals(dataClass.getContainer())) - throw new NotFoundException("Data class is not defined in the given container."); - - return dataClass; - } - } - - @RequiresPermission(ReadPermission.class) - public class ShowDataClassAction extends SimpleViewAction - { - private ExpDataClassImpl _dataClass; - - @Override - public ModelAndView getView(DataClassForm form, BindException errors) - { - _dataClass = form.getDataClass(null); - return new VBox(getDataClassPropertiesView(), getDataClassContentsView(errors)); - } - - private DetailsView getDataClassPropertiesView() - { - ExpSchema expSchema = new ExpSchema(getUser(), _dataClass.getContainer()); - - TableInfo table = ExpSchema.TableType.DataClasses.createTable(expSchema, null, null); - QueryUpdateForm tvf = new QueryUpdateForm(table, getViewContext(), null); - tvf.setPkVal(_dataClass.getRowId()); - DetailsView detailsView = new DetailsView(tvf); - detailsView.setTitle("Data Class Properties"); - - ButtonBar bb = new ButtonBar(); - bb.setStyle(ButtonBar.Style.separateButtons); - boolean inDefinitionContainer = getContainer().equals(_dataClass.getContainer()); - - DomainKind domainKind = _dataClass.getDomain().getDomainKind(); - if (domainKind != null && domainKind.canEditDefinition(getUser(), _dataClass.getDomain())) - { - ActionURL updateURL = new ActionURL(EditDataClassAction.class, _dataClass.getContainer()); - updateURL.addParameter("rowId", _dataClass.getRowId()); - updateURL.addReturnUrl(urlProvider(ExperimentUrls.class).getShowDataClassURL(_dataClass.getContainer(), _dataClass.getRowId())); - - if (inDefinitionContainer) - { - ActionButton updateButton = new ActionButton(updateURL, "Edit Data Class", ActionButton.Action.LINK); - updateButton.setDisplayPermission(DesignDataClassPermission.class); - updateButton.setPrimary(true); - bb.add(updateButton); - } - else if (_dataClass.getContainer().hasPermission(getUser(), DesignDataClassPermission.class)) - { - ActionButton updateButton = new ActionButton("Edit Data Class"); - updateButton.setActionType(ActionButton.Action.SCRIPT); - updateButton.setScript("if (window.confirm('This data class is defined in the " + _dataClass.getContainer().getPath() + " folder. Would you still like to edit it?')) { window.location = '" + updateURL + "' }"); - updateButton.setPrimary(true); - bb.add(updateButton); - } - - ActionURL deleteURL = new ActionURL(DeleteDataClassAction.class, _dataClass.getContainer()); - deleteURL.addParameter("singleObjectRowId", _dataClass.getRowId()); - deleteURL.addReturnUrl(ExperimentUrlsImpl.get().getDataClassListURL(getContainer())); - ActionButton deleteButton = new ActionButton(deleteURL, "Delete Data Class", ActionButton.Action.LINK); - - if (inDefinitionContainer) - { - deleteButton.setDisplayPermission(DesignDataClassPermission.class); - bb.add(deleteButton); - } - else if (_dataClass.getContainer().hasPermission(getUser(), DesignDataClassPermission.class)) - { - bb.add(deleteButton); - } - } - detailsView.getDataRegion().setButtonBar(bb); - - if (!inDefinitionContainer) - { - ActionURL definitionURL = urlProvider(ExperimentUrls.class).getShowDataClassURL(_dataClass.getContainer(), _dataClass.getRowId()); - LinkBuilder link = LinkBuilder.simpleLink(_dataClass.getContainer().getPath(), definitionURL); - SimpleDisplayColumn definedInCol = new SimpleDisplayColumn(link.toString()); - definedInCol.setCaption("Defined In:"); - detailsView.getDataRegion().addDisplayColumn(definedInCol); - } - - return detailsView; - } - - private QueryView getDataClassContentsView(BindException errors) - { - UserSchema dataClassSchema = QueryService.get().getUserSchema(getUser(), getContainer(), ExpSchema.SCHEMA_EXP_DATA); - QuerySettings settings = dataClassSchema.getSettings(getViewContext(), QueryView.DATAREGIONNAME_DEFAULT, _dataClass.getName()); - - return new QueryView(dataClassSchema, settings, errors) - { - @Override - public @NotNull LinkedHashSet getClientDependencies() - { - LinkedHashSet resources = super.getClientDependencies(); - resources.add(ClientDependency.fromPath("Ext4")); - resources.add(ClientDependency.fromPath("dataregion/confirmDelete.js")); - return resources; - } - - @Override - public ActionButton createDeleteButton() - { - ActionButton button = super.createDeleteButton(); - if (button != null) - { - String dependencyText = ExperimentService.get() - .getObjectReferencers() - .stream() - .map(referencer -> referencer.getObjectReferenceDescription(ExpData.class)) - .collect(Collectors.joining(" or ")); - - button.setScript("LABKEY.dataregion.confirmDelete(" + - PageFlowUtil.jsString(getDataRegionName()) + ", " + - PageFlowUtil.jsString(ExpSchema.SCHEMA_EXP_DATA.toString()) + ", " + - PageFlowUtil.jsString(getQueryDef().getName()) + ", " + - "'experiment', 'getDataOperationConfirmationData.api', " + - PageFlowUtil.jsString(getSelectionKey()) + ", " + - "'data object', 'data objects', '" + dependencyText + "', {dataOperation: 'Delete'})"); - button.setRequiresSelection(true); - } - return button; - } - }; - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("dataClass"); - addRootNavTrail(root); - root.addChild("Data Classes", ExperimentUrlsImpl.get().getDataClassListURL(getContainer())); - root.addChild(_dataClass.getName()); - } - } - - @RequiresPermission(DesignDataClassPermission.class) - public class DeleteDataClassAction extends AbstractDeleteAction - { - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("dataClass"); - super.addNavTrail(root); - } - - @Override - protected void deleteObjects(DeleteForm deleteForm) - { - List dataClasses = getDataClasses(deleteForm); - if (!ensureCorrectContainer(dataClasses)) - { - throw new UnauthorizedException(); - } - for (ExpRun run : getRuns(dataClasses)) - { - if (!run.getContainer().hasPermission(getUser(), DeletePermission.class)) - { - throw new UnauthorizedException(); - } - } - for (ExpDataClass dataClass : dataClasses) - { - dataClass.delete(getUser(), deleteForm.getUserComment()); - } - } - - @Override - public ModelAndView getView(DeleteForm deleteForm, boolean reshow, BindException errors) - { - List dataClasses = getDataClasses(deleteForm); - - if (!ensureCorrectContainer(dataClasses)) - { - throw new RedirectException(ExperimentUrlsImpl.get().getDataClassListURL(getContainer(), "To delete a data class, you must be in its folder or project.")); - } - - return new ConfirmDeleteView("Data Class", ShowDataClassAction.class, dataClasses, deleteForm, getRuns(dataClasses)); - } - - private List getDataClasses(DeleteForm deleteForm) - { - List dataClasses = new ArrayList<>(); - for (long rowId : deleteForm.getIds(false)) - { - ExpDataClass dataClass = ExperimentServiceImpl.get().getDataClass(getContainer(), getUser(), rowId); - if (dataClass != null) - { - dataClasses.add(dataClass); - } - } - return dataClasses; - } - - private boolean ensureCorrectContainer(List dataClasses) - { - for (ExpDataClass dataClass : dataClasses) - { - Container sourceContainer = dataClass.getContainer(); - if (!sourceContainer.equals(getContainer())) - { - return false; - } - } - return true; - } - - private List getRuns(List dataClasses) - { - if (!dataClasses.isEmpty()) - { - List runArray = ExperimentService.get().getRunsUsingDataClasses(dataClasses); - return ExperimentService.get().runsDeletedWithInput(runArray); - } - else - { - return Collections.emptyList(); - } - } - } - - @Marshal(Marshaller.Jackson) - @RequiresPermission(ReadPermission.class) - public static class GetDataClassPropertiesAction extends ReadOnlyApiAction - { - @Override - public Object execute(DataClassForm form, BindException errors) throws Exception - { - ExpDataClass dataClass = form.getDataClass(getContainer()); - if (dataClass != null) - return new DataClassDomainKindProperties(dataClass); - else - throw new NotFoundException("Data class does not exist in this container for rowId " + form.getRowId() + "."); - } - } - - @RequiresPermission(DesignDataClassPermission.class) - public static class EditDataClassAction extends SimpleViewAction - { - private ExpDataClassImpl _dataClass; - - @Override - public ModelAndView getView(DataClassForm form, BindException errors) - { - boolean create = form.getLSID() == null && form.getRowId() == 0 && form.getName() == null; - if (!create) - _dataClass = form.getDataClass(getContainer()); - - return ModuleHtmlView.get(ModuleLoader.getInstance().getModule("core"), ModuleHtmlView.getGeneratedViewPath("dataClassDesigner")); - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("dataClass"); - - root.addChild("Data Classes", ExperimentUrlsImpl.get().getDataClassListURL(getContainer())); - if (_dataClass == null) - { - root.addChild("Create Data Class"); - } - else - { - root.addChild(_dataClass.getName(), ExperimentUrlsImpl.get().getShowDataClassURL(getContainer(), _dataClass.getRowId())); - root.addChild("Update Data Class"); - } - } - } - - @RequiresPermission(DesignDataClassPermission.class) - public static class CreateDataClassFromTemplateAction extends FormViewAction - { - private ActionURL _successUrl; - private Map _domainTemplates; - - @Override - public void validateCommand(CreateDataClassFromTemplateForm form, Errors errors) - { - String name = null; - _domainTemplates = DomainTemplateGroup.getAllTemplates(getContainer()); - - if (!_domainTemplates.containsKey(form.getDomainTemplate())) - { - errors.reject(ERROR_MSG, "Unknown template selected: " + form.getDomainTemplate()); - } - else - { - DomainTemplate template = _domainTemplates.get(form.getDomainTemplate()); - name = template.getTemplateName(); - - // Issue 40230: if template includes sample type option, verify that it exists - if (template.getOptions().containsKey("sampleSet")) - { - String sampleTypeName = template.getOptions().get("sampleSet").toString(); - ExpSampleType sampleType = SampleTypeServiceImpl.get().getSampleType(getContainer(), getUser(), sampleTypeName); - if (sampleType == null) - errors.reject(ERROR_MSG, "Unable to find a sample type in this container with name: " + sampleTypeName + "."); - } - } - - if (StringUtils.isBlank(name)) - errors.reject(ERROR_MSG, "DataClass template selection is required."); - else if (ExperimentService.get().getDataClass(getContainer(), getUser(), name) != null) - errors.reject(ERROR_MSG, "DataClass '" + name + "' already exists."); - - } - - @Override - public ModelAndView getView(CreateDataClassFromTemplateForm form, boolean reshow, BindException errors) - { - Set templates = DomainTemplateGroup.getTemplatesForDomainKind(getContainer(), DataClassDomainKind.NAME); - form.setAvailableDomainTemplateNames(templates); - - Set messages = new HashSet<>(); - Map groups = DomainTemplateGroup.getAllGroups(getContainer()); - for (DomainTemplateGroup g : groups.values()) - messages.addAll(g.getErrors()); - form.setXmlParseErrors(messages); - - return new JspView<>("/org/labkey/experiment/createDataClassFromTemplate.jsp", form, errors); - } - - @Override - public boolean handlePost(CreateDataClassFromTemplateForm form, BindException errors) throws Exception - { - DomainTemplate template = _domainTemplates.get(form.getDomainTemplate()); - Domain domain = DomainUtil.createDomain(template, getContainer(), getUser(), form.getName()); - - _successUrl = domain.getDomainKind().urlEditDefinition(domain, getViewContext()); - return true; - } - - @Override - public URLHelper getSuccessURL(CreateDataClassFromTemplateForm form) - { - return _successUrl; - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("dataClass"); - root.addChild("Create Data Class from Template"); - } - } - - public static class CreateDataClassFromTemplateForm extends DataClass - { - private String _domainTemplate; - private Set _availableDomainTemplateNames; - private Set _xmlParseErrors; - private final ReturnUrlForm _returnUrlForm = new ReturnUrlForm(); - - public String getDomainTemplate() - { - return _domainTemplate; - } - - public void setDomainTemplate(String domainTemplate) - { - _domainTemplate = domainTemplate; - } - - public Set getAvailableDomainTemplateNames() - { - return _availableDomainTemplateNames; - } - - public void setAvailableDomainTemplateNames(Set availableDomainTemplateNames) - { - _availableDomainTemplateNames = availableDomainTemplateNames; - } - - public Set getXmlParseErrors() - { - return _xmlParseErrors; - } - - public void setXmlParseErrors(Set xmlParseErrors) - { - _xmlParseErrors = xmlParseErrors; - } - - @Nullable - public String getReturnUrl() - { - return _returnUrlForm.getReturnUrl(); - } - - public void setReturnUrl(String s) - { - _returnUrlForm.setReturnUrl(s); - } - } - - public static class ConceptURIForm - { - private String _conceptURI; - - public String getConceptURI() - { - return _conceptURI; - } - - public void setConceptURI(String conceptURI) - { - _conceptURI = conceptURI; - } - } - - @RequiresPermission(AdminPermission.class) - public static class RemoveConceptMappingAction extends MutatingApiAction - { - @Override - public void validateForm(ConceptURIForm form, Errors errors) - { - if (form.getConceptURI() == null || ConceptURIProperties.getLookup(getContainer(), form.getConceptURI()) == null) - errors.reject(ERROR_MSG, "Concept URI not found: " + form.getConceptURI()); - } - - @Override - public Object execute(ConceptURIForm form, BindException errors) - { - ConceptURIProperties.removeLookup(getContainer(), form.getConceptURI()); - return new ApiSimpleResponse("success", true); - } - } - - @RequiresPermission(ReadPermission.class) - public static class RunAttachmentDownloadAction extends BaseDownloadAction - { - @Nullable - @Override - public Pair getAttachment(AttachmentForm form) - { - if (form.getLsid() == null || form.getName() == null) - throw new NotFoundException("Error: missing required param 'lsid' or 'name'."); - - ExpRun run = ExperimentService.get().getExpRun(form.getLsid()); - if (run == null) - throw new NotFoundException("Run not found: " + form.getLsid()); - - if (!run.getContainer().equals(getContainer())) - { - if (run.getContainer().hasPermission(getUser(), ReadPermission.class)) - throw new RedirectException(getViewContext().cloneActionURL().setContainer(run.getContainer())); - else - throw new NotFoundException("Run not found"); - } - - AttachmentParent parent = new ExpRunAttachmentParent(run); - return new Pair<>(parent, form.getName()); - } - } - - @RequiresPermission(ReadPermission.class) - public static class DataClassAttachmentDownloadAction extends BaseDownloadAction - { - @Nullable - @Override - public Pair getAttachment(AttachmentForm form) - { - if (form.getLsid() == null || form.getName() == null) - throw new NotFoundException("Error: missing required param 'lsid' or 'name'."); - - Lsid lsid = new Lsid(form.getLsid()); - ExpData data = ExperimentServiceImpl.get().getExpData(lsid.toString()); - if (data == null) - throw new NotFoundException("Error: Data object not found for the given LSID: " + lsid); - AttachmentParent parent = new ExpDataClassAttachmentParent(data.getContainer(), lsid); - - return new Pair<>(parent, form.getName()); - } - } - - public static class AttachmentForm extends LsidForm implements BaseDownloadAction.InlineDownloader - { - private String _name; - private boolean _inline = true; - - public String getName() - { - return _name; - } - - public void setName(String name) - { - _name = name; - } - - @Override - public boolean isInline() - { - return _inline; - } - - public void setInline(boolean inline) - { - _inline = inline; - } - } - - // - // END DataClass actions - // - - public static ActionURL getRunGraphURL(Container c, long runId) - { - return new ActionURL(ShowRunGraphAction.class, c).addParameter("rowId", runId); - } - - - @RequiresPermission(ReadPermission.class) - public class ShowRunGraphAction extends AbstractShowRunAction - { - @Override - protected VBox createLowerView(ExpRunImpl experimentRun, BindException errors) - { - return new VBox( - createRunViewTabs(experimentRun, false, true, true), - new ExperimentRunGraphView(experimentRun, false)); - } - } - - - @RequiresPermission(ReadPermission.class) - public static class DownloadGraphAction extends SimpleViewAction - { - @Override - public ModelAndView getView(ExperimentRunForm form, BindException errors) throws Exception - { - boolean detail = form.isDetail(); - String focus = form.getFocus(); - String focusType = form.getFocusType(); - - ExpRunImpl experimentRun = (ExpRunImpl) form.lookupRun(); - ensureCorrectContainer(getContainer(), experimentRun, getViewContext()); - - ExperimentRunGraph.RunGraphFiles files; - try - { - files = ExperimentRunGraph.generateRunGraph(getViewContext(), experimentRun, detail, focus, focusType); - } - catch (ExperimentException e) - { - PageFlowUtil.streamTextAsImage(getViewContext().getResponse(), "ERROR: " + e.getMessage(), 600, 150, Color.RED); - return null; - } - - try - { - PageFlowUtil.streamFile(getViewContext().getResponse(), new File(files.getImageFile().getAbsolutePath()), false); - } - catch (FileNotFoundException e) - { - getViewContext().getResponse().sendRedirect(getViewContext().getRequest().getContextPath() + "/experiment/ExperimentRunNotFound.gif"); - } - finally - { - files.release(); - } - return null; - } - - @Override - public void addNavTrail(NavTree root) - { - throw new UnsupportedOperationException(); - } - } - - private abstract class AbstractShowRunAction extends SimpleViewAction - { - private ExpRunImpl _experimentRun; - - @Override - public ModelAndView getView(ExperimentRunForm form, BindException errors) - { - _experimentRun = (ExpRunImpl) form.lookupRun(); - ensureCorrectContainer(getContainer(), _experimentRun, getViewContext()); - - VBox vbox = new VBox(); - - JspView detailsView = new JspView<>("/org/labkey/experiment/ExperimentRunDetails.jsp", _experimentRun); - detailsView.setTitle("Standard Properties"); - - var attachmentParent = new ExpRunAttachmentParent(_experimentRun); - var attachments = AttachmentService.get().getAttachments(attachmentParent) - .stream() - .map(att -> Pair.of(att.getName(), new ActionURL(RunAttachmentDownloadAction.class, _experimentRun.getContainer()).addParameter("name", att.getName()).addParameter("lsid", _experimentRun.getLSID()))) - .collect(toList()); - CustomPropertiesView cpv = new CustomPropertiesView(_experimentRun.getLSID(), getContainer(), attachments); - - vbox.addView(new StandardAndCustomPropertiesView(detailsView, cpv)); - - HtmlStringBuilder updateLinks = HtmlStringBuilder.of(); - List runEditors = ExperimentService.get().getRunEditors(); - for (ExpRunEditor editor : runEditors) - { - if (editor.isProtocolEditor(form.lookupRun().getProtocol())) - { - updateLinks.append(LinkBuilder.labkeyLink("edit " + editor.getDisplayName() + " run", editor.getEditUrl(getContainer()).addParameter("rowId", form.getRowId()))); - } - } - - if (!updateLinks.isEmpty()) - { - HtmlView view = new HtmlView(updateLinks); - vbox.addView(view); - } - - VBox lowerView = createLowerView(_experimentRun, errors); - lowerView.setFrame(WebPartView.FrameType.PORTAL); - lowerView.setTitle("Run Details"); - NavTree tree = new NavTree(""); - File runRoot = _experimentRun.getFilePathRoot(); - if (NetworkDrive.exists(runRoot)) - { - if (!runRoot.isDirectory()) - { - runRoot = runRoot.getParentFile(); - } - PipeRoot pipelineRoot = PipelineService.get().findPipelineRoot(_experimentRun.getContainer()); - if (pipelineRoot != null) - { - if (pipelineRoot.isUnderRoot(runRoot)) - { - String path = pipelineRoot.relativePath(runRoot); - tree.addChild("View Files", urlProvider(PipelineUrls.class).urlBrowse(_experimentRun.getContainer(), null, path)); - } - } - } - - final String exportFilesFormId = "exportFilesForm"; - NavTree downloadFiles = new NavTree("Download all files"); - downloadFiles.setScript("document.getElementById('" + exportFilesFormId + "').submit();"); - tree.addChild(downloadFiles); - - // CONSIDER: Show modal dialog using ExperimentService.get().createRunExportView() - NavTree exportXarFiles = new NavTree("Export XAR"); - exportXarFiles.setScript("LABKEY.Experiment.exportRuns({runIds: [" + _experimentRun.getRowId() + "] });"); - tree.addChild(exportXarFiles); - - lowerView.setNavMenu(tree); - lowerView.setIsWebPart(false); - - vbox.addView(lowerView); - vbox.addView(new ExperimentRunGroupsView(getUser(), getContainer(), _experimentRun, getViewContext().getActionURL(), errors)); - - DOM.Renderable exportFilesForm = LK.FORM(at( - id, exportFilesFormId, - method, "POST", - action, new ActionURL(ExportRunFilesAction.class, _experimentRun.getContainer())), - INPUT(at(type, "hidden", - name, DataRegionSelection.DATA_REGION_SELECTION_KEY, - value, "ExportSingleRun")), - INPUT(at(type, "hidden", - name, DataRegion.SELECT_CHECKBOX_NAME, - value, _experimentRun.getRowId())), - INPUT(at(type, "hidden", - name, "zipFileName", - value, _experimentRun.getName() + ".zip"))); - - HtmlView hiddenFormView = new HtmlView(exportFilesForm); - vbox.addView(hiddenFormView); - - return vbox; - } - - protected abstract VBox createLowerView(ExpRunImpl experimentRun, BindException errors); - - @Override - public void addNavTrail(NavTree root) - { - root.addChild(_experimentRun.getName()); - } - } - - public static class ToggleRunExperimentMembershipForm - { - private int _runId; - private int _experimentId; - private boolean _included; - - public int getRunId() - { - return _runId; - } - - public void setRunId(int runId) - { - _runId = runId; - } - - public int getExperimentId() - { - return _experimentId; - } - - public void setExperimentId(int experimentId) - { - _experimentId = experimentId; - } - - public boolean isIncluded() - { - return _included; - } - - public void setIncluded(boolean included) - { - _included = included; - } - } - - @RequiresPermission(UpdatePermission.class) - public static class ToggleRunExperimentMembershipAction extends FormHandlerAction - { - @Override - public boolean handlePost(ToggleRunExperimentMembershipForm form, BindException errors) - { - ExpRun run = ExperimentService.get().getExpRun(form.getRunId()); - // Check if the user has permission to update this run - if (run == null || !run.getContainer().hasPermission(getUser(), UpdatePermission.class)) - { - throw new NotFoundException(); - } - - ExpExperiment exp = ExperimentService.get().getExpExperiment(form.getExperimentId()); - if (exp == null) - { - throw new NotFoundException(); - } - // Check if this - if (!ExperimentService.get().getExperiments(run.getContainer(), getUser(), true, false).contains(exp)) - { - throw new NotFoundException(); - } - // Users must have permission to view, but not necessarily update, the container the holds the run group - if (!exp.getContainer().hasPermission(getUser(), ReadPermission.class)) - { - throw new UnauthorizedException(); - } - - if (form.isIncluded()) - { - exp.addRuns(getUser(), run); - } - else - { - exp.removeRun(getUser(), run); - } - - return true; - } - - @Override - public URLHelper getSuccessURL(ToggleRunExperimentMembershipForm form) - { - return null; - } - - @Override - public void validateCommand(ToggleRunExperimentMembershipForm target, Errors errors) - { - } - } - - private HtmlView createRunViewTabs(ExpRun expRun, boolean showGraphSummary, boolean showGraphDetail, boolean showText) - { - return new HtmlView( - TABLE(cl("labkey-tab-strip"), - TR( - createTabSpacer(false), - createTab("Graph Summary View", ExperimentUrlsImpl.get().getRunGraphURL(expRun), !showGraphSummary), - createTabSpacer(false), - createTab("Graph Detail View", ExperimentUrlsImpl.get().getRunGraphDetailURL(expRun), !showGraphDetail), - createTabSpacer(false), - createTab("Text View", ExperimentUrlsImpl.get().getRunTextURL(expRun), !showText), - createTabSpacer(true)))); - } - - private DOM.Renderable createTab(String text, ActionURL url, boolean selected) - { - return TD(cl(selected,"labkey-tab-selected", "labkey-tab"), - A(at(href, url), text)); - } - - private DOM.Renderable createTabSpacer(boolean fullWidth) - { - return TD(cl("labkey-tab-space").at(fullWidth, width, "100%"), - IMG(at(src, AppProps.getInstance().getContextPath() + "/_.gif", width, "5"))); - } - - @RequiresPermission(ReadPermission.class) - public class ShowRunTextAction extends AbstractShowRunAction - { - @Override - protected VBox createLowerView(ExpRunImpl expRun, BindException errors) - { - JspView applicationsView = new JspView<>("/org/labkey/experiment/ProtocolApplications.jsp", expRun); - applicationsView.setFrame(WebPartView.FrameType.TITLE); - applicationsView.setTitle("Protocol Applications"); - - HtmlView toggleView = createRunViewTabs(expRun, true, true, false); - - QuerySettings runDataInputsSettings = new QuerySettings(getViewContext(), "RunDataInputs", DataInputs.name()); - UsageQueryView runDataInputsView = new UsageQueryView("Data Inputs", getViewContext(), expRun, ExpProtocol.ApplicationType.ExperimentRun, runDataInputsSettings, errors); - runDataInputsView.setButtonBarPosition(DataRegion.ButtonBarPosition.TOP); - - QuerySettings runDataOutputsSettings = new QuerySettings(getViewContext(), "RunDataOutputs", DataInputs.name()); - UsageQueryView runDataOutputsView = new UsageQueryView("Data Outputs", getViewContext(), expRun, ExpProtocol.ApplicationType.ExperimentRunOutput, runDataOutputsSettings, errors); - runDataOutputsView.setButtonBarPosition(DataRegion.ButtonBarPosition.NONE); - - QuerySettings runMaterialInputsSetting = new QuerySettings(getViewContext(), "RunMaterialInputs", ExpSchema.TableType.MaterialInputs.name()); - UsageQueryView runMaterialInputsView = new UsageQueryView("Material Inputs", getViewContext(), expRun, ExpProtocol.ApplicationType.ExperimentRun, runMaterialInputsSetting, errors); - runMaterialInputsView.setButtonBarPosition(DataRegion.ButtonBarPosition.TOP); - - QuerySettings runMaterialOutputsSettings = new QuerySettings(getViewContext(), "RunMaterialOutputs", ExpSchema.TableType.MaterialInputs.name()); - UsageQueryView runMaterialOutputsView = new UsageQueryView("Material Outputs", getViewContext(), expRun, ExpProtocol.ApplicationType.ExperimentRunOutput, runMaterialOutputsSettings, errors); - runMaterialOutputsView.setButtonBarPosition(DataRegion.ButtonBarPosition.NONE); - - HBox inputsView = new HBox(runDataInputsView, runMaterialInputsView); - HBox registeredInputsView = new HBox(); - - var expService = ExperimentService.get(); - expService.getRunInputsViewProviders().forEach(provider -> - { - var queryView = provider.createView(getViewContext(), expRun, errors); - if (queryView != null) - { - registeredInputsView.addView(queryView); - } - }); - HBox outputsView = new HBox(runDataOutputsView, runMaterialOutputsView); - HBox registeredOutputsView = new HBox(); - expService.getRunOutputsViewProviders().forEach(provider -> - { - var queryView = provider.createView(getViewContext(), expRun, errors); - if (queryView != null) - { - registeredOutputsView.addView(queryView); - } - }); - - var vBox = new VBox(); - vBox.addView(toggleView); - vBox.addView(inputsView); - if (!registeredInputsView.isEmpty()) - vBox.addView(registeredInputsView); - vBox.addView(outputsView); - if (!registeredOutputsView.isEmpty()) - vBox.addView(registeredOutputsView); - vBox.addView(applicationsView); - - return vBox; - } - } - - private static class UsageQueryView extends QueryView - { - private final ExpRun _run; - private final ExpProtocol.ApplicationType _type; - - public UsageQueryView(String title, ViewContext context, ExpRun run, ExpProtocol.ApplicationType type, - QuerySettings settings, BindException errors) - { - super(new ExpSchema(context.getUser(), context.getContainer()), settings, errors); - setTitle(title); - setFrame(FrameType.TITLE); - _run = run; - _type = type; - setShowBorders(true); - setShadeAlternatingRows(true); - setShowExportButtons(false); - setShowPagination(false); - disableContainerFilterSelection(); - } - - @Override - protected TableInfo createTable() - { - String tableName = getSettings().getQueryName(); - ExpInputTable tableInfo = (ExpInputTable) getSchema().getTable(tableName, new ContainerFilter.AllFolders(getUser()), true, true); - tableInfo.setRun(_run, _type); - tableInfo.setLocked(true); - return tableInfo; - } - } - - - public static ActionURL getShowRunGraphDetailURL(Container c, long rowId) - { - ActionURL url = new ActionURL(ShowRunGraphDetailAction.class, c); - url.addParameter("rowId", rowId); - return url; - } - - - @RequiresPermission(ReadPermission.class) - public class ShowRunGraphDetailAction extends AbstractShowRunAction - { - @Override - protected VBox createLowerView(ExpRunImpl run, BindException errors) - { - ExperimentRunGraphView gw = new ExperimentRunGraphView(run, true); - if (null != getViewContext().getActionURL().getParameter("focus")) - gw.setFocus(getViewContext().getActionURL().getParameter("focus")); - if (null != getViewContext().getActionURL().getParameter("focusType")) - gw.setFocusType(getViewContext().getActionURL().getParameter("focusType")); - return new VBox(createRunViewTabs(run, true, false, true), gw); - } - } - - private abstract class AbstractDataAction extends SimpleViewAction - { - protected ExpDataImpl _data; - - @Override - public final ModelAndView getView(DataForm form, BindException errors) throws Exception - { - _data = form.lookupData(); - if (_data == null) - { - throw new NotFoundException("Could not find a data with RowId " + form.getRowId()); - } - - ensureCorrectContainer(getContainer(), _data, getViewContext()); - return getDataView(form, errors); - } - - protected abstract ModelAndView getDataView(DataForm form, BindException errors) throws Exception; - - @Override - public void addNavTrail(NavTree root) - { - addRootNavTrail(root); - root.addChild("Data " + _data.getName()); - } - } - - @RequiresPermission(ReadPermission.class) - public class ShowDataAction extends AbstractDataAction - { - @Override - public ModelAndView getDataView(DataForm form, BindException errors) - { - ExpRun run = _data.getRun(); - ExpProtocol sourceProtocol = _data.getSourceProtocol(); - ExpProtocolApplication sourceProtocolApplication = _data.getSourceApplication(); - ExpDataClass dataClass = _data.getDataClass(getUser()); - - ExpSchema schema = new ExpSchema(getUser(), getContainer()); - TableInfo table; - long pk; - if (dataClass == null) - { - table = schema.getDatasTable(); - pk = _data.getRowId(); - } - else - { - table = schema.getSchema(ExpSchema.NestedSchemas.data).getTable(dataClass.getName()); - pk = new TableSelector(table, Collections.singleton("rowId"), new SimpleFilter(FieldKey.fromParts("lsid"), _data.getLSID()), null).getObject(Integer.class); - } - - DataRegion dr = new DataRegion(); - dr.setTable(table); - List cols = table.getColumns().stream().filter(ColumnInfo::isShownInDetailsView).collect(toList()); - dr.addColumns(cols); - dr.removeColumns("RowId", "Created", "CreatedBy", "Modified", "ModifiedBy", "DataFileUrl", "Run", "LSID", "CpasType", "SourceApplicationId", "Folder", "Generated"); - dr.addDisplayColumn(new ExperimentRunDisplayColumn(run, "Source Experiment Run")); - dr.addDisplayColumn(new ProtocolDisplayColumn(sourceProtocol, "Source Protocol")); - dr.addDisplayColumn(new ProtocolApplicationDisplayColumn(sourceProtocolApplication, "Source Protocol Application")); - dr.addDisplayColumn(new LineageGraphDisplayColumn(_data, run)); - DetailsView detailsView = new DetailsView(dr, pk); - detailsView.setTitle("Standard Properties"); - detailsView.setFrame(WebPartView.FrameType.PORTAL); - ButtonBar bb = new ButtonBar(); - bb.setStyle(ButtonBar.Style.separateButtons); - - ExperimentDataHandler handler = _data.findDataHandler(); - ActionURL viewDataURL = handler == null ? null : handler.getContentURL(_data); - if (viewDataURL != null) - { - bb.add(new ActionButton("View data", viewDataURL)); - } - - if (_data.isPathAccessible()) - { - bb.add(new ActionButton("View file", ExperimentUrlsImpl.get().getShowFileURL(_data, true))); - bb.add(new ActionButton("Download file", ExperimentUrlsImpl.get().getShowFileURL(_data, false))); - - if (getContainer().hasPermission(getUser(), InsertPermission.class)) - { - String relativePath = null; - PipeRoot root = PipelineService.get().findPipelineRoot(getContainer()); - if (root != null) - { - Path rootFile = root.getRootNioPath(); - Path dataFile = _data.getFilePath(); - if (dataFile != null) - { - Path pathRelative; - try - { - pathRelative = rootFile.relativize(dataFile); - if (null != pathRelative) - relativePath = pathRelative.toString(); - } - catch (IllegalArgumentException e) - { - // dataFile not relative to root - } - } - } - ActionURL browseURL = urlProvider(PipelineUrls.class).urlBrowse(getContainer(), getViewContext().getActionURL(), relativePath); - bb.add(new ActionButton("Browse in pipeline", browseURL)); - } - } - - // add links to any other exp.data that share the same dataFileUrl path - var altDataList = ExperimentService.get().getAllExpDataByURL(_data.getDataFileUrl(), getContainer()); - altDataList.removeIf(_data::equals); - if (!altDataList.isEmpty()) - { - MenuButton menu = new MenuButton("Alternate Data"); - for (ExpData altData : altDataList) - { - ExpRun altDataRun = altData.getRun(); - StringBuilder sb = new StringBuilder(altData.getName()); - if (altDataRun != null) - sb.append(" created by run '").append(altDataRun.getName()).append("' (").append(altDataRun.getProtocol().getName()).append(")"); - menu.addMenuItem(sb.toString(), altData.detailsURL()); - } - bb.add(menu); - } - - dr.setButtonBarPosition(DataRegion.ButtonBarPosition.TOP); - dr.setButtonBar(bb); - - CustomPropertiesView cpv = new CustomPropertiesView(_data.getLSID(), getContainer()); - HBox hbox = new StandardAndCustomPropertiesView(detailsView, cpv); - - VBox vbox = new VBox(hbox); - - ParentChildView pv = new ParentChildView(_data, getViewContext()); - vbox.addView(pv); - - ExperimentRunListView runListView = ExperimentRunListView.createView(getViewContext(), ExperimentRunType.ALL_RUNS_TYPE, true); - runListView.getRunTable().setInputData(_data); - runListView.getRunTable().setContainerFilter(new ContainerFilter.AllFolders(getUser())); - runListView.getRunTable().setLocked(true); - runListView.setTitle("Runs using this data as an input"); - vbox.addView(runListView); - - if (_data.isInlineImage() && _data.isFileOnDisk()) - { - ActionURL showFileURL = new ActionURL(ShowFileAction.class, getContainer()).addParameter("rowId", _data.getRowId()); - HtmlView imageView = new HtmlView(IMG(at(src, showFileURL))); - return new VBox(vbox, imageView); - } - return vbox; - } - } - - @RequiresPermission(AdminPermission.class) - public static class CheckDataFileAction extends MutatingApiAction - { - private ExpDataImpl _data; - - @Override - public void validateForm(DataFileForm form, Errors errors) - { - _data = form.lookupData(); - if (_data == null) - { - errors.reject(ERROR_MSG, "No ExpData found for id: " + form.getRowId()); - } - } - - @Override - public ApiResponse execute(DataFileForm form, BindException errors) - { - File dataFile = _data.getFile(); - Container dataContainer = _data.getContainer(); - boolean fileExists = _data.isFileOnDisk(); - boolean fileExistsAtCurrent = false; - File newDataFile = null; - - ApiSimpleResponse response = new ApiSimpleResponse(); - response.put("dataFileUrl", _data.getDataFileUrl()); - response.put("fileExists", fileExists); - response.put("containerPath", dataContainer.getPath()); - - if (!fileExists) - { - PipeRoot pipelineRoot = PipelineService.get().findPipelineRoot(dataContainer); - if (pipelineRoot != null && pipelineRoot.isValid() && dataFile != null) - { - newDataFile = pipelineRoot.resolvePath("/" + AssayFileWriter.DIR_NAME + "/" + dataFile.getName()); - fileExistsAtCurrent = NetworkDrive.exists(newDataFile); - response.put("fileExistsAtCurrent", fileExistsAtCurrent); - } - } - - // if the current dataFileUrl does not exist on disk and we have the file at the current - // pipeline root /assaydata dir, fix the dataFileUrl value - if (form.isAttemptFilePathFix()) - { - if (fileExistsAtCurrent) - { - ExpDataFileListener fileListener = new ExpDataFileListener(); - fileListener.fileMoved(dataFile, newDataFile, getUser(), dataContainer); - response.put("filePathFixed", true); - - // update the ExpData object so that we can get the new dataFileUrl - _data = form.lookupData(); - response.put("newDataFileUrl", _data.getDataFileUrl()); - } - else - { - response.put("filePathFixed", false); - } - } - - response.put("success", true); - return response; - } - } - - public static class DataFileForm extends DataForm - { - private boolean _attemptFilePathFix; - - public boolean isAttemptFilePathFix() - { - return _attemptFilePathFix; - } - - public void setAttemptFilePathFix(boolean attemptFilePathFix) - { - _attemptFilePathFix = attemptFilePathFix; - } - } - - @RequiresPermission(ReadPermission.class) - public class ShowFileAction extends AbstractDataAction - { - @Override - protected ModelAndView getDataView(DataForm form, BindException errors) throws IOException - { - if (!_data.isPathAccessible()) - { - throw new NotFoundException("Data file " + _data.getDataFileUrl() + " does not exist on disk"); - } - - PipeRoot root = PipelineService.get().findPipelineRoot(getContainer()); - if (root != null && !root.isUnderRoot(_data.getFilePath())) - { - // Issue 35649: ImmPort module "publish" creates exp.data object in this container for paths that originate in a different container - FileContentService fileSvc = FileContentService.get(); - if (fileSvc == null) - throw new UnauthorizedException("Data file is not under the pipeline root for this folder"); - - List containers = fileSvc.getContainersForFilePath(_data.getFilePath()); - if (containers.isEmpty() || containers.stream().noneMatch(c -> c.hasPermission(getUser(), ReadPermission.class))) - throw new UnauthorizedException("Data file is not under the pipeline root for this folder"); - } - - //Issues 25667 and 31152 - if (form.isInline()) - { - ExperimentDataHandler h = _data.findDataHandler(); - if (h != null) - { - URLHelper url = h.getShowFileURL(_data); - if (url != null) - { - throw new RedirectException(url, false); - } - } - } - - try - { - Path realContent = _data.getFilePath(); - if (null == realContent) - throw new IllegalStateException("Path not found."); - - boolean inline = _data.isInlineImage() || form.isInline() || "inlineImage".equalsIgnoreCase(form.getFormat()); - if (_data.isInlineImage() && form.getMaxDimension() != null) - { - try (InputStream inputStream = Files.newInputStream(realContent)) - { - BufferedImage image = ImageIO.read(inputStream); - // If image, create a thumbnail, otherwise fall through as a regular download attempt - if (image != null) - { - int imageMax = Math.max(image.getHeight(), image.getWidth()); - if (imageMax > form.getMaxDimension().intValue()) - { - double scale = (double) form.getMaxDimension().intValue() / (double) imageMax; - ByteArrayOutputStream bOut = new ByteArrayOutputStream(); - ImageUtil.resizeImage(image, bOut, scale, 1); - PageFlowUtil.streamFileBytes(getViewContext().getResponse(), FileUtil.getFileName(realContent) + ".png", bOut.toByteArray(), !inline); - return null; - } - } - } - } - - boolean extended = "jsonTSVExtended".equalsIgnoreCase(form.getFormat()); - boolean ignoreTypes = "jsonTSVIgnoreTypes".equalsIgnoreCase(form.getFormat()); - if ("jsonTSV".equalsIgnoreCase(form.getFormat()) || extended || ignoreTypes) - { - if (!FileUtil.hasCloudScheme(realContent)) // TODO: handle streaming from S3 to JSON - streamToJSON(FileSystemLike.wrapFile(realContent), form.getFormat(), -1, null); - return null; - } - - try (InputStream inputStream = Files.newInputStream(realContent)) - { - PageFlowUtil.streamFile(getViewContext().getResponse(), Collections.emptyMap(), FileUtil.getFileName(realContent), inputStream, !inline); - } - } - catch (IOException e) - { - try - { - // Try to write the exception back to the caller if we haven't already flushed the buffer - ApiJsonWriter writer = new ApiJsonWriter(getViewContext().getResponse()); - writer.writeResponse(e); - } - catch (IllegalStateException ise) - { - // Most likely that a disconnected client caused the IOException writing back the response - } - } - - return null; - } - } - - - public static class ParseForm - { - String format = "jsonTSV"; - int maxRows = -1; - - public String getFormat() - { - return format; - } - - public void setFormat(String format) - { - this.format = format; - } - - public int getMaxRows() - { - return maxRows; - } - - public void setMaxRows(int maxRow) - { - this.maxRows = maxRow; - } - } - - @RequiresNoPermission - public class ParseFileAction extends MutatingApiAction - { - @Override - public Object execute(ParseForm form, BindException errors) throws Exception - { - if (!(getViewContext().getRequest() instanceof MultipartHttpServletRequest)) - throw new BadRequestException("Expected MultipartHttpServletRequest when posting files."); - - MultipartFile formFile = getFileMap().get("file"); - if (formFile == null) - { - return true; - } - - FileLike tempFile = null; - try - { - tempFile = FileUtil.createTempFileLike("parse", formFile.getOriginalFilename()); - FileUtil.copyData(formFile.getInputStream(), tempFile.openOutputStream()); - streamToJSON(tempFile, form.getFormat(), form.getMaxRows(), formFile.getOriginalFilename()); - } - finally - { - if (null != tempFile) - tempFile.delete(); - } - return null; - } - } - - - // SampleTypeTest - private void streamToJSON(FileLike realContent, String format, int maxRow, String originalFileName) throws IOException - { - String lowerCaseFileName = realContent.getName().toLowerCase(); - boolean extended = "jsonTSVExtended".equalsIgnoreCase(format); - boolean ignoreTypes = "jsonTSVIgnoreTypes".equalsIgnoreCase(format); - - JSONArray sheetsArray; - if (lowerCaseFileName.endsWith(".xls") || lowerCaseFileName.endsWith(".xlsx")) - { - try (InputStream in = realContent.openInputStream()) - { - sheetsArray = ExcelFactory.convertExcelToJSON(in, extended, maxRow); - } - } - else - { - DataLoaderFactory dlf = DataLoader.get().findFactory(realContent, null); - if (null == dlf) - { - throw new ApiUsageException("Unable to parse file " + realContent + ", it is likely of an unsupported file type"); - } - - try (InputStream in = realContent.openInputStream(); - DataLoader tabLoader = dlf.createLoader(in, true)) - { - tabLoader.setScanAheadLineCount(5000); - ColumnDescriptor[] cols = tabLoader.getColumns(); - - if (ignoreTypes) - for (ColumnDescriptor col : cols) - col.clazz = String.class; - - JSONArray rowsArray = new JSONArray(); - JSONArray headerArray = new JSONArray(); - for (ColumnDescriptor col : cols) - { - if (extended) - { - JSONObject valueObject = new JSONObject(); - valueObject.put("value", col.name); - headerArray.put(valueObject); - } - else - { - headerArray.put(col.name); - } - } - rowsArray.put(headerArray); - for (Map rowMap : tabLoader) - { - // headers count as a row to be consistent - if (maxRow > -1 && maxRow <= rowsArray.length() + 1) - break; - - JSONArray rowArray = new JSONArray(); - for (ColumnDescriptor col : cols) - { - Object value = rowMap.get(col.name); - if (extended) - { - JSONObject valueObject = new JSONObject(); - valueObject.put("value", value); - rowArray.put(valueObject); - } - else - { - rowArray.put(value); - } - } - rowsArray.put(rowArray); - } - - JSONObject sheetJSON = new JSONObject(); - sheetJSON.put("name", "flat"); - sheetJSON.put("data", rowsArray); - sheetsArray = new JSONArray(); - sheetsArray.put(sheetJSON); - } - } - - try (ApiJsonWriter writer = new ApiJsonWriter(getViewContext().getResponse())) - { - JSONObject workbookJSON = new JSONObject(); - workbookJSON.put("fileName", realContent.getName()); - workbookJSON.put("sheets", sheetsArray); - if (originalFileName != null) - workbookJSON.put("originalFileName", originalFileName); - writer.writeResponse(new ApiSimpleResponse(workbookJSON)); - } - } - - - public static class ConvertArraysToExcelForm - { - private String _json; - - public String getJson() - { - return _json; - } - - public void setJson(String json) - { - _json = json; - } - } - - @RequiresPermission(ReadPermission.class) - public static class ConvertArraysToExcelAction extends ExportAction - { - @Override - public void validate(ConvertArraysToExcelForm form, BindException errors) - { - if (form.getJson() == null) - { - errors.reject(ERROR_MSG, "Unable to convert to Excel - no spreadsheet data given"); - } - } - - @Override - public void export(ConvertArraysToExcelForm form, HttpServletResponse response, BindException errors) throws Exception - { - try - { - JSONObject rootObject; - JSONArray sheetsArray; - if (form.getJson() == null || form.getJson().trim().isEmpty()) - { - // Create JSON so that we return an empty file - rootObject = new JSONObject(); - sheetsArray = new JSONArray(); - JSONObject sheetObject = new JSONObject(); - sheetsArray.put(sheetObject); - } - else - { - rootObject = new JSONObject(form.getJson()); - sheetsArray = rootObject.getJSONArray("sheets"); - } - String filename = rootObject.has("fileName") ? rootObject.getString("fileName") : "ExcelExport.xls"; - ExcelWriter.ExcelDocumentType docType = filename.toLowerCase().endsWith(".xlsx") ? ExcelWriter.ExcelDocumentType.xlsx : ExcelWriter.ExcelDocumentType.xls; - - try (Workbook workbook = ExcelFactory.createFromArray(sheetsArray, docType)) - { - response.setContentType(docType.getMimeType()); - ResponseHelper.setContentDisposition(response, ResponseHelper.ContentDispositionType.attachment, filename); - ResponseHelper.setPrivate(response); - workbook.write(response.getOutputStream()); - - JSONObject qInfo = rootObject.has("queryinfo") ? rootObject.getJSONObject("queryinfo") : null; - if (qInfo != null) - { - QueryService.get().addAuditEvent(getUser(), getContainer(), qInfo.getString("schema"), - qInfo.getString("query"), getViewContext().getActionURL(), - rootObject.getString("auditMessage") + filename, - null); - SimpleMetricsService.get().increment(ExperimentService.MODULE_NAME, "convertTable", "asExcel"); - } - } - } - catch (JSONException | ClassCastException e) - { - // We can get a ClassCastException if we expect an array and get a simple String, for example - ExceptionUtil.renderErrorView(getViewContext(), getPageConfig(), ErrorRenderer.ErrorType.notFound, HttpServletResponse.SC_BAD_REQUEST, "Failed to convert to Excel - invalid input", e, false, false); - } - } - } - - @RequiresPermission(ReadPermission.class) - public static class ConvertArraysToTableAction extends ExportAction - { - @Override - public void validate(ConvertArraysToExcelForm form, BindException errors) - { - if (form.getJson() == null) - { - errors.reject(ERROR_MSG, "Unable to convert to table - no data given"); - } - } - - @Override - public void export(ConvertArraysToExcelForm form, HttpServletResponse response, BindException errors) throws Exception - { - try - { - JSONObject rootObject; - JSONArray rowsArray; - if (form.getJson() == null || form.getJson().trim().isEmpty()) - { - // Create JSON so that we return an empty file - rootObject = new JSONObject(); - rowsArray = new JSONArray(); - } - else - { - rootObject = new JSONObject(form.getJson()); - rowsArray = rootObject.getJSONArray("rows"); - } - - TSVWriter.DELIM delimType = (!rootObject.isNull("delim") ? TSVWriter.DELIM.valueOf(rootObject.getString("delim")) : TSVWriter.DELIM.TAB); - TSVWriter.QUOTE quoteType = (!rootObject.isNull("quoteChar") ? TSVWriter.QUOTE.valueOf(rootObject.getString("quoteChar")) : TSVWriter.QUOTE.NONE); - String filenamePrefix = (!rootObject.isNull("fileNamePrefix") ? rootObject.getString("fileNamePrefix") : "Export"); - String filename = filenamePrefix + "." + delimType.extension; - String newlineChar = !rootObject.isNull("newlineChar") ? rootObject.getString("newlineChar") : "\n"; - - PageFlowUtil.prepareResponseForFile(response, Collections.emptyMap(), filename, true); - response.setContentType(delimType.contentType); - - //NOTE: we could also have used TSVWriter; however, this is in use elsewhere and we dont need a custom subclass - try (CSVWriter writer = new CSVWriter(response.getWriter(), delimType.delim, quoteType.quoteChar, newlineChar)) - { - for (int i = 0; i < rowsArray.length(); i++) - { - List objectList = rowsArray.getJSONArray(i).toList(); - Iterator it = objectList.iterator(); - List list = new ArrayList<>(); - - while (it.hasNext()) - { - Object o = it.next(); - if (o != null) - list.add(o.toString()); - else - list.add(""); - } - - writer.writeNext(list.toArray(new String[0])); - } - } - - JSONObject qInfo = rootObject.optJSONObject("queryinfo"); - if (qInfo != null) - { - QueryService.get().addAuditEvent(getUser(), getContainer(), qInfo.getString("schema"), qInfo.getString("query"), - getViewContext().getActionURL(), - rootObject.getString("auditMessage") + filename, - rowsArray.length()); - SimpleMetricsService.get().increment(ExperimentService.MODULE_NAME, "convertTable", "asDelimited"); - } - } - catch (JSONException e) - { - ExceptionUtil.renderErrorView(getViewContext(), getPageConfig(), ErrorRenderer.ErrorType.notFound, HttpServletResponse.SC_BAD_REQUEST, "Failed to convert to table - invalid input", e, false, false); - } - } - } - - - public static class ConvertHtmlToExcelForm - { - private String _baseUrl; - private String _htmlFragment; - private String _name = "workbook.xls"; - - - public String getName() - { - return _name; - } - - public void setName(String name) - { - _name = name; - } - - public String getBaseUrl() - { - return _baseUrl; - } - - public void setBaseUrl(String baseUrl) - { - _baseUrl = baseUrl; - } - - public String getHtmlFragment() - { - return _htmlFragment; - } - - public void setHtmlFragment(String htmlFragment) - { - _htmlFragment = htmlFragment; - } - } - - - @RequiresPermission(ReadPermission.class) - public static class ConvertHtmlToExcelAction extends FormViewAction - { - String _responseHtml = null; - - @Override - public void validateCommand(ConvertHtmlToExcelForm target, Errors errors) - { - } - - @Override - public ModelAndView getView(ConvertHtmlToExcelForm form, boolean reshow, BindException errors) - { - String html = - "

" + - "" + - new CsrfInput(getViewContext()) + - "
"; - return HtmlView.unsafe(html); - } - - @Override - public boolean handlePost(ConvertHtmlToExcelForm form, BindException errors) - { - ActionURL url = getViewContext().getActionURL(); - String base = url.getBaseServerURI(); - if (!base.endsWith("/")) base += "/"; - - String baseTag = ""; - SafeToRender css = PageFlowUtil.getStylesheetIncludes(getContainer()); - String htmlFragment = StringUtils.trimToEmpty(form.getHtmlFragment()); - String html = "" + baseTag + css + "" + htmlFragment + ""; - - // UNDONE: strip script - List tidyErrors = new ArrayList<>(); - String tidy = JSoupUtil.tidyHTML(html, false, tidyErrors); - - if (!tidyErrors.isEmpty()) - { - for (String err : tidyErrors) - { - errors.reject(ERROR_MSG, err); - } - return false; - } - - _responseHtml = tidy; - return true; - } - - @Override - public ModelAndView getSuccessView(ConvertHtmlToExcelForm form) - { - ResponseHelper.setContentDisposition(getViewContext().getResponse(), ResponseHelper.ContentDispositionType.attachment, form.getName()); - getPageConfig().setTemplate(PageConfig.Template.None); - HtmlView v = HtmlView.unsafe(_responseHtml); - v.setContentType("application/vnd.ms-excel"); - v.setFrame(WebPartView.FrameType.NONE); - return v; - } - - @Override - public URLHelper getSuccessURL(ConvertHtmlToExcelForm convertHtmlToExcelForm) - { - return null; - } - - @Override - public void addNavTrail(NavTree root) - { - } - } - - - public static ActionURL getShowApplicationURL(Container c, long rowId) - { - ActionURL url = new ActionURL(ShowApplicationAction.class, c); - url.addParameter("rowId", rowId); - - return url; - } - - - @RequiresPermission(ReadPermission.class) - public class ShowApplicationAction extends SimpleViewAction - { - private ExpProtocolApplicationImpl _app; - private ExpRun _run; - - @Override - public ModelAndView getView(ExpObjectForm form, BindException errors) - { - _app = ExperimentServiceImpl.get().getExpProtocolApplication(form.getRowId()); - if (_app == null) - { - throw new NotFoundException("Could not find Protocol Application"); - } - _run = _app.getRun(); - if (_run == null) - { - throw new NotFoundException("No experiment run associated with Protocol Application"); - } - ensureCorrectContainer(getContainer(), _app, getViewContext()); - - ExpProtocol protocol = _app.getProtocol(); - - DataRegion dr = new DataRegion(); - dr.addColumns(ExperimentServiceImpl.get().getTinfoProtocolApplication().getUserEditableColumns()); - DetailsView detailsView = new DetailsView(dr, form.getRowId()); - dr.removeColumns("RunId", "ProtocolLSID", "RowId", "LSID"); - dr.addDisplayColumn(new ExperimentRunDisplayColumn(_run)); - dr.addDisplayColumn(new ProtocolDisplayColumn(protocol)); - dr.addDisplayColumn(new LineageGraphDisplayColumn(_app, _run)); - detailsView.setTitle("Protocol Application"); - - Container c = getContainer(); - ApplicationOutputGrid outMGrid = new ApplicationOutputGrid(c, _app.getRowId(), ExperimentServiceImpl.get().getTinfoMaterial()); - ApplicationOutputGrid outDGrid = new ApplicationOutputGrid(c, _app.getRowId(), ExperimentServiceImpl.get().getTinfoData()); - Map map = new HashMap<>(); - for (ProtocolApplicationParameter param : ExperimentService.get().getProtocolApplicationParameters(_app.getRowId())) - { - map.put(param.getOntologyEntryURI(), param); - } - - JspView> paramsView = new JspView<>("/org/labkey/experiment/Parameters.jsp", map); - paramsView.setTitle("Protocol Application Parameters"); - CustomPropertiesView cpv = new CustomPropertiesView(_app.getLSID(), c); - return new VBox(new StandardAndCustomPropertiesView(detailsView, cpv), paramsView, outMGrid, outDGrid); - } - - @Override - public void addNavTrail(NavTree root) - { - addRootNavTrail(root); - root.addChild("Experiment Run", ExperimentUrlsImpl.get().getRunGraphDetailURL(_run)); - root.addChild("Protocol Application " + _app.getName()); - } - } - - @RequiresPermission(ReadPermission.class) - public class ShowProtocolGridAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object o, BindException errors) - { - return new ProtocolWebPart(false, getViewContext()); - } - - @Override - public void addNavTrail(NavTree root) - { - addRootNavTrail(root); - root.addChild("Protocols"); - } - } - - @RequiresPermission(ReadPermission.class) - public class ProtocolDetailsAction extends SimpleViewAction - { - private ExpProtocolImpl _protocol; - - @Override - public ModelAndView getView(ExpObjectForm form, BindException errors) - { - _protocol = ExperimentServiceImpl.get().getExpProtocol(form.getRowId()); - if (_protocol == null) - { - _protocol = ExperimentServiceImpl.get().getExpProtocol(form.getLSID()); - } - - if (_protocol == null) - { - throw new NotFoundException("Unable to find a matching protocol"); - } - ensureCorrectContainer(getContainer(), _protocol, getViewContext()); - - JspView detailsView = new JspView<>("/org/labkey/experiment/ProtocolDetails.jsp", _protocol); - detailsView.setTitle("Standard Properties"); - - CustomPropertiesView cpv = new CustomPropertiesView(_protocol.getLSID(), getContainer()); - ProtocolParametersView parametersView = new ProtocolParametersView(_protocol); - - VBox protocolDetails = new VBox(); - protocolDetails.setFrame(WebPartView.FrameType.PORTAL); - protocolDetails.setTitle("Protocol Details"); - protocolDetails.addView(new ProtocolInputOutputsView(_protocol, errors)); - - JspView stepsView = new JspView<>("/org/labkey/experiment/ProtocolSteps.jsp", _protocol); - stepsView.setTitle("Protocol Steps"); - stepsView.setFrame(WebPartView.FrameType.TITLE); - protocolDetails.addView(stepsView); - - ExpSchema schema = new ExpSchema(getUser(), getContainer()); - ExperimentRunListView runView = new ExperimentRunListView(schema, ExperimentRunListView.getRunListQuerySettings(schema, getViewContext(), ExpSchema.TableType.Runs.name(), true), ExperimentRunType.ALL_RUNS_TYPE) - { - @Override - public DataView createDataView() - { - DataView result = super.createDataView(); - result.getRenderContext().setBaseFilter(new SimpleFilter(FieldKey.fromParts("Protocol", "LSID"), _protocol.getLSID())); - return result; - } - }; - - runView.setTitle("Runs Using This Protocol"); - - return new VBox(new StandardAndCustomPropertiesView(detailsView, cpv), parametersView, protocolDetails, runView); - } - - @Override - public void addNavTrail(NavTree root) - { - addRootNavTrail(root); - root.addChild("Protocols", ExperimentUrlsImpl.get().getProtocolGridURL(getContainer())); - root.addChild("Protocol: " + _protocol.getName()); - } - } - - public class ProtocolInputOutputsView extends VBox - { - ProtocolInputOutputsView(ExpProtocol protocol, Errors errors) - { - HBox inputsView = new HBox(); - addView(inputsView); - - HBox outputsView = new HBox(); - addView(outputsView); - - UserSchema expSchema = QueryService.get().getUserSchema(getUser(), getContainer(), ExpSchema.SCHEMA_NAME); - - class ProtocolInputGrid extends QueryView - { - public ProtocolInputGrid(String title, QuerySettings settings, @Nullable Errors errors) - { - super(expSchema, settings, errors); - - setFrame(FrameType.TITLE); - setTitle(title); - setButtonBarPosition(DataRegion.ButtonBarPosition.NONE); - setShowBorders(true); - setShadeAlternatingRows(true); - setShowExportButtons(false); - setShowPagination(false); - disableContainerFilterSelection(); - } - } - - // INPUTS - - QuerySettings materialInputsSettings = expSchema.getSettings("mpi", ExpSchema.TableType.MaterialProtocolInputs.toString()); - materialInputsSettings.getBaseFilter().addCondition(FieldKey.fromParts(ExpMaterialProtocolInputTable.Column.Protocol.toString()), protocol.getRowId()); - materialInputsSettings.setFieldKeys(Arrays.asList( - FieldKey.fromParts(ExpMaterialProtocolInputTable.Column.Name.toString()), - FieldKey.fromParts(ExpMaterialProtocolInputTable.Column.SampleSet.toString()) - )); - QueryView materialInputsView = new ProtocolInputGrid("Material Inputs", materialInputsSettings, errors); - inputsView.addView(materialInputsView); - - QuerySettings dataInputsSettings = expSchema.getSettings("dpi", ExpSchema.TableType.DataProtocolInputs.toString()); - dataInputsSettings.getBaseFilter().addCondition(FieldKey.fromParts(ExpDataProtocolInputTable.Column.Protocol.toString()), protocol.getRowId()); - dataInputsSettings.setFieldKeys(Arrays.asList( - FieldKey.fromParts(ExpDataProtocolInputTable.Column.Name.toString()), - FieldKey.fromParts(ExpDataProtocolInputTable.Column.DataClass.toString()) - )); - QueryView dataInputsView = new ProtocolInputGrid("Data Inputs", dataInputsSettings, errors); - inputsView.addView(dataInputsView); - - // OUTPUTS - - QuerySettings materialOutputsSettings = expSchema.getSettings("mpo", ExpSchema.TableType.MaterialProtocolInputs.toString()); - materialOutputsSettings.getBaseFilter().addCondition(FieldKey.fromParts(ExpMaterialProtocolInputTable.Column.Protocol.toString()), protocol.getRowId()); - materialOutputsSettings.setFieldKeys(Arrays.asList( - FieldKey.fromParts(ExpMaterialProtocolInputTable.Column.Name.toString()), - FieldKey.fromParts(ExpMaterialProtocolInputTable.Column.SampleSet.toString()) - )); - QueryView materialOutputsView = new ProtocolInputGrid("Material Outputs", materialOutputsSettings, errors); - outputsView.addView(materialOutputsView); - - QuerySettings dataOutputsSettings = expSchema.getSettings("dpo", ExpSchema.TableType.DataProtocolInputs.toString()); - dataOutputsSettings.getBaseFilter().addCondition(FieldKey.fromParts(ExpDataProtocolInputTable.Column.Protocol.toString()), protocol.getRowId()); - dataOutputsSettings.setFieldKeys(Arrays.asList( - FieldKey.fromParts(ExpDataProtocolInputTable.Column.Name.toString()), - FieldKey.fromParts(ExpDataProtocolInputTable.Column.DataClass.toString()) - )); - QueryView dataOutputsView = new ProtocolInputGrid("Data Outputs", dataOutputsSettings, errors); - outputsView.addView(dataOutputsView); - } - } - - - @RequiresPermission(ReadPermission.class) - public class ProtocolPredecessorsAction extends SimpleViewAction - { - private ExpProtocol _parentProtocol; - private ProtocolActionStepDetail _actionStep; - - @Override - public ModelAndView getView(Object o, BindException errors) - { - ActionURL url = getViewContext().getActionURL(); - - String parentProtocolLSID = url.getParameter("ParentLSID"); - int actionSequence; - try - { - actionSequence = Integer.parseInt(url.getParameter("Sequence")); - } - catch (NumberFormatException e) - { - throw new NotFoundException("Could not find SequenceId " + url.getParameter("Sequence")); - } - - _parentProtocol = ExperimentService.get().getExpProtocol(parentProtocolLSID); - if (_parentProtocol == null) - { - throw new NotFoundException("Unable to find a matching protocol"); - } - - ensureCorrectContainer(getContainer(), _parentProtocol, getViewContext()); - - _actionStep = ExperimentServiceImpl.get().getProtocolActionStepDetail(parentProtocolLSID, actionSequence); - - if (_actionStep == null) - { - throw new NotFoundException("Unable to find a matching protocol action step"); - } - - ExpProtocol childProtocol = ExperimentService.get().getExpProtocol(_actionStep.getChildProtocolLSID()); - - JspView detailsView = new JspView<>("/org/labkey/experiment/ProtocolDetails.jsp", childProtocol); - detailsView.setTitle("Standard Properties"); - - CustomPropertiesView cpv = new CustomPropertiesView(childProtocol.getLSID(), getContainer()); - - ProtocolParametersView parametersView = new ProtocolParametersView(childProtocol); - - VBox protocolDetails = new VBox(); - protocolDetails.setFrame(WebPartView.FrameType.PORTAL); - protocolDetails.setTitle("Protocol Details"); - protocolDetails.addView(new ProtocolInputOutputsView(childProtocol, errors)); - protocolDetails.addView(new ProtocolSuccessorPredecessorView(parentProtocolLSID, actionSequence, getContainer(), "PredecessorChildLSID", "PredecessorSequence", "ActionSequence", "Protocol Predecessors")); - protocolDetails.addView(new ProtocolSuccessorPredecessorView(parentProtocolLSID, actionSequence, getContainer(), "ChildProtocolLSID", "ActionSequence", "PredecessorSequence", "Protocol Successors")); - - return new VBox(new StandardAndCustomPropertiesView(detailsView, cpv), parametersView, protocolDetails); - } - - @Override - public void addNavTrail(NavTree root) - { - addRootNavTrail(root); - root.addChild("Protocols", ExperimentUrlsImpl.get().getProtocolGridURL(getContainer())); - root.addChild("Parent Protocol '" + _parentProtocol.getName() + "'", ExperimentUrlsImpl.get().getProtocolDetailsURL(_parentProtocol)); - root.addChild("Protocol Step: " + _actionStep.getName()); - } - } - - public static class DataForm - { - private boolean _inline; - private long _rowId; - private String _lsid; - private Integer _maxDimension; - private String _format; - - public boolean isInline() - { - return _inline; - } - - public void setInline(boolean inline) - { - _inline = inline; - } - - public long getRowId() - { - return _rowId; - } - - public void setRowId(long rowId) - { - _rowId = rowId; - } - - public String getLsid() - { - return _lsid; - } - - public void setLsid(String lsid) - { - _lsid = lsid; - } - - public ExpDataImpl lookupData() - { - ExpDataImpl result = ExperimentServiceImpl.get().getExpData(getRowId()); - if (result == null && getLsid() != null) - { - result = ExperimentServiceImpl.get().getExpData(getLsid()); - } - return result; - } - - public Integer getMaxDimension() - { - return _maxDimension; - } - - public void setMaxDimension(Integer maxDimension) - { - _maxDimension = maxDimension; - } - - public String getFormat() - { - return _format; - } - - public void setFormat(String format) - { - _format = format; - } - } - - public static class ExpObjectForm extends QueryViewAction.QueryExportForm - { - private long _rowId; - private String _lsid; - - public String getLsid() - { - return _lsid; - } - - public void setLsid(String lsid) - { - _lsid = lsid; - } - - public String getLSID() - { - return getLsid(); - } - - public void setLSID(String lsid) - { - setLsid(lsid); - } - - public long getRowId() - { - return _rowId; - } - - public void setRowId(long rowId) - { - _rowId = rowId; - } - } - - @RequiresAnyOf({DeletePermission.class, SampleWorkflowDeletePermission.class}) - public class DeleteSelectedExpRunsAction extends AbstractDeleteAction - { - @Override - public void addNavTrail(NavTree root) - { - // UNDONE: Need help topic on Runs - setHelpTopic("experiment"); - super.addNavTrail(root); - } - - @Override - public ModelAndView getView(DeleteForm deleteForm, boolean reshow, BindException errors) - { - List runs = new ArrayList<>(); - - Map idToRunMap = new LongHashMap<>(); - for (long runId : deleteForm.getIds(false)) - { - ExpRun run = ExperimentService.get().getExpRun(runId); - if (run != null) - { - if (!run.canDelete(getUser())) - throw new UnauthorizedException("You do not have permission to delete " + - (ExpProtocol.isSampleWorkflowProtocol(run.getProtocol().getLSID()) ? "jobs" : "runs") - + " in " + run.getContainer()); - - runs.add(run); - idToRunMap.put(run.getRowId(), run); - } - } - - Map referencedItems = new LongHashMap<>(); - List referenceDescriptions = new ArrayList<>(); - AssayService assayService = AssayService.get(); - if (!idToRunMap.isEmpty() && assayService != null ) - { - // using the first run as a representative, since all interactions here are (I believe) using the same protocol. - ExpProtocol protocol = runs.get(0).getProtocol(); - AssayProvider provider = assayService.getProvider(protocol); - if (provider != null) - { - SchemaKey key = AssayProtocolSchema.schemaName(provider, protocol); - ExperimentService.get().getObjectReferencers() - .forEach(referencer -> { - Collection referenced = referencer.getItemsWithReferences( - idToRunMap.keySet(), - key.toString(), - "Runs" - ); - referenced.forEach(id -> referencedItems.put(id, idToRunMap.get(id))); - referenceDescriptions.add(referencer.getObjectReferenceDescription(ExpRun.class)); - } - ); - } - - } - - List> permissionDatasetRows = new ArrayList<>(); - List> noPermissionDatasetRows = new ArrayList<>(); - if (StudyPublishService.get() != null) - { - for (Dataset dataset : StudyPublishService.get().getDatasetsForAssayRuns(runs, getUser())) - { - ActionURL url = urlProvider(StudyUrls.class).getDatasetURL(dataset.getContainer(), dataset.getDatasetId()); - TableInfo t = dataset.getTableInfo(getUser()); - if (null != t && t.hasPermission(getUser(),DeletePermission.class)) - { - permissionDatasetRows.add(new Pair<>(dataset, url)); - } - else - { - noPermissionDatasetRows.add(new Pair<>(dataset, url)); - } - } - } - - return new ConfirmDeleteView( - "run", - ShowRunGraphAction.class, - runs.stream().filter(run -> !referencedItems.containsKey(run.getRowId())).toList(), - deleteForm, - Collections.emptyList(), - "dataset(s) have one or more rows which", - permissionDatasetRows, - noPermissionDatasetRows, - referencedItems.values().stream().toList(), - referenceDescriptions.stream().filter(Objects::nonNull).collect(Collectors.joining(", or "))); - } - - @Override - protected void deleteObjects(DeleteForm deleteForm) - { - ExperimentServiceImpl.get().deleteExperimentRunsByRowIds(getContainer(), getUser(), deleteForm.getUserComment(), deleteForm.getIds(false)); - } - } - - public static class DeleteRunForm - { - private int _runId; - - public int getRunId() - { - return _runId; - } - - public void setRunId(int runId) - { - _runId = runId; - } - } - - /** - * Separate delete action from the client API - */ - @RequiresAnyOf({DeletePermission.class, SampleWorkflowDeletePermission.class}) - public static class DeleteRunAction extends MutatingApiAction - { - @Override - public ApiResponse execute(DeleteRunForm form, BindException errors) - { - ExpRun run = ExperimentService.get().getExpRun(form.getRunId()); - if (run == null) - { - throw new NotFoundException("Could not find run with ID " + form.getRunId()); - } - if (!run.canDelete(getUser())) - throw new UnauthorizedException("You do not have permission to delete " - + (ExpProtocol.isSampleWorkflowProtocol(run.getProtocol().getLSID()) ? "jobs" : "runs") + " in this container."); - - run.delete(getUser()); - return new ApiSimpleResponse("success", true); - } - } - - - @RequiresAnyOf({DeletePermission.class, SampleWorkflowDeletePermission.class}) - public static class DeleteRunsAction extends AbstractDeleteAPIAction - { - @Override - protected ApiSimpleResponse deleteObjects(CascadeDeleteForm form) - { - Set runIdsToDelete = new HashSet<>(form.getIds(true)); - Set runIdsCascadeDeleted = new HashSet<>(); - - if (form.isCascade()) - { - for (long runId : runIdsToDelete) - { - ExpRun run = ExperimentService.get().getExpRun(runId); - if (run != null) - addReplacesRuns(run, runIdsCascadeDeleted); - } - - if (!runIdsCascadeDeleted.isEmpty()) - runIdsToDelete.addAll(runIdsCascadeDeleted); - } - - ExperimentService.get().deleteExperimentRunsByRowIds(getContainer(), getUser(), form.getUserComment(), runIdsToDelete); - - ApiSimpleResponse response = new ApiSimpleResponse("success", true); - response.put("runIdsDeleted", runIdsToDelete); - if (!runIdsCascadeDeleted.isEmpty()) - response.put("runIdsCascadeDeleted", runIdsCascadeDeleted); - return response; - } - - private void addReplacesRuns(ExpRun run, Set runIds) - { - for (ExpRun replacedRun : run.getReplacesRuns()) - { - runIds.add(replacedRun.getRowId()); - addReplacesRuns(replacedRun, runIds); - } - } - } - - private abstract static class AbstractDeleteAPIAction extends MutatingApiAction - { - @Override - public void validateForm(CascadeDeleteForm form, Errors errors) - { - if (form.getSingleObjectRowId() == null && form.getDataRegionSelectionKey() == null && form.getRowIds() == null) - errors.reject(ERROR_REQUIRED, "Either singleObjectRowId, dataRegionSelectionKey, or rowIds is required"); - } - - @Override - public ApiResponse execute(CascadeDeleteForm form, BindException errors) throws Exception - { - ApiSimpleResponse response; - - try (DbScope.Transaction tx = ExperimentService.get().ensureTransaction()) - { - tx.addCommitTask(form::clearSelected, POSTCOMMIT); - - response = deleteObjects(form); - tx.commit(); - } - - if (null != response.get("success")) - response.put("success", !errors.hasErrors()); - - return response; - } - - protected abstract ApiSimpleResponse deleteObjects(CascadeDeleteForm form) throws Exception; - } - - public static class CascadeDeleteForm extends DeleteForm - { - private boolean _cascade; - - public boolean isCascade() - { - return _cascade; - } - - public void setCascade(boolean cascade) - { - _cascade = cascade; - } - } - - private abstract static class AbstractDeleteAction extends FormViewAction - { - @Override - public void validateCommand(DeleteForm target, Errors errors) - { - } - - @Override - public boolean handlePost(DeleteForm deleteForm, BindException errors) throws Exception - { - if (!deleteForm.isForceDelete()) - { - return false; - } - else - { - try (DbScope.Transaction tx = ExperimentService.get().ensureTransaction()) - { - tx.addCommitTask(deleteForm::clearSelected, POSTCOMMIT); - - deleteObjects(deleteForm); - tx.commit(); - } - catch (BatchValidationException v) - { - v.addToErrors(errors); - } - - return !errors.hasErrors(); - } - } - - @Override - public ActionURL getSuccessURL(DeleteForm form) - { - return form.getSuccessActionURL(ExperimentUrlsImpl.get().getOverviewURL(getContainer())); - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild("Confirm Deletion"); - } - - protected abstract void deleteObjects(DeleteForm form) throws Exception; - } - - @RequiresPermission(DesignAssayPermission.class) - public static class DeleteProtocolByRowIdsAPIAction extends AbstractDeleteAPIAction - { - @Override - protected ApiSimpleResponse deleteObjects(CascadeDeleteForm form) - { - for (ExpProtocol protocol : getProtocolsForDeletion(form)) - { - if (!protocol.getContainer().hasPermission(getUser(), DesignAssayPermission.class)) - throw new UnauthorizedException("You do not have sufficient permissions to delete this assay design."); - - protocol.delete(getUser(), form.getUserComment()); - } - - return new ApiSimpleResponse(); - } - } - - public static List getProtocolsForDeletion(DeleteForm form) - { - List protocols = new ArrayList<>(); - for (long protocolId : form.getIds(false)) - { - ExpProtocol protocol = ExperimentService.get().getExpProtocol(protocolId); - if (protocol != null) - { - protocols.add(protocol); - } - } - return protocols; - } - - @RequiresPermission(DesignAssayPermission.class) - public class DeleteProtocolByRowIdsAction extends AbstractDeleteAction - { - @Override - public void addNavTrail(NavTree root) - { - // UNDONE: Need help topic on protocols - setHelpTopic("experiment"); - super.addNavTrail(root); - } - - @Override - public ModelAndView getView(DeleteForm form, boolean reshow, BindException errors) - { - List runs = ExperimentService.get().getExpRunsForProtocolIds(false, form.getIds(false)); - List protocols = getProtocolsForDeletion(form); - String noun = "Assay Design"; - List> deleteableDatasets = new ArrayList<>(); - List> noPermissionDatasets = new ArrayList<>(); - if (AssayService.get() != null && StudyService.get() != null) - { - for (ExpProtocol protocol : protocols) - { - if (!protocol.getContainer().hasPermission(getUser(), DesignAssayPermission.class)) - throw new UnauthorizedException("You do not have sufficient permissions to delete this assay design."); - - if (AssayService.get().getProvider(protocol) == null) - { - noun = "Protocol"; - } - for (Dataset dataset : StudyPublishService.get().getDatasetsForPublishSource(protocol.getRowId(), Dataset.PublishSource.Assay)) - { - Pair entry = new Pair<>(dataset, urlProvider(StudyUrls.class).getDatasetURL(dataset.getContainer(), dataset.getDatasetId())); - if (dataset.canDeleteDefinition(getUser())) - { - deleteableDatasets.add(entry); - } - else - { - noPermissionDatasets.add(entry); - } - } - } - } - - return new ConfirmDeleteView(noun, ProtocolDetailsAction.class, protocols, form, runs, "Dataset", deleteableDatasets, noPermissionDatasets, Collections.emptyList(), null); - } - - @Override - protected void deleteObjects(DeleteForm form) - { - for (ExpProtocol protocol : getProtocolsForDeletion(form)) - { - protocol.delete(getUser(), form.getUserComment()); - } - } - } - - @Marshal(Marshaller.Jackson) - @RequiresPermission(ReadPermission.class) - public static class GetDataOperationConfirmationDataAction extends ReadOnlyApiAction - { - @Override - public void validateForm(DataOperationConfirmationForm form, Errors errors) - { - if (form.getDataRegionSelectionKey() == null && form.getRowIds() == null) - errors.reject(ERROR_REQUIRED, "You must provide either a set of rowIds or a dataRegionSelectionKey"); - if (form.getDataOperation() == null) - errors.reject(ERROR_REQUIRED, "An operation type must be provided."); - } - - @Override - public Object execute(DataOperationConfirmationForm form, BindException errors) - { - Collection requestIds = form.getIds(false); - ExperimentServiceImpl service = ExperimentServiceImpl.get(); - List allData = service.getExpDatas(requestIds); - - Set notAllowedIds = new HashSet<>(); - if (form.getDataOperation() == ExpDataImpl.DataOperations.Delete) - service.getObjectReferencers().forEach(referencer -> - notAllowedIds.addAll(referencer.getItemsWithReferences(requestIds, "exp.data"))); - - Map>> response = ExperimentServiceImpl.partitionRequestedOperationObjects(getUser(), requestIds, notAllowedIds, allData); - - Collection containers = new HashSet<>(); - Collection notPermittedIds = new ArrayList<>(); - Class permClass = form.getDataOperation().getPermissionClass(); - for (ExpDataImpl expData : allData) - { - Container c = expData.getContainer(); - if (c.hasPermission(getUser(), ReadPermission.class)) - containers.add(c); - if (permClass != null && !c.hasPermission(getUser(), permClass)) - notPermittedIds.add(expData.getRowId()); - } - - NameExpressionOptionService svc = NameExpressionOptionService.get(); - response.put("containers", containers.stream().map(c -> Map.of( - "id", c.getEntityId(), - "path", (Object) c.getPath(), - "permitted", permClass == null || c.hasPermission(getUser(), permClass), - "canEditName", svc.getAllowUserSpecificNamesValue(c) - )).toList()); - - response.put("notPermitted", notPermittedIds.stream().map(id -> Map.of("RowId", (Object) id)).toList()); - - return success(response); - } - } - - - public static class DataOperationConfirmationForm extends DataViewSnapshotSelectionForm - { - private ExpDataImpl.DataOperations _dataOperation; - - public ExpDataImpl.DataOperations getDataOperation() - { - return _dataOperation; - } - - public void setDataOperation(ExpDataImpl.DataOperations dataOperation) - { - _dataOperation = dataOperation; - } - - } - - @Marshal(Marshaller.Jackson) - @RequiresPermission(ReadPermission.class) - public static class GetMaterialOperationConfirmationDataAction extends ReadOnlyApiAction - { - @Override - public void validateForm(MaterialOperationConfirmationForm form, Errors errors) - { - if (form.getDataRegionSelectionKey() == null && form.getRowIds() == null) - errors.reject(ERROR_REQUIRED, "You must provide either a set of rowIds or a dataRegionSelectionKey."); - if (form.getSampleOperation() == null) - errors.reject(ERROR_REQUIRED, "An operation type must be provided."); - } - - @Override - public Object execute(MaterialOperationConfirmationForm form, BindException errors) - { - Set requestIds = form.getIds(false); - ExperimentServiceImpl service = ExperimentServiceImpl.get(); - List allMaterials = service.getExpMaterials(requestIds); - - Set notAllowedIds = new HashSet<>(); - // We prevent deletion if a sample is used as a parent, has assay data, is used in a job, etc. - if (form.getSampleOperation() == SampleTypeService.SampleOperations.Delete) - service.getObjectReferencers().forEach(referencer -> - notAllowedIds.addAll(referencer.getItemsWithReferences(requestIds, "samples"))); - - if (SampleStatusService.get().supportsSampleStatus()) - notAllowedIds.addAll(service.findIdsNotPermittedForOperation(allMaterials, form.getSampleOperation())); - - Map>> response = ExperimentServiceImpl.partitionRequestedOperationObjects(getUser(), requestIds, notAllowedIds, allMaterials); - - Collection containers = new HashSet<>(); - Collection notPermittedIds = new ArrayList<>(); - Class permClass = form.getSampleOperation().getPermissionClass(); - for (ExpMaterial material : allMaterials) - { - Container c = material.getContainer(); - if (c.hasPermission(getUser(), ReadPermission.class)) - containers.add(c); - if (permClass != null && !c.hasPermission(getUser(), permClass)) - notPermittedIds.add(material.getRowId()); - } - - NameExpressionOptionService svc = NameExpressionOptionService.get(); - - response.put("containers", containers.stream().map(c -> Map.of( - "id", c.getEntityId(), - "path", (Object) c.getPath(), - "permitted", permClass == null || c.hasPermission(getUser(), permClass), - "canEditName", svc.getAllowUserSpecificNamesValue(c) - )).toList()); - - response.put("notPermitted", notPermittedIds.stream().map(id -> Map.of("RowId", (Object) id)).toList()); - - if (form.getSampleOperation() == SampleTypeService.SampleOperations.Delete) - // String 'associatedDatasets' must be synced to its handling in confirmDelete.js, confirmDelete() - response.put("associatedDatasets", ExperimentServiceImpl.includeLinkedToStudyText(allMaterials, requestIds, getUser(), getContainer())); - - return success(response); - } - } - - public static class MaterialOperationConfirmationForm extends DataViewSnapshotSelectionForm - { - private SampleTypeService.SampleOperations _sampleOperation; - - public SampleTypeService.SampleOperations getSampleOperation() - { - return _sampleOperation; - } - - public void setSampleOperation(SampleTypeService.SampleOperations sampleOperation) - { - _sampleOperation = sampleOperation; - } - } - - @RequiresPermission(DeletePermission.class) - public class DeleteSelectedDataAction extends AbstractDeleteAction - { - @Override - public void addNavTrail(NavTree root) - { - // UNDONE: Need help topic on Datas - setHelpTopic("experiment"); - super.addNavTrail(root); - } - - @Override - protected void deleteObjects(DeleteForm deleteForm) throws Exception - { - List datas = getDatas(deleteForm, false); - - for (ExpRun run : getRuns(datas)) - { - if (!run.getContainer().hasPermission(getUser(), DeletePermission.class)) - throw new UnauthorizedException(); - } - - // Issue 32076: Delete the exp.Data objects using QueryUpdateService so trigger scripts will be executed - Map, List> byDataClass = datas.stream().collect(Collectors.groupingBy(d -> Optional.ofNullable(d.getDataClass(null)))); - for (Optional opt : byDataClass.keySet()) - { - SchemaKey schemaKey; - String queryName; - ExpDataClass dc = opt.orElse(null); - List ds = byDataClass.get(opt); - if (dc == null) - { - // Reference to exp.Data table - schemaKey = ExpSchema.SCHEMA_EXP; - queryName = ExpSchema.TableType.Data.name(); - } - else - { - // Reference to exp.data. table - schemaKey = ExpSchema.SCHEMA_EXP_DATA; - queryName = dc.getName(); - } - - UserSchema schema = QueryService.get().getUserSchema(getUser(), getContainer(), schemaKey); - if (schema == null) - throw new IllegalStateException("Failed to get schema '" + schemaKey + "'"); - - TableInfo table = schema.getTable(queryName); - if (table == null) - throw new IllegalStateException("Failed to get table '" + queryName + "' in schema '" + schemaKey + "'"); - - QueryUpdateService qus = table.getUpdateService(); - if (qus == null) - throw new IllegalStateException(); - - qus.deleteRows(getUser(), getContainer(), toKeys(ds), null, null); - } - } - - protected List> toKeys(List datas) - { - return datas.stream().map(d -> CaseInsensitiveHashMap.of("rowId", d.getRowId())).collect(toList()); - } - - @Override - public ModelAndView getView(DeleteForm deleteForm, boolean reshow, BindException errors) - { - if (errors.hasErrors()) - return new SimpleErrorView(errors, false); - - List datas = getDatas(deleteForm, false); - List runs = getRuns(datas); - - return new ConfirmDeleteView("Data", ShowDataAction.class, datas, deleteForm, runs); - } - - private List getRuns(List datas) - { - List runArray = ExperimentService.get().getRunsUsingDatas(datas); - return new ArrayList<>(ExperimentService.get().runsDeletedWithInput(runArray)); - } - - private List getDatas(DeleteForm deleteForm, boolean clear) - { - List datas = new ArrayList<>(); - for (long dataId : deleteForm.getIds(clear)) - { - ExpData data = ExperimentService.get().getExpData(dataId); - if (data != null) - { - datas.add(data); - } - } - return datas; - } - } - - @RequiresPermission(DeletePermission.class) - public class DeleteSelectedExperimentsAction extends AbstractDeleteAction - { - @Override - protected void deleteObjects(DeleteForm deleteForm) - { - for (ExpExperiment exp : lookupExperiments(deleteForm)) - { - exp.delete(getUser()); - } - } - - @Override - public ModelAndView getView(DeleteForm deleteForm, boolean reshow, BindException errors) - { - List experiments = lookupExperiments(deleteForm); - - List runs = new ArrayList<>(); - boolean allBatches = true; - for (ExpExperiment experiment : experiments) - { - // Deleting a batch also deletes all of its runs - if (experiment.getBatchProtocol() != null) - { - runs.addAll(experiment.getRuns()); - } - else - { - allBatches = false; - } - } - - return new ConfirmDeleteView(allBatches ? "batch" : "run group", DetailsAction.class, experiments, deleteForm, runs); - } - - private List lookupExperiments(DeleteForm deleteForm) - { - List experiments = new ArrayList<>(); - for (long experimentId : deleteForm.getIds(false)) - { - ExpExperiment experiment = ExperimentService.get().getExpExperiment(experimentId); - if (experiment != null) - { - experiments.add(experiment); - } - } - return experiments; - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("runGroups"); - super.addNavTrail(root); - } - } - - @RequiresPermission(DesignSampleTypePermission.class) - public class DeleteSampleTypesAction extends AbstractDeleteAction - { - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("sampleSets"); - super.addNavTrail(root); - } - - @Override - protected void deleteObjects(DeleteForm deleteForm) - { - List sampleTypes = getSampleTypes(deleteForm); - if (sampleTypes.isEmpty()) - { - throw new NotFoundException("No sample types found for ids provided."); - } - if (!ensureCorrectContainer(sampleTypes)) - { - throw new UnauthorizedException(); - } - - for (ExpRun run : getRuns(sampleTypes)) - { - if (!run.getContainer().hasPermission(getUser(), DeletePermission.class)) - { - throw new UnauthorizedException(); - } - } - - for (ExpSampleType source : sampleTypes) - { - Domain domain = source.getDomain(); - if (!domain.getDomainKind().canDeleteDefinition(getUser(), domain)) - { - throw new UnauthorizedException(); - } - - source.delete(getUser(), deleteForm.getUserComment()); - } - } - - @Override - public ModelAndView getView(DeleteForm deleteForm, boolean reshow, BindException errors) - { - List sampleTypes = getSampleTypes(deleteForm); - if (!ensureCorrectContainer(sampleTypes)) - { - throw new RedirectException(ExperimentUrlsImpl.get().getShowSampleTypeListURL(getContainer(), "To delete a sample type, you must be in its folder or project.")); - } - - List> deleteableDatasets = new ArrayList<>(); - List> noPermissionDatasets = new ArrayList<>(); - if (StudyService.get() != null && StudyPublishService.get() != null) - { - for (ExpSampleType sampleType: sampleTypes) - { - for (Dataset dataset : StudyPublishService.get().getDatasetsForPublishSource(sampleType.getRowId(), Dataset.PublishSource.SampleType)) - { - ActionURL datasetURL = StudyService.get().getDatasetURL(getContainer(), dataset.getDatasetId()); - Pair entry = new Pair<>(dataset, datasetURL); - if (dataset.canDeleteDefinition(getUser())) - { - deleteableDatasets.add(entry); - } - else - { - noPermissionDatasets.add(entry); - } - } - } - } - return new ConfirmDeleteView("Sample Type", ShowSampleTypeAction.class, sampleTypes, deleteForm, getRuns(sampleTypes), "Dataset", deleteableDatasets, noPermissionDatasets, Collections.emptyList(), null); - } - - private List getSampleTypes(DeleteForm deleteForm) - { - List sources = new ArrayList<>(); - for (long rowId : deleteForm.getIds(false)) - { - ExpSampleType sampleType = SampleTypeService.get().getSampleType(getContainer(), getUser(), rowId); - if (sampleType != null) - { - sources.add(sampleType); - } - } - return sources; - } - - private boolean ensureCorrectContainer(List sampleTypes) - { - for (ExpSampleType source : sampleTypes) - { - Container sourceContainer = source.getContainer(); - if (!sourceContainer.equals(getContainer())) - { - return false; - } - } - return true; - } - - private List getRuns(List sampleTypes) - { - if (!sampleTypes.isEmpty()) - { - List runArray = ExperimentService.get().getRunsUsingSampleTypes(sampleTypes.toArray(new ExpSampleType[0])); - return ExperimentService.get().runsDeletedWithInput(runArray); - } - else - { - return Collections.emptyList(); - } - } - } - - private DataRegion getSampleTypeRegion(ViewContext model) - { - TableInfo tableInfo = ExperimentServiceImpl.get().getTinfoSampleType(); - - QuerySettings settings = new QuerySettings(model, "SampleType"); - settings.setSelectionKey(DataRegionSelection.getSelectionKey(tableInfo.getSchema().getName(), tableInfo.getName(), "SampleType", settings.getDataRegionName())); - - DataRegion dr = new DataRegion(); - dr.setSettings(settings); - dr.addColumns(tableInfo.getUserEditableColumns()); - dr.removeColumns("lastindexed"); - dr.getDisplayColumn(0).setVisible(false); - - dr.getDisplayColumn("idcol1").setVisible(false); - dr.getDisplayColumn("idcol2").setVisible(false); - dr.getDisplayColumn("idcol3").setVisible(false); - dr.getDisplayColumn("lsid").setVisible(false); - dr.getDisplayColumn("materiallsidprefix").setVisible(false); - dr.getDisplayColumn("parentcol").setVisible(false); - - ActionURL url = new ActionURL(ShowSampleTypeAction.class, model.getContainer()); - dr.getDisplayColumn(1).setURL(url.addParameter("rowId", "${RowId}")); - dr.setShowRecordSelectors(getContainer().hasOneOf(getUser(), DeletePermission.class, UpdatePermission.class)); - - return dr; - } - - @RequiresPermission(ReadPermission.class) - @ActionNames("getSampleType,getSampleTypeApi") // Referenced in labkey-ui-components components/samples/actions.ts TODO: migrate getSampleTypeApi -> getSampleType - public static class GetSampleTypeAction extends ReadOnlyApiAction - { - @Override - public void validateForm(SampleTypeForm form, Errors errors) - { - if (form.getRowId() == null && form.getLSID() == null) - errors.reject(ERROR_REQUIRED, "RowId or LSID must be provided"); - } - - @Override - public Object execute(SampleTypeForm form, BindException errors) throws Exception - { - ExpSampleTypeImpl st = form.getSampleType(getContainer()); - - return getSampleTypeResponse(st); - } - } - - @NotNull - private static ApiSimpleResponse getSampleTypeResponse(ExpSampleType st) throws IOException - { - Map sampleType = new HashMap<>(); - sampleType.put("name", st.getName()); - sampleType.put("nameExpression", st.getNameExpression()); - sampleType.put("labelColor", st.getLabelColor()); - sampleType.put("metricUnit", st.getMetricUnit()); - sampleType.put("description", st.getDescription()); - sampleType.put("importAliases", st.getImportAliasMap()); - sampleType.put("lsid", st.getLSID()); - sampleType.put("rowId", st.getRowId()); - sampleType.put("domainId", st.getDomain().getTypeId()); - sampleType.put("category", st.getCategory()); - - return new ApiSimpleResponse(Map.of("sampleSet", sampleType, "success", true)); - } - - public static class DataTypesWithRequiredLineageForm - { - private Integer _parentDataTypeRowId; - private boolean _sampleParent; - - public Integer getParentDataTypeRowId() - { - return _parentDataTypeRowId; - } - - public void setParentDataTypeRowId(Integer parentDataTypeRowId) - { - this._parentDataTypeRowId = parentDataTypeRowId; - } - - public boolean isSampleParent() - { - return _sampleParent; - } - - public void setSampleParent(boolean sampleParent) - { - _sampleParent = sampleParent; - } - } - - @RequiresPermission(ReadPermission.class) - public static class GetDataTypesWithRequiredLineageAction extends ReadOnlyApiAction - { - @Override - public void validateForm(DataTypesWithRequiredLineageForm form, Errors errors) - { - if (form.getParentDataTypeRowId() == null) - errors.reject(ERROR_REQUIRED, "ParentDataTypeRowId must be provided"); - } - - @Override - public Object execute(DataTypesWithRequiredLineageForm form, BindException errors) throws Exception - { - return getDataTypesWithRequiredLineageResponse(form.getParentDataTypeRowId(), form.isSampleParent(), getContainer(), getUser()); - } - } - @NotNull - private static ApiSimpleResponse getDataTypesWithRequiredLineageResponse(Integer parentDataType, boolean isSampleParent, Container container, User user) - { - Pair, Set> requiredLineages = ExperimentServiceImpl.get().getDataTypesWithRequiredLineage(parentDataType, isSampleParent, container, user); - return new ApiSimpleResponse(Map.of("sampleTypes", requiredLineages.first, "dataClasses", requiredLineages.second,"success", true)); - } - - @RequiresPermission(DesignSampleTypePermission.class) - public static class EditSampleTypeAction extends SimpleViewAction - { - private ExpSampleTypeImpl _sampleType; - - @Override - public ModelAndView getView(SampleTypeForm form, BindException errors) - { - boolean create = form.getLSID() == null && form.getRowId() == null; - if (!create) - _sampleType = form.getSampleType(getContainer()); - - return ModuleHtmlView.get(ModuleLoader.getInstance().getModule("core"), ModuleHtmlView.getGeneratedViewPath("sampleTypeDesigner")); - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("sampleSets"); - - root.addChild("Sample Types", ExperimentUrlsImpl.get().getShowSampleTypeListURL(getContainer())); - if (_sampleType == null) - { - root.addChild("Create Sample Type"); - } - else - { - root.addChild(_sampleType.getName(), ExperimentUrlsImpl.get().getShowSampleTypeURL(_sampleType)); - root.addChild("Update Sample Type"); - } - } - } - - public static class SampleTypeForm extends ReturnUrlForm - { - private Integer rowId; - private String lsid; - - public Integer getRowId() - { - return rowId; - } - - public void setRowId(Integer rowId) - { - this.rowId = rowId; - } - - public String getLSID() - { - return this.lsid; - } - - public void setLSID(String lsid) - { - this.lsid = lsid; - } - - public ExpSampleTypeImpl getSampleType(Container container) throws NotFoundException - { - ExpSampleTypeImpl sampleType = SampleTypeServiceImpl.get().getSampleType(getLSID()); - if (sampleType == null) - sampleType = SampleTypeServiceImpl.get().getSampleType(getRowId()); - - if (sampleType == null) - { - throw new NotFoundException("Sample type not found: " + (getLSID() != null ? getLSID() : getRowId())); - } - - if (!container.equals(sampleType.getContainer())) - { - throw new NotFoundException("Sample type is not defined in the given container."); - } - - return sampleType; - } - } - - @RequiresPermission(InsertPermission.class) - public static class ImportSamplesAction extends AbstractExpDataImportAction - { - ExpSampleTypeImpl _sampleType; - boolean _isCrossTypeImport = false; - - @Override - public void validateForm(QueryForm queryForm, Errors errors) - { - _form = queryForm; - _insertOption = queryForm.getInsertOption(); - _isCrossTypeImport = getOptionParamValue(Params.crossTypeImport); - _form.setSchemaName(getTargetSchemaName()); - if (_isCrossTypeImport) - { - _form.setQueryName(getPipelineTargetQueryName()); - } - super.validateForm(queryForm, errors); - if (queryForm.getQueryName() == null) - errors.reject(ERROR_REQUIRED, "Sample type name is required"); - else - { - if (!_isCrossTypeImport) - { - _sampleType = SampleTypeServiceImpl.get().getSampleType(getContainer(), getUser(), queryForm.getQueryName()); - if (_sampleType == null) - { - errors.reject(ERROR_GENERIC, "Sample type '" + queryForm.getQueryName() + " not found."); - } - } - } - } - - private String getTargetSchemaName() - { - return getOptionParamValue(Params.crossTypeImport) ? ExpSchema.SCHEMA_NAME : "samples"; - } - - @Override - protected UserSchema getTargetSchema() - { - return getOptionParamValue(Params.crossTypeImport) ? QueryService.get().getUserSchema(getUser(), getContainer(), getTargetSchemaName()) : super.getTargetSchema(); - } - - @Override - protected String getPipelineTargetQueryName() - { - return getOptionParamValue(Params.crossTypeImport) ? "materials" : super.getPipelineTargetQueryName(); - } - - @Override - protected Map getRenamedColumns() - { - Map renamedColumns = super.getRenamedColumns(); - renamedColumns.putAll(SampleTypeUpdateServiceDI.SAMPLE_ALT_IMPORT_NAME_COLS); - return renamedColumns; - } - - @Override - protected @Nullable Set getLineageImportAliases() throws IOException - { - Set aliases = new CaseInsensitiveHashSet(); - // Issue 53419: Aliquot parent with number like names that starts with leading zeroes aren't resolved during import - aliases.add(ExpMaterial.ALIQUOTED_FROM_INPUT); - aliases.add(ExpMaterial.ALIQUOTED_FROM_INPUT_LABEL); - boolean crossTypeImport = getOptionParamValue(AbstractQueryImportAction.Params.crossTypeImport); - // Issue 51894: We need to stop conversion to numbers for alias fields for all type - // If there are aliases defined for one type that are number fields in another type, this will prevent - // conversion to numbers during the initial partitioning, but the conversion will happen when the partition - // file is loaded. - if (crossTypeImport) - { - List sampleTypes = SampleTypeServiceImpl.get().getSampleTypes(getContainer(), true); - for (ExpSampleTypeImpl sampleType : sampleTypes) - aliases.addAll(sampleType.getImportAliases().keySet()); - } - else - { - ExpSampleTypeImpl sampleType = SampleTypeServiceImpl.get().getSampleType(getContainer(), getUser(), _form.getQueryName()); - aliases.addAll(sampleType.getImportAliases().keySet()); - } - return aliases; - } - - @Override - protected int importData( - DataLoader dl, - FileStream file, - String originalName, - BatchValidationException errors, - @Nullable AuditBehaviorType auditBehaviorType, - TransactionAuditProvider.@Nullable TransactionAuditEvent auditEvent, - @Nullable String auditUserComment - ) throws IOException - { - initContext(dl, errors, auditBehaviorType, auditUserComment); - - TableInfo tInfo = _target; - QueryUpdateService updateService = _updateService; - if (getOptionParamValue(Params.crossTypeImport)) - { - tInfo = ExperimentService.get().createMaterialTable(new SamplesSchema(getUser(), getContainer()), ContainerFilter.current(this), null); - updateService = tInfo.getUpdateService(); - } - - int count = importData(dl, tInfo, updateService, _context, auditEvent, getUser(), getContainer()); - - if (getOptionParamValue(Params.crossTypeImport)) - { - SimpleMetricsService.get().increment(ExperimentService.MODULE_NAME, "sampleImport", "crossTypeImport"); - if (_context.getInsertOption() == QueryUpdateService.InsertOption.UPDATE) - SimpleMetricsService.get().increment(ExperimentService.MODULE_NAME, "sampleImport", "crossTypeUpdate"); - else if (_context.getInsertOption() == QueryUpdateService.InsertOption.MERGE) - SimpleMetricsService.get().increment(ExperimentService.MODULE_NAME, "sampleImport", "crossTypeMerge"); - } - - return count; - } - - @Override - public ModelAndView getView(QueryForm form, BindException errors) throws Exception - { - initRequest(form); - setHelpTopic("importSampleSets"); // page-wide help topic - setImportHelpTopic("importSampleSets"); // importOptions help topic - setTypeName("samples"); - return getDefaultImportView(form, errors); - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild("Sample Types", ExperimentUrlsImpl.get().getShowSampleTypeListURL(getContainer())); - ActionURL url = _form.urlFor(QueryAction.executeQuery); - if (_form.getQueryName() != null && url != null) - root.addChild(_form.getQueryName(), url); - root.addChild("Import Data"); - } - - @Override - protected JSONObject createSuccessResponse(int rowCount) - { - JSONObject json = super.createSuccessResponse(rowCount); - if (!_context.getResponseInfo().isEmpty()) - { - for (String key : _context.getResponseInfo().keySet()) - json.put(key, _context.getResponseInfo().get(key)); - } - return json; - } - - @Override - protected void configureLoader(DataLoader loader) throws IOException - { - if (getOptionParamValue(Params.crossTypeImport)) - loader.setInferTypes(false); - configureLoader(loader, _target, getRenamedColumns(), allowLineageColumns(), getLineageImportAliases()); - } - } - - public abstract static class AbstractExpDataImportAction extends AbstractQueryImportAction - { - protected QueryForm _form; - protected DataIteratorContext _context; - - @Override - public void validateForm(QueryForm form, Errors errors) - { - QueryDefinition query = form.getQueryDef(); - if (query.getContainerFilter() != null && query.getContainerFilter().getType() != null) - { - // cross folder import not supported - if (query.getContainerFilter().getType() != ContainerFilter.Type.Current) - errors.reject(ERROR_GENERIC, "ContainerFilter is not supported for import actions."); - } - } - - @Override - protected void initRequest(QueryForm form) throws ServletException - { - QueryDefinition query = form.getQueryDef(); - setContainerFilterForImport(query, getContainer(), getUser()); - List qpe = new ArrayList<>(); - TableInfo t = query.getTable(form.getSchema(), qpe, true); - - if (!qpe.isEmpty()) - throw qpe.get(0); - if (!getOptionParamValue(Params.crossTypeImport) && null != t) - { - setTarget(t); - setShowMergeOption(t.supportsInsertOption(QueryUpdateService.InsertOption.MERGE)); - setShowUpdateOption(t.supportsInsertOption(QueryUpdateService.InsertOption.UPDATE)); - } - - _auditBehaviorType = form.getAuditBehavior(); - _auditUserComment = form.getAuditUserComment(); - } - - @Override - protected Map getRenamedColumns() - { - final String renameParamPrefix = "importAlias."; - Map renameColumns = new CaseInsensitiveHashMap<>(); - PropertyValue[] pvs = _form.getInitParameters().getPropertyValues(); - for (PropertyValue pv : pvs) - { - String paramName = pv.getName(); - if (!paramName.startsWith(renameParamPrefix) || pv.getValue() == null) - continue; - - renameColumns.put(paramName.substring(renameParamPrefix.length()), (String) pv.getValue()); - } - return renameColumns; - } - - @Override - protected Set getLineageImportAliases() throws IOException - { - ExpDataClass dataClass = ExperimentServiceImpl.get().getDataClass(getContainer(), getUser(), _form.getQueryName()); - return new CaseInsensitiveHashSet(dataClass.getImportAliases().keySet()); - } - - protected void initContext(DataLoader dl, BatchValidationException errors, @Nullable AuditBehaviorType auditBehaviorType, @Nullable String auditUserComment) - { - _context = createDataIteratorContext(_insertOption, getOptionParamsMap(), getLookupResolutionType(), auditBehaviorType, auditUserComment, errors, null, getContainer()); - - if (_context.isCrossFolderImport() && !getContainer().hasProductFolders()) - _context.setCrossFolderImport(false); - } - - @Override - protected int importData(DataLoader dl, FileStream file, String originalName, BatchValidationException errors, @Nullable AuditBehaviorType auditBehaviorType, TransactionAuditProvider.@Nullable TransactionAuditEvent auditEvent, @Nullable String auditUserComment) throws IOException - { - initContext(dl, errors, auditBehaviorType, auditUserComment); - return importData(dl, _target, _updateService, _context, auditEvent, getUser(), getContainer()); - } - - @Override - protected String getQueryImportProviderName() - { - PropertyValue pv = _form.getInitParameters().getPropertyValue(QUERY_IMPORT_PIPELINE_PROVIDER_PARAM); - return pv == null ? null : (String) pv.getValue(); - } - - @Override - protected String getQueryImportDescription() - { - PropertyValue pv = _form.getInitParameters().getPropertyValue(QUERY_IMPORT_PIPELINE_DESCRIPTION_PARAM); - return pv == null ? null : (String) pv.getValue(); - } - - @Override - protected String getQueryImportJobNotificationProviderName() - { - PropertyValue pv = _form.getInitParameters().getPropertyValue(QUERY_IMPORT_NOTIFICATION_PROVIDER_PARAM); - return pv == null ? null : (String) pv.getValue(); - } - - @Override - protected boolean isBackgroundImportSupported() - { - return true; - } - - @Override - protected boolean allowLineageColumns() - { - return true; - } - - } - - @RequiresPermission(InsertPermission.class) - public static class ImportDataAction extends AbstractExpDataImportAction - { - @Override - public void validateForm(QueryForm queryForm, Errors errors) - { - _form = queryForm; - _form.setSchemaName("exp.data"); - _insertOption = queryForm.getInsertOption(); - super.validateForm(queryForm, errors); - if (queryForm.getQueryName() == null) - errors.reject(ERROR_REQUIRED, "Data class name is required"); - else - { - ExpDataClass dataClass = ExperimentService.get().getDataClass(getContainer(), getUser(), queryForm.getQueryName()); - if (dataClass == null) - { - errors.reject(ERROR_GENERIC, "Data class '" + queryForm.getQueryName() + " not found."); - } - } - } - - @Override - public ModelAndView getView(QueryForm form, BindException errors) throws Exception - { - initRequest(form); - setHelpTopic("dataClass"); // page wide help topic - setImportHelpTopic("dataClass#ui"); // importOptions help topic - setTypeName("data"); - return getDefaultImportView(form, errors); - } - - - @Override - public void addNavTrail(NavTree root) - { - root.addChild("Data Classes", ExperimentUrlsImpl.get().getDataClassListURL(getContainer())); - ActionURL url = _form.urlFor(QueryAction.executeQuery); - if (_form.getQueryName() != null && url != null) - root.addChild(_form.getQueryName(), url); - root.addChild("Import Data"); - } - - @Override - protected void configureLoader(DataLoader loader) throws IOException - { - configureLoader(loader, _target, getRenamedColumns(), allowLineageColumns(), getLineageImportAliases()); - } - - } - - @RequiresPermission(UpdatePermission.class) - public class ShowUpdateAction extends SimpleViewAction - { - @Override - public ModelAndView getView(ExperimentForm form, BindException errors) - { - form.refreshFromDb(); - Experiment exp = form.getBean(); - if (exp == null) - { - throw new NotFoundException(); - } - ensureCorrectContainer(getContainer(), ExperimentService.get().getExpExperiment(exp.getRowId()), getViewContext()); - - return new ExperimentUpdateView(new DataRegion(), form, errors); - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("runGroups"); - addRootNavTrail(root); - root.addChild("Update Run Group"); - } - } - - @RequiresPermission(UpdatePermission.class) - public static class UpdateAction extends FormHandlerAction - { - private Experiment _exp; - - @Override - public void validateCommand(ExperimentForm target, Errors errors) - { - } - - @Override - public boolean handlePost(ExperimentForm form, BindException errors) throws Exception - { - form.doUpdate(); - form.refreshFromDb(); - _exp = form.getBean(); - return true; - } - - @Override - public ActionURL getSuccessURL(ExperimentForm experimentForm) - { - return ExperimentUrlsImpl.get().getExperimentDetailsURL(getContainer(), ExperimentService.get().getExpExperiment(_exp.getRowId())); - } - } - - public static class ExportBean - { - private final LSIDRelativizer _selectedRelativizer; - private final XarExportType _selectedExportType; - private final String _fileName; - private final String _dataRegionSelectionKey; - private final String _error; - private final Long _expRowId; - private final Long _protocolId; - private final ActionURL _postURL; - private final Set _roles; - - public ExportBean(LSIDRelativizer selectedRelativizer, XarExportType selectedExportType, String fileName, ExportOptionsForm form, Set roles, ActionURL postURL) - { - _selectedRelativizer = selectedRelativizer; - _selectedExportType = selectedExportType; - _fileName = fileName; - _dataRegionSelectionKey = form.getDataRegionSelectionKey(); - _error = form.getError(); - _expRowId = form.getExpRowId(); - _postURL = postURL; - _roles = roles; - _protocolId = form.getProtocolId(); - } - - public LSIDRelativizer getSelectedRelativizer() - { - return _selectedRelativizer; - } - - public XarExportType getSelectedExportType() - { - return _selectedExportType; - } - - public String getError() - { - return _error; - } - - public String getFileName() - { - return _fileName; - } - - public Set getRoles() - { - return _roles; - } - - public String getDataRegionSelectionKey() - { - return _dataRegionSelectionKey; - } - - public ActionURL getPostURL() - { - return _postURL; - } - - public Long getProtocolId() - { - return _protocolId; - } - - public Long getExpRowId() - { - return _expRowId; - } - } - - - private String fixupExportName(String runName) - { - runName = runName.replace('/', '-'); - runName = runName.replace('\\', '-'); - return runName; - } - - public static class ExportOptionsForm extends ExperimentRunListForm - { - private String _error; - private XarExportType _exportType; - private LSIDRelativizer _lsidOutputType; - private String _xarFileName; - private String _zipFileName; - private String _fileExportType; - private Long _protocolId; - private Integer _sampleTypeId; - private long[] _dataIds; - private String[] _roles = new String[0]; - - public String getError() - { - return _error; - } - - public void setError(String error) - { - _error = error; - } - - public XarExportType getExportType() - { - return _exportType; - } - - public LSIDRelativizer getLsidOutputType() - { - return _lsidOutputType; - } - - public String getFileExportType() - { - return _fileExportType; - } - - public void setFileExportType(String fileExportType) - { - _fileExportType = fileExportType; - } - - public String getXarFileName() - { - return _xarFileName; - } - - public void setXarFileName(String xarFileName) - { - _xarFileName = xarFileName; - } - - public String getZipFileName() - { - return _zipFileName; - } - - public void setZipFileName(String zipFileName) - { - _zipFileName = zipFileName; - } - - public void setExportType(XarExportType exportType) - { - _exportType = exportType; - } - - public void setLsidOutputType(LSIDRelativizer lsidOutputType) - { - _lsidOutputType = lsidOutputType; - } - - public Long getProtocolId() - { - return _protocolId; - } - - public void setProtocolId(Long protocolId) - { - _protocolId = protocolId; - } - - public String[] getRoles() - { - return _roles; - } - - public void setRoles(String[] roles) - { - _roles = roles; - } - - public Integer getSampleTypeId() - { - return _sampleTypeId; - } - - public void setSampleTypeId(Integer sampleTypeId) - { - _sampleTypeId = sampleTypeId; - } - - public long[] getDataIds() - { - return _dataIds; - } - - public void setDataIds(long[] dataIds) - { - _dataIds = dataIds; - } - - public List lookupProtocols(ViewContext context, boolean clearSelection) - { - List protocols = new ArrayList<>(); - - if (_protocolId != null) - { - ExpProtocol protocol = ExperimentService.get().getExpProtocol(_protocolId.intValue()); - if (protocol == null || !protocol.getContainer().equals(context.getContainer())) - { - throw new NotFoundException(); - } - protocols.add(protocol); - return protocols; - } - - for (Long protocolId : DataRegionSelection.getSelectedIntegers(context, clearSelection)) - { - try - { - ExpProtocol protocol = ExperimentService.get().getExpProtocol(protocolId); - if (protocol == null || !protocol.getContainer().equals(context.getContainer())) - { - throw new NotFoundException(); - } - protocols.add(protocol); - } - catch (NumberFormatException e) - { - throw new NotFoundException("Invalid protocol id: " + protocolId); - } - } - if (protocols.isEmpty()) - { - throw new NotFoundException("No protocols selected"); - } - return protocols; - } - } - - private ActionURL exportXAR(@NotNull XarExportSelection selection, @Nullable String fileName) - throws ExperimentException, IOException, PipelineValidationException - { - return exportXAR(selection, null, null, fileName); - } - - private ActionURL exportXAR(@NotNull XarExportSelection selection, @Nullable LSIDRelativizer lsidRelativizer, @Nullable XarExportType exportType, @Nullable String fileName) - throws ExperimentException, IOException, PipelineValidationException - { - if (lsidRelativizer == null) - lsidRelativizer = LSIDRelativizer.FOLDER_RELATIVE; - - if (exportType == null) - exportType = XarExportType.BROWSER_DOWNLOAD; - - if (fileName == null || fileName.isEmpty()) - fileName = "export.xar"; - - fileName = fixupExportName(fileName); - String xarXmlFileName = null; - if (StringUtils.endsWithIgnoreCase(fileName, ".xar")) - xarXmlFileName = fileName + ".xml"; - - switch (exportType) - { - case BROWSER_DOWNLOAD: - XarExporter exporter = new XarExporter(lsidRelativizer, selection, getUser(), xarXmlFileName, null, getContainer()); - - getViewContext().getResponse().setContentType("application/zip"); - ResponseHelper.setContentDisposition(getViewContext().getResponse(), ResponseHelper.ContentDispositionType.attachment, fileName); - ResponseHelper.setPrivate(getViewContext().getResponse()); - - exporter.writeAsArchive(getViewContext().getResponse().getOutputStream()); - return null; - case PIPELINE_FILE: - if (!PipelineService.get().hasValidPipelineRoot(getContainer())) - { - throw new IllegalStateException("You must set a valid pipeline root before you can export a XAR to it."); - } - PipeRoot pipeRoot = PipelineService.get().findPipelineRoot(getContainer()); - XarExportPipelineJob job = new XarExportPipelineJob(getViewBackgroundInfo(), pipeRoot, fileName, lsidRelativizer, selection, xarXmlFileName); - PipelineService.get().queueJob(job); - PipelineStatusFile status = PipelineService.get().getStatusFile(job.getJobGUID()); - return PageFlowUtil.urlProvider(PipelineUrls.class).statusDetails(getContainer(), status.getRowId()); - default: - throw new IllegalArgumentException("Unknown export type: " + exportType); - } - } - - @RequiresPermission(ReadPermission.class) - public class ExportProtocolsAction extends AbstractExportAction - { - @Override - public boolean handlePost(ExportOptionsForm form, BindException errors) throws Exception - { - List protocols = form.lookupProtocols(getViewContext(), false); - - long[] ids = new long[protocols.size()]; - for (int i = 0; i < ids.length; i++) - { - ids[i] = protocols.get(i).getRowId(); - } - XarExportSelection selection = new XarExportSelection(); - selection.addProtocolIds(ids); - - exportXAR(selection, form.getLsidOutputType(), form.getExportType(), form.getXarFileName()); - - if (form.getDataRegionSelectionKey() != null) - { - // Clear the selection - form.lookupProtocols(getViewContext(), true); - } - return true; - } - } - - public abstract static class AbstractExportAction extends FormViewAction - { - protected ActionURL _resultURL; - - @Override - public void validateCommand(ExportOptionsForm target, Errors errors) - { - } - - @Override - public ActionURL getSuccessURL(ExportOptionsForm exportOptionsForm) - { - return _resultURL; - } - - @Override - public ModelAndView getSuccessView(ExportOptionsForm exportOptionsForm) - { - return null; - } - - @Override - public ModelAndView getView(ExportOptionsForm form, boolean reshow, BindException errors) throws Exception - { - // FormViewAction can reinvoke getView() in response to a POST if we're not redirecting the browser, - // so avoid double-creating the export - if ("get".equalsIgnoreCase(getViewContext().getRequest().getMethod())) - handlePost(form, errors); - return null; - } - - @Override - public void addNavTrail(NavTree root) - { - } - - public List lookupRuns(ExportOptionsForm form) - { - Set runIds; - if (form.getRunIds() != null && form.getRunIds().length > 0) - runIds = new HashSet<>(Arrays.asList(form.getRunIds())); - else - runIds = DataRegionSelection.getSelectedIntegers(getViewContext(), form.getDataRegionSelectionKey(), false); - - if (runIds.isEmpty()) - { - throw new NotFoundException(); - } - List result = new ArrayList<>(); - - for (long id : runIds) - { - ExpRun run = ExperimentService.get().getExpRun(id); - if (run == null || !run.getContainer().hasPermission(getUser(), ReadPermission.class)) - { - throw new NotFoundException("Could not find run " + id); - } - result.add(run); - } - return result; - } - } - - @RequiresPermission(ReadPermission.class) - public class ExportRunsAction extends AbstractExportAction - { - @Override - public boolean handlePost(ExportOptionsForm form, BindException errors) throws Exception - { - XarExportSelection selection = new XarExportSelection(); - if (form.getExpRowId() != null) - { - ExpExperiment experiment = ExperimentService.get().getExpExperiment(form.getExpRowId()); - if (experiment != null && !experiment.getContainer().hasPermission(getUser(), ReadPermission.class)) - { - throw new NotFoundException("Run group " + form.getExpRowId()); - } - selection.addExperimentIds(experiment.getRowId()); - } - selection.addRuns(lookupRuns(form)); - - _resultURL = exportXAR(selection, form.getLsidOutputType(), form.getExportType(), form.getXarFileName()); - if (form.getDataRegionSelectionKey() != null) - DataRegionSelection.clearAll(getViewContext(), form.getDataRegionSelectionKey()); - return true; - } - } - - @RequiresPermission(ReadPermission.class) - public class ExportSampleTypeAction extends AbstractExportAction - { - @Override - public boolean handlePost(ExportOptionsForm form, BindException errors) throws Exception - { - Integer rowId = form.getSampleTypeId(); - if (rowId == null) - { - throw new NotFoundException("No sampleTypeId parameter specified"); - } - ExpSampleType sampleType = SampleTypeService.get().getSampleType(getContainer(), getUser(), rowId.intValue()); - if (sampleType == null) - { - throw new NotFoundException("No such sample type with RowId " + rowId); - } - if (!sampleType.getContainer().hasPermission(getUser(), ReadPermission.class)) - { - throw new UnauthorizedException(); - } - - XarExportSelection selection = new XarExportSelection(); - selection.addSampleType(sampleType); - - _resultURL = exportXAR(selection, form.getLsidOutputType(), form.getExportType(), FileUtil.makeLegalName(sampleType.getName() + ".xar")); - return true; - } - } - - @RequiresPermission(ReadPermission.class) - public class ExportRunFilesAction extends AbstractExportAction - { - @Override - public boolean handlePost(ExportOptionsForm form, BindException errors) throws Exception - { - XarExportSelection selection = new XarExportSelection(); - selection.setIncludeXarXml(false); - if ("role".equalsIgnoreCase(form.getFileExportType())) - { - selection.addRoles(form.getRoles()); - } - selection.addRuns(lookupRuns(form)); - - _resultURL = exportXAR(selection, form.getZipFileName()); - if (form.getDataRegionSelectionKey() != null) - DataRegionSelection.clearAll(getViewContext(), form.getDataRegionSelectionKey()); - return true; - } - } - - @RequiresPermission(ReadPermission.class) - public class ExportFilesAction extends AbstractExportAction - { - @Override - public boolean handlePost(ExportOptionsForm form, BindException errors) throws Exception - { - long[] dataIds = form.getDataIds(); - if (dataIds == null || dataIds.length == 0) - { - throw new NotFoundException(); - } - - try - { - for (long id : dataIds) - { - ExpData data = ExperimentService.get().getExpData(id); - if (data == null || !data.getContainer().hasPermission(getUser(), ReadPermission.class)) - { - throw new NotFoundException("Could not find file " + id); - } - } - - XarExportSelection selection = new XarExportSelection(); - selection.setIncludeXarXml(false); - selection.addDataIds(dataIds); - - _resultURL = exportXAR(selection, form.getZipFileName()); - return true; - } - catch (NumberFormatException e) - { - throw new NotFoundException(Arrays.toString(dataIds)); - } - } - } - - public static class ExperimentRunListForm implements DataRegionSelection.DataSelectionKeyForm - { - private String _dataRegionSelectionKey; - private Long _expRowId; - private Long[] _runIds; - - @Override - public String getDataRegionSelectionKey() - { - return _dataRegionSelectionKey; - } - - @Override - public void setDataRegionSelectionKey(String key) - { - _dataRegionSelectionKey = key; - } - - public Long getExpRowId() - { - return _expRowId; - } - - public void setExpRowId(Long expRowId) - { - _expRowId = expRowId; - } - - public Long[] getRunIds() - { - return _runIds; - } - - public void setRunIds(Long[] runIds) - { - _runIds = runIds; - } - - public ExpExperiment lookupExperiment() - { - return getExpRowId() == null ? null : ExperimentService.get().getExpExperiment(getExpRowId().intValue()); - } - } - - private void addSelectedRunsToExperiment(ExpExperiment exp, String dataRegionSelectionKey) - { - Collection runIds = DataRegionSelection.getSelectedIntegers(getViewContext(), dataRegionSelectionKey, true); - List runs = new ArrayList<>(); - for (long runId : runIds) - { - ExpRun run = ExperimentServiceImpl.get().getExpRun(runId); - if (run != null) - { - runs.add(run); - } - } - exp.addRuns(getUser(), runs.toArray(new ExpRun[0])); - } - - - @RequiresPermission(InsertPermission.class) - public class AddRunsToExperimentAction extends FormHandlerAction - { - @Override - public void validateCommand(ExperimentRunListForm target, Errors errors) - { - } - - @Override - public boolean handlePost(ExperimentRunListForm form, BindException errors) - { - addSelectedRunsToExperiment(form.lookupExperiment(), form.getDataRegionSelectionKey()); - return true; - } - - @Override - public ActionURL getSuccessURL(ExperimentRunListForm form) - { - return ExperimentUrlsImpl.get().getExperimentDetailsURL(getContainer(), form.lookupExperiment()); - } - } - - @RequiresPermission(DeletePermission.class) - public static class RemoveSelectedExpRunsAction extends FormHandlerAction - { - @Override - public void validateCommand(ExperimentRunListForm target, Errors errors) - { - } - - @Override - public boolean handlePost(ExperimentRunListForm form, BindException errors) - { - ExpExperiment exp = form.lookupExperiment(); - if (exp == null || !exp.getContainer().hasPermission(getUser(), DeletePermission.class)) - { - throw new NotFoundException("Could not find run group with RowId " + form.getExpRowId()); - } - - for (long runId : DataRegionSelection.getSelectedIntegers(getViewContext(), form.getDataRegionSelectionKey(), false)) - { - ExpRun run = ExperimentService.get().getExpRun(runId); - if (run == null || !run.getContainer().hasPermission(getUser(), DeletePermission.class)) - { - throw new NotFoundException("Could not find run with RowId " + runId); - } - exp.removeRun(getUser(), run); - } - if (form.getDataRegionSelectionKey() != null) - DataRegionSelection.clearAll(getViewContext(), form.getDataRegionSelectionKey()); - return true; - } - - @Override - public ActionURL getSuccessURL(ExperimentRunListForm form) - { - return ExperimentUrlsImpl.get().getExperimentDetailsURL(getContainer(), form.lookupExperiment()); - } - } - - public static ActionURL getResolveLsidURL(Container c, @NotNull String type, @NotNull String lsid) - { - ActionURL url = new ActionURL(ResolveLSIDAction.class, c); - url.addParameter("type", type); - url.addParameter("lsid", lsid); - - return url; - } - - - @RequiresPermission(ReadPermission.class) - public static class ResolveLSIDAction extends SimpleViewAction - { - @Override - public ModelAndView getView(LsidForm form, BindException errors) - { - String message = ""; - if (!PageFlowUtil.empty(form.getLsid())) - { - try - { - String lsid = Lsid.canonical(form.getLsid().trim()); - ActionURL url = LsidManager.get().getDisplayURL(lsid); - if (url == null && form.getType() != null) - { - url = switch (form.getType().toLowerCase()) - { - case "data" -> LsidType.Data.getDisplayURL(new Lsid(lsid)); - case "material" -> LsidType.Material.getDisplayURL(new Lsid(lsid)); - default -> url; - }; - } - if (null != url) - { - throw new RedirectException(url); - } - message = "Could not map LSID to URL"; - } - catch (IllegalArgumentException e) - { - message = "Invalid LSID"; - } - } - - return new HtmlView("Enter LSID", - DOM.createHtmlFragment( - message, - DOM.FORM(at(action, getViewContext().cloneActionURL().setAction(ResolveLSIDAction.class)), - "LSID: ", - DOM.INPUT(at(type, "text", name, "lsid", size, "80", value, form.getLsid())), - PageFlowUtil.button("Go").submit(true)))); - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild("Resolve LSID"); - } - } - - public static class LsidForm - { - private String _lsid; - - public String getType() - { - return _type; - } - - public void setType(String type) - { - _type = type; - } - - private String _type; - - public void setLsid(String lsid) - { - _lsid = lsid; - } - - public String getLsid() - { - return _lsid; - } - } - - public static class SetFlagForm extends LsidForm - { - private String _comment; - private boolean _redirect = true; - - public String getComment() - { - return _comment; - } - - public void setComment(String comment) - { - _comment = comment; - } - - public boolean isRedirect() - { - return _redirect; - } - - public void setRedirect(boolean redirect) - { - _redirect = redirect; - } - } - - /** - * Check for update on the object itself - */ - @RequiresNoPermission - public static class SetFlagAction extends FormHandlerAction - { - @Override - public void validateCommand(SetFlagForm target, Errors errors) - { - } - - @Override - public boolean handlePost(SetFlagForm form, BindException errors) throws Exception - { - String lsid = form.getLsid(); - if (lsid == null) - throw new NotFoundException(); - ExpObject obj = ExperimentService.get().findObjectFromLSID(lsid); - if (obj == null) - throw new NotFoundException(); - Container container = obj.getContainer(); - if (!container.hasPermission(getUser(), UpdatePermission.class)) - { - throw new UnauthorizedException(); - } - - obj.setComment(getUser(), form.getComment()); - return true; - } - - @Override - public URLHelper getSuccessURL(SetFlagForm form) - { - return null; - } - } - - @RequiresPermission(InsertPermission.class) - public class DeriveSamplesChooseTargetAction extends SimpleViewAction - { - private List _materials; - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("sampleSets"); - addRootNavTrail(root); - root.addChild("Sample Types", ExperimentUrlsImpl.get().getShowSampleTypeListURL(getContainer())); - ExpSampleType sampleType = _materials != null && !_materials.isEmpty() ? _materials.get(0).getSampleType() : null; - if (sampleType != null) - { - root.addChild(sampleType.getName(), ExperimentUrlsImpl.get().getShowSampleTypeURL(sampleType)); - } - root.addChild("Derive Samples"); - } - - @Override - public void validate(DeriveMaterialForm form, BindException errors) - { - _materials = form.lookupMaterials(); - if (_materials.isEmpty()) - { - throw new NotFoundException("Could not find any matching materials"); - } - } - - @Override - public ModelAndView getView(DeriveMaterialForm form, BindException errors) - { - Container c = getContainer(); - PipeRoot root = PipelineService.get().findPipelineRoot(c); - - if (root == null || !root.isValid()) - { - ActionURL pipelineURL = urlProvider(PipelineUrls.class).urlSetup(c); - return new HtmlView(DIV("You must ", - DOM.A(DOM.at(href, pipelineURL), "configure a valid pipeline root for this folder"), - " before deriving samples.")); - } - else - { - Set materialInputRoles = new TreeSet<>(ExperimentService.get().getMaterialInputRoles(getContainer(), getUser())); - Map materialsWithRoles = new LinkedHashMap<>(); - for (ExpMaterial material : _materials) - { - materialsWithRoles.put(material, null); - } - - List sampleTypes = getUploadableSampleTypes(); - - DeriveSamplesChooseTargetBean bean = new DeriveSamplesChooseTargetBean(form.getDataRegionSelectionKey(), form.getTargetSampleTypeId(), sampleTypes, materialsWithRoles, form.getOutputCount(), materialInputRoles, null); - return new JspView<>("/org/labkey/experiment/deriveSamplesChooseTarget.jsp", bean); - } - } - } - - public static class DeriveSamplesChooseTargetBean implements DataRegionSelection.DataSelectionKeyForm - { - private String _dataRegionSelectionKey; - - private final Integer _targetSampleTypeId; - private final List _sampleTypes; - private final Map _sourceMaterials; - private final int _sampleCount; - private final Collection _inputRoles; - private final DerivedSamplePropertyHelper _propertyHelper; - - public static final String CUSTOM_ROLE = "--CUSTOM--"; - - public DeriveSamplesChooseTargetBean(String dataRegionSelectionKey, Integer targetSampleTypeId, List sampleTypes, Map sourceMaterials, int sampleCount, Collection inputRoles, DerivedSamplePropertyHelper helper) - { - _dataRegionSelectionKey = dataRegionSelectionKey; - _targetSampleTypeId = targetSampleTypeId; - _sampleTypes = sampleTypes; - _sourceMaterials = sourceMaterials; - _sampleCount = sampleCount; - _inputRoles = inputRoles; - _propertyHelper = helper; - } - - public Integer getTargetSampleTypeId() - { - return _targetSampleTypeId; - } - - public DerivedSamplePropertyHelper getPropertyHelper() - { - return _propertyHelper; - } - - public int getSampleCount() - { - return _sampleCount; - } - - public Map getSourceMaterials() - { - return _sourceMaterials; - } - - public List getSampleTypes() - { - return _sampleTypes; - } - - public Collection getInputRoles() - { - return _inputRoles; - } - - @Override - public String getDataRegionSelectionKey() - { - return _dataRegionSelectionKey; - } - - @Override - public void setDataRegionSelectionKey(String key) - { - _dataRegionSelectionKey = key; - } - } - - private List getUploadableSampleTypes() - { - // Make a copy so we can modify it - List sampleTypes = new ArrayList<>(SampleTypeService.get().getSampleTypes(getContainer(), getUser(), true)); - sampleTypes.removeIf(sampleType -> !sampleType.canImportMoreSamples()); - return sampleTypes; - } - - @RequiresPermission(InsertPermission.class) - public class DeriveSamplesAction extends FormViewAction - { - private List _materials; - private ActionURL _successUrl; - private final Map _inputMaterials = new LinkedHashMap<>(); - - @Override - public ModelAndView getView(DeriveMaterialForm form, boolean reshow, BindException errors) - { - _materials = form.lookupMaterials(); - if (_materials.isEmpty()) - { - throw new NotFoundException("Could not find any matching materials"); - } - - Container c = getContainer(); - - if (form.getOutputCount() <= 0) - { - form.setOutputCount(1); - } - - if (form.getTargetSampleTypeId() == 0) - throw new NotFoundException("Target sample type required for the derived samples"); - - ExpSampleTypeImpl sampleType = SampleTypeServiceImpl.get().getSampleType(getContainer(), getUser(), form.getTargetSampleTypeId()); - if (sampleType == null) - throw new NotFoundException("Could not find sample type with rowId " + form.getTargetSampleTypeId()); - - InsertView insertView = new InsertView(new DataRegion(), errors); - - DerivedSamplePropertyHelper helper = new DerivedSamplePropertyHelper(sampleType, form.getOutputCount(), c, getUser()); - helper.addSampleColumns(insertView, getUser()); - - int[] rowIds = form.getRowIds(); - for (int i = 0; i < rowIds.length; i++) - { - insertView.getDataRegion().addHiddenFormField("rowIds", Integer.toString(rowIds[i])); - insertView.getDataRegion().addHiddenFormField("inputRole" + i, form.getInputRole(i) == null ? "" : form.getInputRole(i)); - insertView.getDataRegion().addHiddenFormField("customRole" + i, form.getCustomRole(i) == null ? "" : form.getCustomRole(i)); - } - - insertView.getDataRegion().addHiddenFormField("targetSampleTypeId", Integer.toString(form.getTargetSampleTypeId())); - insertView.getDataRegion().addHiddenFormField("outputCount", Integer.toString(form.getOutputCount())); - if (form.getDataRegionSelectionKey() != null) - insertView.getDataRegion().addHiddenFormField(DataRegionSelection.DATA_REGION_SELECTION_KEY, form.getDataRegionSelectionKey()); - insertView.setInitialValues(ViewServlet.adaptParameterMap(getViewContext().getRequest().getParameterMap())); - ButtonBar bar = new ButtonBar(); - bar.setStyle(ButtonBar.Style.separateButtons); - ActionButton submitButton = new ActionButton(DeriveSamplesAction.class, "Submit"); - submitButton.setActionType(ActionButton.Action.POST); - bar.add(submitButton); - insertView.getDataRegion().setButtonBar(bar); - insertView.setTitle("Output Samples"); - - Map materialsWithRoles = new LinkedHashMap<>(); - List materials = form.lookupMaterials(); - for (int i = 0; i < materials.size(); i++) - { - materialsWithRoles.put(materials.get(i), form.determineLabel(i)); - } - - DeriveSamplesChooseTargetBean bean = new DeriveSamplesChooseTargetBean(form.getDataRegionSelectionKey(), form.getTargetSampleTypeId(), getUploadableSampleTypes(), materialsWithRoles, form.getOutputCount(), Collections.emptyList(), helper); - JspView view = new JspView<>("/org/labkey/experiment/summarizeMaterialInputs.jsp", bean); - view.setTitle("Input Samples"); - - return new VBox(view, insertView); - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("sampleSets"); - addRootNavTrail(root); - root.addChild("Sample Types", ExperimentUrlsImpl.get().getShowSampleTypeListURL(getContainer())); - ExpSampleType sampleType = _materials != null && !_materials.isEmpty() ? _materials.get(0).getSampleType() : null; - if (sampleType != null) - { - root.addChild(sampleType.getName(), ExperimentUrlsImpl.get().getShowSampleTypeURL(sampleType)); - } - root.addChild("Derive Samples"); - } - - @Override - public void validateCommand(DeriveMaterialForm form, Errors errors) - { - List materials = form.lookupMaterials(); - - List lockedSamples = new ArrayList<>(); - for (int i = 0; i < materials.size(); i++) - { - ExpMaterial m = materials.get(i); - if (!m.isOperationPermitted(SampleTypeService.SampleOperations.EditLineage)) - { - lockedSamples.add(m); - } - String inputRole = form.determineLabel(i); - if (inputRole == null || inputRole.isEmpty()) - { - ExpSampleType st = m.getSampleType(); - inputRole = st != null ? st.getName() : ExpMaterialRunInput.DEFAULT_ROLE; - } - _inputMaterials.put(materials.get(i), inputRole); - } - - if (!lockedSamples.isEmpty()) - { - errors.reject(ERROR_MSG, SampleTypeService.get().getOperationNotPermittedMessage(lockedSamples, SampleTypeService.SampleOperations.EditLineage)); - } - } - - @Override - public boolean handlePost(DeriveMaterialForm form, BindException errors) - { - ExpSampleTypeImpl sampleType = SampleTypeServiceImpl.get().getSampleType(getContainer(), getUser(), form.getTargetSampleTypeId()); - - DerivedSamplePropertyHelper helper = new DerivedSamplePropertyHelper(sampleType, form.getOutputCount(), getContainer(), getUser()); - - Map, Map> allProperties; - try - { - boolean valid = true; - for (Map.Entry> entry : helper.getPostedPropertyValues(getViewContext().getRequest()).entrySet()) - valid = UploadWizardAction.validatePostedProperties(getViewContext(), entry.getValue(), errors) && valid; - if (!valid) - return false; - - allProperties = helper.getSampleProperties(getViewContext().getRequest(), _inputMaterials.keySet()); - } - catch (DuplicateMaterialException e) - { - errors.addError(new ObjectError(e.getColName(), null, null, e.getMessage())); - return false; - } - catch (ExperimentException e) - { - errors.reject(SpringActionController.ERROR_MSG, e.getMessage()); - return false; - } - - try (DbScope.Transaction tx = ExperimentService.get().ensureTransaction()) - { - Map outputMaterials = new HashMap<>(); - int i = 0; - for (Map.Entry, Map> entry : allProperties.entrySet()) - { - Lsid lsid = entry.getKey().first; - String name = entry.getKey().second; - assert name != null; - - ExpMaterialImpl outputMaterial = ExperimentServiceImpl.get().createExpMaterial(getContainer(), lsid.toString(), name); - if (sampleType != null) - { - outputMaterial.setCpasType(sampleType.getLSID()); - } - outputMaterial.save(getUser()); - - if (sampleType != null) - { - Map pvs = new HashMap<>(); - for (Map.Entry propertyEntry : entry.getValue().entrySet()) - pvs.put(propertyEntry.getKey().getName(), propertyEntry.getValue()); - outputMaterial.setProperties(getUser(), pvs, false); - } - - outputMaterials.put(outputMaterial, helper.getSampleNames().get(i++)); - } - - ExperimentService.get().deriveSamples(_inputMaterials, outputMaterials, getViewBackgroundInfo(), _log); - - tx.commit(); - - // automatically link samples to study, if configured - StudyPublishService.get().autoLinkDerivedSamples(sampleType, outputMaterials.keySet().stream().map(ExpObject::getRowId).collect(toList()), getContainer(), getUser()); - - _successUrl = ExperimentUrlsImpl.get().getShowSampleURL(getContainer(), outputMaterials.keySet().iterator().next()); - - if (form.getDataRegionSelectionKey() != null) - DataRegionSelection.clearAll(getViewContext(), form.getDataRegionSelectionKey()); - } - catch (Exception e) - { - errors.reject(SpringActionController.ERROR_MSG, e.getMessage()); - return false; - } - - return true; - } - - @Override - public URLHelper getSuccessURL(DeriveMaterialForm deriveMaterialForm) - { - return _successUrl; - } - } - - public static class DeriveMaterialForm implements HasViewContext, DataRegionSelection.DataSelectionKeyForm - { - private String _dataRegionSelectionKey; - private int _outputCount = 1; - private int _targetSampleTypeId; - private int[] _rowIds; - private String _name; - - private ViewContext _context; - - @Override - public void setViewContext(ViewContext context) - { - _context = context; - } - - @Override - public ViewContext getViewContext() - { - return _context; - } - - public List lookupMaterials() - { - List result = new ArrayList<>(); - for (int rowId : getRowIds()) - { - ExpMaterial material = ExperimentService.get().getExpMaterial(rowId); - if (material != null) - { - if (material.getContainer().hasPermission(_context.getUser(), ReadPermission.class)) - { - result.add(material); - } - else - { - throw new UnauthorizedException(); - } - } - else - { - throw new NotFoundException("No material with RowId " + rowId); - } - } - result.sort(Comparator.comparing(Identifiable::getName)); - return result; - } - - public String getName() - { - return _name; - } - - public void setName(String name) - { - _name = name; - } - - @Override - public String getDataRegionSelectionKey() - { - return _dataRegionSelectionKey; - } - - @Override - public void setDataRegionSelectionKey(String dataRegionSelectionKey) - { - _dataRegionSelectionKey = dataRegionSelectionKey; - } - - public int[] getRowIds() - { - if (_rowIds == null) - { - _rowIds = PageFlowUtil.toInts(DataRegionSelection.getSelected(getViewContext(), getDataRegionSelectionKey(), false)); - } - return _rowIds; - } - - public void setRowIds(int[] rowIds) - { - _rowIds = rowIds; - } - - public int getOutputCount() - { - return _outputCount; - } - - public void setOutputCount(int outputCount) - { - _outputCount = outputCount; - } - - public int getTargetSampleTypeId() - { - return _targetSampleTypeId; - } - - public void setTargetSampleTypeId(int targetSampleTypeId) - { - _targetSampleTypeId = targetSampleTypeId; - } - - public String getInputRole(int i) - { - return _context.getRequest().getParameter("inputRole" + i); - } - - public String getCustomRole(int i) - { - return _context.getRequest().getParameter("customRole" + i); - } - - public String determineLabel(int index) - { - String result = getInputRole(index); - if (DeriveSamplesChooseTargetBean.CUSTOM_ROLE.equals(result)) - { - result = getCustomRole(index); - } - if (result != null) - { - result = result.trim(); - } - return result; - } - } - - - public static class ExpInput - { - public String role; - public int rowId; - public Lsid lsid; - } - - public static class DerivationSpec - { - public String role; - public Map values; - } - - public static class DerivationForm - { - public List dataInputs; - public List materialInputs; - - public int dataOutputCount; - public Lsid targetDataClass; - public Map dataDefault; - public List dataOutputs; - - public int materialOutputCount; - public Lsid targetSampleType; - public Map materialDefault; - public List materialOutputs; - } - - @Marshal(Marshaller.Jackson) - @RequiresPermission(InsertPermission.class) - public static class DeriveAction extends MutatingApiAction - { - @Override - public void validateForm(DerivationForm form, Errors errors) - { - if (errors.hasErrors()) - return; - - if (form.materialOutputCount > 0 && form.materialOutputs != null && !form.materialOutputs.isEmpty()) - errors.reject(ERROR_MSG, "Either 'materialOutputCount' or 'materialOutputs' property can be specified, but not both."); - - if (form.dataOutputCount > 0 && form.dataOutputs != null && !form.dataOutputs.isEmpty()) - errors.reject(ERROR_MSG, "Either 'dataOutputCount' or 'dataOutputs' property can be specified, but not both."); - - boolean hasMaterialOutputs = form.materialOutputCount > 0 || form.materialOutputs != null && !form.materialOutputs.isEmpty(); - boolean hasDataOutputs = form.dataOutputCount > 0 || form.dataOutputs != null && !form.dataOutputs.isEmpty(); - - if (!hasMaterialOutputs && !hasDataOutputs) - errors.reject(ERROR_MSG, "At least one data output or material output is required"); - - if (hasMaterialOutputs && form.targetSampleType == null) - errors.reject(ERROR_MSG, "targetSampleType lsid required for material outputs"); - - if (hasDataOutputs && form.targetDataClass == null) - errors.reject(ERROR_MSG, "targetDataClass lsid required for data outputs"); - } - - @Override - public Object execute(DerivationForm form, BindException errors) throws Exception - { - // Find material inputs - Map materialInputs = new LinkedHashMap<>(); - if (form.materialInputs != null) - { - for (ExpInput in : form.materialInputs) - { - ExpMaterial m = null; - if (in.lsid != null) - { - m = ExperimentService.get().getExpMaterial(in.lsid.toString()); - if (m == null) - errors.reject(ERROR_MSG, "Can't resolve sample '" + in.lsid + "'"); - } - else if (in.rowId > 0) - { - m = ExperimentService.get().getExpMaterial(in.rowId); - if (m == null) - errors.reject(ERROR_MSG, "Can't resolve sample '" + in.rowId + "'"); - } - - if (m == null) - { - errors.reject(ERROR_MSG, "Material input lsid or rowId required"); - continue; - } - - ExpSampleType st = m.getSampleType(); - if (st == null) - { - errors.reject(ERROR_MSG, "Material input is not a member of a SampleType"); - continue; - } - - String role = in.role; - if (role == null || role.isEmpty()) - { - role = st.getName(); - } - materialInputs.put(m, role); - } - } - - // Find input data - Map dataInputs = new LinkedHashMap<>(); - if (form.dataInputs != null) - { - for (ExpInput in : form.dataInputs) - { - ExpData d = null; - if (in.lsid != null) - { - d = ExperimentService.get().getExpData(in.lsid.toString()); - if (d == null) - errors.reject(ERROR_MSG, "Can't resolve data '" + in.lsid + "'"); - } - else if (in.rowId > 0) - { - d = ExperimentService.get().getExpData(in.rowId); - if (d == null) - errors.reject(ERROR_MSG, "Can't resolve data '" + in.rowId + "'"); - } - - if (d == null) - { - errors.reject(ERROR_MSG, "Data input lsid or rowId required"); - continue; - } - - ExpDataClass dc = d.getDataClass(getUser()); - if (dc == null) - { - errors.reject(ERROR_MSG, "Data input is not a member of a DataClass"); - continue; - } - - String role = in.role; - if (role == null || role.isEmpty()) - { - role = dc.getName(); - } - dataInputs.put(d, role); - } - } - - ExpSampleType outSampleType; - if (form.targetSampleType != null) - { - // TODO: check in scope and has permission - outSampleType = SampleTypeService.get().getSampleType(form.targetSampleType.toString()); - if (outSampleType == null) - errors.reject(ERROR_MSG, "Sample type not found: " + form.targetSampleType.toString()); - } - else - { - outSampleType = null; - } - - ExpDataClass outDataClass; - if (form.targetDataClass != null) - { - // TODO: check in scope and has permission - outDataClass = ExperimentServiceImpl.get().getDataClass(form.targetDataClass.toString()); - if (outDataClass == null) - errors.reject(ERROR_MSG, "DataClass not found: " + form.targetDataClass.toString()); - } - else - { - outDataClass = null; - } - - if (errors.hasErrors()) - return null; - - // TODO: support list of resolved ExpData or ExpMaterial instead of string concatenated names - // Create "MaterialInputs/" columns with a value containing a comma-separated list of Material names - final Map> parentInputNames = new HashMap<>(); - Set inputTypes = new CaseInsensitiveHashSet(); - for (ExpMaterial material : materialInputs.keySet()) - { - ExpSampleType st = material.getSampleType(); - String keyName = ExpMaterial.MATERIAL_INPUT_PARENT + "/" + st.getName(); - inputTypes.add(keyName); - parentInputNames.computeIfAbsent(keyName, (x) -> new LinkedHashSet<>()).add(material.getName()); - } - - // TODO: support list of resolved ExpData or ExpMaterial instead of string concatenated names - // Create "DataInputs/" columns with a value containing a comma-separated list of ExpData names - for (ExpData d : dataInputs.keySet()) - { - ExpDataClass dc = d.getDataClass(getUser()); - String keyName = ExpData.DATA_INPUT_PARENT + "/" + dc.getName(); - inputTypes.add(keyName); - parentInputNames.computeIfAbsent(keyName, (x) -> new LinkedHashSet<>()).add(d.getName()); - } - - - try (DbScope.Transaction tx = ExperimentService.get().ensureTransaction()) - { - Set requiredParentTypes = new CaseInsensitiveHashSet(); - - // output materials - Map outputMaterials = new HashMap<>(); - int materialOutputCount = Math.max(form.materialOutputCount, form.materialOutputs != null ? form.materialOutputs.size() : 0); - if (materialOutputCount > 0 && outSampleType != null) - { - requiredParentTypes.addAll(outSampleType.getRequiredImportAliases().values()); - DerivedOutputs derived = new DerivedOutputs<>(parentInputNames, form.materialDefault, form.materialOutputs, materialOutputCount, ExpMaterial.DEFAULT_CPAS_TYPE) - { - @Override - protected TableInfo createTable() - { - SamplesSchema schema = new SamplesSchema(getUser(), getContainer()); - return schema.getTable(outSampleType.getName()); - } - - @Override - protected List getExpObject(List> insertedRows) - { - List rowIds = insertedRows.stream().map(r -> MapUtils.getLong(r,"rowid")).collect(toList()); - return ExperimentService.get().getExpMaterials(rowIds); - } - }; - - outputMaterials = derived.createOutputs(); - } - - - // create output data - Map outputData = new HashMap<>(); - int dataOutputCount = Math.max(form.dataOutputCount, form.dataOutputs != null ? form.dataOutputs.size() : 0); - if (dataOutputCount > 0 && outDataClass != null) - { - requiredParentTypes.addAll(outDataClass.getRequiredImportAliases().values()); - DerivedOutputs derived = new DerivedOutputs<>(parentInputNames, form.dataDefault, form.dataOutputs, dataOutputCount, ExpData.DEFAULT_CPAS_TYPE) - { - @Override - protected TableInfo createTable() - { - ExpSchema expSchema = new ExpSchema(getUser(), getContainer()); - UserSchema dataSchema = expSchema.getUserSchema(ExpSchema.NestedSchemas.data.name()); - return dataSchema.getTable(outDataClass.getName()); - } - - @Override - protected List getExpObject(List> insertedRows) - { - List lsids = insertedRows.stream().map(r -> (String) r.get("lsid")).collect(toList()); - return ExperimentService.get().getExpDatasByLSID(lsids); - } - }; - - outputData = derived.createOutputs(); - } - - if (outputMaterials.isEmpty() && outputData.isEmpty()) - throw new IllegalStateException("Expected to create " + materialOutputCount + " materials and " + dataOutputCount + " datas"); - - boolean hasMissingRequiredParent = false; - for (String required : requiredParentTypes) - { - if (!inputTypes.contains(required)) - { - hasMissingRequiredParent = true; - break; - } - } - if (hasMissingRequiredParent) - throw new IllegalStateException("Inputs are required: " + String.join(",", requiredParentTypes)); - - // finally, create the derived run if there are any parents - ExpRun run = null; - if (!materialInputs.isEmpty() || !dataInputs.isEmpty()) - run = ExperimentService.get().derive(materialInputs, dataInputs, outputMaterials, outputData, new ViewBackgroundInfo(getContainer(), getUser(), null), _log); - tx.commit(); - - StringBuilder successMessage = new StringBuilder("Created "); - if (!outputMaterials.isEmpty()) - successMessage.append(outputMaterials.size()).append(" materials"); - if (!outputData.isEmpty()) - successMessage.append(outputData.size()).append(" data"); - - JSONObject ret; - if (run != null) - ret = ExperimentJSONConverter.serializeRun(run, null, getUser(), ExperimentJSONConverter.DEFAULT_SETTINGS); - else - ret = ExperimentJSONConverter.serializeRunOutputs(outputData.keySet(), outputMaterials.keySet(), getUser(), ExperimentJSONConverter.DEFAULT_SETTINGS); - - return success(successMessage.toString(), ret); - } - } - - // Helper class that prepares and executes the QueryUpdateService.insertRows() on the data or material table. - private abstract class DerivedOutputs - { - private final @NotNull Map> _parentInputNames; - private final @Nullable Map _defaultValues; - private final @Nullable List _values; - private final int _outputCount; - private final String _rolePrefix; - - - public DerivedOutputs(@NotNull Map> parentInputNames, @Nullable Map defaultValues, @Nullable List values, int outputCount, String rolePrefix) - { - _parentInputNames = parentInputNames; - _defaultValues = defaultValues; - _values = values; - _outputCount = outputCount; - _rolePrefix = rolePrefix; - } - - public Pair>, List> prepareRows() - { - List> rows = new ArrayList<>(); - List roles = new ArrayList<>(); - int unknownOutputDataCount = 0; - - for (int i = 0; i < _outputCount; i++) - { - Map row = new CaseInsensitiveHashMap<>(); - if (_defaultValues != null) - row.putAll(_defaultValues); - DerivationSpec spec = _values != null && i < _values.size() ? _values.get(i) : null; - String role = null; - if (spec != null) - { - row.putAll(spec.values); - role = spec.role; - } - - // NOTE: Input parents are added to each row, but are only used for name generation and not for derivation. - // NOTE: We will derive the inserted samples in a single derivation run after the sample/date have been inserted. - row.putAll(_parentInputNames); - - rows.add(row); - - if (StringUtils.trimToNull(role) == null) - { - role = _rolePrefix + (unknownOutputDataCount == 0 ? "" : Integer.toString(unknownOutputDataCount + 1)); - unknownOutputDataCount++; - } - roles.add(role); - } - return Pair.of(rows, roles); - } - - protected abstract TableInfo createTable(); - - protected abstract List getExpObject(List> insertedRows); - - public Map createOutputs() throws BatchValidationException, DuplicateKeyException, SQLException, QueryUpdateServiceException - { - Pair>, List> pair = prepareRows(); - List> rows = pair.first; - List roles = pair.second; - - TableInfo table = createTable(); - QueryUpdateService qus = table.getUpdateService(); - if (qus == null) - throw new IllegalStateException(); - - Map configParams = new HashMap<>(); - // Skip derivation during insert -- DeriveAction will call ExperimentService.get().derive() after samples are inserted - configParams.put(SampleTypeUpdateServiceDI.Options.SkipDerivation, true); - - BatchValidationException qusErrors = new BatchValidationException(); - List> insertedRows = qus.insertRows(getUser(), getContainer(), rows, qusErrors, configParams, null); - if (qusErrors.hasErrors()) - throw qusErrors; - - if (insertedRows.size() != roles.size()) - throw new IllegalStateException("Expected to create " + roles.size() + " new exp objects for derivation"); - - List outputs = getExpObject(insertedRows); - if (outputs.size() != roles.size()) - throw new IllegalStateException("Expected to create " + roles.size() + " new exp objects for derivation"); - - Map outputMap = new HashMap<>(); - for (int i = 0; i < outputs.size(); i++) - { - String role = roles.get(i); - T data = outputs.get(i); - outputMap.put(data, role); - } - - return outputMap; - } - } - } - - public static class CreateExperimentForm extends ExperimentForm implements DataRegionSelection.DataSelectionKeyForm - { - private boolean _addSelectedRuns; - private String _dataRegionSelectionKey; - - public boolean isAddSelectedRuns() - { - return _addSelectedRuns; - } - - public void setAddSelectedRuns(boolean addSelectedRuns) - { - _addSelectedRuns = addSelectedRuns; - } - - @Override - public String getDataRegionSelectionKey() - { - return _dataRegionSelectionKey; - } - - @Override - public void setDataRegionSelectionKey(String dataRegionSelectionKey) - { - _dataRegionSelectionKey = dataRegionSelectionKey; - } - } - - @RequiresPermission(InsertPermission.class) - @ActionNames("createRunGroup, createExperiment") - public class CreateRunGroupAction extends FormViewAction - { - @Override - public ModelAndView getView(CreateExperimentForm form, boolean reshow, BindException errors) - { - // HACK - convert ExperimentForm to not be a BeanViewForm - form.setAddSelectedRuns("true".equals(getViewContext().getRequest().getParameter("addSelectedRuns"))); - form.setDataRegionSelectionKey(getViewContext().getRequest().getParameter(DataRegionSelection.DATA_REGION_SELECTION_KEY)); - - DataRegion drg = new DataRegion(); - - drg.addHiddenFormField(ActionURL.Param.returnUrl, getViewContext().getRequest().getParameter(ActionURL.Param.returnUrl.name())); - drg.addHiddenFormField("addSelectedRuns", Boolean.toString("true".equals(getViewContext().getRequest().getParameter("addSelectedRuns")))); - form.setDataRegionSelectionKey(getViewContext().getRequest().getParameter(DataRegionSelection.DATA_REGION_SELECTION_KEY)); - // Fix issue 27562 - include session-stored selection - if (form.getDataRegionSelectionKey() != null) - { - for (String rowId : DataRegionSelection.getSelected(getViewContext(), form.getDataRegionSelectionKey(), false)) - { - drg.addHiddenFormField(DataRegion.SELECT_CHECKBOX_NAME, rowId); - } - } - drg.addHiddenFormField(DataRegionSelection.DATA_REGION_SELECTION_KEY, getViewContext().getRequest().getParameter(DataRegionSelection.DATA_REGION_SELECTION_KEY)); - - drg.addColumns(ExperimentServiceImpl.get().getTinfoExperiment(), "RowId,Name,LSID,ContactId,ExperimentDescriptionURL,Hypothesis,Comments,Created"); - - DisplayColumn col = drg.getDisplayColumn("RowId"); - col.setVisible(false); - drg.getDisplayColumn("LSID").setVisible(false); - drg.getDisplayColumn("Created").setVisible(false); - - ButtonBar bb = new ButtonBar(); - bb.setStyle(ButtonBar.Style.separateButtons); - ActionButton insertButton = new ActionButton(new ActionURL(CreateRunGroupAction.class, getContainer()), "Submit", ActionButton.Action.POST); - bb.add(insertButton); - - drg.setButtonBar(bb); - - return new InsertView(drg, errors); - } - - - @Override - public boolean handlePost(CreateExperimentForm form, BindException errors) throws Exception - { - // This is strange... but the "Create new run group..." menu item on the run grid always POSTs, probably to - // allow for long lists of run IDs. This "noPost" parameter on the initial POST is used to inform the action - // that it wants to display the form, not try to save anything yet. - if (!"true".equals(getViewContext().getRequest().getParameter("noPost"))) - { - form.setAddSelectedRuns("true".equals(getViewContext().getRequest().getParameter("addSelectedRuns"))); - form.setDataRegionSelectionKey(getViewContext().getRequest().getParameter(DataRegionSelection.DATA_REGION_SELECTION_KEY)); - - Experiment exp = form.getBean(); - if (exp.getName() == null || exp.getName().trim().isEmpty()) - { - errors.reject(ERROR_MSG, "You must specify a name for the experiment"); - } - else - { - int maxNameLength = ExperimentService.get().getTinfoExperimentRun().getColumn("Name").getScale(); - if (exp.getName().length() > maxNameLength) - { - errors.reject(ERROR_MSG, "Name of the experiment must be " + maxNameLength + " characters or less."); - } - } - - String lsid; - int suffix = 1; - do - { - String template = "urn:lsid:" + XarContext.LSID_AUTHORITY_SUBSTITUTION + ":Experiment.Folder-" + XarContext.CONTAINER_ID_SUBSTITUTION + ":" + exp.getName(); - if (suffix > 1) - { - template = template + suffix; - } - suffix++; - lsid = LsidUtils.resolveLsidFromTemplate(template, new XarContext("Experiment Creation", getContainer(), getUser()), ExpExperiment.DEFAULT_CPAS_TYPE); - } - while (ExperimentService.get().getExpExperiment(lsid) != null); - exp.setLSID(lsid); - exp.setContainer(getContainer()); - - if (errors.getErrorCount() == 0) - { - ExpExperimentImpl wrapper = new ExpExperimentImpl(exp); - wrapper.save(getUser()); - - if (form.isAddSelectedRuns()) - { - addSelectedRunsToExperiment(wrapper, form.getDataRegionSelectionKey()); - } - - if (form.getReturnUrl() != null) - { - throw new RedirectException(form.getReturnUrl()); - } - throw new RedirectException(ExperimentUrlsImpl.get().getShowExperimentsURL(getContainer())); - } - } - return true; - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("runGroups"); - root.addChild("Create Run Group"); - } - - @Override - public URLHelper getSuccessURL(CreateExperimentForm createExperimentForm) - { - return null; // null is used to show the form in the case where IDs are POSTed from the grid - } - - @Override - public void validateCommand(CreateExperimentForm target, Errors errors) { } - } - - public static class MoveRunsForm implements DataRegionSelection.DataSelectionKeyForm - { - private String _targetContainerId; - private String _dataRegionSelectionKey; - - @Override - public String getDataRegionSelectionKey() - { - return _dataRegionSelectionKey; - } - - @Override - public void setDataRegionSelectionKey(String key) - { - _dataRegionSelectionKey = key; - } - - public String getTargetContainerId() - { - return _targetContainerId; - } - - public void setTargetContainerId(String targetContainerId) - { - _targetContainerId = targetContainerId; - } - } - - @RequiresPermission(DeletePermission.class) - public class MoveRunsLocationAction extends SimpleViewAction - { - @Override - public ModelAndView getView(MoveRunsForm form, BindException errors) - { - ActionURL moveURL = new ActionURL(MoveRunsAction.class, getContainer()); - PipelineRootContainerTree ct = new PipelineRootContainerTree(getUser(), moveURL) - { - private boolean _clickHandlerRegistered = false; - - @Override - protected void renderCellContents(StringBuilder html, Container c, ActionURL url, boolean hasRoot) - { - boolean renderLink = hasRoot && !c.equals(getContainer()); - - if (renderLink) - { - html.append(""); - } - html.append(PageFlowUtil.filter(c.getName())); - if (renderLink) - { - html.append(""); - } - - if (!_clickHandlerRegistered) - { - HttpView.currentPageConfig().addHandlerForQuerySelector("a.move-target-container", "click", "moveTo(this.attributes.getNamedItem('data-objectid').value);" ); - _clickHandlerRegistered = true; - } - } - }; - ct.setInitialLevel(1); - - MoveRunsBean bean = new MoveRunsBean(ct, form.getDataRegionSelectionKey()); - JspView result = new JspView<>("/org/labkey/experiment/moveRunsLocation.jsp", bean); - result.setTitle("Choose Destination Folder"); - result.setFrame(WebPartView.FrameType.PORTAL); - return result; - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild("Move Runs"); - } - } - - - @RequiresPermission(DeletePermission.class) - public class MoveRunsAction extends FormHandlerAction - { - private Container _targetContainer; - - @Override - public void validateCommand(MoveRunsForm target, Errors errors) - { - } - - @Override - public boolean handlePost(MoveRunsForm form, BindException errors) - { - _targetContainer = ContainerManager.getForId(form.getTargetContainerId()); - if (_targetContainer == null || !_targetContainer.hasPermission(getUser(), InsertPermission.class)) - { - throw new UnauthorizedException(); - } - - Set runIds = DataRegionSelection.getSelectedIntegers(getViewContext(), form.getDataRegionSelectionKey(), false); - List runs = new ArrayList<>(); - for (Long runId : runIds) - { - ExpRun run = ExperimentService.get().getExpRun(runId); - if (run != null) - { - runs.add(run); - } - } - - ViewBackgroundInfo info = getViewBackgroundInfo(); - info.setContainer(_targetContainer); - - try - { - ExperimentService.get().moveRuns(info, getContainer(), runs); - if (form.getDataRegionSelectionKey() != null) - DataRegionSelection.clearAll(getViewContext(), form.getDataRegionSelectionKey()); - } - catch (IOException e) - { - throw new NotFoundException("Failed to initialize move. Check that the pipeline root is configured correctly. " + e); - } - return true; - } - - @Override - public ActionURL getSuccessURL(MoveRunsForm form) - { - return urlProvider(PipelineUrls.class).urlBegin(_targetContainer); - } - } - - public static class ShowExternalDocsForm - { - private String _objectURI; - private String _propertyURI; - - public String getObjectURI() - { - return _objectURI; - } - - public void setObjectURI(String objectURI) - { - _objectURI = objectURI; - } - - public String getPropertyURI() - { - return _propertyURI; - } - - public void setPropertyURI(String propertyURI) - { - _propertyURI = propertyURI; - } - } - - @RequiresPermission(ReadPermission.class) - public static class ShowExternalDocsAction extends SimpleViewAction - { - @Override - public ModelAndView getView(ShowExternalDocsForm form, BindException errors) throws Exception - { - Map props = OntologyManager.getPropertyObjects(getContainer(), form.getObjectURI()); - ObjectProperty prop = props.get(form.getPropertyURI()); - if (prop == null || !getContainer().equals(prop.getContainer())) - { - throw new NotFoundException(); - } - URI uri = new URI(prop.getStringValue()); - File f = new File(uri); - if (!f.exists()) - { - throw new NotFoundException(); - } - - PageFlowUtil.streamFile(getViewContext().getResponse(), new File(f.getAbsolutePath()), false); - return null; - } - - @Override - public void addNavTrail(NavTree root) - { - } - } - - - // TODO: DotGraph has been adding a "runId" parameter, but ShowGraphMoreListAction - public static ActionURL getShowGraphMoreListURL(Container c, @Nullable Long runId, @NotNull String objtype) - { - ActionURL url = new ActionURL(ShowGraphMoreListAction.class, c); - - if (null != runId) - url.addParameter("runId", runId); - - url.addParameter("objtype", objtype); - - return url; - } - - - @RequiresPermission(ReadPermission.class) - public static class ShowGraphMoreListAction extends SimpleViewAction - { - private ExperimentRunForm _form; - - @Override - public ModelAndView getView(ExperimentRunForm form, BindException errors) - { - _form = form; - return new GraphMoreGrid(getContainer(), errors, getViewContext().getActionURL()); - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild(new NavTree("Experiments", ExperimentUrlsImpl.get().getShowExperimentsURL(getContainer()))); - ExpRun run = ExperimentService.get().getExpRun(_form.getRowId()); - if (run != null) - { - root.addChild(new NavTree("Experiment Run", ExperimentUrlsImpl.get().getRunGraphURL(_form.lookupRun()))); - } - root.addChild(new NavTree("Selected Protocol Applications")); - } - } - - @RequiresPermission(DesignAssayPermission.class) - public class AssayXarFileAction extends MutatingApiAction - { - - @Override - public Object execute(Object o, BindException errors) throws Exception - { - ApiSimpleResponse response = new ApiSimpleResponse(); - - if (!(getViewContext().getRequest() instanceof MultipartHttpServletRequest)) - throw new BadRequestException("Expected MultipartHttpServletRequest when posting files."); - - if (!PipelineService.get().hasValidPipelineRoot(getContainer())) - { - return false; - } - - MultipartFile formFile = getFileMap().get("file"); - if (formFile == null) - { - errors.reject(ERROR_MSG, "No file was posted by the browser."); - return false; - } - - byte[] bytes = formFile.getBytes(); - if (bytes.length == 0) - { - errors.reject(ERROR_MSG, "No file was posted by the browser."); - return false; - } - - PipeRoot pipeRoot = PipelineService.get().findPipelineRoot(getContainer()); - Path systemDir = pipeRoot.ensureSystemDirectoryPath(); - Path uploadDir = systemDir.resolve("UploadedXARs"); - FileUtil.createDirectories(uploadDir); - if (!Files.isDirectory(uploadDir)) - { - errors.reject(ERROR_MSG, "Unable to create a 'system/UploadedXARs' directory under the pipeline root"); - return false; - } - String userDirName = getUser().getEmail(); - if (userDirName == null || userDirName.isEmpty()) - { - userDirName = GUEST_DIRECTORY_NAME; - } - Path userDir = FileUtil.appendName(uploadDir, userDirName); - FileUtil.createDirectories(userDir); - if (!Files.isDirectory(userDir)) - { - errors.reject(ERROR_MSG, "Unable to create an 'UploadedXARs/" + userDirName + "' directory under the pipeline root"); - return false; - } - - Path xarFile = FileUtil.appendName(userDir, formFile.getOriginalFilename()); - - // As this is multi-part will need to use finally to close, to prevent a stream closure exception - try (OutputStream out = new BufferedOutputStream(Files.newOutputStream(xarFile))) - { - out.write(bytes); - } - catch (IOException e) - { - errors.reject(ERROR_MSG, "Unable to write uploaded XAR file to " + xarFile); - return false; - } - //noinspection EmptyCatchBlock - - ExperimentPipelineJob job = new ExperimentPipelineJob(getViewBackgroundInfo(), xarFile, - "Uploaded file", true, pipeRoot); - PipelineService.get().queueJob(job); - - response.put("success", true); - return response; - } - } - - @RequiresPermission(InsertPermission.class) - public class ImportXarFileAction extends FormHandlerAction - { - @Override - public void validateCommand(ImportXarForm target, Errors errors) - { - } - - @Override - public boolean handlePost(ImportXarForm form, BindException errors) throws Exception - { - for (File f : form.getValidatedFiles(getContainer())) - { - if (f.isFile()) - { - ExperimentPipelineJob job = new ExperimentPipelineJob(getViewBackgroundInfo(), f.toPath(), "Experiment Import", false, form.getPipeRoot(getContainer())); - - // TODO: Configure module resources with the appropriate log location per container - if (form.getModule() != null) - { - FileLike logFile = form.getPipeRoot(getContainer()).getLogDirectoryFileLike(true).resolveChild("module-resource-xar.log"); - job.setLogFile(logFile.toNioPathForWrite()); - } - - PipelineService.get().queueJob(job); - } - else - { - throw new NotFoundException("Expected a file but found a directory: " + f.getName()); - } - } - - return true; - } - - @Override - public URLHelper getSuccessURL(ImportXarForm importXarForm) - { - return getContainer().getStartURL(getUser()); - } - } - - - @RequiresPermission(InsertPermission.class) - public class ImportXarAction extends MutatingApiAction - { - @Override - public Object execute(ImportXarForm form, BindException errors) throws Exception - { - ApiSimpleResponse response = new ApiSimpleResponse(); - - List> archives = new ArrayList<>(); - for (File f : form.getValidatedFiles(getContainer())) - { - Map archive = new HashMap<>(); - ExperimentPipelineJob job = new ExperimentPipelineJob(getViewBackgroundInfo(), f.toPath(), "Experiment Import", false, form.getPipeRoot(getContainer())); - - // TODO: Configure module resources with the appropriate log location per container - if (form.getModule() != null) - { - FileLike logFile = form.getPipeRoot(getContainer()).getLogDirectoryFileLike(true).resolveChild("module-resource-xar.log"); - job.setLogFile(logFile.toNioPathForWrite()); - } - - PipelineService.get().queueJob(job); - - archive.put("file", f.getName()); - archive.put("job", job.getJobGUID()); - archive.put("path", form.getPath()); // echo back the public path - - archives.add(archive); - } - - response.put("success", true); - response.put("archives", archives); - - return response; - } - } - - - /** - * User: jeckels - * Date: Jan 27, 2008 - */ - public static class ExperimentUrlsImpl implements ExperimentUrls - { - public ActionURL getOverviewURL(Container c) - { - return new ActionURL(BeginAction.class, c); - } - - @Override - public ActionURL getExperimentDetailsURL(Container c, ExpExperiment expExperiment) - { - return new ActionURL(DetailsAction.class, c).addParameter("rowId", expExperiment.getRowId()); - } - - public ActionURL getShowSampleURL(Container c, ExpMaterial material) - { - return getMaterialDetailsBaseURL(c, null).addParameter("rowId", material.getRowId()); - } - - @Override - public ActionURL getExportProtocolURL(Container container, ExpProtocol protocol) - { - return new ActionURL(ExperimentController.ExportProtocolsAction.class, container). - addParameter("protocolId", protocol.getRowId()). - addParameter("xarFileName", protocol.getName() + ".xar"); - } - - @Override - public ActionURL getMoveRunsLocationURL(Container container) - { - return new ActionURL(ExperimentController.MoveRunsLocationAction.class, container); - } - - @Override - public ActionURL getProtocolDetailsURL(ExpProtocol protocol) - { - return new ActionURL(ProtocolDetailsAction.class, protocol.getContainer()).addParameter("rowId", protocol.getRowId()); - } - - @Override - public ActionURL getProtocolApplicationDetailsURL(ExpProtocolApplication app) - { - return getShowApplicationURL(app.getContainer(), app.getRowId()); - } - - public ActionURL getProtocolGridURL(Container c) - { - return new ActionURL(ShowProtocolGridAction.class, c); - } - - public ActionURL getRunGraphDetailURL(ExpRun run) - { - return getShowRunGraphDetailURL(run.getContainer(), run.getRowId()); - } - - @Override - public ActionURL getRunGraphDetailURL(ExpRun run, @Nullable ExpData focus) - { - return getRunGraphDetailURL(run, focus, DotGraph.TYPECODE_DATA); - } - - public ActionURL getRunGraphDetailURL(ExpRun run, @Nullable ExpMaterial focus) - { - return getRunGraphDetailURL(run, focus, DotGraph.TYPECODE_MATERIAL); - } - - public ActionURL getRunGraphDetailURL(ExpRun run, @Nullable ExpProtocolApplication focus) - { - return getRunGraphDetailURL(run, focus, DotGraph.TYPECODE_PROT_APP); - } - - private ActionURL getRunGraphDetailURL(ExpRun run, @Nullable ExpObject focus, String typeCode) - { - ActionURL result = getShowRunGraphDetailURL(run.getContainer(), run.getRowId()); - result.addParameter("detail", "true"); - if (focus != null) - { - result.addParameter("focus", typeCode + focus.getRowId()); - } - return result; - } - - @Override - public ActionURL getRunGraphURL(Container container, long runId) - { - return ExperimentController.getRunGraphURL(container, runId); - } - - @Override - public ActionURL getRunGraphURL(ExpRun run) - { - return getRunGraphURL(run.getContainer(), run.getRowId()); - } - - @Override - public ActionURL getRunTextURL(Container c, long runId) - { - return new ActionURL(ShowRunTextAction.class, c).addParameter("rowId", runId); - } - - @Override - public ActionURL getRunTextURL(ExpRun run) - { - return getRunTextURL(run.getContainer(), run.getRowId()); - } - - @Override - public ActionURL getDeleteExperimentsURL(Container container, URLHelper returnUrl) - { - return new ActionURL(DeleteSelectedExperimentsAction.class, container).addReturnUrl(returnUrl); - } - - @Override - public ActionURL getDeleteProtocolURL(@NotNull ExpProtocol protocol, URLHelper returnUrl) - { - ActionURL result = new ActionURL(DeleteProtocolByRowIdsAction.class, protocol.getContainer()); - result.addParameter("singleObjectRowId", protocol.getRowId()); - if (returnUrl != null) - { - result.addReturnUrl(returnUrl); - } - return result; - } - - @Override - public ActionURL getAddRunsToExperimentURL(Container c, ExpExperiment exp) - { - return new ActionURL(AddRunsToExperimentAction.class, c).addParameter("expRowId", exp.getRowId()); - } - - @Override - public ActionURL getShowRunsURL(Container c, ExperimentRunType type) - { - ActionURL result = new ActionURL(ShowRunsAction.class, c); - result.addParameter("experimentRunFilter", type.getDescription()); - return result; - } - - public ActionURL getShowExperimentsURL(Container c) - { - return new ActionURL(ShowRunGroupsAction.class, c); - } - - @Override - public ActionURL getShowSampleTypeListURL(Container c) - { - return getShowSampleTypeListURL(c, null); - } - - @Override - public ActionURL getShowSampleTypeURL(ExpSampleType sampleType) - { - return getShowSampleTypeURL(sampleType, sampleType.getContainer()); - } - - @Override - public ActionURL getShowSampleTypeURL(ExpSampleType sampleType, Container container) - { - return new ActionURL(ShowSampleTypeAction.class, container).addParameter("rowId", sampleType.getRowId()); - } - - public ActionURL getExperimentListURL(Container container) - { - return new ActionURL(ShowRunGroupsAction.class, container); - } - - public ActionURL getShowSampleTypeListURL(Container c, String errorMessage) - { - ActionURL url = new ActionURL(ListSampleTypesAction.class, c); - if (errorMessage != null) - { - url.addParameter("errorMessage", errorMessage); - } - return url; - } - - @Override - public ActionURL getDataClassListURL(Container c) - { - return getDataClassListURL(c, null); - } - - public ActionURL getDataClassListURL(Container c, String errorMessage) - { - ActionURL url = new ActionURL(ListDataClassAction.class, c); - if (errorMessage != null) - { - url.addParameter("errorMessage", errorMessage); - } - return url; - } - - @Override - public ActionURL getDeleteDatasURL(Container c, URLHelper returnUrl) - { - ActionURL url = new ActionURL(DeleteSelectedDataAction.class, c); - if (returnUrl != null) - url.addReturnUrl(returnUrl); - return url; - } - - public ActionURL getDeleteSelectedExperimentsURL(Container c, URLHelper returnUrl) - { - ActionURL result = new ActionURL(DeleteSelectedExperimentsAction.class, c); - if (returnUrl != null) - result.addReturnUrl(returnUrl); - return result; - } - - @Override - public ActionURL getDeleteSelectedExpRunsURL(Container container, URLHelper returnUrl) - { - return new ActionURL(DeleteSelectedExpRunsAction.class, container).addReturnUrl(returnUrl); - } - - public ActionURL getShowUpdateURL(ExpExperiment experiment) - { - return new ActionURL(ShowUpdateAction.class, experiment.getContainer()).addParameter("rowId", experiment.getRowId()); - } - - @Override - public ActionURL getRemoveSelectedExpRunsURL(Container container, URLHelper returnUrl, ExpExperiment exp) - { - return new ActionURL(RemoveSelectedExpRunsAction.class, container).addReturnUrl(returnUrl).addParameter("expRowId", exp.getRowId()); - } - - @Override - public ActionURL getCreateRunGroupURL(Container container, URLHelper returnUrl, boolean addSelectedRuns) - { - ActionURL result = new ActionURL(CreateRunGroupAction.class, container); - if (returnUrl != null) - { - result.addReturnUrl(returnUrl); - } - if (addSelectedRuns) - { - result.addParameter("addSelectedRuns", "true"); - } - return result; - } - - - public static ExperimentUrlsImpl get() - { - return (ExperimentUrlsImpl) urlProvider(ExperimentUrls.class); - } - - public ActionURL getDownloadGraphURL(ExpRun run, boolean detail, String focus, String focusType) - { - ActionURL result = new ActionURL(DownloadGraphAction.class, run.getContainer()); - result.addParameter("rowId", run.getRowId()).addParameter("detail", detail); - if (focus != null) - { - result.addParameter("focus", focus); - } - if (focusType != null) - { - result.addParameter("focusType", focusType); - } - return result; - } - - public ActionURL getBeginURL(Container container) - { - return new ActionURL(BeginAction.class, container); - } - - @Override - public ActionURL getDomainEditorURL(Container container, String domainURI, boolean createOrEdit) - { - Domain domain = PropertyService.get().getDomain(container, domainURI); - if (domain != null) - return getDomainEditorURL(container, domain); - - ActionURL url = new ActionURL(PropertyController.EditDomainAction.class, container); - url.addParameter("domainURI", domainURI); - if (createOrEdit) - url.addParameter("createOrEdit", true); - return url; - } - - @Override - public ActionURL getDomainEditorURL(Container container, Domain domain) - { - ActionURL url = new ActionURL(PropertyController.EditDomainAction.class, container); - url.addParameter("domainId", domain.getTypeId()); - return url; - } - - @Override - public ActionURL getCreateDataClassURL(Container container) - { - return new ActionURL(EditDataClassAction.class, container); - } - - @Override - public ActionURL getShowDataClassURL(Container container, long rowId) - { - ActionURL url = new ActionURL(ShowDataClassAction.class, container); - url.addParameter("rowId", rowId); - return url; - } - - @Override - public ActionURL getShowFileURL(ExpData data, boolean inline) - { - ActionURL result = getShowFileURL(data.getContainer()).addParameter("rowId", data.getRowId()); - if (inline) - { - result.addParameter("inline", inline); - } - return result; - } - - @Override - public ActionURL getMaterialDetailsURL(ExpMaterial material) - { - return getMaterialDetailsURL(material.getContainer(), material.getRowId()); - } - - @Override - public ActionURL getMaterialDetailsURL(Container c, long materialRowId) - { - return getMaterialDetailsBaseURL(c, null).addParameter("rowId", materialRowId); - } - - @Override - public ActionURL getMaterialDetailsBaseURL(Container c, @Nullable String materialIdFieldKey) - { - return new ActionURL(ShowMaterialAction.class, c); - } - - @Override - public ActionURL getCreateSampleTypeURL(Container container) - { - return new ActionURL(EditSampleTypeAction.class, container); - } - - @Override - public ActionURL getImportSamplesURL(Container container, String sampleTypeName) - { - ActionURL url = new ActionURL(ImportSamplesAction.class, container); - url.addParameter("query.queryName", sampleTypeName); - url.addParameter("schemaName", "exp.materials"); - return url; - } - - @Override - public ActionURL getImportDataURL(Container container, String dataClassName) - { - ActionURL url = new ActionURL(ImportDataAction.class, container); - url.addParameter("query.queryName", dataClassName); - url.addParameter("schemaName", "exp.data"); - return url; - } - - @Override - public ActionURL getDataDetailsURL(ExpData data) - { - return new ActionURL(ShowDataAction.class, data.getContainer()).addParameter("rowId", data.getRowId()); - } - - @Override - public ActionURL getShowFileURL(Container c) - { - return new ActionURL(ShowFileAction.class, c); - } - - @Override - public ActionURL getSetFlagURL(Container container) - { - return new ActionURL(SetFlagAction.class, container); - } - - @Override - public ActionURL getShowRunGraphURL(ExpRun run) - { - return ExperimentController.getRunGraphURL(run.getContainer(), run.getRowId()); - } - - @Override - public ActionURL getRepairTypeURL(Container container) - { - return new ActionURL(TypesController.RepairAction.class, container); - } - - @Override - public ActionURL getUpdateMaterialQueryRowAction(Container c, TableInfo table) - { - ActionURL url = new ActionURL(UpdateMaterialQueryRowAction.class, c); - url.addParameter("schemaName", "samples"); - url.addParameter(QueryView.DATAREGIONNAME_DEFAULT + "." + QueryParam.queryName, table.getName()); - - return url; - } - - @Override - public ActionURL getInsertMaterialQueryRowAction(Container c, TableInfo table) - { - ActionURL url = new ActionURL(InsertMaterialQueryRowAction.class, c); - url.addParameter(QueryView.DATAREGIONNAME_DEFAULT + "." + QueryParam.queryName, table.getName()); - - return url; - } - - @Override - public ActionURL getDataClassAttachmentDownloadAction(Container c) - { - return new ActionURL(ExperimentController.DataClassAttachmentDownloadAction.class, c); - } - - } - - private static abstract class BaseResolveLsidApiAction extends ReadOnlyApiAction - { - protected Set _seeds; - - @Override - public void validateForm(F form, Errors errors) - { - if (null != form.getLsids()) - { - _seeds = new LinkedHashSet<>(form.getLsids().size()); - for (String lsid : form.getLsids()) - { - Identifiable id = LsidManager.get().getObject(lsid); - if (id == null) - throw new NotFoundException("Unable to resolve object: " + lsid); - - // ensure the user has read permission in the seed container - if (!getContainer().equals(id.getContainer())) - { - if (!id.getContainer().hasPermission(getUser(), ReadPermission.class)) - throw new UnauthorizedException("User does not have permission to read object: " + lsid); - } - - _seeds.add(id); - } - } - else - { - throw new ApiUsageException("Starting lsids required"); - } - } - } - - @RequiresPermission(ReadPermission.class) - public static class ResolveAction extends BaseResolveLsidApiAction - { - @Override - public Object execute(ResolveLsidsForm form, BindException errors) - { - var settings = new ExperimentJSONConverter.Settings(form.isIncludeProperties(), form.isIncludeInputsAndOutputs(), form.isIncludeRunSteps()); - var data = _seeds.stream().map(n -> ExperimentJSONConverter.serialize(n, getUser(), settings)).collect(toList()); - return new ApiSimpleResponse("data", data); - } - } - - @RequiresPermission(ReadPermission.class) - public static class LineageAction extends BaseResolveLsidApiAction - { - @Override - public Object execute(ExpLineageOptions options, BindException errors) throws Exception - { - ExpLineageServiceImpl.get().streamLineage(getContainer(), getUser(), getViewContext().getResponse(), _seeds, options); - return null; - } - } - - @Marshal(Marshaller.Jackson) - @RequiresPermission(AdminPermission.class) - public static class RebuildEdgesAction extends MutatingApiAction - { - @Override - public Object execute(ExperimentRunForm form, BindException errors) - { - if (form.getRowId() != 0 || form.getLsid() != null) - { - ExpRun run = form.lookupRun(); - if (!run.getContainer().hasPermission(getUser(), ReadPermission.class)) - throw new UnauthorizedException("Not permitted"); - - ExperimentServiceImpl.get().syncRunEdges(run); - } - else - { - // should this require site admin permissions? - ExperimentServiceImpl.get().rebuildAllRunEdges(); - } - return success(); - } - } - - private static class VerifyEdgesForm extends ExperimentRunForm - { - private Integer _limit; - - public Integer getLimit() - { - return _limit; - } - - public void setLimit(Integer limit) - { - _limit = limit; - } - } - - @Marshal(Marshaller.Jackson) - @RequiresPermission(AdminPermission.class) - public static class VerifyEdgesAction extends ReadOnlyApiAction - { - @Override - public Object execute(VerifyEdgesForm form, BindException errors) - { - if (form.getRowId() != 0 || form.getLsid() != null) - { - ExpRun run = form.lookupRun(); - if (!run.getContainer().hasPermission(getUser(), ReadPermission.class)) - throw new UnauthorizedException("Not permitted"); - - ExperimentServiceImpl.get().verifyRunEdges(run); - } - else - { - ExperimentServiceImpl.get().verifyAllEdges(getContainer(), form.getLimit()); - } - return success(); - } - } - - @Marshal(Marshaller.Jackson) - @RequiresPermission(AdminPermission.class) - public static class RebuildAncestorsAction extends MutatingApiAction - { - @Override - public Object execute(Object form, BindException errors) - { - ClosureQueryHelper.truncateAndRecreate(); - return success(); - } - } - - - @Marshal(Marshaller.Jackson) - @RequiresPermission(AdminPermission.class) - public static class CheckDataClassesIndexedAction extends ReadOnlyApiAction - { - @Override - public Object execute(Object o, BindException errors) throws Exception - { - List> notInIndex = new ArrayList<>(100); - - List list = ExperimentService.get().getDataClasses(getContainer(), getUser(), false); - for (ExpDataClass dc : list) - { - for (ExpData d : dc.getDatas()) - { - String docId = d.getDocumentId(); - if (docId != null) - { - SearchService.SearchHit hit = SearchService.get().find(docId); - if (hit == null) - { - JSONObject props = ExperimentJSONConverter.serializeData(d, getUser(), ExperimentJSONConverter.DEFAULT_SETTINGS); - props.put("docid", docId); - notInIndex.add(props.toMap()); - } - } - } - } - - return success(notInIndex); - } - } - - @Marshal(Marshaller.Jackson) - @RequiresPermission(AdminPermission.class) - public static class CheckEdgesAction extends ReadOnlyApiAction - { - @Override - public Object execute(Object o, BindException errors) throws Exception - { - List result; - DbSchema schema = ExperimentService.get().getSchema(); - TableInfo edgeTable = schema.getTable("Edge"); - - if (null != edgeTable.getColumn("fromObjectId")) - { - var edges = new SqlSelector(ExperimentService.get().getSchema(), "SELECT fromObjectId, toObjectId FROM exp.Edge") - .resultSetStream() - .map(r -> { try { return new Pair<>(r.getInt(1), r.getInt(2)); } catch (SQLException x) { throw new RuntimeException(x); } }) - .collect(toList()); - var cycles = (new GraphAlgorithms()).detectCycleInDirectedGraph(edges); - result = cycles.stream().map(e -> new Integer[]{e.first, e.second}).collect(toList()); - } - else - { - var edges = new SqlSelector(ExperimentService.get().getSchema(), "SELECT fromLsid, toLsid FROM exp.Edge") - .resultSetStream() - .map(r -> { try { return new Pair<>(r.getString(1), r.getString(2)); } catch (SQLException x) { throw new RuntimeException(x); } }) - .collect(toList()); - var cycles = (new GraphAlgorithms()).detectCycleInDirectedGraph(edges); - result = cycles.stream().map(e -> new String[]{e.first, e.second}).collect(toList()); - } - - JSONObject ret = new JSONObject(); - ret.put("result", result); - ret.put("success", true); - return ret; - } - } - - @RequiresPermission(UpdatePermission.class) - public static class UpdateMaterialQueryRowAction extends UserSchemaAction - { - @Override - protected QueryForm createQueryForm(ViewContext context) - { - QueryForm form = new QueryForm("samples", null); - form.setViewContext(getViewContext()); - form.bindParameters(getViewContext().getBindPropertyValues()); - return form; - } - - @Override - public BindException bindParameters(PropertyValues m) throws Exception - { - BindException bind = super.bindParameters(m); - - QueryUpdateForm tableForm = (QueryUpdateForm)bind.getTarget(); - - int sampleId; - try - { - sampleId = Integer.parseInt((String) tableForm.getPkVal()); - } - catch (NumberFormatException e) - { - throw new NotFoundException("Invalid RowId: " + tableForm.getPkVal()); - } - - ExpMaterial material = ExperimentService.get().getExpMaterial(sampleId); - if (material == null) - throw new NotFoundException("Invalid material: " + tableForm.getPkVal()); - - return bind; - } - - @Override - public ModelAndView getView(QueryUpdateForm tableForm, boolean reshow, BindException errors) - { - int sampleId = Integer.parseInt((String) tableForm.getPkVal()); - - ExpMaterial material = ExperimentService.get().getExpMaterial(sampleId); - if (material == null) - throw new NotFoundException("Invalid material: " + tableForm.getPkVal()); - - boolean isAliquot = !StringUtils.isEmpty(material.getAliquotedFromLSID()); - - TableInfo tableInfo = tableForm.getTable(); - Map scopedFields = new CaseInsensitiveHashMap<>(); - for (DomainProperty dp : tableInfo.getDomain().getProperties()) - { - if (!ExpSchema.DerivationDataScopeType.All.name().equalsIgnoreCase(dp.getDerivationDataScope())) - scopedFields.put(dp.getName(), ExpSchema.DerivationDataScopeType.ChildOnly.name().equalsIgnoreCase(dp.getDerivationDataScope())); - } - - for (var column : tableInfo.getColumns()) - { - String columnName = column.getName(); - if (scopedFields.containsKey(columnName)) - { - boolean isAliquotField = scopedFields.get(columnName); - boolean show = (isAliquot && isAliquotField) || (!isAliquot && !isAliquotField); - ((BaseColumnInfo)column).setUserEditable(show); - ((BaseColumnInfo)column).setHidden(!show); - } - } - - ButtonBar bb = createSubmitCancelButtonBar(tableForm); - UpdateView view = new UpdateView(tableForm, errors); - view.getDataRegion().setButtonBar(bb); - return view; - } - - @Override - public boolean handlePost(QueryUpdateForm tableForm, BindException errors) - { - doInsertUpdate(tableForm, errors, false); - return 0 == errors.getErrorCount(); - } - - @Override - public void addNavTrail(NavTree root) - { - super.addNavTrail(root); - root.addChild("Edit " + _form.getQueryName()); - } - } - - @RequiresPermission(InsertPermission.class) - public static class InsertMaterialQueryRowAction extends UserSchemaAction - { - @Override - protected QueryForm createQueryForm(ViewContext context) - { - QueryForm form = new QueryForm("samples", null); - form.setViewContext(getViewContext()); - form.bindParameters(getViewContext().getBindPropertyValues()); - - return form; - } - - @Override - public ModelAndView getView(QueryUpdateForm tableForm, boolean reshow, BindException errors) - { - TableInfo tableInfo = tableForm.getTable(); - Map propertyFields = new CaseInsensitiveHashMap<>(); - for (DomainProperty dp : tableInfo.getDomain().getProperties()) - { - propertyFields.put(dp.getName(), ExpSchema.DerivationDataScopeType.ChildOnly.name().equalsIgnoreCase(dp.getDerivationDataScope())); - } - - for (var column : tableInfo.getColumns()) - { - String columnName = column.getName(); - if (propertyFields.containsKey(columnName)) - { - boolean isAliquotField = propertyFields.get(columnName); - ((BaseColumnInfo)column).setUserEditable(!isAliquotField); - ((BaseColumnInfo)column).setHidden(isAliquotField); - } - } - - InsertView view = new InsertView(tableForm, errors); - view.getDataRegion().setButtonBar(createSubmitCancelButtonBar(tableForm)); - return view; - } - - @Override - public boolean handlePost(QueryUpdateForm tableForm, BindException errors) - { - doInsertUpdate(tableForm, errors, true); - return 0 == errors.getErrorCount(); - } - - @Override - public void addNavTrail(NavTree root) - { - super.addNavTrail(root); - root.addChild("Insert " + _form.getQueryName()); - } - } - - @Marshal(Marshaller.Jackson) - @RequiresPermission(ReadPermission.class) - public static class SaveFindIdsAction extends ReadOnlyApiAction - { - - public static final String FIND_BY_IDS_SESSION_KEY_PREFIX = "findByIds"; - - @Override - public Object execute(FindByIdsForm form, BindException errors) throws Exception - { - HttpServletRequest request = getViewContext().getRequest(); - String key = form.getSessionKey(); - boolean removePrevious = false; - - if (key == null) - { - removePrevious = true; - key = FIND_BY_IDS_SESSION_KEY_PREFIX + "_" + UniqueID.getServerSessionScopedUID(); - } - - if (request != null) - { - if (removePrevious) - SessionHelper.clearAttributesWithPrefix(request, FIND_BY_IDS_SESSION_KEY_PREFIX); - HttpSession session = request.getSession(false); - if (session != null) - { - @SuppressWarnings("unchecked") - List existingIds = (List) session.getAttribute(key); - - // deduplicate from existing ids - if (existingIds != null && form.getSessionKey() != null) - { - existingIds.addAll(form.getIds().stream().filter(id -> !existingIds.contains(id)).toList()); - session.setAttribute(key, existingIds); - } - else - { - session.setAttribute(key, form.getIds()); - } - return success("Saved ids to session key", key); - } - } - - return new SimpleResponse<>(false, "Unable to save to session. Session or request may be null."); - } - } - - @Marshal(Marshaller.Jackson) - @RequiresPermission(ReadPermission.class) - public static class SaveOrderedSamplesQueryAction extends ReadOnlyApiAction - { - private static final String SAMPLE_ID_PREFIX = "s:"; - private static final String UNIQUE_ID_PREFIX = "u:"; - - private List _ids; - private Map> _uniqueIdLsids; - - @Override - public void validateForm(FindByIdsForm form, Errors errors) - { - if (form.getSessionKey() == null) - errors.reject(ERROR_REQUIRED, "sessionKey must be provided"); - else - { - _ids = getFindIdsFromSession(form.getSessionKey()); - if (_ids == null || _ids.isEmpty()) - errors.reject(ERROR_REQUIRED, "No ids found corresponding to session key " + form.getSessionKey()); - } - } - - private void ensureUniqueIdLsids() - { - boolean hasUniqueId = _ids.stream().anyMatch(s -> s.startsWith(UNIQUE_ID_PREFIX)); - if (hasUniqueId && _uniqueIdLsids == null) - { - List uniqueIds = _ids.stream().map(s -> s.substring(UNIQUE_ID_PREFIX.length())).toList(); - _uniqueIdLsids = ExperimentService.get().getUniqueIdLsids(uniqueIds, getUser(), getContainer()); - } - } - - @Override - public Object execute(FindByIdsForm form, BindException errors) throws Exception - { - ensureUniqueIdLsids(); - - SQLFragment select = getOrderedRowsSql(); - // need to set the key field so selections are possible - // need the SampleTypeUnits so we will display using that unit - String metadata = - """ - - - - - true - true - - - true - - - true - - -
-
"""; - QueryDefinition def = QueryService.get().saveSessionQuery(getViewContext(), getContainer(), ExperimentServiceImpl.getExpSchema().getName(), select.getSQL(), metadata); - return success("Session query created", Map.of("queryName", def.getName(), "ids", _ids)); - } - - - private List getFindIdsFromSession(String sessionKey) - { - HttpServletRequest request = getViewContext().getRequest(); - List ids = new ArrayList<>(); - if (request != null) - { - HttpSession session = request.getSession(false); - if (session != null) - { - ids = (List) session.getAttribute(sessionKey); - } - } - return ids; - } - - private SQLFragment getOrderedRowsSql() - { - boolean isFMEnabled = InventoryService.isFreezerManagementEnabled(getContainer()); - String samplesTable = isFMEnabled ? "inventory.SampleItems" : "exp.materials"; - List orderedIdCols = new ArrayList<>(Arrays.asList("Id AS ProvidedID", "RowId", "Ordinal")); - List sampleColumns = new ArrayList<>(); - if (!isFMEnabled) - { - sampleColumns.addAll(Arrays.asList( - "S.Name AS SampleID", - "S.MaterialExpDate AS ExpirationDate", - "S.SampleSet as SampleType", - "S.SampleState", - "S.isAliquot", - "S.Created", - "S.CreatedBy" - )); - } - else - { - sampleColumns.addAll(Arrays.asList( - "S.Name AS SampleID", - "S.MaterialExpDate AS ExpirationDate", - "S.LabelColor", - "S.SampleSet", - "S.SampleState", - "S.StoredAmount", - "S.Units", - "S.SampleTypeUnits", - "S.FreezeThawCount", - "S.StorageStatus", - "S.CheckedOutBy", - "S.StorageLocation", - "S.StorageRow", - "S.StorageCol", - "S.StoragePositionNumber", - "S.IsAliquot", - "S.Created", - "S.CreatedBy" - )); - } - - - String sampleIdComma = ""; - String uniqueIdComma = ""; - int index = 1; - SQLFragment sampleIdValuesSql = new SQLFragment(); - SQLFragment uniqueIdValuesSql = new SQLFragment(); - for (String id : _ids) - { - if (id.startsWith(SAMPLE_ID_PREFIX)) - { - sampleIdValuesSql.append(sampleIdComma).append("\t(").appendValue(index); - sampleIdValuesSql.append(", "); - sampleIdValuesSql.append(LabKeySql.quoteString(id.substring(SAMPLE_ID_PREFIX.length()))); - sampleIdValuesSql.append(", "); - sampleIdValuesSql.append(LabKeySql.quoteString("null")); - sampleIdValuesSql.append(")"); - sampleIdComma = "\n,"; - } - else if (id.startsWith(UNIQUE_ID_PREFIX)) - { - String idClean = id.substring(UNIQUE_ID_PREFIX.length()); - - List lsids = _uniqueIdLsids.get(idClean); - if (lsids != null) - { - for (String lsid : lsids) - { - uniqueIdValuesSql.append(uniqueIdComma).append("\t(").appendValue(index); - uniqueIdValuesSql.append(", "); - uniqueIdValuesSql.append(LabKeySql.quoteString(idClean)); - uniqueIdValuesSql.append(", "); - uniqueIdValuesSql.append(LabKeySql.quoteString(lsid)); - uniqueIdValuesSql.append(")"); - uniqueIdComma = "\n,"; - } - } - } - index++; - } - - boolean haveData = !sampleIdValuesSql.isEmpty() || !_uniqueIdLsids.isEmpty(); - SQLFragment sql = new SQLFragment(); - if (!sampleIdValuesSql.isEmpty()) - { - sql.append("WITH _ordered_ids_ AS (\nSELECT * FROM (VALUES\n"); - sql.append(sampleIdValuesSql); - sql.append("\n) AS _values_ )\n"); // name of the alias here doesn't matter - } - if (!uniqueIdValuesSql.isEmpty()) - { - if (!sampleIdValuesSql.isEmpty()) - sql.append(",\n"); - else - sql.append("WITH "); - - sql.append("_ordered_unique_ids_ AS (\nSELECT * FROM (VALUES\n"); - sql.append(uniqueIdValuesSql); - sql.append("\n) AS _values_ )\n"); // name of the alias here doesn't matter - } - - sql.append("SELECT "); - sql.append("\n\tOID.").append(StringUtils.join(orderedIdCols, ",\n\tOID.")); - sql.append(",\n\t").append(StringUtils.join( sampleColumns, ",\n\t")); - sql.append("\nFROM\n("); - if (!sampleIdValuesSql.isEmpty()) - { - sql.append("SELECT\n\tM.RowId,\n\t_ordered_ids_.column1 as Ordinal,\n\t_ordered_ids_.column2 as Id,\n\t_ordered_ids_.column2 as lsid"); - sql.append("\nFROM _ordered_ids_\n"); - sql.append("INNER JOIN exp.materials M ON _ordered_ids_.column2 = M.Name"); - sql.append("\n"); - } - if (!uniqueIdValuesSql.isEmpty()) - { - if (!sampleIdValuesSql.isEmpty()) - sql.append("\nUNION ALL\n\n"); - - sql.append("SELECT\n\tM.RowId,\n\t_ordered_unique_ids_.column1 as Ordinal,\n\t_ordered_unique_ids_.column2 as Id,\n\t_ordered_unique_ids_.column3 as lsid"); - sql.append("\nFROM _ordered_unique_ids_\n"); - sql.append("INNER JOIN exp.materials M ON _ordered_unique_ids_.column3 = M.lsid"); - sql.append("\n"); - } - if (!haveData) // no data to return but return data in the expected shape. - { - sql = new SQLFragment("SELECT\n"); - sql.append(orderedIdCols.stream() - .map(col -> { - int asIndex = col.indexOf("AS"); - if (asIndex > 0) - return "NULL AS " + col.substring(asIndex+ 3); - else - return "NULL AS " + col; - }) - .collect(Collectors.joining(",\t\n"))); - sql.append(",\t\n").append(StringUtils.join(sampleColumns, ",\t\n")); - sql.append("\nFROM ").append(samplesTable).append(" S WHERE 1 = 2"); - return sql; - } - else - { - sql.append(") OID"); - if (isFMEnabled) - sql.append("\nLEFT JOIN inventory.SampleItems S on S.RowId = OID.RowId"); - else - sql.append("\nINNER JOIN exp.materials S on S.RowId = OID.RowId"); - sql.append("\n\nORDER BY Ordinal"); - return sql; - } - } - } - - public static class FindByIdsForm extends FindSessionKeyForm - { - List _ids; - - public List getIds() - { - return _ids; - } - - public void setIds(List ids) - { - _ids = ids; - } - } - - - public static class FindSessionKeyForm - { - private String _sessionKey; - - public String getSessionKey() - { - return _sessionKey; - } - - public void setSessionKey(String sessionKey) - { - _sessionKey = sessionKey; - } - } - - static void validateEntitySequenceForm(EntitySequenceForm form, Errors errors) - { - String kindName = form.getKindName(); - if (StringUtils.isEmpty(kindName) || form.getSeqType() == null) - { - errors.reject(ERROR_REQUIRED, "KindName and SeqType must be provided"); - return; - } - - if (form.getSeqType() == NameGenerator.EntityCounter.genId) - { - if (form.getRowId() == null) - errors.reject(ERROR_REQUIRED, "Data type RowId must be provided for genId"); - } - else if (!SampleTypeDomainKind.NAME.equalsIgnoreCase(kindName)) - { - errors.reject(ERROR_MSG, form.getSeqType() + " is not supported for " + kindName); - } - - if (!SampleTypeDomainKind.NAME.equalsIgnoreCase(kindName) && !DataClassDomainKind.NAME.equalsIgnoreCase(kindName)) - errors.reject(ERROR_MSG, "Invalid KindName. Should be either " + SampleTypeDomainKind.NAME + " or " + DataClassDomainKind.NAME + "."); - - } - - @RequiresPermission(ReadPermission.class) - public static class GetEntitySequenceAction extends ReadOnlyApiAction - { - @Override - public void validateForm(EntitySequenceForm form, Errors errors) - { - validateEntitySequenceForm(form, errors); - } - - @Override - public Object execute(EntitySequenceForm form, BindException errors) throws Exception - { - long value = -1; - if (SampleTypeDomainKind.NAME.equalsIgnoreCase(form.getKindName())) - { - if (form.getSeqType() == NameGenerator.EntityCounter.genId) - { - ExpSampleType sampleType = SampleTypeService.get().getSampleType(form.getRowId()); - if (sampleType != null) - value = sampleType.getCurrentGenId(); - } - else - { - value = SampleTypeService.get().getCurrentCount(form.getSeqType(), getContainer()); - } - - } - else if (DataClassDomainKind.NAME.equalsIgnoreCase(form.getKindName())) - { - ExpDataClass dataClass = ExperimentService.get().getDataClass(form.getRowId()); - if (dataClass != null) - value = dataClass.getCurrentGenId(); - } - - ApiSimpleResponse resp = new ApiSimpleResponse(); - resp.put("success", value > -1); - resp.put("value", value); - return resp; - } - } - - @RequiresPermission(ReadPermission.class) // actual permission checked later - public static class SetEntitySequenceAction extends MutatingApiAction - { - @Override - public void validateForm(EntitySequenceForm form, Errors errors) - { - validateEntitySequenceForm(form, errors); - - if (form.getNewValue() == null || form.getNewValue() < 0) - errors.reject(ERROR_MSG, "Invalid newValue."); - } - - @Override - public Object execute(EntitySequenceForm form, BindException errors) - { - ApiSimpleResponse resp = new ApiSimpleResponse(); - resp.put("success", true); - - try - { - Domain domain = null; - if (SampleTypeDomainKind.NAME.equalsIgnoreCase(form.getKindName())) - { - if (form.getSeqType() == NameGenerator.EntityCounter.genId) - { - if (!getContainer().hasPermission(getUser(), DesignSampleTypePermission.class)) - throw new UnauthorizedException("Insufficient permissions."); - - ExpSampleType sampleType = SampleTypeService.get().getSampleType(form.getRowId()); - if (sampleType != null) - { - sampleType.ensureMinGenId(form.getNewValue()); - domain = sampleType.getDomain(); - } - else - { - resp.put("success", false); - resp.put("error", "Sample type does not exist."); - } - } - else - { - if (!getContainer().hasPermission(getUser(), AdminPermission.class)) - throw new UnauthorizedException("Insufficient permissions."); - - SampleTypeService.get().ensureMinSampleCount(form.getNewValue(), form.getSeqType(), getContainer()); - } - } - else if (DataClassDomainKind.NAME.equalsIgnoreCase(form.getKindName())) - { - if (!getContainer().hasPermission(getUser(), DesignDataClassPermission.class)) - throw new BadRequestException("Insufficient permissions."); - - ExpDataClass dataClass = ExperimentService.get().getDataClass(form.getRowId()); - if (dataClass != null) - { - dataClass.ensureMinGenId(form.getNewValue(), getContainer()); - domain = dataClass.getDomain(); - } - else - { - resp.put("success", false); - resp.put("error", "DataClass does not exist."); - } - } - - if (domain != null) - { - DomainAuditProvider.DomainAuditEvent event = new DomainAuditProvider.DomainAuditEvent(getContainer(), "The genId for domain " + domain.getName() + " has been updated to " + form.getNewValue() + "."); - event.setDomainUri(domain.getTypeURI()); - event.setDomainName(domain.getName()); - AuditLogService.get().addEvent(getUser(), event); - } - } - catch (ExperimentException e) - { - resp.put("success", false); - resp.put("error", e.getMessage()); - } - - return resp; - } - } - - public static class EntitySequenceForm - { - private String _kindName; - private NameGenerator.EntityCounter _seqType; - private Integer _rowId; - private Long _newValue; - - public Integer getRowId() - { - return _rowId; - } - - public void setRowId(Integer rowId) - { - _rowId = rowId; - } - - public String getKindName() - { - return _kindName; - } - - public void setKindName(String kindName) - { - _kindName = kindName; - } - - public Long getNewValue() - { - return _newValue; - } - - public void setNewValue(Long newValue) - { - this._newValue = newValue; - } - - public NameGenerator.EntityCounter getSeqType() - { - return _seqType; - } - - public void setSeqType(String seqType) - { - _seqType = NameGenerator.EntityCounter.valueOf(seqType); - } - - } - - @Marshal(Marshaller.Jackson) - @RequiresPermission(ReadPermission.class) - public static class GetCrossFolderDataSelectionAction extends ReadOnlyApiAction - { - @Override - public void validateForm(CrossFolderSelectionForm form, Errors errors) - { - if (form.getDataRegionSelectionKey() == null && form.getRowIds() == null) - errors.reject(ERROR_REQUIRED, "You must provide either a set of rowIds or a dataRegionSelectionKey."); - if (!"samples".equalsIgnoreCase(form.getDataType()) && !"exp.data".equalsIgnoreCase(form.getDataType())&& !"assay".equalsIgnoreCase(form.getDataType())) - errors.reject(ERROR_REQUIRED, "Data type (sample, data or assayrun) must be specified."); - } - - @Override - public Object execute(CrossFolderSelectionForm form, BindException errors) - { - Pair result = ExperimentServiceImpl.getCurrentAndCrossFolderDataCount(form.getIds(false), form.getDataType(), getContainer()); - - ApiSimpleResponse resp = new ApiSimpleResponse(); - resp.put("success", true); - resp.put("currentFolderSelectionCount", result.first); - resp.put("crossFolderSelectionCount", result.second); - - return success(resp); - } - } - - public static class CrossFolderSelectionForm extends DataViewSnapshotSelectionForm - { - private String _dataType; - private String _picklistName; - - public String getDataType() - { - return _dataType; - } - - public void setDataType(String dataType) - { - _dataType = dataType; - } - - public String getPicklistName() - { - return _picklistName; - } - - public void setPicklistName(String picklistName) - { - _picklistName = picklistName; - } - - @Override - public Set getIds(boolean clear) - { - Set selectedIds; - - if (_rowIds != null) - selectedIds = _rowIds; - else if (isUseSnapshotSelection()) - selectedIds = new HashSet<>(DataRegionSelection.getSnapshotSelectedIntegers(getViewContext(), getDataRegionSelectionKey())); - else - selectedIds = DataRegionSelection.getSelectedIntegers(getViewContext(), getDataRegionSelectionKey(), clear); - - if (_picklistName != null) - { - User user = getViewContext().getUser(); - Container container = getViewContext().getContainer(); - UserSchema schema = ListService.get().getUserSchema(user, container); - TableInfo tInfo = schema.getTable(_picklistName); - if (tInfo != null) - { - SimpleFilter filter = SimpleFilter.createContainerFilter(container); - filter.addInClause(FieldKey.fromParts("id"), selectedIds); - TableSelector selector = new TableSelector(tInfo, Collections.singleton("SampleID"), filter, null); - return new HashSet<>(selector.getArrayList(Long.class)); - } - } - return selectedIds; - } - } - - @RequiresPermission(AdminPermission.class) - public static class RecomputeAliquotRollup extends SimpleViewAction - { - @Override - public void addNavTrail(NavTree root) - { - } - - @Override - public ModelAndView getView(Object o, BindException errors) throws SQLException - { - try (var ignore = SpringActionController.ignoreSqlUpdates()) - { - Container container = getContainer(); - User user = getUser(); - - List sampleTypes = SampleTypeService.get() - .getSampleTypes(container, user, true); - - HtmlStringBuilder builder = HtmlStringBuilder.of(); - builder.unsafeAppend(""); - - SampleTypeService service = SampleTypeService.get(); - for (ExpSampleType sampleType : sampleTypes) - { - int updatedCount; - updatedCount = service.recomputeSampleTypeRollup(sampleType, container); - // we could check "if (0 < updatedCount) refresh(rollup)", but since this is a "manual" usage lets just always refresh - SampleTypeServiceImpl.get().refreshSampleTypeMaterializedView(sampleType, update); - builder.unsafeAppend(""); - } - - builder.unsafeAppend("
Sample Type#Recomputed
") - .append(sampleType.getName()) - .unsafeAppend("") - .append(updatedCount) - .unsafeAppend("
"); - return new HtmlView("Aliquot Rollup Recalculation Result", builder); - } - } - } - - /* Also see API CheckEdgesAction */ - @RequiresPermission(TroubleshooterPermission.class) - public static class CycleCheckAction extends FormViewAction - { - List cycleObjectIds = null; - - @Override - public void validateCommand(Object target, Errors errors) - { - - } - - @Override - public ModelAndView getView(Object o, boolean reshow, BindException errors) - { - if (!reshow) - { - return new HtmlView( - DIV("This operation can use a lot of memory.", - LK.FORM(at(method,"POST"), - PageFlowUtil.button("Continue").submit(true))) - ); - } - - if (null == cycleObjectIds) - return new HtmlView(HtmlString.of("No cycles found")); - - Map map = new LongHashMap<>(); - var cf = new ContainerFilter.AllFolders(getUser()); - var materials = ExperimentServiceImpl.get().getExpMaterialsByObjectId(cf, cycleObjectIds); - materials.forEach( (m) -> map.put(m.getObjectId(), m)); - var datas = ExperimentServiceImpl.get().getExpDatasByObjectId(cf, cycleObjectIds); - datas.forEach( (d) -> map.put(d.getObjectId(), d)); - var runs = ExperimentServiceImpl.get().getRunsByObjectId(cf, cycleObjectIds); - runs.forEach( (r) -> map.put(r.getObjectId(), r)); - - ExperimentUrls urls = ExperimentUrls.get(); - return new HtmlView( - DIV("Cycle found involving these objects.", - UL(cycleObjectIds.stream().map((objectid) -> - { - ExpObject exp = map.get(objectid); - if (exp instanceof ExpMaterial mat) - return LI(A(at(target, "_blank", href, urls.getMaterialDetailsURL(mat)), objectid + " : material - " + mat.getName())); - else if (exp instanceof ExpRun run) - return LI(A(at(target, "_blank", href, urls.getRunTextURL(run)), objectid + " : run - " + run.getName())); - else if (exp instanceof ExpData data) - return LI(A(at(target, "_blank", href, urls.getDataDetailsURL(data)), objectid + " : run - " + data.getName())); - else - return LI(String.valueOf(objectid)); - })) - ) - ); - } - - @Override - public boolean handlePost(Object o, BindException errors) - { - var edges = new SqlSelector(ExperimentService.get().getSchema(), "SELECT fromObjectId, toObjectId FROM exp.Edge") - .resultSetStream() - .map(r -> { try { return new Pair<>(r.getInt(1), r.getInt(2)); } catch (SQLException x) { throw new RuntimeException(x); } }) - .collect(toList()); - var cyclesEdges = (new GraphAlgorithms()).detectCycleInDirectedGraph(edges); - - var set = new LinkedHashSet(); - cyclesEdges.forEach( (edge) -> { - set.add(edge.first); - set.add(edge.second); - }); - cycleObjectIds = set.stream().toList(); - return false; - } - - @Override - public URLHelper getSuccessURL(Object o) - { - return null; - } - - @Override - public void addNavTrail(NavTree root) - { - - } - } - - @RequiresPermission(AdminPermission.class) - public static class MissingFilesCheckAction extends ReadOnlyApiAction - { - @Override - public Object execute(Object o, BindException errors) throws Exception - { - Map> info = ExperimentServiceImpl.get().doMissingFilesCheck(getUser(), getContainer(), true); - JSONObject results = new JSONObject(); - for (String containerId : info.keySet()) - { - JSONObject containerResults = new JSONObject(); - for (String sourceName : info.get(containerId).keySet()) - containerResults.put(sourceName, info.get(containerId).get(sourceName).toJSON()); - results.put(containerId, containerResults); - } - - ApiSimpleResponse response = new ApiSimpleResponse(); - response.put("success", true); - response.put("result", results); - return response; - } - } - -} +/* + * Copyright (c) 2008-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.labkey.experiment.controllers.exp; + +import au.com.bytecode.opencsv.CSVWriter; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import org.apache.commons.collections4.MapUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.poi.ss.usermodel.Workbook; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.labkey.api.action.ApiJsonWriter; +import org.labkey.api.action.ApiResponse; +import org.labkey.api.action.ApiSimpleResponse; +import org.labkey.api.action.ApiUsageException; +import org.labkey.api.action.ExportAction; +import org.labkey.api.action.FormHandlerAction; +import org.labkey.api.action.FormViewAction; +import org.labkey.api.action.HasViewContext; +import org.labkey.api.action.Marshal; +import org.labkey.api.action.Marshaller; +import org.labkey.api.action.MutatingApiAction; +import org.labkey.api.action.QueryViewAction; +import org.labkey.api.action.ReadOnlyApiAction; +import org.labkey.api.action.ReturnUrlForm; +import org.labkey.api.action.SimpleApiJsonForm; +import org.labkey.api.action.SimpleErrorView; +import org.labkey.api.action.SimpleResponse; +import org.labkey.api.action.SimpleViewAction; +import org.labkey.api.action.SpringActionController; +import org.labkey.api.assay.AssayFileWriter; +import org.labkey.api.assay.AssayProtocolSchema; +import org.labkey.api.assay.AssayProvider; +import org.labkey.api.assay.AssayService; +import org.labkey.api.assay.actions.UploadWizardAction; +import org.labkey.api.assay.security.DesignAssayPermission; +import org.labkey.api.attachments.AttachmentParent; +import org.labkey.api.attachments.AttachmentService; +import org.labkey.api.attachments.BaseDownloadAction; +import org.labkey.api.audit.AbstractAuditTypeProvider; +import org.labkey.api.audit.AuditLogService; +import org.labkey.api.audit.DetailedAuditTypeEvent; +import org.labkey.api.audit.SampleTimelineAuditEvent; +import org.labkey.api.audit.TransactionAuditProvider; +import org.labkey.api.collections.CaseInsensitiveHashMap; +import org.labkey.api.collections.CaseInsensitiveHashSet; +import org.labkey.api.collections.LongHashMap; +import org.labkey.api.data.ActionButton; +import org.labkey.api.data.BaseColumnInfo; +import org.labkey.api.data.ButtonBar; +import org.labkey.api.data.ColumnInfo; +import org.labkey.api.data.CompareType; +import org.labkey.api.data.Container; +import org.labkey.api.data.ContainerFilter; +import org.labkey.api.data.ContainerManager; +import org.labkey.api.data.DataRegion; +import org.labkey.api.data.DataRegionSelection; +import org.labkey.api.data.DbSchema; +import org.labkey.api.data.DbScope; +import org.labkey.api.data.DisplayColumn; +import org.labkey.api.data.ExcelWriter; +import org.labkey.api.data.MenuButton; +import org.labkey.api.data.NameGenerator; +import org.labkey.api.data.SQLFragment; +import org.labkey.api.data.ShowRows; +import org.labkey.api.data.SimpleDisplayColumn; +import org.labkey.api.data.SimpleFilter; +import org.labkey.api.data.Sort; +import org.labkey.api.data.SqlSelector; +import org.labkey.api.data.TSVWriter; +import org.labkey.api.data.TableInfo; +import org.labkey.api.data.TableSelector; +import org.labkey.api.dataiterator.DataIteratorContext; +import org.labkey.api.exp.AbstractParameter; +import org.labkey.api.exp.DeleteForm; +import org.labkey.api.exp.DuplicateMaterialException; +import org.labkey.api.exp.ExperimentDataHandler; +import org.labkey.api.exp.ExperimentException; +import org.labkey.api.exp.ExperimentRunForm; +import org.labkey.api.exp.ExperimentRunListView; +import org.labkey.api.exp.ExperimentRunType; +import org.labkey.api.exp.Identifiable; +import org.labkey.api.exp.Lsid; +import org.labkey.api.exp.LsidManager; +import org.labkey.api.exp.LsidType; +import org.labkey.api.exp.ObjectProperty; +import org.labkey.api.exp.OntologyManager; +import org.labkey.api.exp.ProtocolApplicationParameter; +import org.labkey.api.exp.XarContext; +import org.labkey.api.exp.api.DataClassDomainKindProperties; +import org.labkey.api.exp.api.ExpData; +import org.labkey.api.exp.api.ExpDataClass; +import org.labkey.api.exp.api.ExpExperiment; +import org.labkey.api.exp.api.ExpLineageOptions; +import org.labkey.api.exp.api.ExpMaterial; +import org.labkey.api.exp.api.ExpMaterialRunInput; +import org.labkey.api.exp.api.ExpObject; +import org.labkey.api.exp.api.ExpProtocol; +import org.labkey.api.exp.api.ExpProtocolApplication; +import org.labkey.api.exp.api.ExpRun; +import org.labkey.api.exp.api.ExpRunAttachmentParent; +import org.labkey.api.exp.api.ExpRunEditor; +import org.labkey.api.exp.api.ExpRunItem; +import org.labkey.api.exp.api.ExpSampleType; +import org.labkey.api.exp.api.ExperimentJSONConverter; +import org.labkey.api.exp.api.ExperimentService; +import org.labkey.api.exp.api.ExperimentUrls; +import org.labkey.api.exp.api.NameExpressionOptionService; +import org.labkey.api.exp.api.ResolveLsidsForm; +import org.labkey.api.exp.api.SampleTypeDomainKind; +import org.labkey.api.exp.api.SampleTypeService; +import org.labkey.api.exp.list.ListService; +import org.labkey.api.exp.property.Domain; +import org.labkey.api.exp.property.DomainAuditProvider; +import org.labkey.api.exp.property.DomainKind; +import org.labkey.api.exp.property.DomainProperty; +import org.labkey.api.exp.property.DomainTemplate; +import org.labkey.api.exp.property.DomainTemplateGroup; +import org.labkey.api.exp.property.DomainUtil; +import org.labkey.api.exp.property.PropertyService; +import org.labkey.api.exp.query.ExpDataProtocolInputTable; +import org.labkey.api.exp.query.ExpInputTable; +import org.labkey.api.exp.query.ExpMaterialProtocolInputTable; +import org.labkey.api.exp.query.ExpSchema; +import org.labkey.api.exp.query.SamplesSchema; +import org.labkey.api.exp.xar.LSIDRelativizer; +import org.labkey.api.exp.xar.LsidUtils; +import org.labkey.api.files.FileContentService; +import org.labkey.api.gwt.client.AuditBehaviorType; +import org.labkey.api.inventory.InventoryService; +import org.labkey.api.module.ModuleHtmlView; +import org.labkey.api.module.ModuleLoader; +import org.labkey.api.pipeline.PipeRoot; +import org.labkey.api.pipeline.PipelineService; +import org.labkey.api.pipeline.PipelineStatusFile; +import org.labkey.api.pipeline.PipelineUrls; +import org.labkey.api.pipeline.PipelineValidationException; +import org.labkey.api.qc.SampleStatusService; +import org.labkey.api.query.AbstractQueryImportAction; +import org.labkey.api.query.BatchValidationException; +import org.labkey.api.query.DetailsURL; +import org.labkey.api.query.DuplicateKeyException; +import org.labkey.api.query.FieldKey; +import org.labkey.api.query.QueryAction; +import org.labkey.api.query.QueryDefinition; +import org.labkey.api.query.QueryException; +import org.labkey.api.query.QueryForm; +import org.labkey.api.query.QueryParam; +import org.labkey.api.query.QueryService; +import org.labkey.api.query.QuerySettings; +import org.labkey.api.query.QueryUpdateForm; +import org.labkey.api.query.QueryUpdateService; +import org.labkey.api.query.QueryUpdateServiceException; +import org.labkey.api.query.QueryView; +import org.labkey.api.query.SchemaKey; +import org.labkey.api.query.UserSchema; +import org.labkey.api.query.UserSchemaAction; +import org.labkey.api.reader.ColumnDescriptor; +import org.labkey.api.reader.DataLoader; +import org.labkey.api.reader.DataLoaderFactory; +import org.labkey.api.reader.ExcelFactory; +import org.labkey.api.search.SearchService; +import org.labkey.api.security.ActionNames; +import org.labkey.api.security.RequiresAnyOf; +import org.labkey.api.security.RequiresNoPermission; +import org.labkey.api.security.RequiresPermission; +import org.labkey.api.security.SecurableResource; +import org.labkey.api.security.User; +import org.labkey.api.security.permissions.AdminPermission; +import org.labkey.api.security.permissions.DeletePermission; +import org.labkey.api.security.permissions.DesignDataClassPermission; +import org.labkey.api.security.permissions.DesignSampleTypePermission; +import org.labkey.api.security.permissions.InsertPermission; +import org.labkey.api.security.permissions.Permission; +import org.labkey.api.security.permissions.ReadPermission; +import org.labkey.api.security.permissions.SampleWorkflowDeletePermission; +import org.labkey.api.security.permissions.SiteAdminPermission; +import org.labkey.api.security.permissions.TroubleshooterPermission; +import org.labkey.api.security.permissions.UpdatePermission; +import org.labkey.api.settings.AppProps; +import org.labkey.api.settings.ConceptURIProperties; +import org.labkey.api.sql.LabKeySql; +import org.labkey.api.study.Dataset; +import org.labkey.api.study.StudyService; +import org.labkey.api.study.StudyUrls; +import org.labkey.api.study.publish.StudyPublishService; +import org.labkey.api.usageMetrics.SimpleMetricsService; +import org.labkey.api.util.DOM; +import org.labkey.api.util.DOM.LK; +import org.labkey.api.util.ErrorRenderer; +import org.labkey.api.util.ExceptionUtil; +import org.labkey.api.util.FileStream; +import org.labkey.api.util.FileUtil; +import org.labkey.api.util.HtmlString; +import org.labkey.api.util.HtmlStringBuilder; +import org.labkey.api.util.ImageUtil; +import org.labkey.api.util.JSoupUtil; +import org.labkey.api.util.LinkBuilder; +import org.labkey.api.util.NetworkDrive; +import org.labkey.api.util.PageFlowUtil; +import org.labkey.api.util.Pair; +import org.labkey.api.util.ResponseHelper; +import org.labkey.api.util.SafeToRender; +import org.labkey.api.util.SessionHelper; +import org.labkey.api.util.StringExpression; +import org.labkey.api.util.URLHelper; +import org.labkey.api.util.UniqueID; +import org.labkey.api.util.CsrfInput; +import org.labkey.api.view.ActionURL; +import org.labkey.api.view.BadRequestException; +import org.labkey.api.view.DataView; +import org.labkey.api.view.DataViewSnapshotSelectionForm; +import org.labkey.api.view.DetailsView; +import org.labkey.api.view.HBox; +import org.labkey.api.view.HtmlView; +import org.labkey.api.view.HttpView; +import org.labkey.api.view.InsertView; +import org.labkey.api.view.JspView; +import org.labkey.api.view.NavTree; +import org.labkey.api.view.NotFoundException; +import org.labkey.api.view.RedirectException; +import org.labkey.api.view.UnauthorizedException; +import org.labkey.api.view.UpdateView; +import org.labkey.api.view.VBox; +import org.labkey.api.view.ViewBackgroundInfo; +import org.labkey.api.view.ViewContext; +import org.labkey.api.view.ViewServlet; +import org.labkey.api.view.WebPartView; +import org.labkey.api.view.template.ClientDependency; +import org.labkey.api.view.template.PageConfig; +import org.labkey.experiment.ChooseExperimentTypeBean; +import org.labkey.experiment.ConfirmDeleteView; +import org.labkey.experiment.CustomPropertiesView; +import org.labkey.experiment.DataClassWebPart; +import org.labkey.experiment.DerivedSamplePropertyHelper; +import org.labkey.experiment.DotGraph; +import org.labkey.experiment.ExpDataFileListener; +import org.labkey.experiment.ExperimentRunDisplayColumn; +import org.labkey.experiment.ExperimentRunGraph; +import org.labkey.experiment.LineageGraphDisplayColumn; +import org.labkey.experiment.MissingFilesCheckInfo; +import org.labkey.experiment.MoveRunsBean; +import org.labkey.experiment.ParentChildView; +import org.labkey.experiment.ProtocolApplicationDisplayColumn; +import org.labkey.experiment.ProtocolDisplayColumn; +import org.labkey.experiment.ProtocolWebPart; +import org.labkey.experiment.RunGroupWebPart; +import org.labkey.experiment.SampleTypeDisplayColumn; +import org.labkey.experiment.SampleTypeWebPart; +import org.labkey.experiment.StandardAndCustomPropertiesView; +import org.labkey.experiment.XarExportPipelineJob; +import org.labkey.experiment.XarExportType; +import org.labkey.experiment.XarExporter; +import org.labkey.experiment.api.ClosureQueryHelper; +import org.labkey.experiment.api.DataClass; +import org.labkey.experiment.api.DataClassDomainKind; +import org.labkey.experiment.api.ExpDataClassAttachmentParent; +import org.labkey.experiment.api.ExpDataClassImpl; +import org.labkey.experiment.api.ExpDataImpl; +import org.labkey.experiment.api.ExpExperimentImpl; +import org.labkey.experiment.api.ExpMaterialImpl; +import org.labkey.experiment.api.ExpProtocolApplicationImpl; +import org.labkey.experiment.api.ExpProtocolImpl; +import org.labkey.experiment.api.ExpRunImpl; +import org.labkey.experiment.api.ExpSampleTypeImpl; +import org.labkey.experiment.api.Experiment; +import org.labkey.experiment.api.ExperimentServiceImpl; +import org.labkey.experiment.api.GraphAlgorithms; +import org.labkey.experiment.api.ProtocolActionStepDetail; +import org.labkey.experiment.api.SampleTypeServiceImpl; +import org.labkey.experiment.api.SampleTypeUpdateServiceDI; +import org.labkey.experiment.controllers.property.PropertyController; +import org.labkey.experiment.lineage.ExpLineageServiceImpl; +import org.labkey.experiment.pipeline.ExperimentPipelineJob; +import org.labkey.experiment.types.TypesController; +import org.labkey.experiment.xar.XarExportSelection; +import org.labkey.vfs.FileLike; +import org.labkey.vfs.FileSystemLike; +import org.springframework.beans.PropertyValue; +import org.springframework.beans.PropertyValues; +import org.springframework.validation.BindException; +import org.springframework.validation.Errors; +import org.springframework.validation.ObjectError; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.multipart.MultipartHttpServletRequest; +import org.springframework.web.servlet.ModelAndView; + +import javax.imageio.ImageIO; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.BufferedOutputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.TreeSet; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static java.util.stream.Collectors.toList; +import static org.labkey.api.data.DbScope.CommitTaskOption.POSTCOMMIT; +import static org.labkey.api.exp.query.ExpSchema.TableType.DataInputs; +import static org.labkey.api.query.QueryImportPipelineJob.QUERY_IMPORT_NOTIFICATION_PROVIDER_PARAM; +import static org.labkey.api.query.QueryImportPipelineJob.QUERY_IMPORT_PIPELINE_DESCRIPTION_PARAM; +import static org.labkey.api.query.QueryImportPipelineJob.QUERY_IMPORT_PIPELINE_PROVIDER_PARAM; +import static org.labkey.api.util.DOM.A; +import static org.labkey.api.util.DOM.Attribute.action; +import static org.labkey.api.util.DOM.Attribute.href; +import static org.labkey.api.util.DOM.Attribute.id; +import static org.labkey.api.util.DOM.Attribute.method; +import static org.labkey.api.util.DOM.Attribute.name; +import static org.labkey.api.util.DOM.Attribute.size; +import static org.labkey.api.util.DOM.Attribute.src; +import static org.labkey.api.util.DOM.Attribute.target; +import static org.labkey.api.util.DOM.Attribute.type; +import static org.labkey.api.util.DOM.Attribute.value; +import static org.labkey.api.util.DOM.Attribute.width; +import static org.labkey.api.util.DOM.DIV; +import static org.labkey.api.util.DOM.IMG; +import static org.labkey.api.util.DOM.INPUT; +import static org.labkey.api.util.DOM.LI; +import static org.labkey.api.util.DOM.TABLE; +import static org.labkey.api.util.DOM.TD; +import static org.labkey.api.util.DOM.TR; +import static org.labkey.api.util.DOM.UL; +import static org.labkey.api.util.DOM.at; +import static org.labkey.api.util.DOM.cl; +import static org.labkey.experiment.ExpDataIterators.setContainerFilterForImport; +import static org.labkey.experiment.api.SampleTypeServiceImpl.SampleChangeType.update; + +public class ExperimentController extends SpringActionController +{ + private static final Logger _log = LogManager.getLogger(ExperimentController.class); + private static final DefaultActionResolver _actionResolver = new DefaultActionResolver( + ExperimentController.class + ); + private static final String GUEST_DIRECTORY_NAME = "guest"; + + public ExperimentController() + { + setActionResolver(_actionResolver); + } + + public static void ensureCorrectContainer(Container requestContainer, ExpObject object, ViewContext viewContext) + { + Container objectContainer = object.getContainer(); + if (!requestContainer.equals(objectContainer)) + { + ActionURL url = viewContext.cloneActionURL(); + url.setContainer(objectContainer); + throw new RedirectException(url); + } + } + + // Complete no-op, but leave in place in case we decide to adjust the base nav trail + private void addRootNavTrail(NavTree root) + { + // Intentionally don't add an "Experiment" node to the list because it's too overloaded. All content on the + // default action can be added to a portal page if desired. + } + + @Override + public PageConfig defaultPageConfig() + { + // set default help topic for controller + PageConfig config = super.defaultPageConfig(); + config.setHelpTopic("experiment"); + return config; + } + + @ActionNames("begin,gridView") + @RequiresPermission(ReadPermission.class) + public class BeginAction extends SimpleViewAction + { + @Override + public VBox getView(Object o, BindException errors) + { + VBox result = new VBox(); + + VBox runListView = createRunListView(20); + result.addView(runListView); + + RunGroupWebPart runGroups = new RunGroupWebPart(getViewContext(), false); + runGroups.showHeader(); + result.addView(runGroups); + + result.addView(new ProtocolWebPart(false, getViewContext())); + result.addView(new SampleTypeWebPart(false, getViewContext())); + result.addView(new DataClassWebPart(false, getViewContext(), null)); + + return result; + } + + @Override + public void addNavTrail(NavTree root) + { + addRootNavTrail(root); + root.addChild("Experiment"); + } + } + + @RequiresPermission(ReadPermission.class) + public class ShowRunsAction extends SimpleViewAction + { + @Override + public VBox getView(Object o, BindException errors) + { + return createRunListView(100); + } + + @Override + public void addNavTrail(NavTree root) + { + addRootNavTrail(root); + root.addChild("Experiment Runs"); + } + } + + private VBox createRunListView(int defaultMaxRows) + { + Set types = ExperimentService.get().getExperimentRunTypes(getContainer()); + ChooseExperimentTypeBean bean = new ChooseExperimentTypeBean(types, ExperimentRunType.getSelectedFilter(types, getViewContext().getRequest().getParameter("experimentRunFilter")), getViewContext().getActionURL().clone(), Collections.emptyList()); + JspView chooserView = new JspView<>("/org/labkey/experiment/experimentRunQueryHeader.jsp", bean); + + ExperimentRunListView view = ExperimentService.get().createExperimentRunWebPart(getViewContext(), bean.getSelectedFilter()); + view.setFrame(WebPartView.FrameType.NONE); + + // When paginated and the user hasn't explicitly set a maxRows, use the default maxRows size. + QuerySettings settings = view.getSettings(); + if (!settings.isMaxRowsSet() && settings.getShowRows() == ShowRows.PAGINATED) + { + settings.setMaxRows(defaultMaxRows); + } + + VBox result = new VBox(chooserView, view); + result.setFrame(WebPartView.FrameType.PORTAL); + return result; + } + + @RequiresPermission(ReadPermission.class) + @ActionNames("showRunGroups, showExperiments") + public class ShowRunGroupsAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object o, BindException errors) + { + RunGroupWebPart webPart = new RunGroupWebPart(getViewContext(), false); + webPart.setFrame(WebPartView.FrameType.NONE); + return webPart; + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("runGroups"); + addRootNavTrail(root); + root.addChild("Run Groups"); + } + } + + public record Field(String domainURI, String domainName, String name, Container container) {} + public record MiniExpObject(Object rowId, String name) {} + public record TimelineSummary(MiniExpObject miniExpObject, String mostRecentValue) {} + public record ProblemType(String tableName, String fieldName, String pkName) { + public Object toHtml(List summaries) + { + return DOM.DIV( + DOM.H4(tableName), + DOM.TABLE(at(cl("table-condensed", "labkey-data-region", "table-bordered")), + DOM.THEAD(DOM.TH(pkName), DOM.TH(fieldName)), + summaries.stream().map(summary -> + DOM.TR(DOM.TD(summary.miniExpObject.name), DOM.TD(summary.mostRecentValue))) + )); + } + } + + @RequiresPermission(SiteAdminPermission.class) + public static class ReportLostFieldValuesAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object o, BindException errors) + { + // Find all the fields that could have lost data due to issue 52666 + TableInfo t = new ExpSchema(getUser(), ContainerManager.getRoot()).getTable(ExpSchema.TableType.Fields.name(), ContainerFilter.getUnsafeEverythingFilter()); + List fields = new TableSelector(t, + new SimpleFilter(FieldKey.fromParts("StorageColumnNameMatch"), false). + addCondition(FieldKey.fromParts("DomainURI"), ":AssayDomain-Data.", CompareType.DOES_NOT_CONTAIN), + null). + getArrayList(Field.class); + + // Prep audit table for querying + UserSchema auditSchema = AuditLogService.get().createSchema(getUser(), ContainerManager.getRoot()); + + Map> sampleTypeSummaries = new HashMap<>(); + Map> dataClassSummaries = new HashMap<>(); + Map> listSummaries = new HashMap<>(); + + Map> problematicFields = new LinkedHashMap<>(); + + for (Field field : fields) + { + String domainURI = field.domainURI; + String fieldName = field.name; + Container container = field.container; + Domain domain = PropertyService.get().getDomain(container, domainURI); + if (domain != null && domain.getDomainKind() != null) + { + TableInfo table = domain.getDomainKind().getTableInfo(getUser(), container, domain, ContainerFilter.getUnsafeEverythingFilter()); + + if (table != null) + { + // Drill into sample types + if (domain.getDomainKind().getClass().equals(SampleTypeDomainKind.class)) + { + // rows that currently have no value for the field with potential for data loss + List rowsWithNull = new TableSelector(table, + new HashSet<>(List.of("RowId", "Name")), + new SimpleFilter(new CompareType.CompareClause(FieldKey.fromParts(fieldName), CompareType.ISBLANK, null)), + null). + getArrayList(MiniExpObject.class); + + List fixupsNeeded = checkData( + rowsWithNull, + fieldName, + obj -> new SimpleFilter(FieldKey.fromParts("SampleId"), obj.rowId), + auditSchema.getTable(SampleTimelineAuditEvent.EVENT_TYPE, ContainerFilter.getUnsafeEverythingFilter())); + if (!fixupsNeeded.isEmpty()) + { + sampleTypeSummaries.put(new ProblemType(table.getName(), fieldName, "SampleID"), fixupsNeeded); + } + } + // and data classes/sample sources + if (domain.getDomainKind().getClass().equals(DataClassDomainKind.class)) + { + // rows samples that current have no value for the field with potential for data loss + List rowsWithNull = new TableSelector(table, + new HashSet<>(List.of("RowId", "Name")), + new SimpleFilter(new CompareType.CompareClause(FieldKey.fromParts(fieldName), CompareType.ISBLANK, null)), + null). + getArrayList(MiniExpObject.class); + + List fixupsNeeded = checkData( + rowsWithNull, + fieldName, + obj -> new SimpleFilter(FieldKey.fromParts("RowPk"), Objects.toString(obj.rowId)). + addCondition(FieldKey.fromParts("SchemaName"), "exp.data"). + addCondition(FieldKey.fromParts("QueryName"), domain.getName()), + auditSchema.getTable("QueryUpdateAuditEvent", ContainerFilter.getUnsafeEverythingFilter())); + + if (!fixupsNeeded.isEmpty()) + { + dataClassSummaries.put(new ProblemType(table.getName(), fieldName, "SourceID"), fixupsNeeded); + } + } + // and lists + if ("lists".equals(table.getUserSchema().getName())) + { + // rows samples that current have no value for the field with potential for data loss + List rowsWithNull = new ArrayList<>(); + + ColumnInfo entityIdCol = table.getColumn("EntityId"); + ColumnInfo pkCol = table.getPkColumns().get(0); + + new TableSelector(table, + List.of(entityIdCol, pkCol), + new SimpleFilter(new CompareType.CompareClause(FieldKey.fromParts(fieldName), CompareType.ISBLANK, null)), + null). + forEachResults(r -> + { + Object entityId = entityIdCol.getValue(r); + Object pk = pkCol.getValue(r); + rowsWithNull.add(new MiniExpObject(entityId, pk.toString())); + }); + + + List fixupsNeeded = checkData( + rowsWithNull, + fieldName, + obj -> new SimpleFilter(FieldKey.fromParts("ListItemEntityId"), obj.rowId), + auditSchema.getTable("ListAuditEvent", ContainerFilter.getUnsafeEverythingFilter())); + + if (!fixupsNeeded.isEmpty()) + { + listSummaries.put(new ProblemType(table.getName(), fieldName, table.getPkColumnNames().get(0)), fixupsNeeded); + } + } + + long totalRows = new TableSelector(table).getRowCount(); + long emptyRows = new TableSelector(table, new SimpleFilter(new CompareType.CompareClause(FieldKey.fromParts(fieldName), CompareType.ISBLANK, null)), null).getRowCount(); + problematicFields.put(field, Pair.of(totalRows, emptyRows)); + } + else + { + problematicFields.put(field, Pair.of(null, null)); + } + } + } + + return new HtmlView("Fixups Needed", + DOM.createHtmlFragment( + DOM.H2("Potentially Problematic Fields"), + problematicFields.isEmpty() ? "No problematic fields detected!" : + DOM.TABLE(at(cl("table-condensed", "labkey-data-region", "table-bordered")), + DOM.THEAD(DOM.TH("Domain Name"), DOM.TH("Domain URI"), DOM.TH("Field Name"), DOM.TH("Container"), DOM.TH("Total Rows"), DOM.TH("Rows with Nulls")), + problematicFields.entrySet().stream().map(e -> { + Field f = e.getKey(); + Pair counts = e.getValue(); + return DOM.TR( + DOM.TD(f.domainName), + DOM.TD(f.domainURI), + DOM.TD(f.name), + DOM.TD(f.container.getPath()), + DOM.TD(counts.first), + DOM.TD(counts.second) + ); + } + )), + + DOM.H2("Sample Types"), + sampleTypeSummaries.isEmpty() ? "No problems detected!" : + sampleTypeSummaries.entrySet().stream().map(e -> + e.getKey().toHtml(e.getValue())), + + DOM.H2("Data Classes"), + dataClassSummaries.isEmpty() ? "No problems detected!" : + dataClassSummaries.entrySet().stream().map(e -> + e.getKey().toHtml(e.getValue())), + + DOM.H2("Lists"), + listSummaries.isEmpty() ? "No problems detected!" : + listSummaries.entrySet().stream().map(e -> + e.getKey().toHtml(e.getValue())) + )); + } + + @NotNull + private List checkData(List rowsWithNull, String fieldName, Function filterGenerator, TableInfo auditTable) + { + List fixupsNeeded = new ArrayList<>(); + + // For each sample without a value today, check the audit history + for (MiniExpObject row : rowsWithNull) + { + // Order by RowId to get them in the sequence they happened in + var events = new TableSelector(auditTable, filterGenerator.apply(row), new Sort("RowId")).getArrayList(DetailedAuditTypeEvent.class); + // Remember the most recently set value + String mostRecentValue = null; + for (DetailedAuditTypeEvent event : events) + { + Map newValues = new CaseInsensitiveHashMap<>(AbstractAuditTypeProvider.decodeFromDataMap(event.getNewRecordMap())); + if (newValues.containsKey(fieldName)) + { + // Will be the empty string if the value was intentionally set to blank + mostRecentValue = newValues.get(fieldName); + } + } + // If the value had been set before, and its most recent insert/update wasn't setting it blank, + // it's most likely a lost value + if (mostRecentValue != null && !mostRecentValue.isEmpty()) + { + fixupsNeeded.add(new TimelineSummary(row, mostRecentValue)); + } + } + return fixupsNeeded; + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("Accidentally Nulled Field Report"); + } + } + + @RequiresPermission(ReadPermission.class) + public static class CreateHiddenRunGroupAction extends MutatingApiAction + { + @Override + public ApiResponse execute(SimpleApiJsonForm form, BindException errors) throws Exception + { + JSONObject json = form.getJsonObject(); + String selectionKey = json.optString("selectionKey", null); + List runs = new ArrayList<>(); + + // Accept either an explicit list of run IDs + if (json.has("runIds")) + { + JSONArray runIds = json.getJSONArray("runIds"); + for (int i = 0; i < runIds.length(); i++) + { + ExpRunImpl run = ExperimentServiceImpl.get().getExpRun(runIds.getInt(i)); + if (run != null) + { + runs.add(run); + } + } + } + // Or a reference to a DataRegion selection key + else if (selectionKey != null) + { + Set ids = DataRegionSelection.getSelectedIntegers(getViewContext(), selectionKey, false); + for (Long id : ids) + { + ExpRunImpl run = ExperimentServiceImpl.get().getExpRun(id); + if (run != null) + { + runs.add(run); + } + } + } + if (runs.isEmpty()) + { + throw new NotFoundException(); + } + + ExpExperiment group = ExperimentService.get().createHiddenRunGroup(getContainer(), getUser(), runs.toArray(new ExpRun[0])); + if (selectionKey != null) + DataRegionSelection.clearAll(getViewContext(), selectionKey); + + ApiSimpleResponse response = new ApiSimpleResponse(); + response.putBean(group, "rowId", "LSID", "name", "hidden"); + return response; + } + } + + + @RequiresPermission(ReadPermission.class) + public class DetailsAction extends QueryViewAction + { + private ExpExperimentImpl _experiment; + + public DetailsAction() + { + super(ExpObjectForm.class); + } + + private Pair> createViews(ExpObjectForm form, BindException errors) + { + _experiment = ExperimentServiceImpl.get().getExpExperiment(form.getRowId()); + if (_experiment == null) + { + throw new NotFoundException("Could not find an experiment with RowId " + form.getRowId()); + } + + if (!_experiment.getContainer().equals(getContainer())) + { + throw new RedirectException(getViewContext().cloneActionURL().setContainer(_experiment.getContainer())); + } + + List protocols = _experiment.getAllProtocols(); + + Set types = new TreeSet<>(ExperimentService.get().getExperimentRunTypes(getContainer())); + ExperimentRunType selectedType = ExperimentRunType.getSelectedFilter(types, getViewContext().getRequest().getParameter("experimentRunFilter")); + + ChooseExperimentTypeBean bean = new ChooseExperimentTypeBean(types, selectedType, getViewContext().getActionURL().clone(), protocols); + JspView chooserView = new JspView<>("/org/labkey/experiment/experimentRunQueryHeader.jsp", bean, errors); + + ExperimentRunListView runListView = ExperimentRunListView.createView(getViewContext(), bean.getSelectedFilter(), true); + runListView.getRunTable().setExperiment(_experiment); + runListView.setShowRemoveFromExperimentButton(true); + runListView.setShowDeleteButton(true); + runListView.setShowAddToRunGroupButton(true); + runListView.setShowExportButtons(true); + runListView.setShowMoveRunsButton(true); + return new Pair<>(runListView, chooserView); + } + + @Override + protected ModelAndView getHtmlView(ExpObjectForm form, BindException errors) throws Exception + { + Pair> views = createViews(form, errors); + + CustomPropertiesView customPropertiesView = new CustomPropertiesView(_experiment.getLSID(), getContainer()); + + TableInfo runGroupsTable = new ExpSchema(getUser(), getContainer()).getTable(ExpSchema.TableType.RunGroups); + + DetailsView detailsView = new DetailsView(new DataRegion(), _experiment.getRowId()); + detailsView.getDataRegion().setTable(runGroupsTable); + detailsView.getDataRegion().addColumns(runGroupsTable, "RowId,Name,Created,Modified,Contact,ExperimentDescriptionURL,Hypothesis,Comments"); + detailsView.getDataRegion().getDisplayColumn(0).setVisible(false); + detailsView.getDataRegion().getDisplayColumn(2).setWidth("60%"); + + ButtonBar bb = new ButtonBar(); + bb.setStyle(ButtonBar.Style.separateButtons); + ActionButton b = new ActionButton(ExperimentUrlsImpl.get().getShowUpdateURL(_experiment), "Edit"); + b.setDisplayPermission(UpdatePermission.class); + bb.add(b); + detailsView.getDataRegion().setButtonBar(bb); + if (_experiment.getBatchProtocol() != null) + { + detailsView.setTitle("Batch Details"); + detailsView.getDataRegion().addColumns(runGroupsTable, "BatchProtocolId"); + } + else + { + detailsView.setTitle("Run Group Details"); + } + + VBox runsVBox = new VBox(views.second, createInitializedQueryView(form, errors, false, null)); + runsVBox.setTitle("Experiment Runs"); + runsVBox.setFrame(WebPartView.FrameType.PORTAL); + + return new VBox(new StandardAndCustomPropertiesView(detailsView, customPropertiesView), runsVBox); + } + + @Override + protected ExperimentRunListView createQueryView(ExpObjectForm form, BindException errors, boolean forExport, String dataRegion) + { + return createViews(form, errors).first; + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("runGroups"); + addRootNavTrail(root); + root.addChild("Run Groups", ExperimentUrlsImpl.get().getShowExperimentsURL(getContainer())); + root.addChild(_experiment.getName()); + } + } + + @RequiresPermission(ReadPermission.class) + public class ListSampleTypesAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object o, BindException errors) + { + SampleTypeWebPart view = new SampleTypeWebPart(false, getViewContext()); + view.setFrame(WebPartView.FrameType.NONE); + view.setErrorMessage(getViewContext().getRequest().getParameter("errorMessage")); + + return view; + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("sampleSets"); + addRootNavTrail(root); + root.addChild("Sample Types"); + } + } + + @RequiresPermission(ReadPermission.class) + public class ShowSampleTypeAction extends SimpleViewAction + { + private ExpSampleTypeImpl _sampleType; + + @Override + public ModelAndView getView(ExpObjectForm form, BindException errors) + { + _sampleType = SampleTypeServiceImpl.get().getSampleType(getContainer(), getUser(), form.getRowId()); + if (_sampleType == null && form.getLsid() != null) + { + if (form.getLsid().equalsIgnoreCase("Material") || form.getLsid().equalsIgnoreCase("Sample")) + { + // Not a real sample type - just show all the materials instead + throw new RedirectException(new ActionURL(ShowAllMaterialsAction.class, getContainer())); + } + // Check if the URL specifies the LSID, and stick the bean back into the form + _sampleType = SampleTypeServiceImpl.get().getSampleType(form.getLsid()); + } + + if (_sampleType == null) + { + throw new NotFoundException("No matching sample type found"); + } + + List allScopedSampleTypes = (List) SampleTypeService.get().getSampleTypes(getContainer(), getUser(), true); + if (!allScopedSampleTypes.contains(_sampleType)) + { + ensureCorrectContainer(getContainer(), _sampleType, getViewContext()); + } + + SamplesSchema schema = new SamplesSchema(getUser(), getContainer()); + QuerySettings settings = schema.getSettings(getViewContext(), "Material", _sampleType.getName()); + QueryView queryView = new SampleTypeContentsView(_sampleType, schema, settings, errors); + + DetailsView detailsView = new DetailsView(getSampleTypeRegion(getViewContext()), _sampleType.getRowId()); + detailsView.getDataRegion().getDisplayColumn("Name").setURL((ActionURL)null); + detailsView.getDataRegion().getDisplayColumn("LSID").setVisible(false); + detailsView.getDataRegion().getDisplayColumn("MaterialLSIDPrefix").setVisible(false); + detailsView.getDataRegion().getDisplayColumn("LabelColor").setVisible(false); + detailsView.getDataRegion().getDisplayColumn("MetricUnit").setVisible(false); + detailsView.getDataRegion().getDisplayColumn("Category").setVisible(false); + + detailsView.setTitle("Sample Type Properties"); + detailsView.getDataRegion().getButtonBar(DataRegion.MODE_DETAILS).setStyle(ButtonBar.Style.separateButtons); + + Container autoLinkContainer = _sampleType.getAutoLinkTargetContainer(); + if (null != autoLinkContainer) + { + DisplayColumn autoLinkTargetColumn = detailsView.getDataRegion().getDisplayColumn("autoLinkTargetContainer"); + autoLinkTargetColumn.setVisible(false); + + SimpleDisplayColumn displayAutoLinkTargetColumn = new SimpleDisplayColumn(); + displayAutoLinkTargetColumn.setCaption("Auto Link Target Container:"); + String path = autoLinkContainer.getPath(); + displayAutoLinkTargetColumn.setDisplayHtml(path.equals("/") ? "" : path); + detailsView.getDataRegion().addDisplayColumn(displayAutoLinkTargetColumn); + } + + DisplayColumn autoLinkCategoryColumn = detailsView.getDataRegion().getDisplayColumn("autoLinkCategory"); + autoLinkCategoryColumn.setVisible(false); + SimpleDisplayColumn displayAutoLinkCategoryColumn = new SimpleDisplayColumn(); + displayAutoLinkCategoryColumn.setCaption("Auto Link Category:"); + displayAutoLinkCategoryColumn.setDisplayHtml(_sampleType.getAutoLinkCategory()); + detailsView.getDataRegion().addDisplayColumn(displayAutoLinkCategoryColumn); + + if (_sampleType.hasNameAsIdCol()) + { + SimpleDisplayColumn nameIdCol = new SimpleDisplayColumn(); + nameIdCol.setCaption("Has Name Id Column:"); + nameIdCol.setDisplayHtml("true"); + detailsView.getDataRegion().addDisplayColumn(nameIdCol); + } + + if (_sampleType.hasIdColumns()) + { + SimpleDisplayColumn idCols = new SimpleDisplayColumn(); + idCols.setCaption("Id Column(s):"); + String names = _sampleType.getIdCols().stream() + .filter(Objects::nonNull) + .map(DomainProperty::getName) + .collect(Collectors.joining(", ")); + if (!names.isEmpty()) + { + idCols.setDisplayHtml(PageFlowUtil.filter(names)); + detailsView.getDataRegion().addDisplayColumn(idCols); + } + } + + if (_sampleType.getParentCol() != null) + { + SimpleDisplayColumn parentCol = new SimpleDisplayColumn(PageFlowUtil.filter(_sampleType.getParentCol().getName())); + parentCol.setCaption("Parent Column:"); + detailsView.getDataRegion().addDisplayColumn(parentCol); + } + + try + { + SimpleDisplayColumn importAliasCol = new SimpleDisplayColumn(); + importAliasCol.setCaption("Parent Import Alias(es):"); + if (!_sampleType.getImportAliases().isEmpty()) + importAliasCol.setDisplayHtml(PageFlowUtil.filter(StringUtils.join(_sampleType.getImportAliases().keySet(), ", "))); + detailsView.getDataRegion().addDisplayColumn(importAliasCol); + } + catch (IOException e) + { + // unable to parse import alias map from JSON + } + + if (!getContainer().equals(_sampleType.getContainer())) + { + ActionURL definitionURL = urlProvider(ExperimentUrls.class).getShowSampleTypeURL(_sampleType); + SimpleDisplayColumn definedInCol = new SimpleDisplayColumn("" + + PageFlowUtil.filter(_sampleType.getContainer().getPath()) + + ""); + definedInCol.setCaption("Defined In:"); + detailsView.getDataRegion().addDisplayColumn(definedInCol); + } + + // Not all sample types can be edited + DomainKind domainKind = _sampleType.getDomain().getDomainKind(); + if (domainKind != null && domainKind.canEditDefinition(getUser(), _sampleType.getDomain())) + { + if (domainKind instanceof SampleTypeDomainKind) + { + ActionURL updateURL = new ActionURL(EditSampleTypeAction.class, _sampleType.getContainer()); + updateURL.addParameter("RowId", _sampleType.getRowId()); + updateURL.addReturnUrl(getViewContext().getActionURL()); + + if (!getContainer().equals(_sampleType.getContainer())) + { + String editLink = updateURL.toString(); + ActionButton updateButton = new ActionButton("Edit Type"); + updateButton.setActionType(ActionButton.Action.SCRIPT); + updateButton.setScript("if (window.confirm('This sample type is defined in the " + _sampleType.getContainer().getPath() + " folder. Would you still like to edit it?')) { window.location = '" + editLink + "' }"); + detailsView.getDataRegion().getButtonBar(DataRegion.MODE_DETAILS).add(updateButton); + } + else + { + ActionButton updateButton = new ActionButton(updateURL, "Edit Type", ActionButton.Action.LINK); + updateButton.setDisplayPermission(DesignSampleTypePermission.class); + updateButton.setPrimary(true); + detailsView.getDataRegion().getButtonBar(DataRegion.MODE_DETAILS).add(updateButton); + } + + ActionURL deleteURL = new ActionURL(DeleteSampleTypesAction.class, _sampleType.getContainer()); + deleteURL.addParameter("singleObjectRowId", _sampleType.getRowId()); + deleteURL.addReturnUrl(ExperimentUrlsImpl.get().getShowSampleTypeListURL(getContainer())); + ActionButton deleteButton = new ActionButton(deleteURL, "Delete Type", ActionButton.Action.LINK); + deleteButton.setDisplayPermission(DesignSampleTypePermission.class); + detailsView.getDataRegion().getButtonBar(DataRegion.MODE_DETAILS).add(deleteButton); + } + else + { + ActionURL editURL = domainKind.urlEditDefinition(_sampleType.getDomain(), new ViewBackgroundInfo(_sampleType.getContainer(), getUser(), getViewContext().getActionURL())); + if (editURL != null) + { + editURL.addReturnUrl(getViewContext().getActionURL()); + ActionButton editTypeButton = new ActionButton(editURL, "Edit Fields"); + editTypeButton.setDisplayPermission(UpdatePermission.class); + detailsView.getDataRegion().getButtonBar(DataRegion.MODE_DETAILS).add(editTypeButton); + } + } + } + + if (_sampleType.canImportMoreSamples()) + { + TableInfo table = queryView.getTable(); + if (table != null) + { + ActionURL importURL = table.getImportDataURL(getContainer()); + if (importURL != null) + { + importURL = importURL.clone(); + importURL.addReturnUrl(getViewContext().getActionURL()); + ActionButton uploadButton = new ActionButton(importURL, "Import More Samples", ActionButton.Action.LINK); + uploadButton.setDisplayPermission(UpdatePermission.class); + detailsView.getDataRegion().getButtonBar(DataRegion.MODE_DETAILS).add(uploadButton); + } + } + } + + var publish = StudyPublishService.get(); + if (AuditLogService.get().isViewable() && publish != null) + { + ContainerFilter cf = ContainerFilter.Type.CurrentAndSubfolders.create(getContainer(), getUser()); + ActionURL linkToStudyHistoryURL = publish.getPublishHistory(getContainer(), Dataset.PublishSource.SampleType, _sampleType.getRowId(), cf); + ActionButton linkToStudyHistoryButton = new ActionButton(linkToStudyHistoryURL, "Link to Study History", ActionButton.Action.LINK); + linkToStudyHistoryButton.setDisplayPermission(InsertPermission.class); + detailsView.getDataRegion().getButtonBar(DataRegion.MODE_DETAILS).add(linkToStudyHistoryButton); + } + + return new VBox(detailsView, queryView); + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("sampleSets"); + ActionURL url = ExperimentUrls.get().getShowSampleTypeListURL(getContainer()); + addRootNavTrail(root); + root.addChild("Sample Types", url); + root.addChild("Sample Type " + _sampleType.getName()); + } + } + + @RequiresPermission(ReadPermission.class) + public class ShowAllMaterialsAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object o, BindException errors) + { + ExpSchema schema = new ExpSchema(getUser(), getContainer()); + QuerySettings settings = schema.getSettings(getViewContext(), "Materials", ExpSchema.TableType.Materials.toString()); + QueryView view = new QueryView(schema, settings, errors) + { + @Override + protected void populateButtonBar(DataView view, ButtonBar bar) + { + super.populateButtonBar(view, bar); + bar.add(SampleTypeContentsView.getDeriveSamplesButton(getContainer(),null)); + } + }; + view.setShowDetailsColumn(false); + return view; + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("sampleSets"); + addRootNavTrail(root); + root.addChild("All Materials"); + } + } + + /** + * Only shows standard and custom properties, not parent and child samples. Used for indexing + */ + @RequiresPermission(ReadPermission.class) + public class ShowMaterialSimpleAction extends SimpleViewAction + { + protected ExpMaterialImpl _material; + + @Override + public VBox getView(ExpObjectForm form, BindException errors) throws Exception + { + Container c = getContainer(); + _material = ExperimentServiceImpl.get().getExpMaterial(form.getRowId()); + if (_material == null && form.getLsid() != null) + { + _material = ExperimentServiceImpl.get().getExpMaterial(form.getLsid()); + } + if (_material == null) + { + throw new NotFoundException("Could not find a material with RowId " + form.getRowId()); + } + + ensureCorrectContainer(getContainer(), _material, getViewContext()); + + ExpRunImpl run = _material.getRun(); + ExpProtocol sourceProtocol = _material.getSourceProtocol(); + ExpProtocolApplication sourceProtocolApplication = _material.getSourceApplication(); + + DataRegion dr = new DataRegion(); + dr.addColumns(ExperimentServiceImpl.get().getTinfoMaterial().getUserEditableColumns()); + dr.removeColumns("RowId", "RunId", "LastIndexed", "LSID", "SourceApplicationId", "CpasType"); + + //dr.addColumns(extraProps); + dr.addDisplayColumn(new ExperimentRunDisplayColumn(run, "Source Experiment Run")); + dr.addDisplayColumn(new ProtocolDisplayColumn(sourceProtocol, "Source Protocol")); + dr.addDisplayColumn(new ProtocolApplicationDisplayColumn(sourceProtocolApplication, "Source Protocol Application")); + dr.addDisplayColumn(new LineageGraphDisplayColumn(_material, run)); + dr.addDisplayColumn(new SampleTypeDisplayColumn(_material)); + + //TODO: Can't yet edit materials uploaded from a material source + dr.setButtonBar(new ButtonBar()); + DetailsView detailsView = new DetailsView(dr, _material.getRowId()); + detailsView.setTitle("Standard Properties"); + detailsView.setFrame(WebPartView.FrameType.PORTAL); + + CustomPropertiesView cpv = new CustomPropertiesView(_material, c, getUser()); + + return new VBox(new StandardAndCustomPropertiesView(detailsView, cpv)); + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("sampleSets"); + addRootNavTrail(root); + root.addChild("Sample Types", ExperimentUrlsImpl.get().getShowSampleTypeListURL(getContainer())); + ExpSampleType sampleType = _material.getSampleType(); + if (sampleType != null) + { + root.addChild(sampleType.getName(), ExperimentUrlsImpl.get().getShowSampleTypeURL(sampleType)); + } + root.addChild("Sample " + _material.getName()); + } + } + + @RequiresPermission(ReadPermission.class) + public class ShowMaterialAction extends ShowMaterialSimpleAction + { + @Override + public VBox getView(ExpObjectForm form, BindException errors) throws Exception + { + VBox vbox = super.getView(form, errors); + + List materialsToInvestigate = new ArrayList<>(); + final Set successorRuns = new HashSet<>(); + materialsToInvestigate.add(_material); + Set investigatedMaterials = new HashSet<>(); + do + { + // Query for all the next tier of materials at once - issue 45402 + List followupRuns = ExperimentService.get().getRunsUsingMaterials(materialsToInvestigate); + + // Mark this set as investigated and reset for the next cycle + investigatedMaterials.addAll(materialsToInvestigate); + materialsToInvestigate = new ArrayList<>(); + + for (ExpRun r : followupRuns) + { + // Only expand the material outputs of the run if it's our first time visiting it + if (successorRuns.add(r)) + { + materialsToInvestigate.addAll(r.getMaterialOutputs()); + } + } + + if (successorRuns.size() > 1000) + { + // Give up - there may be a cycle or other problematic data + break; + } + + // Cull the ones we've already looked up + materialsToInvestigate.removeAll(investigatedMaterials); + } + while (!materialsToInvestigate.isEmpty()); + + HtmlStringBuilder updateLinks = HtmlStringBuilder.of(); + ExpSampleType st = _material.getSampleType(); + if (st != null && st.getContainer() != null && st.getContainer().hasPermission(getUser(), UpdatePermission.class)) + { + // XXX: ridiculous amount of work to get a update url expression for the sample type's table. + UserSchema samplesSchema = QueryService.get().getUserSchema(getUser(), st.getContainer(), "Samples"); + QueryDefinition queryDef = samplesSchema.getQueryDefForTable(st.getName()); + StringExpression expr = queryDef.urlExpr(QueryAction.updateQueryRow, null); + if (expr != null) + { + // Since we're building a detailsURL outside the context of a "row" need to set the correct + // container context on the generated expr. + ((DetailsURL) expr).setContainerContext(st.getContainer()); + String url = expr.eval(Collections.singletonMap(new FieldKey(null, "RowId"), _material.getRowId())); + updateLinks.append(LinkBuilder.labkeyLink("edit", url)).append(" "); + } + } + + if (getContainer().hasPermission(getUser(), InsertPermission.class)) + { + ActionURL deriveURL = new ActionURL(DeriveSamplesChooseTargetAction.class, getContainer()); + deriveURL.addParameter("rowIds", _material.getRowId()); + if (st != null) + deriveURL.addParameter("targetSampleTypeId", st.getRowId()); + + updateLinks.append(LinkBuilder.labkeyLink("derive samples from this sample", deriveURL)).append(" "); + } + + vbox.addView(new HtmlView(updateLinks)); + + ExperimentRunListView runListView = ExperimentRunListView.createView(getViewContext(), ExperimentRunType.ALL_RUNS_TYPE, true); + runListView.setShowRecordSelectors(false); + runListView.getRunTable().setRuns(successorRuns); + runListView.getRunTable().setContainerFilter(new ContainerFilter.AllFolders(getUser())); + runListView.setAllowableContainerFilterTypes(ContainerFilter.Type.Current, ContainerFilter.Type.CurrentAndSubfolders, ContainerFilter.Type.AllFolders); + runListView.setTitle("Runs associated with this material or a derived material"); + + ParentChildView pv = new ParentChildView(_material, getViewContext()); + vbox.addView(pv); + vbox.addView(runListView); + + return vbox; + } + } + + + // + // DataClass + // + + @RequiresPermission(ReadPermission.class) + public class ListDataClassAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object o, BindException errors) + { + DataClassWebPart view = new DataClassWebPart(false, getViewContext(), null); + view.setFrame(WebPartView.FrameType.NONE); + view.setErrorMessage(getViewContext().getRequest().getParameter("errorMessage")); + + return view; + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("dataClass"); + addRootNavTrail(root); + root.addChild("Data Classes"); + } + } + + public static class DataClassForm extends ExpObjectForm + { + private String _name; + + public String getName() + { + return _name; + } + + public void setName(String name) + { + _name = name; + } + + public ExpDataClassImpl getDataClass(@Nullable Container container) + { + ExpDataClassImpl dataClass = null; + + if (getName() != null) + { + dataClass = ExperimentServiceImpl.get().getDataClass(getContainer(), getUser(), getName()); + if (dataClass == null) + throw new NotFoundException("No data class found for name '" + getName() + "'."); + } + else if (getRowId() > 0) + { + dataClass = ExperimentServiceImpl.get().getDataClass(getContainer(), getUser(), getRowId()); + } + + if (dataClass == null) + throw new NotFoundException("No data class found."); + else if (container != null && !container.equals(dataClass.getContainer())) + throw new NotFoundException("Data class is not defined in the given container."); + + return dataClass; + } + } + + @RequiresPermission(ReadPermission.class) + public class ShowDataClassAction extends SimpleViewAction + { + private ExpDataClassImpl _dataClass; + + @Override + public ModelAndView getView(DataClassForm form, BindException errors) + { + _dataClass = form.getDataClass(null); + return new VBox(getDataClassPropertiesView(), getDataClassContentsView(errors)); + } + + private DetailsView getDataClassPropertiesView() + { + ExpSchema expSchema = new ExpSchema(getUser(), _dataClass.getContainer()); + + TableInfo table = ExpSchema.TableType.DataClasses.createTable(expSchema, null, null); + QueryUpdateForm tvf = new QueryUpdateForm(table, getViewContext(), null); + tvf.setPkVal(_dataClass.getRowId()); + DetailsView detailsView = new DetailsView(tvf); + detailsView.setTitle("Data Class Properties"); + + ButtonBar bb = new ButtonBar(); + bb.setStyle(ButtonBar.Style.separateButtons); + boolean inDefinitionContainer = getContainer().equals(_dataClass.getContainer()); + + DomainKind domainKind = _dataClass.getDomain().getDomainKind(); + if (domainKind != null && domainKind.canEditDefinition(getUser(), _dataClass.getDomain())) + { + ActionURL updateURL = new ActionURL(EditDataClassAction.class, _dataClass.getContainer()); + updateURL.addParameter("rowId", _dataClass.getRowId()); + updateURL.addReturnUrl(urlProvider(ExperimentUrls.class).getShowDataClassURL(_dataClass.getContainer(), _dataClass.getRowId())); + + if (inDefinitionContainer) + { + ActionButton updateButton = new ActionButton(updateURL, "Edit Data Class", ActionButton.Action.LINK); + updateButton.setDisplayPermission(DesignDataClassPermission.class); + updateButton.setPrimary(true); + bb.add(updateButton); + } + else if (_dataClass.getContainer().hasPermission(getUser(), DesignDataClassPermission.class)) + { + ActionButton updateButton = new ActionButton("Edit Data Class"); + updateButton.setActionType(ActionButton.Action.SCRIPT); + updateButton.setScript("if (window.confirm('This data class is defined in the " + _dataClass.getContainer().getPath() + " folder. Would you still like to edit it?')) { window.location = '" + updateURL + "' }"); + updateButton.setPrimary(true); + bb.add(updateButton); + } + + ActionURL deleteURL = new ActionURL(DeleteDataClassAction.class, _dataClass.getContainer()); + deleteURL.addParameter("singleObjectRowId", _dataClass.getRowId()); + deleteURL.addReturnUrl(ExperimentUrlsImpl.get().getDataClassListURL(getContainer())); + ActionButton deleteButton = new ActionButton(deleteURL, "Delete Data Class", ActionButton.Action.LINK); + + if (inDefinitionContainer) + { + deleteButton.setDisplayPermission(DesignDataClassPermission.class); + bb.add(deleteButton); + } + else if (_dataClass.getContainer().hasPermission(getUser(), DesignDataClassPermission.class)) + { + bb.add(deleteButton); + } + } + detailsView.getDataRegion().setButtonBar(bb); + + if (!inDefinitionContainer) + { + ActionURL definitionURL = urlProvider(ExperimentUrls.class).getShowDataClassURL(_dataClass.getContainer(), _dataClass.getRowId()); + LinkBuilder link = LinkBuilder.simpleLink(_dataClass.getContainer().getPath(), definitionURL); + SimpleDisplayColumn definedInCol = new SimpleDisplayColumn(link.toString()); + definedInCol.setCaption("Defined In:"); + detailsView.getDataRegion().addDisplayColumn(definedInCol); + } + + return detailsView; + } + + private QueryView getDataClassContentsView(BindException errors) + { + UserSchema dataClassSchema = QueryService.get().getUserSchema(getUser(), getContainer(), ExpSchema.SCHEMA_EXP_DATA); + QuerySettings settings = dataClassSchema.getSettings(getViewContext(), QueryView.DATAREGIONNAME_DEFAULT, _dataClass.getName()); + + return new QueryView(dataClassSchema, settings, errors) + { + @Override + public @NotNull LinkedHashSet getClientDependencies() + { + LinkedHashSet resources = super.getClientDependencies(); + resources.add(ClientDependency.fromPath("Ext4")); + resources.add(ClientDependency.fromPath("dataregion/confirmDelete.js")); + return resources; + } + + @Override + public ActionButton createDeleteButton() + { + ActionButton button = super.createDeleteButton(); + if (button != null) + { + String dependencyText = ExperimentService.get() + .getObjectReferencers() + .stream() + .map(referencer -> referencer.getObjectReferenceDescription(ExpData.class)) + .collect(Collectors.joining(" or ")); + + button.setScript("LABKEY.dataregion.confirmDelete(" + + PageFlowUtil.jsString(getDataRegionName()) + ", " + + PageFlowUtil.jsString(ExpSchema.SCHEMA_EXP_DATA.toString()) + ", " + + PageFlowUtil.jsString(getQueryDef().getName()) + ", " + + "'experiment', 'getDataOperationConfirmationData.api', " + + PageFlowUtil.jsString(getSelectionKey()) + ", " + + "'data object', 'data objects', '" + dependencyText + "', {dataOperation: 'Delete'})"); + button.setRequiresSelection(true); + } + return button; + } + }; + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("dataClass"); + addRootNavTrail(root); + root.addChild("Data Classes", ExperimentUrlsImpl.get().getDataClassListURL(getContainer())); + root.addChild(_dataClass.getName()); + } + } + + @RequiresPermission(DesignDataClassPermission.class) + public class DeleteDataClassAction extends AbstractDeleteAction + { + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("dataClass"); + super.addNavTrail(root); + } + + @Override + protected void deleteObjects(DeleteForm deleteForm) + { + List dataClasses = getDataClasses(deleteForm); + if (!ensureCorrectContainer(dataClasses)) + { + throw new UnauthorizedException(); + } + for (ExpRun run : getRuns(dataClasses)) + { + if (!run.getContainer().hasPermission(getUser(), DeletePermission.class)) + { + throw new UnauthorizedException(); + } + } + for (ExpDataClass dataClass : dataClasses) + { + dataClass.delete(getUser(), deleteForm.getUserComment()); + } + } + + @Override + public ModelAndView getView(DeleteForm deleteForm, boolean reshow, BindException errors) + { + List dataClasses = getDataClasses(deleteForm); + + if (!ensureCorrectContainer(dataClasses)) + { + throw new RedirectException(ExperimentUrlsImpl.get().getDataClassListURL(getContainer(), "To delete a data class, you must be in its folder or project.")); + } + + return new ConfirmDeleteView("Data Class", ShowDataClassAction.class, dataClasses, deleteForm, getRuns(dataClasses)); + } + + private List getDataClasses(DeleteForm deleteForm) + { + List dataClasses = new ArrayList<>(); + for (long rowId : deleteForm.getIds(false)) + { + ExpDataClass dataClass = ExperimentServiceImpl.get().getDataClass(getContainer(), getUser(), rowId); + if (dataClass != null) + { + dataClasses.add(dataClass); + } + } + return dataClasses; + } + + private boolean ensureCorrectContainer(List dataClasses) + { + for (ExpDataClass dataClass : dataClasses) + { + Container sourceContainer = dataClass.getContainer(); + if (!sourceContainer.equals(getContainer())) + { + return false; + } + } + return true; + } + + private List getRuns(List dataClasses) + { + if (!dataClasses.isEmpty()) + { + List runArray = ExperimentService.get().getRunsUsingDataClasses(dataClasses); + return ExperimentService.get().runsDeletedWithInput(runArray); + } + else + { + return Collections.emptyList(); + } + } + } + + @Marshal(Marshaller.Jackson) + @RequiresPermission(ReadPermission.class) + public static class GetDataClassPropertiesAction extends ReadOnlyApiAction + { + @Override + public Object execute(DataClassForm form, BindException errors) throws Exception + { + ExpDataClass dataClass = form.getDataClass(getContainer()); + if (dataClass != null) + return new DataClassDomainKindProperties(dataClass); + else + throw new NotFoundException("Data class does not exist in this container for rowId " + form.getRowId() + "."); + } + } + + @RequiresPermission(DesignDataClassPermission.class) + public static class EditDataClassAction extends SimpleViewAction + { + private ExpDataClassImpl _dataClass; + + @Override + public ModelAndView getView(DataClassForm form, BindException errors) + { + boolean create = form.getLSID() == null && form.getRowId() == 0 && form.getName() == null; + if (!create) + _dataClass = form.getDataClass(getContainer()); + + return ModuleHtmlView.get(ModuleLoader.getInstance().getModule("core"), ModuleHtmlView.getGeneratedViewPath("dataClassDesigner")); + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("dataClass"); + + root.addChild("Data Classes", ExperimentUrlsImpl.get().getDataClassListURL(getContainer())); + if (_dataClass == null) + { + root.addChild("Create Data Class"); + } + else + { + root.addChild(_dataClass.getName(), ExperimentUrlsImpl.get().getShowDataClassURL(getContainer(), _dataClass.getRowId())); + root.addChild("Update Data Class"); + } + } + } + + @RequiresPermission(DesignDataClassPermission.class) + public static class CreateDataClassFromTemplateAction extends FormViewAction + { + private ActionURL _successUrl; + private Map _domainTemplates; + + @Override + public void validateCommand(CreateDataClassFromTemplateForm form, Errors errors) + { + String name = null; + _domainTemplates = DomainTemplateGroup.getAllTemplates(getContainer()); + + if (!_domainTemplates.containsKey(form.getDomainTemplate())) + { + errors.reject(ERROR_MSG, "Unknown template selected: " + form.getDomainTemplate()); + } + else + { + DomainTemplate template = _domainTemplates.get(form.getDomainTemplate()); + name = template.getTemplateName(); + + // Issue 40230: if template includes sample type option, verify that it exists + if (template.getOptions().containsKey("sampleSet")) + { + String sampleTypeName = template.getOptions().get("sampleSet").toString(); + ExpSampleType sampleType = SampleTypeServiceImpl.get().getSampleType(getContainer(), getUser(), sampleTypeName); + if (sampleType == null) + errors.reject(ERROR_MSG, "Unable to find a sample type in this container with name: " + sampleTypeName + "."); + } + } + + if (StringUtils.isBlank(name)) + errors.reject(ERROR_MSG, "DataClass template selection is required."); + else if (ExperimentService.get().getDataClass(getContainer(), getUser(), name) != null) + errors.reject(ERROR_MSG, "DataClass '" + name + "' already exists."); + + } + + @Override + public ModelAndView getView(CreateDataClassFromTemplateForm form, boolean reshow, BindException errors) + { + Set templates = DomainTemplateGroup.getTemplatesForDomainKind(getContainer(), DataClassDomainKind.NAME); + form.setAvailableDomainTemplateNames(templates); + + Set messages = new HashSet<>(); + Map groups = DomainTemplateGroup.getAllGroups(getContainer()); + for (DomainTemplateGroup g : groups.values()) + messages.addAll(g.getErrors()); + form.setXmlParseErrors(messages); + + return new JspView<>("/org/labkey/experiment/createDataClassFromTemplate.jsp", form, errors); + } + + @Override + public boolean handlePost(CreateDataClassFromTemplateForm form, BindException errors) throws Exception + { + DomainTemplate template = _domainTemplates.get(form.getDomainTemplate()); + Domain domain = DomainUtil.createDomain(template, getContainer(), getUser(), form.getName()); + + _successUrl = domain.getDomainKind().urlEditDefinition(domain, getViewContext()); + return true; + } + + @Override + public URLHelper getSuccessURL(CreateDataClassFromTemplateForm form) + { + return _successUrl; + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("dataClass"); + root.addChild("Create Data Class from Template"); + } + } + + public static class CreateDataClassFromTemplateForm extends DataClass + { + private String _domainTemplate; + private Set _availableDomainTemplateNames; + private Set _xmlParseErrors; + private final ReturnUrlForm _returnUrlForm = new ReturnUrlForm(); + + public String getDomainTemplate() + { + return _domainTemplate; + } + + public void setDomainTemplate(String domainTemplate) + { + _domainTemplate = domainTemplate; + } + + public Set getAvailableDomainTemplateNames() + { + return _availableDomainTemplateNames; + } + + public void setAvailableDomainTemplateNames(Set availableDomainTemplateNames) + { + _availableDomainTemplateNames = availableDomainTemplateNames; + } + + public Set getXmlParseErrors() + { + return _xmlParseErrors; + } + + public void setXmlParseErrors(Set xmlParseErrors) + { + _xmlParseErrors = xmlParseErrors; + } + + @Nullable + public String getReturnUrl() + { + return _returnUrlForm.getReturnUrl(); + } + + public void setReturnUrl(String s) + { + _returnUrlForm.setReturnUrl(s); + } + } + + public static class ConceptURIForm + { + private String _conceptURI; + + public String getConceptURI() + { + return _conceptURI; + } + + public void setConceptURI(String conceptURI) + { + _conceptURI = conceptURI; + } + } + + @RequiresPermission(AdminPermission.class) + public static class RemoveConceptMappingAction extends MutatingApiAction + { + @Override + public void validateForm(ConceptURIForm form, Errors errors) + { + if (form.getConceptURI() == null || ConceptURIProperties.getLookup(getContainer(), form.getConceptURI()) == null) + errors.reject(ERROR_MSG, "Concept URI not found: " + form.getConceptURI()); + } + + @Override + public Object execute(ConceptURIForm form, BindException errors) + { + ConceptURIProperties.removeLookup(getContainer(), form.getConceptURI()); + return new ApiSimpleResponse("success", true); + } + } + + @RequiresPermission(ReadPermission.class) + public static class RunAttachmentDownloadAction extends BaseDownloadAction + { + @Nullable + @Override + public Pair getAttachment(AttachmentForm form) + { + if (form.getLsid() == null || form.getName() == null) + throw new NotFoundException("Error: missing required param 'lsid' or 'name'."); + + ExpRun run = ExperimentService.get().getExpRun(form.getLsid()); + if (run == null) + throw new NotFoundException("Run not found: " + form.getLsid()); + + if (!run.getContainer().equals(getContainer())) + { + if (run.getContainer().hasPermission(getUser(), ReadPermission.class)) + throw new RedirectException(getViewContext().cloneActionURL().setContainer(run.getContainer())); + else + throw new NotFoundException("Run not found"); + } + + AttachmentParent parent = new ExpRunAttachmentParent(run); + return new Pair<>(parent, form.getName()); + } + } + + @RequiresPermission(ReadPermission.class) + public static class DataClassAttachmentDownloadAction extends BaseDownloadAction + { + @Nullable + @Override + public Pair getAttachment(AttachmentForm form) + { + if (form.getLsid() == null || form.getName() == null) + throw new NotFoundException("Error: missing required param 'lsid' or 'name'."); + + Lsid lsid = new Lsid(form.getLsid()); + ExpData data = ExperimentServiceImpl.get().getExpData(lsid.toString()); + if (data == null) + throw new NotFoundException("Error: Data object not found for the given LSID: " + lsid); + AttachmentParent parent = new ExpDataClassAttachmentParent(data.getContainer(), lsid); + + return new Pair<>(parent, form.getName()); + } + } + + public static class AttachmentForm extends LsidForm implements BaseDownloadAction.InlineDownloader + { + private String _name; + private boolean _inline = true; + + public String getName() + { + return _name; + } + + public void setName(String name) + { + _name = name; + } + + @Override + public boolean isInline() + { + return _inline; + } + + public void setInline(boolean inline) + { + _inline = inline; + } + } + + // + // END DataClass actions + // + + public static ActionURL getRunGraphURL(Container c, long runId) + { + return new ActionURL(ShowRunGraphAction.class, c).addParameter("rowId", runId); + } + + + @RequiresPermission(ReadPermission.class) + public class ShowRunGraphAction extends AbstractShowRunAction + { + @Override + protected VBox createLowerView(ExpRunImpl experimentRun, BindException errors) + { + return new VBox( + createRunViewTabs(experimentRun, false, true, true), + new ExperimentRunGraphView(experimentRun, false)); + } + } + + + @RequiresPermission(ReadPermission.class) + public static class DownloadGraphAction extends SimpleViewAction + { + @Override + public ModelAndView getView(ExperimentRunForm form, BindException errors) throws Exception + { + boolean detail = form.isDetail(); + String focus = form.getFocus(); + String focusType = form.getFocusType(); + + ExpRunImpl experimentRun = (ExpRunImpl) form.lookupRun(); + ensureCorrectContainer(getContainer(), experimentRun, getViewContext()); + + ExperimentRunGraph.RunGraphFiles files; + try + { + files = ExperimentRunGraph.generateRunGraph(getViewContext(), experimentRun, detail, focus, focusType); + } + catch (ExperimentException e) + { + PageFlowUtil.streamTextAsImage(getViewContext().getResponse(), "ERROR: " + e.getMessage(), 600, 150, Color.RED); + return null; + } + + try + { + PageFlowUtil.streamFile(getViewContext().getResponse(), new File(files.getImageFile().getAbsolutePath()), false); + } + catch (FileNotFoundException e) + { + getViewContext().getResponse().sendRedirect(getViewContext().getRequest().getContextPath() + "/experiment/ExperimentRunNotFound.gif"); + } + finally + { + files.release(); + } + return null; + } + + @Override + public void addNavTrail(NavTree root) + { + throw new UnsupportedOperationException(); + } + } + + private abstract class AbstractShowRunAction extends SimpleViewAction + { + private ExpRunImpl _experimentRun; + + @Override + public ModelAndView getView(ExperimentRunForm form, BindException errors) + { + _experimentRun = (ExpRunImpl) form.lookupRun(); + ensureCorrectContainer(getContainer(), _experimentRun, getViewContext()); + + VBox vbox = new VBox(); + + JspView detailsView = new JspView<>("/org/labkey/experiment/ExperimentRunDetails.jsp", _experimentRun); + detailsView.setTitle("Standard Properties"); + + var attachmentParent = new ExpRunAttachmentParent(_experimentRun); + var attachments = AttachmentService.get().getAttachments(attachmentParent) + .stream() + .map(att -> Pair.of(att.getName(), new ActionURL(RunAttachmentDownloadAction.class, _experimentRun.getContainer()).addParameter("name", att.getName()).addParameter("lsid", _experimentRun.getLSID()))) + .collect(toList()); + CustomPropertiesView cpv = new CustomPropertiesView(_experimentRun.getLSID(), getContainer(), attachments); + + vbox.addView(new StandardAndCustomPropertiesView(detailsView, cpv)); + + HtmlStringBuilder updateLinks = HtmlStringBuilder.of(); + List runEditors = ExperimentService.get().getRunEditors(); + for (ExpRunEditor editor : runEditors) + { + if (editor.isProtocolEditor(form.lookupRun().getProtocol())) + { + updateLinks.append(LinkBuilder.labkeyLink("edit " + editor.getDisplayName() + " run", editor.getEditUrl(getContainer()).addParameter("rowId", form.getRowId()))); + } + } + + if (!updateLinks.isEmpty()) + { + HtmlView view = new HtmlView(updateLinks); + vbox.addView(view); + } + + VBox lowerView = createLowerView(_experimentRun, errors); + lowerView.setFrame(WebPartView.FrameType.PORTAL); + lowerView.setTitle("Run Details"); + NavTree tree = new NavTree(""); + File runRoot = _experimentRun.getFilePathRoot(); + if (NetworkDrive.exists(runRoot)) + { + if (!runRoot.isDirectory()) + { + runRoot = runRoot.getParentFile(); + } + PipeRoot pipelineRoot = PipelineService.get().findPipelineRoot(_experimentRun.getContainer()); + if (pipelineRoot != null) + { + if (pipelineRoot.isUnderRoot(runRoot)) + { + String path = pipelineRoot.relativePath(runRoot); + tree.addChild("View Files", urlProvider(PipelineUrls.class).urlBrowse(_experimentRun.getContainer(), null, path)); + } + } + } + + final String exportFilesFormId = "exportFilesForm"; + NavTree downloadFiles = new NavTree("Download all files"); + downloadFiles.setScript("document.getElementById('" + exportFilesFormId + "').submit();"); + tree.addChild(downloadFiles); + + // CONSIDER: Show modal dialog using ExperimentService.get().createRunExportView() + NavTree exportXarFiles = new NavTree("Export XAR"); + exportXarFiles.setScript("LABKEY.Experiment.exportRuns({runIds: [" + _experimentRun.getRowId() + "] });"); + tree.addChild(exportXarFiles); + + lowerView.setNavMenu(tree); + lowerView.setIsWebPart(false); + + vbox.addView(lowerView); + vbox.addView(new ExperimentRunGroupsView(getUser(), getContainer(), _experimentRun, getViewContext().getActionURL(), errors)); + + DOM.Renderable exportFilesForm = LK.FORM(at( + id, exportFilesFormId, + method, "POST", + action, new ActionURL(ExportRunFilesAction.class, _experimentRun.getContainer())), + INPUT(at(type, "hidden", + name, DataRegionSelection.DATA_REGION_SELECTION_KEY, + value, "ExportSingleRun")), + INPUT(at(type, "hidden", + name, DataRegion.SELECT_CHECKBOX_NAME, + value, _experimentRun.getRowId())), + INPUT(at(type, "hidden", + name, "zipFileName", + value, _experimentRun.getName() + ".zip"))); + + HtmlView hiddenFormView = new HtmlView(exportFilesForm); + vbox.addView(hiddenFormView); + + return vbox; + } + + protected abstract VBox createLowerView(ExpRunImpl experimentRun, BindException errors); + + @Override + public void addNavTrail(NavTree root) + { + root.addChild(_experimentRun.getName()); + } + } + + public static class ToggleRunExperimentMembershipForm + { + private int _runId; + private int _experimentId; + private boolean _included; + + public int getRunId() + { + return _runId; + } + + public void setRunId(int runId) + { + _runId = runId; + } + + public int getExperimentId() + { + return _experimentId; + } + + public void setExperimentId(int experimentId) + { + _experimentId = experimentId; + } + + public boolean isIncluded() + { + return _included; + } + + public void setIncluded(boolean included) + { + _included = included; + } + } + + @RequiresPermission(UpdatePermission.class) + public static class ToggleRunExperimentMembershipAction extends FormHandlerAction + { + @Override + public boolean handlePost(ToggleRunExperimentMembershipForm form, BindException errors) + { + ExpRun run = ExperimentService.get().getExpRun(form.getRunId()); + // Check if the user has permission to update this run + if (run == null || !run.getContainer().hasPermission(getUser(), UpdatePermission.class)) + { + throw new NotFoundException(); + } + + ExpExperiment exp = ExperimentService.get().getExpExperiment(form.getExperimentId()); + if (exp == null) + { + throw new NotFoundException(); + } + // Check if this + if (!ExperimentService.get().getExperiments(run.getContainer(), getUser(), true, false).contains(exp)) + { + throw new NotFoundException(); + } + // Users must have permission to view, but not necessarily update, the container the holds the run group + if (!exp.getContainer().hasPermission(getUser(), ReadPermission.class)) + { + throw new UnauthorizedException(); + } + + if (form.isIncluded()) + { + exp.addRuns(getUser(), run); + } + else + { + exp.removeRun(getUser(), run); + } + + return true; + } + + @Override + public URLHelper getSuccessURL(ToggleRunExperimentMembershipForm form) + { + return null; + } + + @Override + public void validateCommand(ToggleRunExperimentMembershipForm target, Errors errors) + { + } + } + + private HtmlView createRunViewTabs(ExpRun expRun, boolean showGraphSummary, boolean showGraphDetail, boolean showText) + { + return new HtmlView( + TABLE(cl("labkey-tab-strip"), + TR( + createTabSpacer(false), + createTab("Graph Summary View", ExperimentUrlsImpl.get().getRunGraphURL(expRun), !showGraphSummary), + createTabSpacer(false), + createTab("Graph Detail View", ExperimentUrlsImpl.get().getRunGraphDetailURL(expRun), !showGraphDetail), + createTabSpacer(false), + createTab("Text View", ExperimentUrlsImpl.get().getRunTextURL(expRun), !showText), + createTabSpacer(true)))); + } + + private DOM.Renderable createTab(String text, ActionURL url, boolean selected) + { + return TD(cl(selected,"labkey-tab-selected", "labkey-tab"), + A(at(href, url), text)); + } + + private DOM.Renderable createTabSpacer(boolean fullWidth) + { + return TD(cl("labkey-tab-space").at(fullWidth, width, "100%"), + IMG(at(src, AppProps.getInstance().getContextPath() + "/_.gif", width, "5"))); + } + + @RequiresPermission(ReadPermission.class) + public class ShowRunTextAction extends AbstractShowRunAction + { + @Override + protected VBox createLowerView(ExpRunImpl expRun, BindException errors) + { + JspView applicationsView = new JspView<>("/org/labkey/experiment/ProtocolApplications.jsp", expRun); + applicationsView.setFrame(WebPartView.FrameType.TITLE); + applicationsView.setTitle("Protocol Applications"); + + HtmlView toggleView = createRunViewTabs(expRun, true, true, false); + + QuerySettings runDataInputsSettings = new QuerySettings(getViewContext(), "RunDataInputs", DataInputs.name()); + UsageQueryView runDataInputsView = new UsageQueryView("Data Inputs", getViewContext(), expRun, ExpProtocol.ApplicationType.ExperimentRun, runDataInputsSettings, errors); + runDataInputsView.setButtonBarPosition(DataRegion.ButtonBarPosition.TOP); + + QuerySettings runDataOutputsSettings = new QuerySettings(getViewContext(), "RunDataOutputs", DataInputs.name()); + UsageQueryView runDataOutputsView = new UsageQueryView("Data Outputs", getViewContext(), expRun, ExpProtocol.ApplicationType.ExperimentRunOutput, runDataOutputsSettings, errors); + runDataOutputsView.setButtonBarPosition(DataRegion.ButtonBarPosition.NONE); + + QuerySettings runMaterialInputsSetting = new QuerySettings(getViewContext(), "RunMaterialInputs", ExpSchema.TableType.MaterialInputs.name()); + UsageQueryView runMaterialInputsView = new UsageQueryView("Material Inputs", getViewContext(), expRun, ExpProtocol.ApplicationType.ExperimentRun, runMaterialInputsSetting, errors); + runMaterialInputsView.setButtonBarPosition(DataRegion.ButtonBarPosition.TOP); + + QuerySettings runMaterialOutputsSettings = new QuerySettings(getViewContext(), "RunMaterialOutputs", ExpSchema.TableType.MaterialInputs.name()); + UsageQueryView runMaterialOutputsView = new UsageQueryView("Material Outputs", getViewContext(), expRun, ExpProtocol.ApplicationType.ExperimentRunOutput, runMaterialOutputsSettings, errors); + runMaterialOutputsView.setButtonBarPosition(DataRegion.ButtonBarPosition.NONE); + + HBox inputsView = new HBox(runDataInputsView, runMaterialInputsView); + HBox registeredInputsView = new HBox(); + + var expService = ExperimentService.get(); + expService.getRunInputsViewProviders().forEach(provider -> + { + var queryView = provider.createView(getViewContext(), expRun, errors); + if (queryView != null) + { + registeredInputsView.addView(queryView); + } + }); + HBox outputsView = new HBox(runDataOutputsView, runMaterialOutputsView); + HBox registeredOutputsView = new HBox(); + expService.getRunOutputsViewProviders().forEach(provider -> + { + var queryView = provider.createView(getViewContext(), expRun, errors); + if (queryView != null) + { + registeredOutputsView.addView(queryView); + } + }); + + var vBox = new VBox(); + vBox.addView(toggleView); + vBox.addView(inputsView); + if (!registeredInputsView.isEmpty()) + vBox.addView(registeredInputsView); + vBox.addView(outputsView); + if (!registeredOutputsView.isEmpty()) + vBox.addView(registeredOutputsView); + vBox.addView(applicationsView); + + return vBox; + } + } + + private static class UsageQueryView extends QueryView + { + private final ExpRun _run; + private final ExpProtocol.ApplicationType _type; + + public UsageQueryView(String title, ViewContext context, ExpRun run, ExpProtocol.ApplicationType type, + QuerySettings settings, BindException errors) + { + super(new ExpSchema(context.getUser(), context.getContainer()), settings, errors); + setTitle(title); + setFrame(FrameType.TITLE); + _run = run; + _type = type; + setShowBorders(true); + setShadeAlternatingRows(true); + setShowExportButtons(false); + setShowPagination(false); + disableContainerFilterSelection(); + } + + @Override + protected TableInfo createTable() + { + String tableName = getSettings().getQueryName(); + ExpInputTable tableInfo = (ExpInputTable) getSchema().getTable(tableName, new ContainerFilter.AllFolders(getUser()), true, true); + tableInfo.setRun(_run, _type); + tableInfo.setLocked(true); + return tableInfo; + } + } + + + public static ActionURL getShowRunGraphDetailURL(Container c, long rowId) + { + ActionURL url = new ActionURL(ShowRunGraphDetailAction.class, c); + url.addParameter("rowId", rowId); + return url; + } + + + @RequiresPermission(ReadPermission.class) + public class ShowRunGraphDetailAction extends AbstractShowRunAction + { + @Override + protected VBox createLowerView(ExpRunImpl run, BindException errors) + { + ExperimentRunGraphView gw = new ExperimentRunGraphView(run, true); + if (null != getViewContext().getActionURL().getParameter("focus")) + gw.setFocus(getViewContext().getActionURL().getParameter("focus")); + if (null != getViewContext().getActionURL().getParameter("focusType")) + gw.setFocusType(getViewContext().getActionURL().getParameter("focusType")); + return new VBox(createRunViewTabs(run, true, false, true), gw); + } + } + + private abstract class AbstractDataAction extends SimpleViewAction + { + protected ExpDataImpl _data; + + @Override + public final ModelAndView getView(DataForm form, BindException errors) throws Exception + { + _data = form.lookupData(); + if (_data == null) + { + throw new NotFoundException("Could not find a data with RowId " + form.getRowId()); + } + + ensureCorrectContainer(getContainer(), _data, getViewContext()); + return getDataView(form, errors); + } + + protected abstract ModelAndView getDataView(DataForm form, BindException errors) throws Exception; + + @Override + public void addNavTrail(NavTree root) + { + addRootNavTrail(root); + root.addChild("Data " + _data.getName()); + } + } + + @RequiresPermission(ReadPermission.class) + public class ShowDataAction extends AbstractDataAction + { + @Override + public ModelAndView getDataView(DataForm form, BindException errors) + { + ExpRun run = _data.getRun(); + ExpProtocol sourceProtocol = _data.getSourceProtocol(); + ExpProtocolApplication sourceProtocolApplication = _data.getSourceApplication(); + ExpDataClass dataClass = _data.getDataClass(getUser()); + + ExpSchema schema = new ExpSchema(getUser(), getContainer()); + TableInfo table; + long pk; + if (dataClass == null) + { + table = schema.getDatasTable(); + pk = _data.getRowId(); + } + else + { + table = schema.getSchema(ExpSchema.NestedSchemas.data).getTable(dataClass.getName()); + pk = new TableSelector(table, Collections.singleton("rowId"), new SimpleFilter(FieldKey.fromParts("lsid"), _data.getLSID()), null).getObject(Integer.class); + } + + DataRegion dr = new DataRegion(); + dr.setTable(table); + List cols = table.getColumns().stream().filter(ColumnInfo::isShownInDetailsView).collect(toList()); + dr.addColumns(cols); + dr.removeColumns("RowId", "Created", "CreatedBy", "Modified", "ModifiedBy", "DataFileUrl", "Run", "LSID", "CpasType", "SourceApplicationId", "Folder", "Generated"); + dr.addDisplayColumn(new ExperimentRunDisplayColumn(run, "Source Experiment Run")); + dr.addDisplayColumn(new ProtocolDisplayColumn(sourceProtocol, "Source Protocol")); + dr.addDisplayColumn(new ProtocolApplicationDisplayColumn(sourceProtocolApplication, "Source Protocol Application")); + dr.addDisplayColumn(new LineageGraphDisplayColumn(_data, run)); + DetailsView detailsView = new DetailsView(dr, pk); + detailsView.setTitle("Standard Properties"); + detailsView.setFrame(WebPartView.FrameType.PORTAL); + ButtonBar bb = new ButtonBar(); + bb.setStyle(ButtonBar.Style.separateButtons); + + ExperimentDataHandler handler = _data.findDataHandler(); + ActionURL viewDataURL = handler == null ? null : handler.getContentURL(_data); + if (viewDataURL != null) + { + bb.add(new ActionButton("View data", viewDataURL)); + } + + if (_data.isPathAccessible()) + { + bb.add(new ActionButton("View file", ExperimentUrlsImpl.get().getShowFileURL(_data, true))); + bb.add(new ActionButton("Download file", ExperimentUrlsImpl.get().getShowFileURL(_data, false))); + + if (getContainer().hasPermission(getUser(), InsertPermission.class)) + { + String relativePath = null; + PipeRoot root = PipelineService.get().findPipelineRoot(getContainer()); + if (root != null) + { + Path rootFile = root.getRootNioPath(); + Path dataFile = _data.getFilePath(); + if (dataFile != null) + { + Path pathRelative; + try + { + pathRelative = rootFile.relativize(dataFile); + if (null != pathRelative) + relativePath = pathRelative.toString(); + } + catch (IllegalArgumentException e) + { + // dataFile not relative to root + } + } + } + ActionURL browseURL = urlProvider(PipelineUrls.class).urlBrowse(getContainer(), getViewContext().getActionURL(), relativePath); + bb.add(new ActionButton("Browse in pipeline", browseURL)); + } + } + + // add links to any other exp.data that share the same dataFileUrl path + var altDataList = ExperimentService.get().getAllExpDataByURL(_data.getDataFileUrl(), getContainer()); + altDataList.removeIf(_data::equals); + if (!altDataList.isEmpty()) + { + MenuButton menu = new MenuButton("Alternate Data"); + for (ExpData altData : altDataList) + { + ExpRun altDataRun = altData.getRun(); + StringBuilder sb = new StringBuilder(altData.getName()); + if (altDataRun != null) + sb.append(" created by run '").append(altDataRun.getName()).append("' (").append(altDataRun.getProtocol().getName()).append(")"); + menu.addMenuItem(sb.toString(), altData.detailsURL()); + } + bb.add(menu); + } + + dr.setButtonBarPosition(DataRegion.ButtonBarPosition.TOP); + dr.setButtonBar(bb); + + CustomPropertiesView cpv = new CustomPropertiesView(_data.getLSID(), getContainer()); + HBox hbox = new StandardAndCustomPropertiesView(detailsView, cpv); + + VBox vbox = new VBox(hbox); + + ParentChildView pv = new ParentChildView(_data, getViewContext()); + vbox.addView(pv); + + ExperimentRunListView runListView = ExperimentRunListView.createView(getViewContext(), ExperimentRunType.ALL_RUNS_TYPE, true); + runListView.getRunTable().setInputData(_data); + runListView.getRunTable().setContainerFilter(new ContainerFilter.AllFolders(getUser())); + runListView.getRunTable().setLocked(true); + runListView.setTitle("Runs using this data as an input"); + vbox.addView(runListView); + + if (_data.isInlineImage() && _data.isFileOnDisk()) + { + ActionURL showFileURL = new ActionURL(ShowFileAction.class, getContainer()).addParameter("rowId", _data.getRowId()); + HtmlView imageView = new HtmlView(IMG(at(src, showFileURL))); + return new VBox(vbox, imageView); + } + return vbox; + } + } + + @RequiresPermission(AdminPermission.class) + public static class CheckDataFileAction extends MutatingApiAction + { + private ExpDataImpl _data; + + @Override + public void validateForm(DataFileForm form, Errors errors) + { + _data = form.lookupData(); + if (_data == null) + { + errors.reject(ERROR_MSG, "No ExpData found for id: " + form.getRowId()); + } + } + + @Override + public ApiResponse execute(DataFileForm form, BindException errors) + { + File dataFile = _data.getFile(); + Container dataContainer = _data.getContainer(); + boolean fileExists = _data.isFileOnDisk(); + boolean fileExistsAtCurrent = false; + File newDataFile = null; + + ApiSimpleResponse response = new ApiSimpleResponse(); + response.put("dataFileUrl", _data.getDataFileUrl()); + response.put("fileExists", fileExists); + response.put("containerPath", dataContainer.getPath()); + + if (!fileExists) + { + PipeRoot pipelineRoot = PipelineService.get().findPipelineRoot(dataContainer); + if (pipelineRoot != null && pipelineRoot.isValid() && dataFile != null) + { + newDataFile = pipelineRoot.resolvePath("/" + AssayFileWriter.DIR_NAME + "/" + dataFile.getName()); + fileExistsAtCurrent = NetworkDrive.exists(newDataFile); + response.put("fileExistsAtCurrent", fileExistsAtCurrent); + } + } + + // if the current dataFileUrl does not exist on disk and we have the file at the current + // pipeline root /assaydata dir, fix the dataFileUrl value + if (form.isAttemptFilePathFix()) + { + if (fileExistsAtCurrent) + { + ExpDataFileListener fileListener = new ExpDataFileListener(); + fileListener.fileMoved(dataFile, newDataFile, getUser(), dataContainer); + response.put("filePathFixed", true); + + // update the ExpData object so that we can get the new dataFileUrl + _data = form.lookupData(); + response.put("newDataFileUrl", _data.getDataFileUrl()); + } + else + { + response.put("filePathFixed", false); + } + } + + response.put("success", true); + return response; + } + } + + public static class DataFileForm extends DataForm + { + private boolean _attemptFilePathFix; + + public boolean isAttemptFilePathFix() + { + return _attemptFilePathFix; + } + + public void setAttemptFilePathFix(boolean attemptFilePathFix) + { + _attemptFilePathFix = attemptFilePathFix; + } + } + + @RequiresPermission(ReadPermission.class) + public class ShowFileAction extends AbstractDataAction + { + @Override + protected ModelAndView getDataView(DataForm form, BindException errors) throws IOException + { + if (!_data.isPathAccessible()) + { + throw new NotFoundException("Data file " + _data.getDataFileUrl() + " does not exist on disk"); + } + + PipeRoot root = PipelineService.get().findPipelineRoot(getContainer()); + if (root != null && !root.isUnderRoot(_data.getFilePath())) + { + // Issue 35649: ImmPort module "publish" creates exp.data object in this container for paths that originate in a different container + FileContentService fileSvc = FileContentService.get(); + if (fileSvc == null) + throw new UnauthorizedException("Data file is not under the pipeline root for this folder"); + + List containers = fileSvc.getContainersForFilePath(_data.getFilePath()); + if (containers.isEmpty() || containers.stream().noneMatch(c -> c.hasPermission(getUser(), ReadPermission.class))) + throw new UnauthorizedException("Data file is not under the pipeline root for this folder"); + } + + //Issues 25667 and 31152 + if (form.isInline()) + { + ExperimentDataHandler h = _data.findDataHandler(); + if (h != null) + { + URLHelper url = h.getShowFileURL(_data); + if (url != null) + { + throw new RedirectException(url, false); + } + } + } + + try + { + Path realContent = _data.getFilePath(); + if (null == realContent) + throw new IllegalStateException("Path not found."); + + boolean inline = _data.isInlineImage() || form.isInline() || "inlineImage".equalsIgnoreCase(form.getFormat()); + if (_data.isInlineImage() && form.getMaxDimension() != null) + { + try (InputStream inputStream = Files.newInputStream(realContent)) + { + BufferedImage image = ImageIO.read(inputStream); + // If image, create a thumbnail, otherwise fall through as a regular download attempt + if (image != null) + { + int imageMax = Math.max(image.getHeight(), image.getWidth()); + if (imageMax > form.getMaxDimension().intValue()) + { + double scale = (double) form.getMaxDimension().intValue() / (double) imageMax; + ByteArrayOutputStream bOut = new ByteArrayOutputStream(); + ImageUtil.resizeImage(image, bOut, scale, 1); + PageFlowUtil.streamFileBytes(getViewContext().getResponse(), FileUtil.getFileName(realContent) + ".png", bOut.toByteArray(), !inline); + return null; + } + } + } + } + + boolean extended = "jsonTSVExtended".equalsIgnoreCase(form.getFormat()); + boolean ignoreTypes = "jsonTSVIgnoreTypes".equalsIgnoreCase(form.getFormat()); + if ("jsonTSV".equalsIgnoreCase(form.getFormat()) || extended || ignoreTypes) + { + if (!FileUtil.hasCloudScheme(realContent)) // TODO: handle streaming from S3 to JSON + streamToJSON(FileSystemLike.wrapFile(realContent), form.getFormat(), -1, null); + return null; + } + + try (InputStream inputStream = Files.newInputStream(realContent)) + { + PageFlowUtil.streamFile(getViewContext().getResponse(), Collections.emptyMap(), FileUtil.getFileName(realContent), inputStream, !inline); + } + } + catch (IOException e) + { + try + { + // Try to write the exception back to the caller if we haven't already flushed the buffer + ApiJsonWriter writer = new ApiJsonWriter(getViewContext().getResponse()); + writer.writeResponse(e); + } + catch (IllegalStateException ise) + { + // Most likely that a disconnected client caused the IOException writing back the response + } + } + + return null; + } + } + + + public static class ParseForm + { + String format = "jsonTSV"; + int maxRows = -1; + + public String getFormat() + { + return format; + } + + public void setFormat(String format) + { + this.format = format; + } + + public int getMaxRows() + { + return maxRows; + } + + public void setMaxRows(int maxRow) + { + this.maxRows = maxRow; + } + } + + @RequiresNoPermission + public class ParseFileAction extends MutatingApiAction + { + @Override + public Object execute(ParseForm form, BindException errors) throws Exception + { + if (!(getViewContext().getRequest() instanceof MultipartHttpServletRequest)) + throw new BadRequestException("Expected MultipartHttpServletRequest when posting files."); + + MultipartFile formFile = getFileMap().get("file"); + if (formFile == null) + { + return true; + } + + FileLike tempFile = null; + try + { + tempFile = FileUtil.createTempFileLike("parse", formFile.getOriginalFilename()); + FileUtil.copyData(formFile.getInputStream(), tempFile.openOutputStream()); + streamToJSON(tempFile, form.getFormat(), form.getMaxRows(), formFile.getOriginalFilename()); + } + finally + { + if (null != tempFile) + tempFile.delete(); + } + return null; + } + } + + + // SampleTypeTest + private void streamToJSON(FileLike realContent, String format, int maxRow, String originalFileName) throws IOException + { + String lowerCaseFileName = realContent.getName().toLowerCase(); + boolean extended = "jsonTSVExtended".equalsIgnoreCase(format); + boolean ignoreTypes = "jsonTSVIgnoreTypes".equalsIgnoreCase(format); + + JSONArray sheetsArray; + if (lowerCaseFileName.endsWith(".xls") || lowerCaseFileName.endsWith(".xlsx")) + { + try (InputStream in = realContent.openInputStream()) + { + sheetsArray = ExcelFactory.convertExcelToJSON(in, extended, maxRow); + } + } + else + { + DataLoaderFactory dlf = DataLoader.get().findFactory(realContent, null); + if (null == dlf) + { + throw new ApiUsageException("Unable to parse file " + realContent + ", it is likely of an unsupported file type"); + } + + try (InputStream in = realContent.openInputStream(); + DataLoader tabLoader = dlf.createLoader(in, true)) + { + tabLoader.setScanAheadLineCount(5000); + ColumnDescriptor[] cols = tabLoader.getColumns(); + + if (ignoreTypes) + for (ColumnDescriptor col : cols) + col.clazz = String.class; + + JSONArray rowsArray = new JSONArray(); + JSONArray headerArray = new JSONArray(); + for (ColumnDescriptor col : cols) + { + if (extended) + { + JSONObject valueObject = new JSONObject(); + valueObject.put("value", col.name); + headerArray.put(valueObject); + } + else + { + headerArray.put(col.name); + } + } + rowsArray.put(headerArray); + for (Map rowMap : tabLoader) + { + // headers count as a row to be consistent + if (maxRow > -1 && maxRow <= rowsArray.length() + 1) + break; + + JSONArray rowArray = new JSONArray(); + for (ColumnDescriptor col : cols) + { + Object value = rowMap.get(col.name); + if (extended) + { + JSONObject valueObject = new JSONObject(); + valueObject.put("value", value); + rowArray.put(valueObject); + } + else + { + rowArray.put(value); + } + } + rowsArray.put(rowArray); + } + + JSONObject sheetJSON = new JSONObject(); + sheetJSON.put("name", "flat"); + sheetJSON.put("data", rowsArray); + sheetsArray = new JSONArray(); + sheetsArray.put(sheetJSON); + } + } + + try (ApiJsonWriter writer = new ApiJsonWriter(getViewContext().getResponse())) + { + JSONObject workbookJSON = new JSONObject(); + workbookJSON.put("fileName", realContent.getName()); + workbookJSON.put("sheets", sheetsArray); + if (originalFileName != null) + workbookJSON.put("originalFileName", originalFileName); + writer.writeResponse(new ApiSimpleResponse(workbookJSON)); + } + } + + + public static class ConvertArraysToExcelForm + { + private String _json; + + public String getJson() + { + return _json; + } + + public void setJson(String json) + { + _json = json; + } + } + + @RequiresPermission(ReadPermission.class) + public static class ConvertArraysToExcelAction extends ExportAction + { + @Override + public void validate(ConvertArraysToExcelForm form, BindException errors) + { + if (form.getJson() == null) + { + errors.reject(ERROR_MSG, "Unable to convert to Excel - no spreadsheet data given"); + } + } + + @Override + public void export(ConvertArraysToExcelForm form, HttpServletResponse response, BindException errors) throws Exception + { + try + { + JSONObject rootObject; + JSONArray sheetsArray; + if (form.getJson() == null || form.getJson().trim().isEmpty()) + { + // Create JSON so that we return an empty file + rootObject = new JSONObject(); + sheetsArray = new JSONArray(); + JSONObject sheetObject = new JSONObject(); + sheetsArray.put(sheetObject); + } + else + { + rootObject = new JSONObject(form.getJson()); + sheetsArray = rootObject.getJSONArray("sheets"); + } + String filename = rootObject.has("fileName") ? rootObject.getString("fileName") : "ExcelExport.xls"; + ExcelWriter.ExcelDocumentType docType = filename.toLowerCase().endsWith(".xlsx") ? ExcelWriter.ExcelDocumentType.xlsx : ExcelWriter.ExcelDocumentType.xls; + + try (Workbook workbook = ExcelFactory.createFromArray(sheetsArray, docType)) + { + response.setContentType(docType.getMimeType()); + ResponseHelper.setContentDisposition(response, ResponseHelper.ContentDispositionType.attachment, filename); + ResponseHelper.setPrivate(response); + workbook.write(response.getOutputStream()); + + JSONObject qInfo = rootObject.has("queryinfo") ? rootObject.getJSONObject("queryinfo") : null; + if (qInfo != null) + { + QueryService.get().addAuditEvent(getUser(), getContainer(), qInfo.getString("schema"), + qInfo.getString("query"), getViewContext().getActionURL(), + rootObject.getString("auditMessage") + filename, + null); + SimpleMetricsService.get().increment(ExperimentService.MODULE_NAME, "convertTable", "asExcel"); + } + } + } + catch (JSONException | ClassCastException e) + { + // We can get a ClassCastException if we expect an array and get a simple String, for example + ExceptionUtil.renderErrorView(getViewContext(), getPageConfig(), ErrorRenderer.ErrorType.notFound, HttpServletResponse.SC_BAD_REQUEST, "Failed to convert to Excel - invalid input", e, false, false); + } + } + } + + @RequiresPermission(ReadPermission.class) + public static class ConvertArraysToTableAction extends ExportAction + { + @Override + public void validate(ConvertArraysToExcelForm form, BindException errors) + { + if (form.getJson() == null) + { + errors.reject(ERROR_MSG, "Unable to convert to table - no data given"); + } + } + + @Override + public void export(ConvertArraysToExcelForm form, HttpServletResponse response, BindException errors) throws Exception + { + try + { + JSONObject rootObject; + JSONArray rowsArray; + if (form.getJson() == null || form.getJson().trim().isEmpty()) + { + // Create JSON so that we return an empty file + rootObject = new JSONObject(); + rowsArray = new JSONArray(); + } + else + { + rootObject = new JSONObject(form.getJson()); + rowsArray = rootObject.getJSONArray("rows"); + } + + TSVWriter.DELIM delimType = (!rootObject.isNull("delim") ? TSVWriter.DELIM.valueOf(rootObject.getString("delim")) : TSVWriter.DELIM.TAB); + TSVWriter.QUOTE quoteType = (!rootObject.isNull("quoteChar") ? TSVWriter.QUOTE.valueOf(rootObject.getString("quoteChar")) : TSVWriter.QUOTE.NONE); + String filenamePrefix = (!rootObject.isNull("fileNamePrefix") ? rootObject.getString("fileNamePrefix") : "Export"); + String filename = filenamePrefix + "." + delimType.extension; + String newlineChar = !rootObject.isNull("newlineChar") ? rootObject.getString("newlineChar") : "\n"; + + PageFlowUtil.prepareResponseForFile(response, Collections.emptyMap(), filename, true); + response.setContentType(delimType.contentType); + + //NOTE: we could also have used TSVWriter; however, this is in use elsewhere and we dont need a custom subclass + try (CSVWriter writer = new CSVWriter(response.getWriter(), delimType.delim, quoteType.quoteChar, newlineChar)) + { + for (int i = 0; i < rowsArray.length(); i++) + { + List objectList = rowsArray.getJSONArray(i).toList(); + Iterator it = objectList.iterator(); + List list = new ArrayList<>(); + + while (it.hasNext()) + { + Object o = it.next(); + if (o != null) + list.add(o.toString()); + else + list.add(""); + } + + writer.writeNext(list.toArray(new String[0])); + } + } + + JSONObject qInfo = rootObject.optJSONObject("queryinfo"); + if (qInfo != null) + { + QueryService.get().addAuditEvent(getUser(), getContainer(), qInfo.getString("schema"), qInfo.getString("query"), + getViewContext().getActionURL(), + rootObject.getString("auditMessage") + filename, + rowsArray.length()); + SimpleMetricsService.get().increment(ExperimentService.MODULE_NAME, "convertTable", "asDelimited"); + } + } + catch (JSONException e) + { + ExceptionUtil.renderErrorView(getViewContext(), getPageConfig(), ErrorRenderer.ErrorType.notFound, HttpServletResponse.SC_BAD_REQUEST, "Failed to convert to table - invalid input", e, false, false); + } + } + } + + + public static class ConvertHtmlToExcelForm + { + private String _baseUrl; + private String _htmlFragment; + private String _name = "workbook.xls"; + + + public String getName() + { + return _name; + } + + public void setName(String name) + { + _name = name; + } + + public String getBaseUrl() + { + return _baseUrl; + } + + public void setBaseUrl(String baseUrl) + { + _baseUrl = baseUrl; + } + + public String getHtmlFragment() + { + return _htmlFragment; + } + + public void setHtmlFragment(String htmlFragment) + { + _htmlFragment = htmlFragment; + } + } + + + @RequiresPermission(ReadPermission.class) + public static class ConvertHtmlToExcelAction extends FormViewAction + { + String _responseHtml = null; + + @Override + public void validateCommand(ConvertHtmlToExcelForm target, Errors errors) + { + } + + @Override + public ModelAndView getView(ConvertHtmlToExcelForm form, boolean reshow, BindException errors) + { + String html = + "

" + + "" + + new CsrfInput(getViewContext()) + + "
"; + return HtmlView.unsafe(html); + } + + @Override + public boolean handlePost(ConvertHtmlToExcelForm form, BindException errors) + { + ActionURL url = getViewContext().getActionURL(); + String base = url.getBaseServerURI(); + if (!base.endsWith("/")) base += "/"; + + String baseTag = ""; + SafeToRender css = PageFlowUtil.getStylesheetIncludes(getContainer()); + String htmlFragment = StringUtils.trimToEmpty(form.getHtmlFragment()); + String html = "" + baseTag + css + "" + htmlFragment + ""; + + // UNDONE: strip script + List tidyErrors = new ArrayList<>(); + String tidy = JSoupUtil.tidyHTML(html, false, tidyErrors); + + if (!tidyErrors.isEmpty()) + { + for (String err : tidyErrors) + { + errors.reject(ERROR_MSG, err); + } + return false; + } + + _responseHtml = tidy; + return true; + } + + @Override + public ModelAndView getSuccessView(ConvertHtmlToExcelForm form) + { + ResponseHelper.setContentDisposition(getViewContext().getResponse(), ResponseHelper.ContentDispositionType.attachment, form.getName()); + getPageConfig().setTemplate(PageConfig.Template.None); + HtmlView v = HtmlView.unsafe(_responseHtml); + v.setContentType("application/vnd.ms-excel"); + v.setFrame(WebPartView.FrameType.NONE); + return v; + } + + @Override + public URLHelper getSuccessURL(ConvertHtmlToExcelForm convertHtmlToExcelForm) + { + return null; + } + + @Override + public void addNavTrail(NavTree root) + { + } + } + + + public static ActionURL getShowApplicationURL(Container c, long rowId) + { + ActionURL url = new ActionURL(ShowApplicationAction.class, c); + url.addParameter("rowId", rowId); + + return url; + } + + + @RequiresPermission(ReadPermission.class) + public class ShowApplicationAction extends SimpleViewAction + { + private ExpProtocolApplicationImpl _app; + private ExpRun _run; + + @Override + public ModelAndView getView(ExpObjectForm form, BindException errors) + { + _app = ExperimentServiceImpl.get().getExpProtocolApplication(form.getRowId()); + if (_app == null) + { + throw new NotFoundException("Could not find Protocol Application"); + } + _run = _app.getRun(); + if (_run == null) + { + throw new NotFoundException("No experiment run associated with Protocol Application"); + } + ensureCorrectContainer(getContainer(), _app, getViewContext()); + + ExpProtocol protocol = _app.getProtocol(); + + DataRegion dr = new DataRegion(); + dr.addColumns(ExperimentServiceImpl.get().getTinfoProtocolApplication().getUserEditableColumns()); + DetailsView detailsView = new DetailsView(dr, form.getRowId()); + dr.removeColumns("RunId", "ProtocolLSID", "RowId", "LSID"); + dr.addDisplayColumn(new ExperimentRunDisplayColumn(_run)); + dr.addDisplayColumn(new ProtocolDisplayColumn(protocol)); + dr.addDisplayColumn(new LineageGraphDisplayColumn(_app, _run)); + detailsView.setTitle("Protocol Application"); + + Container c = getContainer(); + ApplicationOutputGrid outMGrid = new ApplicationOutputGrid(c, _app.getRowId(), ExperimentServiceImpl.get().getTinfoMaterial()); + ApplicationOutputGrid outDGrid = new ApplicationOutputGrid(c, _app.getRowId(), ExperimentServiceImpl.get().getTinfoData()); + Map map = new HashMap<>(); + for (ProtocolApplicationParameter param : ExperimentService.get().getProtocolApplicationParameters(_app.getRowId())) + { + map.put(param.getOntologyEntryURI(), param); + } + + JspView> paramsView = new JspView<>("/org/labkey/experiment/Parameters.jsp", map); + paramsView.setTitle("Protocol Application Parameters"); + CustomPropertiesView cpv = new CustomPropertiesView(_app.getLSID(), c); + return new VBox(new StandardAndCustomPropertiesView(detailsView, cpv), paramsView, outMGrid, outDGrid); + } + + @Override + public void addNavTrail(NavTree root) + { + addRootNavTrail(root); + root.addChild("Experiment Run", ExperimentUrlsImpl.get().getRunGraphDetailURL(_run)); + root.addChild("Protocol Application " + _app.getName()); + } + } + + @RequiresPermission(ReadPermission.class) + public class ShowProtocolGridAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object o, BindException errors) + { + return new ProtocolWebPart(false, getViewContext()); + } + + @Override + public void addNavTrail(NavTree root) + { + addRootNavTrail(root); + root.addChild("Protocols"); + } + } + + @RequiresPermission(ReadPermission.class) + public class ProtocolDetailsAction extends SimpleViewAction + { + private ExpProtocolImpl _protocol; + + @Override + public ModelAndView getView(ExpObjectForm form, BindException errors) + { + _protocol = ExperimentServiceImpl.get().getExpProtocol(form.getRowId()); + if (_protocol == null) + { + _protocol = ExperimentServiceImpl.get().getExpProtocol(form.getLSID()); + } + + if (_protocol == null) + { + throw new NotFoundException("Unable to find a matching protocol"); + } + ensureCorrectContainer(getContainer(), _protocol, getViewContext()); + + JspView detailsView = new JspView<>("/org/labkey/experiment/ProtocolDetails.jsp", _protocol); + detailsView.setTitle("Standard Properties"); + + CustomPropertiesView cpv = new CustomPropertiesView(_protocol.getLSID(), getContainer()); + ProtocolParametersView parametersView = new ProtocolParametersView(_protocol); + + VBox protocolDetails = new VBox(); + protocolDetails.setFrame(WebPartView.FrameType.PORTAL); + protocolDetails.setTitle("Protocol Details"); + protocolDetails.addView(new ProtocolInputOutputsView(_protocol, errors)); + + JspView stepsView = new JspView<>("/org/labkey/experiment/ProtocolSteps.jsp", _protocol); + stepsView.setTitle("Protocol Steps"); + stepsView.setFrame(WebPartView.FrameType.TITLE); + protocolDetails.addView(stepsView); + + ExpSchema schema = new ExpSchema(getUser(), getContainer()); + ExperimentRunListView runView = new ExperimentRunListView(schema, ExperimentRunListView.getRunListQuerySettings(schema, getViewContext(), ExpSchema.TableType.Runs.name(), true), ExperimentRunType.ALL_RUNS_TYPE) + { + @Override + public DataView createDataView() + { + DataView result = super.createDataView(); + result.getRenderContext().setBaseFilter(new SimpleFilter(FieldKey.fromParts("Protocol", "LSID"), _protocol.getLSID())); + return result; + } + }; + + runView.setTitle("Runs Using This Protocol"); + + return new VBox(new StandardAndCustomPropertiesView(detailsView, cpv), parametersView, protocolDetails, runView); + } + + @Override + public void addNavTrail(NavTree root) + { + addRootNavTrail(root); + root.addChild("Protocols", ExperimentUrlsImpl.get().getProtocolGridURL(getContainer())); + root.addChild("Protocol: " + _protocol.getName()); + } + } + + public class ProtocolInputOutputsView extends VBox + { + ProtocolInputOutputsView(ExpProtocol protocol, Errors errors) + { + HBox inputsView = new HBox(); + addView(inputsView); + + HBox outputsView = new HBox(); + addView(outputsView); + + UserSchema expSchema = QueryService.get().getUserSchema(getUser(), getContainer(), ExpSchema.SCHEMA_NAME); + + class ProtocolInputGrid extends QueryView + { + public ProtocolInputGrid(String title, QuerySettings settings, @Nullable Errors errors) + { + super(expSchema, settings, errors); + + setFrame(FrameType.TITLE); + setTitle(title); + setButtonBarPosition(DataRegion.ButtonBarPosition.NONE); + setShowBorders(true); + setShadeAlternatingRows(true); + setShowExportButtons(false); + setShowPagination(false); + disableContainerFilterSelection(); + } + } + + // INPUTS + + QuerySettings materialInputsSettings = expSchema.getSettings("mpi", ExpSchema.TableType.MaterialProtocolInputs.toString()); + materialInputsSettings.getBaseFilter().addCondition(FieldKey.fromParts(ExpMaterialProtocolInputTable.Column.Protocol.toString()), protocol.getRowId()); + materialInputsSettings.setFieldKeys(Arrays.asList( + FieldKey.fromParts(ExpMaterialProtocolInputTable.Column.Name.toString()), + FieldKey.fromParts(ExpMaterialProtocolInputTable.Column.SampleSet.toString()) + )); + QueryView materialInputsView = new ProtocolInputGrid("Material Inputs", materialInputsSettings, errors); + inputsView.addView(materialInputsView); + + QuerySettings dataInputsSettings = expSchema.getSettings("dpi", ExpSchema.TableType.DataProtocolInputs.toString()); + dataInputsSettings.getBaseFilter().addCondition(FieldKey.fromParts(ExpDataProtocolInputTable.Column.Protocol.toString()), protocol.getRowId()); + dataInputsSettings.setFieldKeys(Arrays.asList( + FieldKey.fromParts(ExpDataProtocolInputTable.Column.Name.toString()), + FieldKey.fromParts(ExpDataProtocolInputTable.Column.DataClass.toString()) + )); + QueryView dataInputsView = new ProtocolInputGrid("Data Inputs", dataInputsSettings, errors); + inputsView.addView(dataInputsView); + + // OUTPUTS + + QuerySettings materialOutputsSettings = expSchema.getSettings("mpo", ExpSchema.TableType.MaterialProtocolInputs.toString()); + materialOutputsSettings.getBaseFilter().addCondition(FieldKey.fromParts(ExpMaterialProtocolInputTable.Column.Protocol.toString()), protocol.getRowId()); + materialOutputsSettings.setFieldKeys(Arrays.asList( + FieldKey.fromParts(ExpMaterialProtocolInputTable.Column.Name.toString()), + FieldKey.fromParts(ExpMaterialProtocolInputTable.Column.SampleSet.toString()) + )); + QueryView materialOutputsView = new ProtocolInputGrid("Material Outputs", materialOutputsSettings, errors); + outputsView.addView(materialOutputsView); + + QuerySettings dataOutputsSettings = expSchema.getSettings("dpo", ExpSchema.TableType.DataProtocolInputs.toString()); + dataOutputsSettings.getBaseFilter().addCondition(FieldKey.fromParts(ExpDataProtocolInputTable.Column.Protocol.toString()), protocol.getRowId()); + dataOutputsSettings.setFieldKeys(Arrays.asList( + FieldKey.fromParts(ExpDataProtocolInputTable.Column.Name.toString()), + FieldKey.fromParts(ExpDataProtocolInputTable.Column.DataClass.toString()) + )); + QueryView dataOutputsView = new ProtocolInputGrid("Data Outputs", dataOutputsSettings, errors); + outputsView.addView(dataOutputsView); + } + } + + + @RequiresPermission(ReadPermission.class) + public class ProtocolPredecessorsAction extends SimpleViewAction + { + private ExpProtocol _parentProtocol; + private ProtocolActionStepDetail _actionStep; + + @Override + public ModelAndView getView(Object o, BindException errors) + { + ActionURL url = getViewContext().getActionURL(); + + String parentProtocolLSID = url.getParameter("ParentLSID"); + int actionSequence; + try + { + actionSequence = Integer.parseInt(url.getParameter("Sequence")); + } + catch (NumberFormatException e) + { + throw new NotFoundException("Could not find SequenceId " + url.getParameter("Sequence")); + } + + _parentProtocol = ExperimentService.get().getExpProtocol(parentProtocolLSID); + if (_parentProtocol == null) + { + throw new NotFoundException("Unable to find a matching protocol"); + } + + ensureCorrectContainer(getContainer(), _parentProtocol, getViewContext()); + + _actionStep = ExperimentServiceImpl.get().getProtocolActionStepDetail(parentProtocolLSID, actionSequence); + + if (_actionStep == null) + { + throw new NotFoundException("Unable to find a matching protocol action step"); + } + + ExpProtocol childProtocol = ExperimentService.get().getExpProtocol(_actionStep.getChildProtocolLSID()); + + JspView detailsView = new JspView<>("/org/labkey/experiment/ProtocolDetails.jsp", childProtocol); + detailsView.setTitle("Standard Properties"); + + CustomPropertiesView cpv = new CustomPropertiesView(childProtocol.getLSID(), getContainer()); + + ProtocolParametersView parametersView = new ProtocolParametersView(childProtocol); + + VBox protocolDetails = new VBox(); + protocolDetails.setFrame(WebPartView.FrameType.PORTAL); + protocolDetails.setTitle("Protocol Details"); + protocolDetails.addView(new ProtocolInputOutputsView(childProtocol, errors)); + protocolDetails.addView(new ProtocolSuccessorPredecessorView(parentProtocolLSID, actionSequence, getContainer(), "PredecessorChildLSID", "PredecessorSequence", "ActionSequence", "Protocol Predecessors")); + protocolDetails.addView(new ProtocolSuccessorPredecessorView(parentProtocolLSID, actionSequence, getContainer(), "ChildProtocolLSID", "ActionSequence", "PredecessorSequence", "Protocol Successors")); + + return new VBox(new StandardAndCustomPropertiesView(detailsView, cpv), parametersView, protocolDetails); + } + + @Override + public void addNavTrail(NavTree root) + { + addRootNavTrail(root); + root.addChild("Protocols", ExperimentUrlsImpl.get().getProtocolGridURL(getContainer())); + root.addChild("Parent Protocol '" + _parentProtocol.getName() + "'", ExperimentUrlsImpl.get().getProtocolDetailsURL(_parentProtocol)); + root.addChild("Protocol Step: " + _actionStep.getName()); + } + } + + public static class DataForm + { + private boolean _inline; + private long _rowId; + private String _lsid; + private Integer _maxDimension; + private String _format; + + public boolean isInline() + { + return _inline; + } + + public void setInline(boolean inline) + { + _inline = inline; + } + + public long getRowId() + { + return _rowId; + } + + public void setRowId(long rowId) + { + _rowId = rowId; + } + + public String getLsid() + { + return _lsid; + } + + public void setLsid(String lsid) + { + _lsid = lsid; + } + + public ExpDataImpl lookupData() + { + ExpDataImpl result = ExperimentServiceImpl.get().getExpData(getRowId()); + if (result == null && getLsid() != null) + { + result = ExperimentServiceImpl.get().getExpData(getLsid()); + } + return result; + } + + public Integer getMaxDimension() + { + return _maxDimension; + } + + public void setMaxDimension(Integer maxDimension) + { + _maxDimension = maxDimension; + } + + public String getFormat() + { + return _format; + } + + public void setFormat(String format) + { + _format = format; + } + } + + public static class ExpObjectForm extends QueryViewAction.QueryExportForm + { + private long _rowId; + private String _lsid; + + public String getLsid() + { + return _lsid; + } + + public void setLsid(String lsid) + { + _lsid = lsid; + } + + public String getLSID() + { + return getLsid(); + } + + public void setLSID(String lsid) + { + setLsid(lsid); + } + + public long getRowId() + { + return _rowId; + } + + public void setRowId(long rowId) + { + _rowId = rowId; + } + } + + @RequiresAnyOf({DeletePermission.class, SampleWorkflowDeletePermission.class}) + public class DeleteSelectedExpRunsAction extends AbstractDeleteAction + { + @Override + public void addNavTrail(NavTree root) + { + // UNDONE: Need help topic on Runs + setHelpTopic("experiment"); + super.addNavTrail(root); + } + + @Override + public ModelAndView getView(DeleteForm deleteForm, boolean reshow, BindException errors) + { + List runs = new ArrayList<>(); + + Map idToRunMap = new LongHashMap<>(); + for (long runId : deleteForm.getIds(false)) + { + ExpRun run = ExperimentService.get().getExpRun(runId); + if (run != null) + { + if (!run.canDelete(getUser())) + throw new UnauthorizedException("You do not have permission to delete " + + (ExpProtocol.isSampleWorkflowProtocol(run.getProtocol().getLSID()) ? "jobs" : "runs") + + " in " + run.getContainer()); + + runs.add(run); + idToRunMap.put(run.getRowId(), run); + } + } + + Map referencedItems = new LongHashMap<>(); + List referenceDescriptions = new ArrayList<>(); + AssayService assayService = AssayService.get(); + if (!idToRunMap.isEmpty() && assayService != null ) + { + // using the first run as a representative, since all interactions here are (I believe) using the same protocol. + ExpProtocol protocol = runs.get(0).getProtocol(); + AssayProvider provider = assayService.getProvider(protocol); + if (provider != null) + { + SchemaKey key = AssayProtocolSchema.schemaName(provider, protocol); + ExperimentService.get().getObjectReferencers() + .forEach(referencer -> { + Collection referenced = referencer.getItemsWithReferences( + idToRunMap.keySet(), + key.toString(), + "Runs" + ); + referenced.forEach(id -> referencedItems.put(id, idToRunMap.get(id))); + referenceDescriptions.add(referencer.getObjectReferenceDescription(ExpRun.class)); + } + ); + } + + } + + List> permissionDatasetRows = new ArrayList<>(); + List> noPermissionDatasetRows = new ArrayList<>(); + if (StudyPublishService.get() != null) + { + for (Dataset dataset : StudyPublishService.get().getDatasetsForAssayRuns(runs, getUser())) + { + ActionURL url = urlProvider(StudyUrls.class).getDatasetURL(dataset.getContainer(), dataset.getDatasetId()); + TableInfo t = dataset.getTableInfo(getUser()); + if (null != t && t.hasPermission(getUser(),DeletePermission.class)) + { + permissionDatasetRows.add(new Pair<>(dataset, url)); + } + else + { + noPermissionDatasetRows.add(new Pair<>(dataset, url)); + } + } + } + + return new ConfirmDeleteView( + "run", + ShowRunGraphAction.class, + runs.stream().filter(run -> !referencedItems.containsKey(run.getRowId())).toList(), + deleteForm, + Collections.emptyList(), + "dataset(s) have one or more rows which", + permissionDatasetRows, + noPermissionDatasetRows, + referencedItems.values().stream().toList(), + referenceDescriptions.stream().filter(Objects::nonNull).collect(Collectors.joining(", or "))); + } + + @Override + protected void deleteObjects(DeleteForm deleteForm) + { + ExperimentServiceImpl.get().deleteExperimentRunsByRowIds(getContainer(), getUser(), deleteForm.getUserComment(), deleteForm.getIds(false)); + } + } + + public static class DeleteRunForm + { + private int _runId; + + public int getRunId() + { + return _runId; + } + + public void setRunId(int runId) + { + _runId = runId; + } + } + + /** + * Separate delete action from the client API + */ + @RequiresAnyOf({DeletePermission.class, SampleWorkflowDeletePermission.class}) + public static class DeleteRunAction extends MutatingApiAction + { + @Override + public ApiResponse execute(DeleteRunForm form, BindException errors) + { + ExpRun run = ExperimentService.get().getExpRun(form.getRunId()); + if (run == null) + { + throw new NotFoundException("Could not find run with ID " + form.getRunId()); + } + if (!run.canDelete(getUser())) + throw new UnauthorizedException("You do not have permission to delete " + + (ExpProtocol.isSampleWorkflowProtocol(run.getProtocol().getLSID()) ? "jobs" : "runs") + " in this container."); + + run.delete(getUser()); + return new ApiSimpleResponse("success", true); + } + } + + + @RequiresAnyOf({DeletePermission.class, SampleWorkflowDeletePermission.class}) + public static class DeleteRunsAction extends AbstractDeleteAPIAction + { + @Override + protected ApiSimpleResponse deleteObjects(CascadeDeleteForm form) + { + Set runIdsToDelete = new HashSet<>(form.getIds(true)); + Set runIdsCascadeDeleted = new HashSet<>(); + + if (form.isCascade()) + { + for (long runId : runIdsToDelete) + { + ExpRun run = ExperimentService.get().getExpRun(runId); + if (run != null) + addReplacesRuns(run, runIdsCascadeDeleted); + } + + if (!runIdsCascadeDeleted.isEmpty()) + runIdsToDelete.addAll(runIdsCascadeDeleted); + } + + ExperimentService.get().deleteExperimentRunsByRowIds(getContainer(), getUser(), form.getUserComment(), runIdsToDelete); + + ApiSimpleResponse response = new ApiSimpleResponse("success", true); + response.put("runIdsDeleted", runIdsToDelete); + if (!runIdsCascadeDeleted.isEmpty()) + response.put("runIdsCascadeDeleted", runIdsCascadeDeleted); + return response; + } + + private void addReplacesRuns(ExpRun run, Set runIds) + { + for (ExpRun replacedRun : run.getReplacesRuns()) + { + runIds.add(replacedRun.getRowId()); + addReplacesRuns(replacedRun, runIds); + } + } + } + + private abstract static class AbstractDeleteAPIAction extends MutatingApiAction + { + @Override + public void validateForm(CascadeDeleteForm form, Errors errors) + { + if (form.getSingleObjectRowId() == null && form.getDataRegionSelectionKey() == null && form.getRowIds() == null) + errors.reject(ERROR_REQUIRED, "Either singleObjectRowId, dataRegionSelectionKey, or rowIds is required"); + } + + @Override + public ApiResponse execute(CascadeDeleteForm form, BindException errors) throws Exception + { + ApiSimpleResponse response; + + try (DbScope.Transaction tx = ExperimentService.get().ensureTransaction()) + { + tx.addCommitTask(form::clearSelected, POSTCOMMIT); + + response = deleteObjects(form); + tx.commit(); + } + + if (null != response.get("success")) + response.put("success", !errors.hasErrors()); + + return response; + } + + protected abstract ApiSimpleResponse deleteObjects(CascadeDeleteForm form) throws Exception; + } + + public static class CascadeDeleteForm extends DeleteForm + { + private boolean _cascade; + + public boolean isCascade() + { + return _cascade; + } + + public void setCascade(boolean cascade) + { + _cascade = cascade; + } + } + + private abstract static class AbstractDeleteAction extends FormViewAction + { + @Override + public void validateCommand(DeleteForm target, Errors errors) + { + } + + @Override + public boolean handlePost(DeleteForm deleteForm, BindException errors) throws Exception + { + if (!deleteForm.isForceDelete()) + { + return false; + } + else + { + try (DbScope.Transaction tx = ExperimentService.get().ensureTransaction()) + { + tx.addCommitTask(deleteForm::clearSelected, POSTCOMMIT); + + deleteObjects(deleteForm); + tx.commit(); + } + catch (BatchValidationException v) + { + v.addToErrors(errors); + } + + return !errors.hasErrors(); + } + } + + @Override + public ActionURL getSuccessURL(DeleteForm form) + { + return form.getSuccessActionURL(ExperimentUrlsImpl.get().getOverviewURL(getContainer())); + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("Confirm Deletion"); + } + + protected abstract void deleteObjects(DeleteForm form) throws Exception; + } + + @RequiresPermission(DesignAssayPermission.class) + public static class DeleteProtocolByRowIdsAPIAction extends AbstractDeleteAPIAction + { + @Override + protected ApiSimpleResponse deleteObjects(CascadeDeleteForm form) + { + for (ExpProtocol protocol : getProtocolsForDeletion(form)) + { + if (!protocol.getContainer().hasPermission(getUser(), DesignAssayPermission.class)) + throw new UnauthorizedException("You do not have sufficient permissions to delete this assay design."); + + protocol.delete(getUser(), form.getUserComment()); + } + + return new ApiSimpleResponse(); + } + } + + public static List getProtocolsForDeletion(DeleteForm form) + { + List protocols = new ArrayList<>(); + for (long protocolId : form.getIds(false)) + { + ExpProtocol protocol = ExperimentService.get().getExpProtocol(protocolId); + if (protocol != null) + { + protocols.add(protocol); + } + } + return protocols; + } + + @RequiresPermission(DesignAssayPermission.class) + public class DeleteProtocolByRowIdsAction extends AbstractDeleteAction + { + @Override + public void addNavTrail(NavTree root) + { + // UNDONE: Need help topic on protocols + setHelpTopic("experiment"); + super.addNavTrail(root); + } + + @Override + public ModelAndView getView(DeleteForm form, boolean reshow, BindException errors) + { + List runs = ExperimentService.get().getExpRunsForProtocolIds(false, form.getIds(false)); + List protocols = getProtocolsForDeletion(form); + String noun = "Assay Design"; + List> deleteableDatasets = new ArrayList<>(); + List> noPermissionDatasets = new ArrayList<>(); + if (AssayService.get() != null && StudyService.get() != null) + { + for (ExpProtocol protocol : protocols) + { + if (!protocol.getContainer().hasPermission(getUser(), DesignAssayPermission.class)) + throw new UnauthorizedException("You do not have sufficient permissions to delete this assay design."); + + if (AssayService.get().getProvider(protocol) == null) + { + noun = "Protocol"; + } + for (Dataset dataset : StudyPublishService.get().getDatasetsForPublishSource(protocol.getRowId(), Dataset.PublishSource.Assay)) + { + Pair entry = new Pair<>(dataset, urlProvider(StudyUrls.class).getDatasetURL(dataset.getContainer(), dataset.getDatasetId())); + if (dataset.canDeleteDefinition(getUser())) + { + deleteableDatasets.add(entry); + } + else + { + noPermissionDatasets.add(entry); + } + } + } + } + + return new ConfirmDeleteView(noun, ProtocolDetailsAction.class, protocols, form, runs, "Dataset", deleteableDatasets, noPermissionDatasets, Collections.emptyList(), null); + } + + @Override + protected void deleteObjects(DeleteForm form) + { + for (ExpProtocol protocol : getProtocolsForDeletion(form)) + { + protocol.delete(getUser(), form.getUserComment()); + } + } + } + + @Marshal(Marshaller.Jackson) + @RequiresPermission(ReadPermission.class) + public static class GetDataOperationConfirmationDataAction extends ReadOnlyApiAction + { + @Override + public void validateForm(DataOperationConfirmationForm form, Errors errors) + { + if (form.getDataRegionSelectionKey() == null && form.getRowIds() == null) + errors.reject(ERROR_REQUIRED, "You must provide either a set of rowIds or a dataRegionSelectionKey"); + if (form.getDataOperation() == null) + errors.reject(ERROR_REQUIRED, "An operation type must be provided."); + } + + @Override + public Object execute(DataOperationConfirmationForm form, BindException errors) + { + Collection requestIds = form.getIds(false); + ExperimentServiceImpl service = ExperimentServiceImpl.get(); + List allData = service.getExpDatas(requestIds); + + Set notAllowedIds = new HashSet<>(); + if (form.getDataOperation() == ExpDataImpl.DataOperations.Delete) + service.getObjectReferencers().forEach(referencer -> + notAllowedIds.addAll(referencer.getItemsWithReferences(requestIds, "exp.data"))); + + Map>> response = ExperimentServiceImpl.partitionRequestedOperationObjects(getUser(), requestIds, notAllowedIds, allData); + + Collection containers = new HashSet<>(); + Collection notPermittedIds = new ArrayList<>(); + Class permClass = form.getDataOperation().getPermissionClass(); + for (ExpDataImpl expData : allData) + { + Container c = expData.getContainer(); + if (c.hasPermission(getUser(), ReadPermission.class)) + containers.add(c); + if (permClass != null && !c.hasPermission(getUser(), permClass)) + notPermittedIds.add(expData.getRowId()); + } + + NameExpressionOptionService svc = NameExpressionOptionService.get(); + response.put("containers", containers.stream().map(c -> Map.of( + "id", c.getEntityId(), + "path", (Object) c.getPath(), + "permitted", permClass == null || c.hasPermission(getUser(), permClass), + "canEditName", svc.getAllowUserSpecificNamesValue(c) + )).toList()); + + response.put("notPermitted", notPermittedIds.stream().map(id -> Map.of("RowId", (Object) id)).toList()); + + return success(response); + } + } + + + public static class DataOperationConfirmationForm extends DataViewSnapshotSelectionForm + { + private ExpDataImpl.DataOperations _dataOperation; + + public ExpDataImpl.DataOperations getDataOperation() + { + return _dataOperation; + } + + public void setDataOperation(ExpDataImpl.DataOperations dataOperation) + { + _dataOperation = dataOperation; + } + + } + + @Marshal(Marshaller.Jackson) + @RequiresPermission(ReadPermission.class) + public static class GetMaterialOperationConfirmationDataAction extends ReadOnlyApiAction + { + @Override + public void validateForm(MaterialOperationConfirmationForm form, Errors errors) + { + if (form.getDataRegionSelectionKey() == null && form.getRowIds() == null) + errors.reject(ERROR_REQUIRED, "You must provide either a set of rowIds or a dataRegionSelectionKey."); + if (form.getSampleOperation() == null) + errors.reject(ERROR_REQUIRED, "An operation type must be provided."); + } + + @Override + public Object execute(MaterialOperationConfirmationForm form, BindException errors) + { + Set requestIds = form.getIds(false); + ExperimentServiceImpl service = ExperimentServiceImpl.get(); + List allMaterials = service.getExpMaterials(requestIds); + + Set notAllowedIds = new HashSet<>(); + // We prevent deletion if a sample is used as a parent, has assay data, is used in a job, etc. + if (form.getSampleOperation() == SampleTypeService.SampleOperations.Delete) + service.getObjectReferencers().forEach(referencer -> + notAllowedIds.addAll(referencer.getItemsWithReferences(requestIds, "samples"))); + + if (SampleStatusService.get().supportsSampleStatus()) + notAllowedIds.addAll(service.findIdsNotPermittedForOperation(allMaterials, form.getSampleOperation())); + + Map>> response = ExperimentServiceImpl.partitionRequestedOperationObjects(getUser(), requestIds, notAllowedIds, allMaterials); + + Collection containers = new HashSet<>(); + Collection notPermittedIds = new ArrayList<>(); + Class permClass = form.getSampleOperation().getPermissionClass(); + for (ExpMaterial material : allMaterials) + { + Container c = material.getContainer(); + if (c.hasPermission(getUser(), ReadPermission.class)) + containers.add(c); + if (permClass != null && !c.hasPermission(getUser(), permClass)) + notPermittedIds.add(material.getRowId()); + } + + NameExpressionOptionService svc = NameExpressionOptionService.get(); + + response.put("containers", containers.stream().map(c -> Map.of( + "id", c.getEntityId(), + "path", (Object) c.getPath(), + "permitted", permClass == null || c.hasPermission(getUser(), permClass), + "canEditName", svc.getAllowUserSpecificNamesValue(c) + )).toList()); + + response.put("notPermitted", notPermittedIds.stream().map(id -> Map.of("RowId", (Object) id)).toList()); + + if (form.getSampleOperation() == SampleTypeService.SampleOperations.Delete) + // String 'associatedDatasets' must be synced to its handling in confirmDelete.js, confirmDelete() + response.put("associatedDatasets", ExperimentServiceImpl.includeLinkedToStudyText(allMaterials, requestIds, getUser(), getContainer())); + + return success(response); + } + } + + public static class MaterialOperationConfirmationForm extends DataViewSnapshotSelectionForm + { + private SampleTypeService.SampleOperations _sampleOperation; + + public SampleTypeService.SampleOperations getSampleOperation() + { + return _sampleOperation; + } + + public void setSampleOperation(SampleTypeService.SampleOperations sampleOperation) + { + _sampleOperation = sampleOperation; + } + } + + @RequiresPermission(DeletePermission.class) + public class DeleteSelectedDataAction extends AbstractDeleteAction + { + @Override + public void addNavTrail(NavTree root) + { + // UNDONE: Need help topic on Datas + setHelpTopic("experiment"); + super.addNavTrail(root); + } + + @Override + protected void deleteObjects(DeleteForm deleteForm) throws Exception + { + List datas = getDatas(deleteForm, false); + + for (ExpRun run : getRuns(datas)) + { + if (!run.getContainer().hasPermission(getUser(), DeletePermission.class)) + throw new UnauthorizedException(); + } + + // Issue 32076: Delete the exp.Data objects using QueryUpdateService so trigger scripts will be executed + Map, List> byDataClass = datas.stream().collect(Collectors.groupingBy(d -> Optional.ofNullable(d.getDataClass(null)))); + for (Optional opt : byDataClass.keySet()) + { + SchemaKey schemaKey; + String queryName; + ExpDataClass dc = opt.orElse(null); + List ds = byDataClass.get(opt); + if (dc == null) + { + // Reference to exp.Data table + schemaKey = ExpSchema.SCHEMA_EXP; + queryName = ExpSchema.TableType.Data.name(); + } + else + { + // Reference to exp.data. table + schemaKey = ExpSchema.SCHEMA_EXP_DATA; + queryName = dc.getName(); + } + + UserSchema schema = QueryService.get().getUserSchema(getUser(), getContainer(), schemaKey); + if (schema == null) + throw new IllegalStateException("Failed to get schema '" + schemaKey + "'"); + + TableInfo table = schema.getTable(queryName); + if (table == null) + throw new IllegalStateException("Failed to get table '" + queryName + "' in schema '" + schemaKey + "'"); + + QueryUpdateService qus = table.getUpdateService(); + if (qus == null) + throw new IllegalStateException(); + + qus.deleteRows(getUser(), getContainer(), toKeys(ds), null, null); + } + } + + protected List> toKeys(List datas) + { + return datas.stream().map(d -> CaseInsensitiveHashMap.of("rowId", d.getRowId())).collect(toList()); + } + + @Override + public ModelAndView getView(DeleteForm deleteForm, boolean reshow, BindException errors) + { + if (errors.hasErrors()) + return new SimpleErrorView(errors, false); + + List datas = getDatas(deleteForm, false); + List runs = getRuns(datas); + + return new ConfirmDeleteView("Data", ShowDataAction.class, datas, deleteForm, runs); + } + + private List getRuns(List datas) + { + List runArray = ExperimentService.get().getRunsUsingDatas(datas); + return new ArrayList<>(ExperimentService.get().runsDeletedWithInput(runArray)); + } + + private List getDatas(DeleteForm deleteForm, boolean clear) + { + List datas = new ArrayList<>(); + for (long dataId : deleteForm.getIds(clear)) + { + ExpData data = ExperimentService.get().getExpData(dataId); + if (data != null) + { + datas.add(data); + } + } + return datas; + } + } + + @RequiresPermission(DeletePermission.class) + public class DeleteSelectedExperimentsAction extends AbstractDeleteAction + { + @Override + protected void deleteObjects(DeleteForm deleteForm) + { + for (ExpExperiment exp : lookupExperiments(deleteForm)) + { + exp.delete(getUser()); + } + } + + @Override + public ModelAndView getView(DeleteForm deleteForm, boolean reshow, BindException errors) + { + List experiments = lookupExperiments(deleteForm); + + List runs = new ArrayList<>(); + boolean allBatches = true; + for (ExpExperiment experiment : experiments) + { + // Deleting a batch also deletes all of its runs + if (experiment.getBatchProtocol() != null) + { + runs.addAll(experiment.getRuns()); + } + else + { + allBatches = false; + } + } + + return new ConfirmDeleteView(allBatches ? "batch" : "run group", DetailsAction.class, experiments, deleteForm, runs); + } + + private List lookupExperiments(DeleteForm deleteForm) + { + List experiments = new ArrayList<>(); + for (long experimentId : deleteForm.getIds(false)) + { + ExpExperiment experiment = ExperimentService.get().getExpExperiment(experimentId); + if (experiment != null) + { + experiments.add(experiment); + } + } + return experiments; + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("runGroups"); + super.addNavTrail(root); + } + } + + @RequiresPermission(DesignSampleTypePermission.class) + public class DeleteSampleTypesAction extends AbstractDeleteAction + { + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("sampleSets"); + super.addNavTrail(root); + } + + @Override + protected void deleteObjects(DeleteForm deleteForm) + { + List sampleTypes = getSampleTypes(deleteForm); + if (sampleTypes.isEmpty()) + { + throw new NotFoundException("No sample types found for ids provided."); + } + if (!ensureCorrectContainer(sampleTypes)) + { + throw new UnauthorizedException(); + } + + for (ExpRun run : getRuns(sampleTypes)) + { + if (!run.getContainer().hasPermission(getUser(), DeletePermission.class)) + { + throw new UnauthorizedException(); + } + } + + for (ExpSampleType source : sampleTypes) + { + Domain domain = source.getDomain(); + if (!domain.getDomainKind().canDeleteDefinition(getUser(), domain)) + { + throw new UnauthorizedException(); + } + + source.delete(getUser(), deleteForm.getUserComment()); + } + } + + @Override + public ModelAndView getView(DeleteForm deleteForm, boolean reshow, BindException errors) + { + List sampleTypes = getSampleTypes(deleteForm); + if (!ensureCorrectContainer(sampleTypes)) + { + throw new RedirectException(ExperimentUrlsImpl.get().getShowSampleTypeListURL(getContainer(), "To delete a sample type, you must be in its folder or project.")); + } + + List> deleteableDatasets = new ArrayList<>(); + List> noPermissionDatasets = new ArrayList<>(); + if (StudyService.get() != null && StudyPublishService.get() != null) + { + for (ExpSampleType sampleType: sampleTypes) + { + for (Dataset dataset : StudyPublishService.get().getDatasetsForPublishSource(sampleType.getRowId(), Dataset.PublishSource.SampleType)) + { + ActionURL datasetURL = StudyService.get().getDatasetURL(getContainer(), dataset.getDatasetId()); + Pair entry = new Pair<>(dataset, datasetURL); + if (dataset.canDeleteDefinition(getUser())) + { + deleteableDatasets.add(entry); + } + else + { + noPermissionDatasets.add(entry); + } + } + } + } + return new ConfirmDeleteView("Sample Type", ShowSampleTypeAction.class, sampleTypes, deleteForm, getRuns(sampleTypes), "Dataset", deleteableDatasets, noPermissionDatasets, Collections.emptyList(), null); + } + + private List getSampleTypes(DeleteForm deleteForm) + { + List sources = new ArrayList<>(); + for (long rowId : deleteForm.getIds(false)) + { + ExpSampleType sampleType = SampleTypeService.get().getSampleType(getContainer(), getUser(), rowId); + if (sampleType != null) + { + sources.add(sampleType); + } + } + return sources; + } + + private boolean ensureCorrectContainer(List sampleTypes) + { + for (ExpSampleType source : sampleTypes) + { + Container sourceContainer = source.getContainer(); + if (!sourceContainer.equals(getContainer())) + { + return false; + } + } + return true; + } + + private List getRuns(List sampleTypes) + { + if (!sampleTypes.isEmpty()) + { + List runArray = ExperimentService.get().getRunsUsingSampleTypes(sampleTypes.toArray(new ExpSampleType[0])); + return ExperimentService.get().runsDeletedWithInput(runArray); + } + else + { + return Collections.emptyList(); + } + } + } + + private DataRegion getSampleTypeRegion(ViewContext model) + { + TableInfo tableInfo = ExperimentServiceImpl.get().getTinfoSampleType(); + + QuerySettings settings = new QuerySettings(model, "SampleType"); + settings.setSelectionKey(DataRegionSelection.getSelectionKey(tableInfo.getSchema().getName(), tableInfo.getName(), "SampleType", settings.getDataRegionName())); + + DataRegion dr = new DataRegion(); + dr.setSettings(settings); + dr.addColumns(tableInfo.getUserEditableColumns()); + dr.removeColumns("lastindexed"); + dr.getDisplayColumn(0).setVisible(false); + + dr.getDisplayColumn("idcol1").setVisible(false); + dr.getDisplayColumn("idcol2").setVisible(false); + dr.getDisplayColumn("idcol3").setVisible(false); + dr.getDisplayColumn("lsid").setVisible(false); + dr.getDisplayColumn("materiallsidprefix").setVisible(false); + dr.getDisplayColumn("parentcol").setVisible(false); + + ActionURL url = new ActionURL(ShowSampleTypeAction.class, model.getContainer()); + dr.getDisplayColumn(1).setURL(url.addParameter("rowId", "${RowId}")); + dr.setShowRecordSelectors(getContainer().hasOneOf(getUser(), DeletePermission.class, UpdatePermission.class)); + + return dr; + } + + @RequiresPermission(ReadPermission.class) + @ActionNames("getSampleType,getSampleTypeApi") // Referenced in labkey-ui-components components/samples/actions.ts TODO: migrate getSampleTypeApi -> getSampleType + public static class GetSampleTypeAction extends ReadOnlyApiAction + { + @Override + public void validateForm(SampleTypeForm form, Errors errors) + { + if (form.getRowId() == null && form.getLSID() == null) + errors.reject(ERROR_REQUIRED, "RowId or LSID must be provided"); + } + + @Override + public Object execute(SampleTypeForm form, BindException errors) throws Exception + { + ExpSampleTypeImpl st = form.getSampleType(getContainer()); + + return getSampleTypeResponse(st); + } + } + + @NotNull + private static ApiSimpleResponse getSampleTypeResponse(ExpSampleType st) throws IOException + { + Map sampleType = new HashMap<>(); + sampleType.put("name", st.getName()); + sampleType.put("nameExpression", st.getNameExpression()); + sampleType.put("labelColor", st.getLabelColor()); + sampleType.put("metricUnit", st.getMetricUnit()); + sampleType.put("description", st.getDescription()); + sampleType.put("importAliases", st.getImportAliasMap()); + sampleType.put("lsid", st.getLSID()); + sampleType.put("rowId", st.getRowId()); + sampleType.put("domainId", st.getDomain().getTypeId()); + sampleType.put("category", st.getCategory()); + + return new ApiSimpleResponse(Map.of("sampleSet", sampleType, "success", true)); + } + + public static class DataTypesWithRequiredLineageForm + { + private Integer _parentDataTypeRowId; + private boolean _sampleParent; + + public Integer getParentDataTypeRowId() + { + return _parentDataTypeRowId; + } + + public void setParentDataTypeRowId(Integer parentDataTypeRowId) + { + this._parentDataTypeRowId = parentDataTypeRowId; + } + + public boolean isSampleParent() + { + return _sampleParent; + } + + public void setSampleParent(boolean sampleParent) + { + _sampleParent = sampleParent; + } + } + + @RequiresPermission(ReadPermission.class) + public static class GetDataTypesWithRequiredLineageAction extends ReadOnlyApiAction + { + @Override + public void validateForm(DataTypesWithRequiredLineageForm form, Errors errors) + { + if (form.getParentDataTypeRowId() == null) + errors.reject(ERROR_REQUIRED, "ParentDataTypeRowId must be provided"); + } + + @Override + public Object execute(DataTypesWithRequiredLineageForm form, BindException errors) throws Exception + { + return getDataTypesWithRequiredLineageResponse(form.getParentDataTypeRowId(), form.isSampleParent(), getContainer(), getUser()); + } + } + @NotNull + private static ApiSimpleResponse getDataTypesWithRequiredLineageResponse(Integer parentDataType, boolean isSampleParent, Container container, User user) + { + Pair, Set> requiredLineages = ExperimentServiceImpl.get().getDataTypesWithRequiredLineage(parentDataType, isSampleParent, container, user); + return new ApiSimpleResponse(Map.of("sampleTypes", requiredLineages.first, "dataClasses", requiredLineages.second,"success", true)); + } + + @RequiresPermission(DesignSampleTypePermission.class) + public static class EditSampleTypeAction extends SimpleViewAction + { + private ExpSampleTypeImpl _sampleType; + + @Override + public ModelAndView getView(SampleTypeForm form, BindException errors) + { + boolean create = form.getLSID() == null && form.getRowId() == null; + if (!create) + _sampleType = form.getSampleType(getContainer()); + + return ModuleHtmlView.get(ModuleLoader.getInstance().getModule("core"), ModuleHtmlView.getGeneratedViewPath("sampleTypeDesigner")); + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("sampleSets"); + + root.addChild("Sample Types", ExperimentUrlsImpl.get().getShowSampleTypeListURL(getContainer())); + if (_sampleType == null) + { + root.addChild("Create Sample Type"); + } + else + { + root.addChild(_sampleType.getName(), ExperimentUrlsImpl.get().getShowSampleTypeURL(_sampleType)); + root.addChild("Update Sample Type"); + } + } + } + + public static class SampleTypeForm extends ReturnUrlForm + { + private Integer rowId; + private String lsid; + + public Integer getRowId() + { + return rowId; + } + + public void setRowId(Integer rowId) + { + this.rowId = rowId; + } + + public String getLSID() + { + return this.lsid; + } + + public void setLSID(String lsid) + { + this.lsid = lsid; + } + + public ExpSampleTypeImpl getSampleType(Container container) throws NotFoundException + { + ExpSampleTypeImpl sampleType = SampleTypeServiceImpl.get().getSampleType(getLSID()); + if (sampleType == null) + sampleType = SampleTypeServiceImpl.get().getSampleType(getRowId()); + + if (sampleType == null) + { + throw new NotFoundException("Sample type not found: " + (getLSID() != null ? getLSID() : getRowId())); + } + + if (!container.equals(sampleType.getContainer())) + { + throw new NotFoundException("Sample type is not defined in the given container."); + } + + return sampleType; + } + } + + @RequiresPermission(InsertPermission.class) + public static class ImportSamplesAction extends AbstractExpDataImportAction + { + ExpSampleTypeImpl _sampleType; + boolean _isCrossTypeImport = false; + + @Override + public void validateForm(QueryForm queryForm, Errors errors) + { + _form = queryForm; + _insertOption = queryForm.getInsertOption(); + _isCrossTypeImport = getOptionParamValue(Params.crossTypeImport); + _form.setSchemaName(getTargetSchemaName()); + if (_isCrossTypeImport) + { + _form.setQueryName(getPipelineTargetQueryName()); + } + super.validateForm(queryForm, errors); + if (queryForm.getQueryName() == null) + errors.reject(ERROR_REQUIRED, "Sample type name is required"); + else + { + if (!_isCrossTypeImport) + { + _sampleType = SampleTypeServiceImpl.get().getSampleType(getContainer(), getUser(), queryForm.getQueryName()); + if (_sampleType == null) + { + errors.reject(ERROR_GENERIC, "Sample type '" + queryForm.getQueryName() + " not found."); + } + } + } + } + + private String getTargetSchemaName() + { + return getOptionParamValue(Params.crossTypeImport) ? ExpSchema.SCHEMA_NAME : "samples"; + } + + @Override + protected UserSchema getTargetSchema() + { + return getOptionParamValue(Params.crossTypeImport) ? QueryService.get().getUserSchema(getUser(), getContainer(), getTargetSchemaName()) : super.getTargetSchema(); + } + + @Override + protected String getPipelineTargetQueryName() + { + return getOptionParamValue(Params.crossTypeImport) ? "materials" : super.getPipelineTargetQueryName(); + } + + @Override + protected Map getRenamedColumns() + { + Map renamedColumns = super.getRenamedColumns(); + renamedColumns.putAll(SampleTypeUpdateServiceDI.SAMPLE_ALT_IMPORT_NAME_COLS); + return renamedColumns; + } + + @Override + protected @Nullable Set getLineageImportAliases() throws IOException + { + Set aliases = new CaseInsensitiveHashSet(); + // Issue 53419: Aliquot parent with number like names that starts with leading zeroes aren't resolved during import + aliases.add(ExpMaterial.ALIQUOTED_FROM_INPUT); + aliases.add(ExpMaterial.ALIQUOTED_FROM_INPUT_LABEL); + boolean crossTypeImport = getOptionParamValue(AbstractQueryImportAction.Params.crossTypeImport); + // Issue 51894: We need to stop conversion to numbers for alias fields for all type + // If there are aliases defined for one type that are number fields in another type, this will prevent + // conversion to numbers during the initial partitioning, but the conversion will happen when the partition + // file is loaded. + if (crossTypeImport) + { + List sampleTypes = SampleTypeServiceImpl.get().getSampleTypes(getContainer(), true); + for (ExpSampleTypeImpl sampleType : sampleTypes) + aliases.addAll(sampleType.getImportAliases().keySet()); + } + else + { + ExpSampleTypeImpl sampleType = SampleTypeServiceImpl.get().getSampleType(getContainer(), getUser(), _form.getQueryName()); + aliases.addAll(sampleType.getImportAliases().keySet()); + } + return aliases; + } + + @Override + protected int importData( + DataLoader dl, + FileStream file, + String originalName, + BatchValidationException errors, + @Nullable AuditBehaviorType auditBehaviorType, + TransactionAuditProvider.@Nullable TransactionAuditEvent auditEvent, + @Nullable String auditUserComment + ) throws IOException + { + initContext(dl, errors, auditBehaviorType, auditUserComment); + + TableInfo tInfo = _target; + QueryUpdateService updateService = _updateService; + if (getOptionParamValue(Params.crossTypeImport)) + { + tInfo = ExperimentService.get().createMaterialTable(new SamplesSchema(getUser(), getContainer()), ContainerFilter.current(this), null); + updateService = tInfo.getUpdateService(); + } + + int count = importData(dl, tInfo, updateService, _context, auditEvent, getUser(), getContainer()); + + if (getOptionParamValue(Params.crossTypeImport)) + { + SimpleMetricsService.get().increment(ExperimentService.MODULE_NAME, "sampleImport", "crossTypeImport"); + if (_context.getInsertOption() == QueryUpdateService.InsertOption.UPDATE) + SimpleMetricsService.get().increment(ExperimentService.MODULE_NAME, "sampleImport", "crossTypeUpdate"); + else if (_context.getInsertOption() == QueryUpdateService.InsertOption.MERGE) + SimpleMetricsService.get().increment(ExperimentService.MODULE_NAME, "sampleImport", "crossTypeMerge"); + } + + return count; + } + + @Override + public ModelAndView getView(QueryForm form, BindException errors) throws Exception + { + initRequest(form); + setHelpTopic("importSampleSets"); // page-wide help topic + setImportHelpTopic("importSampleSets"); // importOptions help topic + setTypeName("samples"); + return getDefaultImportView(form, errors); + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("Sample Types", ExperimentUrlsImpl.get().getShowSampleTypeListURL(getContainer())); + ActionURL url = _form.urlFor(QueryAction.executeQuery); + if (_form.getQueryName() != null && url != null) + root.addChild(_form.getQueryName(), url); + root.addChild("Import Data"); + } + + @Override + protected JSONObject createSuccessResponse(int rowCount) + { + JSONObject json = super.createSuccessResponse(rowCount); + if (!_context.getResponseInfo().isEmpty()) + { + for (String key : _context.getResponseInfo().keySet()) + json.put(key, _context.getResponseInfo().get(key)); + } + return json; + } + + @Override + protected void configureLoader(DataLoader loader) throws IOException + { + if (getOptionParamValue(Params.crossTypeImport)) + loader.setInferTypes(false); + configureLoader(loader, _target, getRenamedColumns(), allowLineageColumns(), getLineageImportAliases()); + } + } + + public abstract static class AbstractExpDataImportAction extends AbstractQueryImportAction + { + protected QueryForm _form; + protected DataIteratorContext _context; + + @Override + public void validateForm(QueryForm form, Errors errors) + { + QueryDefinition query = form.getQueryDef(); + if (query.getContainerFilter() != null && query.getContainerFilter().getType() != null) + { + // cross folder import not supported + if (query.getContainerFilter().getType() != ContainerFilter.Type.Current) + errors.reject(ERROR_GENERIC, "ContainerFilter is not supported for import actions."); + } + } + + @Override + protected void initRequest(QueryForm form) throws ServletException + { + QueryDefinition query = form.getQueryDef(); + setContainerFilterForImport(query, getContainer(), getUser()); + List qpe = new ArrayList<>(); + TableInfo t = query.getTable(form.getSchema(), qpe, true); + + if (!qpe.isEmpty()) + throw qpe.get(0); + if (!getOptionParamValue(Params.crossTypeImport) && null != t) + { + setTarget(t); + setShowMergeOption(t.supportsInsertOption(QueryUpdateService.InsertOption.MERGE)); + setShowUpdateOption(t.supportsInsertOption(QueryUpdateService.InsertOption.UPDATE)); + } + + _auditBehaviorType = form.getAuditBehavior(); + _auditUserComment = form.getAuditUserComment(); + } + + @Override + protected Map getRenamedColumns() + { + final String renameParamPrefix = "importAlias."; + Map renameColumns = new CaseInsensitiveHashMap<>(); + PropertyValue[] pvs = _form.getInitParameters().getPropertyValues(); + for (PropertyValue pv : pvs) + { + String paramName = pv.getName(); + if (!paramName.startsWith(renameParamPrefix) || pv.getValue() == null) + continue; + + renameColumns.put(paramName.substring(renameParamPrefix.length()), (String) pv.getValue()); + } + return renameColumns; + } + + @Override + protected Set getLineageImportAliases() throws IOException + { + ExpDataClass dataClass = ExperimentServiceImpl.get().getDataClass(getContainer(), getUser(), _form.getQueryName()); + return new CaseInsensitiveHashSet(dataClass.getImportAliases().keySet()); + } + + protected void initContext(DataLoader dl, BatchValidationException errors, @Nullable AuditBehaviorType auditBehaviorType, @Nullable String auditUserComment) + { + _context = createDataIteratorContext(_insertOption, getOptionParamsMap(), getLookupResolutionType(), auditBehaviorType, auditUserComment, errors, null, getContainer()); + + if (_context.isCrossFolderImport() && !getContainer().hasProductFolders()) + _context.setCrossFolderImport(false); + } + + @Override + protected int importData(DataLoader dl, FileStream file, String originalName, BatchValidationException errors, @Nullable AuditBehaviorType auditBehaviorType, TransactionAuditProvider.@Nullable TransactionAuditEvent auditEvent, @Nullable String auditUserComment) throws IOException + { + initContext(dl, errors, auditBehaviorType, auditUserComment); + return importData(dl, _target, _updateService, _context, auditEvent, getUser(), getContainer()); + } + + @Override + protected String getQueryImportProviderName() + { + PropertyValue pv = _form.getInitParameters().getPropertyValue(QUERY_IMPORT_PIPELINE_PROVIDER_PARAM); + return pv == null ? null : (String) pv.getValue(); + } + + @Override + protected String getQueryImportDescription() + { + PropertyValue pv = _form.getInitParameters().getPropertyValue(QUERY_IMPORT_PIPELINE_DESCRIPTION_PARAM); + return pv == null ? null : (String) pv.getValue(); + } + + @Override + protected String getQueryImportJobNotificationProviderName() + { + PropertyValue pv = _form.getInitParameters().getPropertyValue(QUERY_IMPORT_NOTIFICATION_PROVIDER_PARAM); + return pv == null ? null : (String) pv.getValue(); + } + + @Override + protected boolean isBackgroundImportSupported() + { + return true; + } + + @Override + protected boolean allowLineageColumns() + { + return true; + } + + } + + @RequiresPermission(InsertPermission.class) + public static class ImportDataAction extends AbstractExpDataImportAction + { + @Override + public void validateForm(QueryForm queryForm, Errors errors) + { + _form = queryForm; + _form.setSchemaName("exp.data"); + _insertOption = queryForm.getInsertOption(); + super.validateForm(queryForm, errors); + if (queryForm.getQueryName() == null) + errors.reject(ERROR_REQUIRED, "Data class name is required"); + else + { + ExpDataClass dataClass = ExperimentService.get().getDataClass(getContainer(), getUser(), queryForm.getQueryName()); + if (dataClass == null) + { + errors.reject(ERROR_GENERIC, "Data class '" + queryForm.getQueryName() + " not found."); + } + } + } + + @Override + public ModelAndView getView(QueryForm form, BindException errors) throws Exception + { + initRequest(form); + setHelpTopic("dataClass"); // page wide help topic + setImportHelpTopic("dataClass#ui"); // importOptions help topic + setTypeName("data"); + return getDefaultImportView(form, errors); + } + + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("Data Classes", ExperimentUrlsImpl.get().getDataClassListURL(getContainer())); + ActionURL url = _form.urlFor(QueryAction.executeQuery); + if (_form.getQueryName() != null && url != null) + root.addChild(_form.getQueryName(), url); + root.addChild("Import Data"); + } + + @Override + protected void configureLoader(DataLoader loader) throws IOException + { + configureLoader(loader, _target, getRenamedColumns(), allowLineageColumns(), getLineageImportAliases()); + } + + } + + @RequiresPermission(UpdatePermission.class) + public class ShowUpdateAction extends SimpleViewAction + { + @Override + public ModelAndView getView(ExperimentForm form, BindException errors) + { + form.refreshFromDb(); + Experiment exp = form.getBean(); + if (exp == null) + { + throw new NotFoundException(); + } + ensureCorrectContainer(getContainer(), ExperimentService.get().getExpExperiment(exp.getRowId()), getViewContext()); + + return new ExperimentUpdateView(new DataRegion(), form, errors); + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("runGroups"); + addRootNavTrail(root); + root.addChild("Update Run Group"); + } + } + + @RequiresPermission(UpdatePermission.class) + public static class UpdateAction extends FormHandlerAction + { + private Experiment _exp; + + @Override + public void validateCommand(ExperimentForm target, Errors errors) + { + } + + @Override + public boolean handlePost(ExperimentForm form, BindException errors) throws Exception + { + form.doUpdate(); + form.refreshFromDb(); + _exp = form.getBean(); + return true; + } + + @Override + public ActionURL getSuccessURL(ExperimentForm experimentForm) + { + return ExperimentUrlsImpl.get().getExperimentDetailsURL(getContainer(), ExperimentService.get().getExpExperiment(_exp.getRowId())); + } + } + + public static class ExportBean + { + private final LSIDRelativizer _selectedRelativizer; + private final XarExportType _selectedExportType; + private final String _fileName; + private final String _dataRegionSelectionKey; + private final String _error; + private final Long _expRowId; + private final Long _protocolId; + private final ActionURL _postURL; + private final Set _roles; + + public ExportBean(LSIDRelativizer selectedRelativizer, XarExportType selectedExportType, String fileName, ExportOptionsForm form, Set roles, ActionURL postURL) + { + _selectedRelativizer = selectedRelativizer; + _selectedExportType = selectedExportType; + _fileName = fileName; + _dataRegionSelectionKey = form.getDataRegionSelectionKey(); + _error = form.getError(); + _expRowId = form.getExpRowId(); + _postURL = postURL; + _roles = roles; + _protocolId = form.getProtocolId(); + } + + public LSIDRelativizer getSelectedRelativizer() + { + return _selectedRelativizer; + } + + public XarExportType getSelectedExportType() + { + return _selectedExportType; + } + + public String getError() + { + return _error; + } + + public String getFileName() + { + return _fileName; + } + + public Set getRoles() + { + return _roles; + } + + public String getDataRegionSelectionKey() + { + return _dataRegionSelectionKey; + } + + public ActionURL getPostURL() + { + return _postURL; + } + + public Long getProtocolId() + { + return _protocolId; + } + + public Long getExpRowId() + { + return _expRowId; + } + } + + + private String fixupExportName(String runName) + { + runName = runName.replace('/', '-'); + runName = runName.replace('\\', '-'); + return runName; + } + + public static class ExportOptionsForm extends ExperimentRunListForm + { + private String _error; + private XarExportType _exportType; + private LSIDRelativizer _lsidOutputType; + private String _xarFileName; + private String _zipFileName; + private String _fileExportType; + private Long _protocolId; + private Integer _sampleTypeId; + private long[] _dataIds; + private String[] _roles = new String[0]; + + public String getError() + { + return _error; + } + + public void setError(String error) + { + _error = error; + } + + public XarExportType getExportType() + { + return _exportType; + } + + public LSIDRelativizer getLsidOutputType() + { + return _lsidOutputType; + } + + public String getFileExportType() + { + return _fileExportType; + } + + public void setFileExportType(String fileExportType) + { + _fileExportType = fileExportType; + } + + public String getXarFileName() + { + return _xarFileName; + } + + public void setXarFileName(String xarFileName) + { + _xarFileName = xarFileName; + } + + public String getZipFileName() + { + return _zipFileName; + } + + public void setZipFileName(String zipFileName) + { + _zipFileName = zipFileName; + } + + public void setExportType(XarExportType exportType) + { + _exportType = exportType; + } + + public void setLsidOutputType(LSIDRelativizer lsidOutputType) + { + _lsidOutputType = lsidOutputType; + } + + public Long getProtocolId() + { + return _protocolId; + } + + public void setProtocolId(Long protocolId) + { + _protocolId = protocolId; + } + + public String[] getRoles() + { + return _roles; + } + + public void setRoles(String[] roles) + { + _roles = roles; + } + + public Integer getSampleTypeId() + { + return _sampleTypeId; + } + + public void setSampleTypeId(Integer sampleTypeId) + { + _sampleTypeId = sampleTypeId; + } + + public long[] getDataIds() + { + return _dataIds; + } + + public void setDataIds(long[] dataIds) + { + _dataIds = dataIds; + } + + public List lookupProtocols(ViewContext context, boolean clearSelection) + { + List protocols = new ArrayList<>(); + + if (_protocolId != null) + { + ExpProtocol protocol = ExperimentService.get().getExpProtocol(_protocolId.intValue()); + if (protocol == null || !protocol.getContainer().equals(context.getContainer())) + { + throw new NotFoundException(); + } + protocols.add(protocol); + return protocols; + } + + for (Long protocolId : DataRegionSelection.getSelectedIntegers(context, clearSelection)) + { + try + { + ExpProtocol protocol = ExperimentService.get().getExpProtocol(protocolId); + if (protocol == null || !protocol.getContainer().equals(context.getContainer())) + { + throw new NotFoundException(); + } + protocols.add(protocol); + } + catch (NumberFormatException e) + { + throw new NotFoundException("Invalid protocol id: " + protocolId); + } + } + if (protocols.isEmpty()) + { + throw new NotFoundException("No protocols selected"); + } + return protocols; + } + } + + private ActionURL exportXAR(@NotNull XarExportSelection selection, @Nullable String fileName) + throws ExperimentException, IOException, PipelineValidationException + { + return exportXAR(selection, null, null, fileName); + } + + private ActionURL exportXAR(@NotNull XarExportSelection selection, @Nullable LSIDRelativizer lsidRelativizer, @Nullable XarExportType exportType, @Nullable String fileName) + throws ExperimentException, IOException, PipelineValidationException + { + if (lsidRelativizer == null) + lsidRelativizer = LSIDRelativizer.FOLDER_RELATIVE; + + if (exportType == null) + exportType = XarExportType.BROWSER_DOWNLOAD; + + if (fileName == null || fileName.isEmpty()) + fileName = "export.xar"; + + fileName = fixupExportName(fileName); + String xarXmlFileName = null; + if (Strings.CI.endsWith(fileName, ".xar")) + xarXmlFileName = fileName + ".xml"; + + switch (exportType) + { + case BROWSER_DOWNLOAD: + XarExporter exporter = new XarExporter(lsidRelativizer, selection, getUser(), xarXmlFileName, null, getContainer()); + + getViewContext().getResponse().setContentType("application/zip"); + ResponseHelper.setContentDisposition(getViewContext().getResponse(), ResponseHelper.ContentDispositionType.attachment, fileName); + ResponseHelper.setPrivate(getViewContext().getResponse()); + + exporter.writeAsArchive(getViewContext().getResponse().getOutputStream()); + return null; + case PIPELINE_FILE: + if (!PipelineService.get().hasValidPipelineRoot(getContainer())) + { + throw new IllegalStateException("You must set a valid pipeline root before you can export a XAR to it."); + } + PipeRoot pipeRoot = PipelineService.get().findPipelineRoot(getContainer()); + XarExportPipelineJob job = new XarExportPipelineJob(getViewBackgroundInfo(), pipeRoot, fileName, lsidRelativizer, selection, xarXmlFileName); + PipelineService.get().queueJob(job); + PipelineStatusFile status = PipelineService.get().getStatusFile(job.getJobGUID()); + return PageFlowUtil.urlProvider(PipelineUrls.class).statusDetails(getContainer(), status.getRowId()); + default: + throw new IllegalArgumentException("Unknown export type: " + exportType); + } + } + + @RequiresPermission(ReadPermission.class) + public class ExportProtocolsAction extends AbstractExportAction + { + @Override + public boolean handlePost(ExportOptionsForm form, BindException errors) throws Exception + { + List protocols = form.lookupProtocols(getViewContext(), false); + + long[] ids = new long[protocols.size()]; + for (int i = 0; i < ids.length; i++) + { + ids[i] = protocols.get(i).getRowId(); + } + XarExportSelection selection = new XarExportSelection(); + selection.addProtocolIds(ids); + + exportXAR(selection, form.getLsidOutputType(), form.getExportType(), form.getXarFileName()); + + if (form.getDataRegionSelectionKey() != null) + { + // Clear the selection + form.lookupProtocols(getViewContext(), true); + } + return true; + } + } + + public abstract static class AbstractExportAction extends FormViewAction + { + protected ActionURL _resultURL; + + @Override + public void validateCommand(ExportOptionsForm target, Errors errors) + { + } + + @Override + public ActionURL getSuccessURL(ExportOptionsForm exportOptionsForm) + { + return _resultURL; + } + + @Override + public ModelAndView getSuccessView(ExportOptionsForm exportOptionsForm) + { + return null; + } + + @Override + public ModelAndView getView(ExportOptionsForm form, boolean reshow, BindException errors) throws Exception + { + // FormViewAction can reinvoke getView() in response to a POST if we're not redirecting the browser, + // so avoid double-creating the export + if ("get".equalsIgnoreCase(getViewContext().getRequest().getMethod())) + handlePost(form, errors); + return null; + } + + @Override + public void addNavTrail(NavTree root) + { + } + + public List lookupRuns(ExportOptionsForm form) + { + Set runIds; + if (form.getRunIds() != null && form.getRunIds().length > 0) + runIds = new HashSet<>(Arrays.asList(form.getRunIds())); + else + runIds = DataRegionSelection.getSelectedIntegers(getViewContext(), form.getDataRegionSelectionKey(), false); + + if (runIds.isEmpty()) + { + throw new NotFoundException(); + } + List result = new ArrayList<>(); + + for (long id : runIds) + { + ExpRun run = ExperimentService.get().getExpRun(id); + if (run == null || !run.getContainer().hasPermission(getUser(), ReadPermission.class)) + { + throw new NotFoundException("Could not find run " + id); + } + result.add(run); + } + return result; + } + } + + @RequiresPermission(ReadPermission.class) + public class ExportRunsAction extends AbstractExportAction + { + @Override + public boolean handlePost(ExportOptionsForm form, BindException errors) throws Exception + { + XarExportSelection selection = new XarExportSelection(); + if (form.getExpRowId() != null) + { + ExpExperiment experiment = ExperimentService.get().getExpExperiment(form.getExpRowId()); + if (experiment != null && !experiment.getContainer().hasPermission(getUser(), ReadPermission.class)) + { + throw new NotFoundException("Run group " + form.getExpRowId()); + } + selection.addExperimentIds(experiment.getRowId()); + } + selection.addRuns(lookupRuns(form)); + + _resultURL = exportXAR(selection, form.getLsidOutputType(), form.getExportType(), form.getXarFileName()); + if (form.getDataRegionSelectionKey() != null) + DataRegionSelection.clearAll(getViewContext(), form.getDataRegionSelectionKey()); + return true; + } + } + + @RequiresPermission(ReadPermission.class) + public class ExportSampleTypeAction extends AbstractExportAction + { + @Override + public boolean handlePost(ExportOptionsForm form, BindException errors) throws Exception + { + Integer rowId = form.getSampleTypeId(); + if (rowId == null) + { + throw new NotFoundException("No sampleTypeId parameter specified"); + } + ExpSampleType sampleType = SampleTypeService.get().getSampleType(getContainer(), getUser(), rowId.intValue()); + if (sampleType == null) + { + throw new NotFoundException("No such sample type with RowId " + rowId); + } + if (!sampleType.getContainer().hasPermission(getUser(), ReadPermission.class)) + { + throw new UnauthorizedException(); + } + + XarExportSelection selection = new XarExportSelection(); + selection.addSampleType(sampleType); + + _resultURL = exportXAR(selection, form.getLsidOutputType(), form.getExportType(), FileUtil.makeLegalName(sampleType.getName() + ".xar")); + return true; + } + } + + @RequiresPermission(ReadPermission.class) + public class ExportRunFilesAction extends AbstractExportAction + { + @Override + public boolean handlePost(ExportOptionsForm form, BindException errors) throws Exception + { + XarExportSelection selection = new XarExportSelection(); + selection.setIncludeXarXml(false); + if ("role".equalsIgnoreCase(form.getFileExportType())) + { + selection.addRoles(form.getRoles()); + } + selection.addRuns(lookupRuns(form)); + + _resultURL = exportXAR(selection, form.getZipFileName()); + if (form.getDataRegionSelectionKey() != null) + DataRegionSelection.clearAll(getViewContext(), form.getDataRegionSelectionKey()); + return true; + } + } + + @RequiresPermission(ReadPermission.class) + public class ExportFilesAction extends AbstractExportAction + { + @Override + public boolean handlePost(ExportOptionsForm form, BindException errors) throws Exception + { + long[] dataIds = form.getDataIds(); + if (dataIds == null || dataIds.length == 0) + { + throw new NotFoundException(); + } + + try + { + for (long id : dataIds) + { + ExpData data = ExperimentService.get().getExpData(id); + if (data == null || !data.getContainer().hasPermission(getUser(), ReadPermission.class)) + { + throw new NotFoundException("Could not find file " + id); + } + } + + XarExportSelection selection = new XarExportSelection(); + selection.setIncludeXarXml(false); + selection.addDataIds(dataIds); + + _resultURL = exportXAR(selection, form.getZipFileName()); + return true; + } + catch (NumberFormatException e) + { + throw new NotFoundException(Arrays.toString(dataIds)); + } + } + } + + public static class ExperimentRunListForm implements DataRegionSelection.DataSelectionKeyForm + { + private String _dataRegionSelectionKey; + private Long _expRowId; + private Long[] _runIds; + + @Override + public String getDataRegionSelectionKey() + { + return _dataRegionSelectionKey; + } + + @Override + public void setDataRegionSelectionKey(String key) + { + _dataRegionSelectionKey = key; + } + + public Long getExpRowId() + { + return _expRowId; + } + + public void setExpRowId(Long expRowId) + { + _expRowId = expRowId; + } + + public Long[] getRunIds() + { + return _runIds; + } + + public void setRunIds(Long[] runIds) + { + _runIds = runIds; + } + + public ExpExperiment lookupExperiment() + { + return getExpRowId() == null ? null : ExperimentService.get().getExpExperiment(getExpRowId().intValue()); + } + } + + private void addSelectedRunsToExperiment(ExpExperiment exp, String dataRegionSelectionKey) + { + Collection runIds = DataRegionSelection.getSelectedIntegers(getViewContext(), dataRegionSelectionKey, true); + List runs = new ArrayList<>(); + for (long runId : runIds) + { + ExpRun run = ExperimentServiceImpl.get().getExpRun(runId); + if (run != null) + { + runs.add(run); + } + } + exp.addRuns(getUser(), runs.toArray(new ExpRun[0])); + } + + + @RequiresPermission(InsertPermission.class) + public class AddRunsToExperimentAction extends FormHandlerAction + { + @Override + public void validateCommand(ExperimentRunListForm target, Errors errors) + { + } + + @Override + public boolean handlePost(ExperimentRunListForm form, BindException errors) + { + addSelectedRunsToExperiment(form.lookupExperiment(), form.getDataRegionSelectionKey()); + return true; + } + + @Override + public ActionURL getSuccessURL(ExperimentRunListForm form) + { + return ExperimentUrlsImpl.get().getExperimentDetailsURL(getContainer(), form.lookupExperiment()); + } + } + + @RequiresPermission(DeletePermission.class) + public static class RemoveSelectedExpRunsAction extends FormHandlerAction + { + @Override + public void validateCommand(ExperimentRunListForm target, Errors errors) + { + } + + @Override + public boolean handlePost(ExperimentRunListForm form, BindException errors) + { + ExpExperiment exp = form.lookupExperiment(); + if (exp == null || !exp.getContainer().hasPermission(getUser(), DeletePermission.class)) + { + throw new NotFoundException("Could not find run group with RowId " + form.getExpRowId()); + } + + for (long runId : DataRegionSelection.getSelectedIntegers(getViewContext(), form.getDataRegionSelectionKey(), false)) + { + ExpRun run = ExperimentService.get().getExpRun(runId); + if (run == null || !run.getContainer().hasPermission(getUser(), DeletePermission.class)) + { + throw new NotFoundException("Could not find run with RowId " + runId); + } + exp.removeRun(getUser(), run); + } + if (form.getDataRegionSelectionKey() != null) + DataRegionSelection.clearAll(getViewContext(), form.getDataRegionSelectionKey()); + return true; + } + + @Override + public ActionURL getSuccessURL(ExperimentRunListForm form) + { + return ExperimentUrlsImpl.get().getExperimentDetailsURL(getContainer(), form.lookupExperiment()); + } + } + + public static ActionURL getResolveLsidURL(Container c, @NotNull String type, @NotNull String lsid) + { + ActionURL url = new ActionURL(ResolveLSIDAction.class, c); + url.addParameter("type", type); + url.addParameter("lsid", lsid); + + return url; + } + + + @RequiresPermission(ReadPermission.class) + public static class ResolveLSIDAction extends SimpleViewAction + { + @Override + public ModelAndView getView(LsidForm form, BindException errors) + { + String message = ""; + if (!PageFlowUtil.empty(form.getLsid())) + { + try + { + String lsid = Lsid.canonical(form.getLsid().trim()); + ActionURL url = LsidManager.get().getDisplayURL(lsid); + if (url == null && form.getType() != null) + { + url = switch (form.getType().toLowerCase()) + { + case "data" -> LsidType.Data.getDisplayURL(new Lsid(lsid)); + case "material" -> LsidType.Material.getDisplayURL(new Lsid(lsid)); + default -> url; + }; + } + if (null != url) + { + throw new RedirectException(url); + } + message = "Could not map LSID to URL"; + } + catch (IllegalArgumentException e) + { + message = "Invalid LSID"; + } + } + + return new HtmlView("Enter LSID", + DOM.createHtmlFragment( + message, + DOM.FORM(at(action, getViewContext().cloneActionURL().setAction(ResolveLSIDAction.class)), + "LSID: ", + DOM.INPUT(at(type, "text", name, "lsid", size, "80", value, form.getLsid())), + PageFlowUtil.button("Go").submit(true)))); + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("Resolve LSID"); + } + } + + public static class LsidForm + { + private String _lsid; + + public String getType() + { + return _type; + } + + public void setType(String type) + { + _type = type; + } + + private String _type; + + public void setLsid(String lsid) + { + _lsid = lsid; + } + + public String getLsid() + { + return _lsid; + } + } + + public static class SetFlagForm extends LsidForm + { + private String _comment; + private boolean _redirect = true; + + public String getComment() + { + return _comment; + } + + public void setComment(String comment) + { + _comment = comment; + } + + public boolean isRedirect() + { + return _redirect; + } + + public void setRedirect(boolean redirect) + { + _redirect = redirect; + } + } + + /** + * Check for update on the object itself + */ + @RequiresNoPermission + public static class SetFlagAction extends FormHandlerAction + { + @Override + public void validateCommand(SetFlagForm target, Errors errors) + { + } + + @Override + public boolean handlePost(SetFlagForm form, BindException errors) throws Exception + { + String lsid = form.getLsid(); + if (lsid == null) + throw new NotFoundException(); + ExpObject obj = ExperimentService.get().findObjectFromLSID(lsid); + if (obj == null) + throw new NotFoundException(); + Container container = obj.getContainer(); + if (!container.hasPermission(getUser(), UpdatePermission.class)) + { + throw new UnauthorizedException(); + } + + obj.setComment(getUser(), form.getComment()); + return true; + } + + @Override + public URLHelper getSuccessURL(SetFlagForm form) + { + return null; + } + } + + @RequiresPermission(InsertPermission.class) + public class DeriveSamplesChooseTargetAction extends SimpleViewAction + { + private List _materials; + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("sampleSets"); + addRootNavTrail(root); + root.addChild("Sample Types", ExperimentUrlsImpl.get().getShowSampleTypeListURL(getContainer())); + ExpSampleType sampleType = _materials != null && !_materials.isEmpty() ? _materials.get(0).getSampleType() : null; + if (sampleType != null) + { + root.addChild(sampleType.getName(), ExperimentUrlsImpl.get().getShowSampleTypeURL(sampleType)); + } + root.addChild("Derive Samples"); + } + + @Override + public void validate(DeriveMaterialForm form, BindException errors) + { + _materials = form.lookupMaterials(); + if (_materials.isEmpty()) + { + throw new NotFoundException("Could not find any matching materials"); + } + } + + @Override + public ModelAndView getView(DeriveMaterialForm form, BindException errors) + { + Container c = getContainer(); + PipeRoot root = PipelineService.get().findPipelineRoot(c); + + if (root == null || !root.isValid()) + { + ActionURL pipelineURL = urlProvider(PipelineUrls.class).urlSetup(c); + return new HtmlView(DIV("You must ", + DOM.A(DOM.at(href, pipelineURL), "configure a valid pipeline root for this folder"), + " before deriving samples.")); + } + else + { + Set materialInputRoles = new TreeSet<>(ExperimentService.get().getMaterialInputRoles(getContainer(), getUser())); + Map materialsWithRoles = new LinkedHashMap<>(); + for (ExpMaterial material : _materials) + { + materialsWithRoles.put(material, null); + } + + List sampleTypes = getUploadableSampleTypes(); + + DeriveSamplesChooseTargetBean bean = new DeriveSamplesChooseTargetBean(form.getDataRegionSelectionKey(), form.getTargetSampleTypeId(), sampleTypes, materialsWithRoles, form.getOutputCount(), materialInputRoles, null); + return new JspView<>("/org/labkey/experiment/deriveSamplesChooseTarget.jsp", bean); + } + } + } + + public static class DeriveSamplesChooseTargetBean implements DataRegionSelection.DataSelectionKeyForm + { + private String _dataRegionSelectionKey; + + private final Integer _targetSampleTypeId; + private final List _sampleTypes; + private final Map _sourceMaterials; + private final int _sampleCount; + private final Collection _inputRoles; + private final DerivedSamplePropertyHelper _propertyHelper; + + public static final String CUSTOM_ROLE = "--CUSTOM--"; + + public DeriveSamplesChooseTargetBean(String dataRegionSelectionKey, Integer targetSampleTypeId, List sampleTypes, Map sourceMaterials, int sampleCount, Collection inputRoles, DerivedSamplePropertyHelper helper) + { + _dataRegionSelectionKey = dataRegionSelectionKey; + _targetSampleTypeId = targetSampleTypeId; + _sampleTypes = sampleTypes; + _sourceMaterials = sourceMaterials; + _sampleCount = sampleCount; + _inputRoles = inputRoles; + _propertyHelper = helper; + } + + public Integer getTargetSampleTypeId() + { + return _targetSampleTypeId; + } + + public DerivedSamplePropertyHelper getPropertyHelper() + { + return _propertyHelper; + } + + public int getSampleCount() + { + return _sampleCount; + } + + public Map getSourceMaterials() + { + return _sourceMaterials; + } + + public List getSampleTypes() + { + return _sampleTypes; + } + + public Collection getInputRoles() + { + return _inputRoles; + } + + @Override + public String getDataRegionSelectionKey() + { + return _dataRegionSelectionKey; + } + + @Override + public void setDataRegionSelectionKey(String key) + { + _dataRegionSelectionKey = key; + } + } + + private List getUploadableSampleTypes() + { + // Make a copy so we can modify it + List sampleTypes = new ArrayList<>(SampleTypeService.get().getSampleTypes(getContainer(), getUser(), true)); + sampleTypes.removeIf(sampleType -> !sampleType.canImportMoreSamples()); + return sampleTypes; + } + + @RequiresPermission(InsertPermission.class) + public class DeriveSamplesAction extends FormViewAction + { + private List _materials; + private ActionURL _successUrl; + private final Map _inputMaterials = new LinkedHashMap<>(); + + @Override + public ModelAndView getView(DeriveMaterialForm form, boolean reshow, BindException errors) + { + _materials = form.lookupMaterials(); + if (_materials.isEmpty()) + { + throw new NotFoundException("Could not find any matching materials"); + } + + Container c = getContainer(); + + if (form.getOutputCount() <= 0) + { + form.setOutputCount(1); + } + + if (form.getTargetSampleTypeId() == 0) + throw new NotFoundException("Target sample type required for the derived samples"); + + ExpSampleTypeImpl sampleType = SampleTypeServiceImpl.get().getSampleType(getContainer(), getUser(), form.getTargetSampleTypeId()); + if (sampleType == null) + throw new NotFoundException("Could not find sample type with rowId " + form.getTargetSampleTypeId()); + + InsertView insertView = new InsertView(new DataRegion(), errors); + + DerivedSamplePropertyHelper helper = new DerivedSamplePropertyHelper(sampleType, form.getOutputCount(), c, getUser()); + helper.addSampleColumns(insertView, getUser()); + + int[] rowIds = form.getRowIds(); + for (int i = 0; i < rowIds.length; i++) + { + insertView.getDataRegion().addHiddenFormField("rowIds", Integer.toString(rowIds[i])); + insertView.getDataRegion().addHiddenFormField("inputRole" + i, form.getInputRole(i) == null ? "" : form.getInputRole(i)); + insertView.getDataRegion().addHiddenFormField("customRole" + i, form.getCustomRole(i) == null ? "" : form.getCustomRole(i)); + } + + insertView.getDataRegion().addHiddenFormField("targetSampleTypeId", Integer.toString(form.getTargetSampleTypeId())); + insertView.getDataRegion().addHiddenFormField("outputCount", Integer.toString(form.getOutputCount())); + if (form.getDataRegionSelectionKey() != null) + insertView.getDataRegion().addHiddenFormField(DataRegionSelection.DATA_REGION_SELECTION_KEY, form.getDataRegionSelectionKey()); + insertView.setInitialValues(ViewServlet.adaptParameterMap(getViewContext().getRequest().getParameterMap())); + ButtonBar bar = new ButtonBar(); + bar.setStyle(ButtonBar.Style.separateButtons); + ActionButton submitButton = new ActionButton(DeriveSamplesAction.class, "Submit"); + submitButton.setActionType(ActionButton.Action.POST); + bar.add(submitButton); + insertView.getDataRegion().setButtonBar(bar); + insertView.setTitle("Output Samples"); + + Map materialsWithRoles = new LinkedHashMap<>(); + List materials = form.lookupMaterials(); + for (int i = 0; i < materials.size(); i++) + { + materialsWithRoles.put(materials.get(i), form.determineLabel(i)); + } + + DeriveSamplesChooseTargetBean bean = new DeriveSamplesChooseTargetBean(form.getDataRegionSelectionKey(), form.getTargetSampleTypeId(), getUploadableSampleTypes(), materialsWithRoles, form.getOutputCount(), Collections.emptyList(), helper); + JspView view = new JspView<>("/org/labkey/experiment/summarizeMaterialInputs.jsp", bean); + view.setTitle("Input Samples"); + + return new VBox(view, insertView); + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("sampleSets"); + addRootNavTrail(root); + root.addChild("Sample Types", ExperimentUrlsImpl.get().getShowSampleTypeListURL(getContainer())); + ExpSampleType sampleType = _materials != null && !_materials.isEmpty() ? _materials.get(0).getSampleType() : null; + if (sampleType != null) + { + root.addChild(sampleType.getName(), ExperimentUrlsImpl.get().getShowSampleTypeURL(sampleType)); + } + root.addChild("Derive Samples"); + } + + @Override + public void validateCommand(DeriveMaterialForm form, Errors errors) + { + List materials = form.lookupMaterials(); + + List lockedSamples = new ArrayList<>(); + for (int i = 0; i < materials.size(); i++) + { + ExpMaterial m = materials.get(i); + if (!m.isOperationPermitted(SampleTypeService.SampleOperations.EditLineage)) + { + lockedSamples.add(m); + } + String inputRole = form.determineLabel(i); + if (inputRole == null || inputRole.isEmpty()) + { + ExpSampleType st = m.getSampleType(); + inputRole = st != null ? st.getName() : ExpMaterialRunInput.DEFAULT_ROLE; + } + _inputMaterials.put(materials.get(i), inputRole); + } + + if (!lockedSamples.isEmpty()) + { + errors.reject(ERROR_MSG, SampleTypeService.get().getOperationNotPermittedMessage(lockedSamples, SampleTypeService.SampleOperations.EditLineage)); + } + } + + @Override + public boolean handlePost(DeriveMaterialForm form, BindException errors) + { + ExpSampleTypeImpl sampleType = SampleTypeServiceImpl.get().getSampleType(getContainer(), getUser(), form.getTargetSampleTypeId()); + + DerivedSamplePropertyHelper helper = new DerivedSamplePropertyHelper(sampleType, form.getOutputCount(), getContainer(), getUser()); + + Map, Map> allProperties; + try + { + boolean valid = true; + for (Map.Entry> entry : helper.getPostedPropertyValues(getViewContext().getRequest()).entrySet()) + valid = UploadWizardAction.validatePostedProperties(getViewContext(), entry.getValue(), errors) && valid; + if (!valid) + return false; + + allProperties = helper.getSampleProperties(getViewContext().getRequest(), _inputMaterials.keySet()); + } + catch (DuplicateMaterialException e) + { + errors.addError(new ObjectError(e.getColName(), null, null, e.getMessage())); + return false; + } + catch (ExperimentException e) + { + errors.reject(SpringActionController.ERROR_MSG, e.getMessage()); + return false; + } + + try (DbScope.Transaction tx = ExperimentService.get().ensureTransaction()) + { + Map outputMaterials = new HashMap<>(); + int i = 0; + for (Map.Entry, Map> entry : allProperties.entrySet()) + { + Lsid lsid = entry.getKey().first; + String name = entry.getKey().second; + assert name != null; + + ExpMaterialImpl outputMaterial = ExperimentServiceImpl.get().createExpMaterial(getContainer(), lsid.toString(), name); + if (sampleType != null) + { + outputMaterial.setCpasType(sampleType.getLSID()); + } + outputMaterial.save(getUser()); + + if (sampleType != null) + { + Map pvs = new HashMap<>(); + for (Map.Entry propertyEntry : entry.getValue().entrySet()) + pvs.put(propertyEntry.getKey().getName(), propertyEntry.getValue()); + outputMaterial.setProperties(getUser(), pvs, false); + } + + outputMaterials.put(outputMaterial, helper.getSampleNames().get(i++)); + } + + ExperimentService.get().deriveSamples(_inputMaterials, outputMaterials, getViewBackgroundInfo(), _log); + + tx.commit(); + + // automatically link samples to study, if configured + StudyPublishService.get().autoLinkDerivedSamples(sampleType, outputMaterials.keySet().stream().map(ExpObject::getRowId).collect(toList()), getContainer(), getUser()); + + _successUrl = ExperimentUrlsImpl.get().getShowSampleURL(getContainer(), outputMaterials.keySet().iterator().next()); + + if (form.getDataRegionSelectionKey() != null) + DataRegionSelection.clearAll(getViewContext(), form.getDataRegionSelectionKey()); + } + catch (Exception e) + { + errors.reject(SpringActionController.ERROR_MSG, e.getMessage()); + return false; + } + + return true; + } + + @Override + public URLHelper getSuccessURL(DeriveMaterialForm deriveMaterialForm) + { + return _successUrl; + } + } + + public static class DeriveMaterialForm implements HasViewContext, DataRegionSelection.DataSelectionKeyForm + { + private String _dataRegionSelectionKey; + private int _outputCount = 1; + private int _targetSampleTypeId; + private int[] _rowIds; + private String _name; + + private ViewContext _context; + + @Override + public void setViewContext(ViewContext context) + { + _context = context; + } + + @Override + public ViewContext getViewContext() + { + return _context; + } + + public List lookupMaterials() + { + List result = new ArrayList<>(); + for (int rowId : getRowIds()) + { + ExpMaterial material = ExperimentService.get().getExpMaterial(rowId); + if (material != null) + { + if (material.getContainer().hasPermission(_context.getUser(), ReadPermission.class)) + { + result.add(material); + } + else + { + throw new UnauthorizedException(); + } + } + else + { + throw new NotFoundException("No material with RowId " + rowId); + } + } + result.sort(Comparator.comparing(Identifiable::getName)); + return result; + } + + public String getName() + { + return _name; + } + + public void setName(String name) + { + _name = name; + } + + @Override + public String getDataRegionSelectionKey() + { + return _dataRegionSelectionKey; + } + + @Override + public void setDataRegionSelectionKey(String dataRegionSelectionKey) + { + _dataRegionSelectionKey = dataRegionSelectionKey; + } + + public int[] getRowIds() + { + if (_rowIds == null) + { + _rowIds = PageFlowUtil.toInts(DataRegionSelection.getSelected(getViewContext(), getDataRegionSelectionKey(), false)); + } + return _rowIds; + } + + public void setRowIds(int[] rowIds) + { + _rowIds = rowIds; + } + + public int getOutputCount() + { + return _outputCount; + } + + public void setOutputCount(int outputCount) + { + _outputCount = outputCount; + } + + public int getTargetSampleTypeId() + { + return _targetSampleTypeId; + } + + public void setTargetSampleTypeId(int targetSampleTypeId) + { + _targetSampleTypeId = targetSampleTypeId; + } + + public String getInputRole(int i) + { + return _context.getRequest().getParameter("inputRole" + i); + } + + public String getCustomRole(int i) + { + return _context.getRequest().getParameter("customRole" + i); + } + + public String determineLabel(int index) + { + String result = getInputRole(index); + if (DeriveSamplesChooseTargetBean.CUSTOM_ROLE.equals(result)) + { + result = getCustomRole(index); + } + if (result != null) + { + result = result.trim(); + } + return result; + } + } + + + public static class ExpInput + { + public String role; + public int rowId; + public Lsid lsid; + } + + public static class DerivationSpec + { + public String role; + public Map values; + } + + public static class DerivationForm + { + public List dataInputs; + public List materialInputs; + + public int dataOutputCount; + public Lsid targetDataClass; + public Map dataDefault; + public List dataOutputs; + + public int materialOutputCount; + public Lsid targetSampleType; + public Map materialDefault; + public List materialOutputs; + } + + @Marshal(Marshaller.Jackson) + @RequiresPermission(InsertPermission.class) + public static class DeriveAction extends MutatingApiAction + { + @Override + public void validateForm(DerivationForm form, Errors errors) + { + if (errors.hasErrors()) + return; + + if (form.materialOutputCount > 0 && form.materialOutputs != null && !form.materialOutputs.isEmpty()) + errors.reject(ERROR_MSG, "Either 'materialOutputCount' or 'materialOutputs' property can be specified, but not both."); + + if (form.dataOutputCount > 0 && form.dataOutputs != null && !form.dataOutputs.isEmpty()) + errors.reject(ERROR_MSG, "Either 'dataOutputCount' or 'dataOutputs' property can be specified, but not both."); + + boolean hasMaterialOutputs = form.materialOutputCount > 0 || form.materialOutputs != null && !form.materialOutputs.isEmpty(); + boolean hasDataOutputs = form.dataOutputCount > 0 || form.dataOutputs != null && !form.dataOutputs.isEmpty(); + + if (!hasMaterialOutputs && !hasDataOutputs) + errors.reject(ERROR_MSG, "At least one data output or material output is required"); + + if (hasMaterialOutputs && form.targetSampleType == null) + errors.reject(ERROR_MSG, "targetSampleType lsid required for material outputs"); + + if (hasDataOutputs && form.targetDataClass == null) + errors.reject(ERROR_MSG, "targetDataClass lsid required for data outputs"); + } + + @Override + public Object execute(DerivationForm form, BindException errors) throws Exception + { + // Find material inputs + Map materialInputs = new LinkedHashMap<>(); + if (form.materialInputs != null) + { + for (ExpInput in : form.materialInputs) + { + ExpMaterial m = null; + if (in.lsid != null) + { + m = ExperimentService.get().getExpMaterial(in.lsid.toString()); + if (m == null) + errors.reject(ERROR_MSG, "Can't resolve sample '" + in.lsid + "'"); + } + else if (in.rowId > 0) + { + m = ExperimentService.get().getExpMaterial(in.rowId); + if (m == null) + errors.reject(ERROR_MSG, "Can't resolve sample '" + in.rowId + "'"); + } + + if (m == null) + { + errors.reject(ERROR_MSG, "Material input lsid or rowId required"); + continue; + } + + ExpSampleType st = m.getSampleType(); + if (st == null) + { + errors.reject(ERROR_MSG, "Material input is not a member of a SampleType"); + continue; + } + + String role = in.role; + if (role == null || role.isEmpty()) + { + role = st.getName(); + } + materialInputs.put(m, role); + } + } + + // Find input data + Map dataInputs = new LinkedHashMap<>(); + if (form.dataInputs != null) + { + for (ExpInput in : form.dataInputs) + { + ExpData d = null; + if (in.lsid != null) + { + d = ExperimentService.get().getExpData(in.lsid.toString()); + if (d == null) + errors.reject(ERROR_MSG, "Can't resolve data '" + in.lsid + "'"); + } + else if (in.rowId > 0) + { + d = ExperimentService.get().getExpData(in.rowId); + if (d == null) + errors.reject(ERROR_MSG, "Can't resolve data '" + in.rowId + "'"); + } + + if (d == null) + { + errors.reject(ERROR_MSG, "Data input lsid or rowId required"); + continue; + } + + ExpDataClass dc = d.getDataClass(getUser()); + if (dc == null) + { + errors.reject(ERROR_MSG, "Data input is not a member of a DataClass"); + continue; + } + + String role = in.role; + if (role == null || role.isEmpty()) + { + role = dc.getName(); + } + dataInputs.put(d, role); + } + } + + ExpSampleType outSampleType; + if (form.targetSampleType != null) + { + // TODO: check in scope and has permission + outSampleType = SampleTypeService.get().getSampleType(form.targetSampleType.toString()); + if (outSampleType == null) + errors.reject(ERROR_MSG, "Sample type not found: " + form.targetSampleType.toString()); + } + else + { + outSampleType = null; + } + + ExpDataClass outDataClass; + if (form.targetDataClass != null) + { + // TODO: check in scope and has permission + outDataClass = ExperimentServiceImpl.get().getDataClass(form.targetDataClass.toString()); + if (outDataClass == null) + errors.reject(ERROR_MSG, "DataClass not found: " + form.targetDataClass.toString()); + } + else + { + outDataClass = null; + } + + if (errors.hasErrors()) + return null; + + // TODO: support list of resolved ExpData or ExpMaterial instead of string concatenated names + // Create "MaterialInputs/" columns with a value containing a comma-separated list of Material names + final Map> parentInputNames = new HashMap<>(); + Set inputTypes = new CaseInsensitiveHashSet(); + for (ExpMaterial material : materialInputs.keySet()) + { + ExpSampleType st = material.getSampleType(); + String keyName = ExpMaterial.MATERIAL_INPUT_PARENT + "/" + st.getName(); + inputTypes.add(keyName); + parentInputNames.computeIfAbsent(keyName, (x) -> new LinkedHashSet<>()).add(material.getName()); + } + + // TODO: support list of resolved ExpData or ExpMaterial instead of string concatenated names + // Create "DataInputs/" columns with a value containing a comma-separated list of ExpData names + for (ExpData d : dataInputs.keySet()) + { + ExpDataClass dc = d.getDataClass(getUser()); + String keyName = ExpData.DATA_INPUT_PARENT + "/" + dc.getName(); + inputTypes.add(keyName); + parentInputNames.computeIfAbsent(keyName, (x) -> new LinkedHashSet<>()).add(d.getName()); + } + + + try (DbScope.Transaction tx = ExperimentService.get().ensureTransaction()) + { + Set requiredParentTypes = new CaseInsensitiveHashSet(); + + // output materials + Map outputMaterials = new HashMap<>(); + int materialOutputCount = Math.max(form.materialOutputCount, form.materialOutputs != null ? form.materialOutputs.size() : 0); + if (materialOutputCount > 0 && outSampleType != null) + { + requiredParentTypes.addAll(outSampleType.getRequiredImportAliases().values()); + DerivedOutputs derived = new DerivedOutputs<>(parentInputNames, form.materialDefault, form.materialOutputs, materialOutputCount, ExpMaterial.DEFAULT_CPAS_TYPE) + { + @Override + protected TableInfo createTable() + { + SamplesSchema schema = new SamplesSchema(getUser(), getContainer()); + return schema.getTable(outSampleType.getName()); + } + + @Override + protected List getExpObject(List> insertedRows) + { + List rowIds = insertedRows.stream().map(r -> MapUtils.getLong(r,"rowid")).collect(toList()); + return ExperimentService.get().getExpMaterials(rowIds); + } + }; + + outputMaterials = derived.createOutputs(); + } + + + // create output data + Map outputData = new HashMap<>(); + int dataOutputCount = Math.max(form.dataOutputCount, form.dataOutputs != null ? form.dataOutputs.size() : 0); + if (dataOutputCount > 0 && outDataClass != null) + { + requiredParentTypes.addAll(outDataClass.getRequiredImportAliases().values()); + DerivedOutputs derived = new DerivedOutputs<>(parentInputNames, form.dataDefault, form.dataOutputs, dataOutputCount, ExpData.DEFAULT_CPAS_TYPE) + { + @Override + protected TableInfo createTable() + { + ExpSchema expSchema = new ExpSchema(getUser(), getContainer()); + UserSchema dataSchema = expSchema.getUserSchema(ExpSchema.NestedSchemas.data.name()); + return dataSchema.getTable(outDataClass.getName()); + } + + @Override + protected List getExpObject(List> insertedRows) + { + List lsids = insertedRows.stream().map(r -> (String) r.get("lsid")).collect(toList()); + return ExperimentService.get().getExpDatasByLSID(lsids); + } + }; + + outputData = derived.createOutputs(); + } + + if (outputMaterials.isEmpty() && outputData.isEmpty()) + throw new IllegalStateException("Expected to create " + materialOutputCount + " materials and " + dataOutputCount + " datas"); + + boolean hasMissingRequiredParent = false; + for (String required : requiredParentTypes) + { + if (!inputTypes.contains(required)) + { + hasMissingRequiredParent = true; + break; + } + } + if (hasMissingRequiredParent) + throw new IllegalStateException("Inputs are required: " + String.join(",", requiredParentTypes)); + + // finally, create the derived run if there are any parents + ExpRun run = null; + if (!materialInputs.isEmpty() || !dataInputs.isEmpty()) + run = ExperimentService.get().derive(materialInputs, dataInputs, outputMaterials, outputData, new ViewBackgroundInfo(getContainer(), getUser(), null), _log); + tx.commit(); + + StringBuilder successMessage = new StringBuilder("Created "); + if (!outputMaterials.isEmpty()) + successMessage.append(outputMaterials.size()).append(" materials"); + if (!outputData.isEmpty()) + successMessage.append(outputData.size()).append(" data"); + + JSONObject ret; + if (run != null) + ret = ExperimentJSONConverter.serializeRun(run, null, getUser(), ExperimentJSONConverter.DEFAULT_SETTINGS); + else + ret = ExperimentJSONConverter.serializeRunOutputs(outputData.keySet(), outputMaterials.keySet(), getUser(), ExperimentJSONConverter.DEFAULT_SETTINGS); + + return success(successMessage.toString(), ret); + } + } + + // Helper class that prepares and executes the QueryUpdateService.insertRows() on the data or material table. + private abstract class DerivedOutputs + { + private final @NotNull Map> _parentInputNames; + private final @Nullable Map _defaultValues; + private final @Nullable List _values; + private final int _outputCount; + private final String _rolePrefix; + + + public DerivedOutputs(@NotNull Map> parentInputNames, @Nullable Map defaultValues, @Nullable List values, int outputCount, String rolePrefix) + { + _parentInputNames = parentInputNames; + _defaultValues = defaultValues; + _values = values; + _outputCount = outputCount; + _rolePrefix = rolePrefix; + } + + public Pair>, List> prepareRows() + { + List> rows = new ArrayList<>(); + List roles = new ArrayList<>(); + int unknownOutputDataCount = 0; + + for (int i = 0; i < _outputCount; i++) + { + Map row = new CaseInsensitiveHashMap<>(); + if (_defaultValues != null) + row.putAll(_defaultValues); + DerivationSpec spec = _values != null && i < _values.size() ? _values.get(i) : null; + String role = null; + if (spec != null) + { + row.putAll(spec.values); + role = spec.role; + } + + // NOTE: Input parents are added to each row, but are only used for name generation and not for derivation. + // NOTE: We will derive the inserted samples in a single derivation run after the sample/date have been inserted. + row.putAll(_parentInputNames); + + rows.add(row); + + if (StringUtils.trimToNull(role) == null) + { + role = _rolePrefix + (unknownOutputDataCount == 0 ? "" : Integer.toString(unknownOutputDataCount + 1)); + unknownOutputDataCount++; + } + roles.add(role); + } + return Pair.of(rows, roles); + } + + protected abstract TableInfo createTable(); + + protected abstract List getExpObject(List> insertedRows); + + public Map createOutputs() throws BatchValidationException, DuplicateKeyException, SQLException, QueryUpdateServiceException + { + Pair>, List> pair = prepareRows(); + List> rows = pair.first; + List roles = pair.second; + + TableInfo table = createTable(); + QueryUpdateService qus = table.getUpdateService(); + if (qus == null) + throw new IllegalStateException(); + + Map configParams = new HashMap<>(); + // Skip derivation during insert -- DeriveAction will call ExperimentService.get().derive() after samples are inserted + configParams.put(SampleTypeUpdateServiceDI.Options.SkipDerivation, true); + + BatchValidationException qusErrors = new BatchValidationException(); + List> insertedRows = qus.insertRows(getUser(), getContainer(), rows, qusErrors, configParams, null); + if (qusErrors.hasErrors()) + throw qusErrors; + + if (insertedRows.size() != roles.size()) + throw new IllegalStateException("Expected to create " + roles.size() + " new exp objects for derivation"); + + List outputs = getExpObject(insertedRows); + if (outputs.size() != roles.size()) + throw new IllegalStateException("Expected to create " + roles.size() + " new exp objects for derivation"); + + Map outputMap = new HashMap<>(); + for (int i = 0; i < outputs.size(); i++) + { + String role = roles.get(i); + T data = outputs.get(i); + outputMap.put(data, role); + } + + return outputMap; + } + } + } + + public static class CreateExperimentForm extends ExperimentForm implements DataRegionSelection.DataSelectionKeyForm + { + private boolean _addSelectedRuns; + private String _dataRegionSelectionKey; + + public boolean isAddSelectedRuns() + { + return _addSelectedRuns; + } + + public void setAddSelectedRuns(boolean addSelectedRuns) + { + _addSelectedRuns = addSelectedRuns; + } + + @Override + public String getDataRegionSelectionKey() + { + return _dataRegionSelectionKey; + } + + @Override + public void setDataRegionSelectionKey(String dataRegionSelectionKey) + { + _dataRegionSelectionKey = dataRegionSelectionKey; + } + } + + @RequiresPermission(InsertPermission.class) + @ActionNames("createRunGroup, createExperiment") + public class CreateRunGroupAction extends FormViewAction + { + @Override + public ModelAndView getView(CreateExperimentForm form, boolean reshow, BindException errors) + { + // HACK - convert ExperimentForm to not be a BeanViewForm + form.setAddSelectedRuns("true".equals(getViewContext().getRequest().getParameter("addSelectedRuns"))); + form.setDataRegionSelectionKey(getViewContext().getRequest().getParameter(DataRegionSelection.DATA_REGION_SELECTION_KEY)); + + DataRegion drg = new DataRegion(); + + drg.addHiddenFormField(ActionURL.Param.returnUrl, getViewContext().getRequest().getParameter(ActionURL.Param.returnUrl.name())); + drg.addHiddenFormField("addSelectedRuns", Boolean.toString("true".equals(getViewContext().getRequest().getParameter("addSelectedRuns")))); + form.setDataRegionSelectionKey(getViewContext().getRequest().getParameter(DataRegionSelection.DATA_REGION_SELECTION_KEY)); + // Fix issue 27562 - include session-stored selection + if (form.getDataRegionSelectionKey() != null) + { + for (String rowId : DataRegionSelection.getSelected(getViewContext(), form.getDataRegionSelectionKey(), false)) + { + drg.addHiddenFormField(DataRegion.SELECT_CHECKBOX_NAME, rowId); + } + } + drg.addHiddenFormField(DataRegionSelection.DATA_REGION_SELECTION_KEY, getViewContext().getRequest().getParameter(DataRegionSelection.DATA_REGION_SELECTION_KEY)); + + drg.addColumns(ExperimentServiceImpl.get().getTinfoExperiment(), "RowId,Name,LSID,ContactId,ExperimentDescriptionURL,Hypothesis,Comments,Created"); + + DisplayColumn col = drg.getDisplayColumn("RowId"); + col.setVisible(false); + drg.getDisplayColumn("LSID").setVisible(false); + drg.getDisplayColumn("Created").setVisible(false); + + ButtonBar bb = new ButtonBar(); + bb.setStyle(ButtonBar.Style.separateButtons); + ActionButton insertButton = new ActionButton(new ActionURL(CreateRunGroupAction.class, getContainer()), "Submit", ActionButton.Action.POST); + bb.add(insertButton); + + drg.setButtonBar(bb); + + return new InsertView(drg, errors); + } + + + @Override + public boolean handlePost(CreateExperimentForm form, BindException errors) throws Exception + { + // This is strange... but the "Create new run group..." menu item on the run grid always POSTs, probably to + // allow for long lists of run IDs. This "noPost" parameter on the initial POST is used to inform the action + // that it wants to display the form, not try to save anything yet. + if (!"true".equals(getViewContext().getRequest().getParameter("noPost"))) + { + form.setAddSelectedRuns("true".equals(getViewContext().getRequest().getParameter("addSelectedRuns"))); + form.setDataRegionSelectionKey(getViewContext().getRequest().getParameter(DataRegionSelection.DATA_REGION_SELECTION_KEY)); + + Experiment exp = form.getBean(); + if (exp.getName() == null || exp.getName().trim().isEmpty()) + { + errors.reject(ERROR_MSG, "You must specify a name for the experiment"); + } + else + { + int maxNameLength = ExperimentService.get().getTinfoExperimentRun().getColumn("Name").getScale(); + if (exp.getName().length() > maxNameLength) + { + errors.reject(ERROR_MSG, "Name of the experiment must be " + maxNameLength + " characters or less."); + } + } + + String lsid; + int suffix = 1; + do + { + String template = "urn:lsid:" + XarContext.LSID_AUTHORITY_SUBSTITUTION + ":Experiment.Folder-" + XarContext.CONTAINER_ID_SUBSTITUTION + ":" + exp.getName(); + if (suffix > 1) + { + template = template + suffix; + } + suffix++; + lsid = LsidUtils.resolveLsidFromTemplate(template, new XarContext("Experiment Creation", getContainer(), getUser()), ExpExperiment.DEFAULT_CPAS_TYPE); + } + while (ExperimentService.get().getExpExperiment(lsid) != null); + exp.setLSID(lsid); + exp.setContainer(getContainer()); + + if (errors.getErrorCount() == 0) + { + ExpExperimentImpl wrapper = new ExpExperimentImpl(exp); + wrapper.save(getUser()); + + if (form.isAddSelectedRuns()) + { + addSelectedRunsToExperiment(wrapper, form.getDataRegionSelectionKey()); + } + + if (form.getReturnUrl() != null) + { + throw new RedirectException(form.getReturnUrl()); + } + throw new RedirectException(ExperimentUrlsImpl.get().getShowExperimentsURL(getContainer())); + } + } + return true; + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("runGroups"); + root.addChild("Create Run Group"); + } + + @Override + public URLHelper getSuccessURL(CreateExperimentForm createExperimentForm) + { + return null; // null is used to show the form in the case where IDs are POSTed from the grid + } + + @Override + public void validateCommand(CreateExperimentForm target, Errors errors) { } + } + + public static class MoveRunsForm implements DataRegionSelection.DataSelectionKeyForm + { + private String _targetContainerId; + private String _dataRegionSelectionKey; + + @Override + public String getDataRegionSelectionKey() + { + return _dataRegionSelectionKey; + } + + @Override + public void setDataRegionSelectionKey(String key) + { + _dataRegionSelectionKey = key; + } + + public String getTargetContainerId() + { + return _targetContainerId; + } + + public void setTargetContainerId(String targetContainerId) + { + _targetContainerId = targetContainerId; + } + } + + @RequiresPermission(DeletePermission.class) + public class MoveRunsLocationAction extends SimpleViewAction + { + @Override + public ModelAndView getView(MoveRunsForm form, BindException errors) + { + ActionURL moveURL = new ActionURL(MoveRunsAction.class, getContainer()); + PipelineRootContainerTree ct = new PipelineRootContainerTree(getUser(), moveURL) + { + private boolean _clickHandlerRegistered = false; + + @Override + protected void renderCellContents(StringBuilder html, Container c, ActionURL url, boolean hasRoot) + { + boolean renderLink = hasRoot && !c.equals(getContainer()); + + if (renderLink) + { + html.append(""); + } + html.append(PageFlowUtil.filter(c.getName())); + if (renderLink) + { + html.append(""); + } + + if (!_clickHandlerRegistered) + { + HttpView.currentPageConfig().addHandlerForQuerySelector("a.move-target-container", "click", "moveTo(this.attributes.getNamedItem('data-objectid').value);" ); + _clickHandlerRegistered = true; + } + } + }; + ct.setInitialLevel(1); + + MoveRunsBean bean = new MoveRunsBean(ct, form.getDataRegionSelectionKey()); + JspView result = new JspView<>("/org/labkey/experiment/moveRunsLocation.jsp", bean); + result.setTitle("Choose Destination Folder"); + result.setFrame(WebPartView.FrameType.PORTAL); + return result; + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("Move Runs"); + } + } + + + @RequiresPermission(DeletePermission.class) + public class MoveRunsAction extends FormHandlerAction + { + private Container _targetContainer; + + @Override + public void validateCommand(MoveRunsForm target, Errors errors) + { + } + + @Override + public boolean handlePost(MoveRunsForm form, BindException errors) + { + _targetContainer = ContainerManager.getForId(form.getTargetContainerId()); + if (_targetContainer == null || !_targetContainer.hasPermission(getUser(), InsertPermission.class)) + { + throw new UnauthorizedException(); + } + + Set runIds = DataRegionSelection.getSelectedIntegers(getViewContext(), form.getDataRegionSelectionKey(), false); + List runs = new ArrayList<>(); + for (Long runId : runIds) + { + ExpRun run = ExperimentService.get().getExpRun(runId); + if (run != null) + { + runs.add(run); + } + } + + ViewBackgroundInfo info = getViewBackgroundInfo(); + info.setContainer(_targetContainer); + + try + { + ExperimentService.get().moveRuns(info, getContainer(), runs); + if (form.getDataRegionSelectionKey() != null) + DataRegionSelection.clearAll(getViewContext(), form.getDataRegionSelectionKey()); + } + catch (IOException e) + { + throw new NotFoundException("Failed to initialize move. Check that the pipeline root is configured correctly. " + e); + } + return true; + } + + @Override + public ActionURL getSuccessURL(MoveRunsForm form) + { + return urlProvider(PipelineUrls.class).urlBegin(_targetContainer); + } + } + + public static class ShowExternalDocsForm + { + private String _objectURI; + private String _propertyURI; + + public String getObjectURI() + { + return _objectURI; + } + + public void setObjectURI(String objectURI) + { + _objectURI = objectURI; + } + + public String getPropertyURI() + { + return _propertyURI; + } + + public void setPropertyURI(String propertyURI) + { + _propertyURI = propertyURI; + } + } + + @RequiresPermission(ReadPermission.class) + public static class ShowExternalDocsAction extends SimpleViewAction + { + @Override + public ModelAndView getView(ShowExternalDocsForm form, BindException errors) throws Exception + { + Map props = OntologyManager.getPropertyObjects(getContainer(), form.getObjectURI()); + ObjectProperty prop = props.get(form.getPropertyURI()); + if (prop == null || !getContainer().equals(prop.getContainer())) + { + throw new NotFoundException(); + } + URI uri = new URI(prop.getStringValue()); + File f = new File(uri); + if (!f.exists()) + { + throw new NotFoundException(); + } + + PageFlowUtil.streamFile(getViewContext().getResponse(), new File(f.getAbsolutePath()), false); + return null; + } + + @Override + public void addNavTrail(NavTree root) + { + } + } + + + // TODO: DotGraph has been adding a "runId" parameter, but ShowGraphMoreListAction + public static ActionURL getShowGraphMoreListURL(Container c, @Nullable Long runId, @NotNull String objtype) + { + ActionURL url = new ActionURL(ShowGraphMoreListAction.class, c); + + if (null != runId) + url.addParameter("runId", runId); + + url.addParameter("objtype", objtype); + + return url; + } + + + @RequiresPermission(ReadPermission.class) + public static class ShowGraphMoreListAction extends SimpleViewAction + { + private ExperimentRunForm _form; + + @Override + public ModelAndView getView(ExperimentRunForm form, BindException errors) + { + _form = form; + return new GraphMoreGrid(getContainer(), errors, getViewContext().getActionURL()); + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild(new NavTree("Experiments", ExperimentUrlsImpl.get().getShowExperimentsURL(getContainer()))); + ExpRun run = ExperimentService.get().getExpRun(_form.getRowId()); + if (run != null) + { + root.addChild(new NavTree("Experiment Run", ExperimentUrlsImpl.get().getRunGraphURL(_form.lookupRun()))); + } + root.addChild(new NavTree("Selected Protocol Applications")); + } + } + + @RequiresPermission(DesignAssayPermission.class) + public class AssayXarFileAction extends MutatingApiAction + { + + @Override + public Object execute(Object o, BindException errors) throws Exception + { + ApiSimpleResponse response = new ApiSimpleResponse(); + + if (!(getViewContext().getRequest() instanceof MultipartHttpServletRequest)) + throw new BadRequestException("Expected MultipartHttpServletRequest when posting files."); + + if (!PipelineService.get().hasValidPipelineRoot(getContainer())) + { + return false; + } + + MultipartFile formFile = getFileMap().get("file"); + if (formFile == null) + { + errors.reject(ERROR_MSG, "No file was posted by the browser."); + return false; + } + + byte[] bytes = formFile.getBytes(); + if (bytes.length == 0) + { + errors.reject(ERROR_MSG, "No file was posted by the browser."); + return false; + } + + PipeRoot pipeRoot = PipelineService.get().findPipelineRoot(getContainer()); + FileLike systemDir = pipeRoot.ensureSystemFileLike(); + FileLike uploadDir = systemDir.resolveChild("UploadedXARs"); + FileUtil.createDirectories(uploadDir); + if (!uploadDir.isDirectory()) + { + errors.reject(ERROR_MSG, "Unable to create a 'system/UploadedXARs' directory under the pipeline root"); + return false; + } + String userDirName = getUser().getEmail(); + if (userDirName == null || userDirName.isEmpty()) + { + userDirName = GUEST_DIRECTORY_NAME; + } + FileLike userDir = uploadDir.resolveChild(userDirName); + FileUtil.createDirectories(userDir); + if (!userDir.isDirectory()) + { + errors.reject(ERROR_MSG, "Unable to create an 'UploadedXARs/" + userDirName + "' directory under the pipeline root"); + return false; + } + + FileLike xarFile = userDir.resolveChild(formFile.getOriginalFilename()); + + // As this is multi-part will need to use finally to close, to prevent a stream closure exception + try (OutputStream out = new BufferedOutputStream(xarFile.openOutputStream())) + { + out.write(bytes); + } + catch (IOException e) + { + errors.reject(ERROR_MSG, "Unable to write uploaded XAR file to " + xarFile); + return false; + } + //noinspection EmptyCatchBlock + + ExperimentPipelineJob job = new ExperimentPipelineJob(getViewBackgroundInfo(), xarFile, + "Uploaded file", true, pipeRoot); + PipelineService.get().queueJob(job); + + response.put("success", true); + return response; + } + } + + @RequiresPermission(InsertPermission.class) + public class ImportXarFileAction extends FormHandlerAction + { + @Override + public void validateCommand(ImportXarForm target, Errors errors) + { + } + + @Override + public boolean handlePost(ImportXarForm form, BindException errors) throws Exception + { + for (FileLike f : form.getValidatedFiles(getContainer())) + { + if (f.isFile()) + { + ExperimentPipelineJob job = new ExperimentPipelineJob(getViewBackgroundInfo(), f, "Experiment Import", false, form.getPipeRoot(getContainer())); + + // TODO: Configure module resources with the appropriate log location per container + if (form.getModule() != null) + { + FileLike logFile = form.getPipeRoot(getContainer()).getLogDirectoryFileLike(true).resolveChild("module-resource-xar.log"); + job.setLogFile(logFile.toNioPathForWrite()); + } + + PipelineService.get().queueJob(job); + } + else + { + throw new NotFoundException("Expected a file but found a directory: " + f.getName()); + } + } + + return true; + } + + @Override + public URLHelper getSuccessURL(ImportXarForm importXarForm) + { + return getContainer().getStartURL(getUser()); + } + } + + + @RequiresPermission(InsertPermission.class) + public class ImportXarAction extends MutatingApiAction + { + @Override + public Object execute(ImportXarForm form, BindException errors) throws Exception + { + ApiSimpleResponse response = new ApiSimpleResponse(); + + List> archives = new ArrayList<>(); + for (FileLike f : form.getValidatedFiles(getContainer())) + { + Map archive = new HashMap<>(); + ExperimentPipelineJob job = new ExperimentPipelineJob(getViewBackgroundInfo(), f, "Experiment Import", false, form.getPipeRoot(getContainer())); + + // TODO: Configure module resources with the appropriate log location per container + if (form.getModule() != null) + { + FileLike logFile = form.getPipeRoot(getContainer()).getLogDirectoryFileLike(true).resolveChild("module-resource-xar.log"); + job.setLogFile(logFile.toNioPathForWrite()); + } + + PipelineService.get().queueJob(job); + + archive.put("file", f.getName()); + archive.put("job", job.getJobGUID()); + archive.put("path", form.getPath()); // echo back the public path + + archives.add(archive); + } + + response.put("success", true); + response.put("archives", archives); + + return response; + } + } + + + /** + * User: jeckels + * Date: Jan 27, 2008 + */ + public static class ExperimentUrlsImpl implements ExperimentUrls + { + public ActionURL getOverviewURL(Container c) + { + return new ActionURL(BeginAction.class, c); + } + + @Override + public ActionURL getExperimentDetailsURL(Container c, ExpExperiment expExperiment) + { + return new ActionURL(DetailsAction.class, c).addParameter("rowId", expExperiment.getRowId()); + } + + public ActionURL getShowSampleURL(Container c, ExpMaterial material) + { + return getMaterialDetailsBaseURL(c, null).addParameter("rowId", material.getRowId()); + } + + @Override + public ActionURL getExportProtocolURL(Container container, ExpProtocol protocol) + { + return new ActionURL(ExperimentController.ExportProtocolsAction.class, container). + addParameter("protocolId", protocol.getRowId()). + addParameter("xarFileName", protocol.getName() + ".xar"); + } + + @Override + public ActionURL getMoveRunsLocationURL(Container container) + { + return new ActionURL(ExperimentController.MoveRunsLocationAction.class, container); + } + + @Override + public ActionURL getProtocolDetailsURL(ExpProtocol protocol) + { + return new ActionURL(ProtocolDetailsAction.class, protocol.getContainer()).addParameter("rowId", protocol.getRowId()); + } + + @Override + public ActionURL getProtocolApplicationDetailsURL(ExpProtocolApplication app) + { + return getShowApplicationURL(app.getContainer(), app.getRowId()); + } + + public ActionURL getProtocolGridURL(Container c) + { + return new ActionURL(ShowProtocolGridAction.class, c); + } + + public ActionURL getRunGraphDetailURL(ExpRun run) + { + return getShowRunGraphDetailURL(run.getContainer(), run.getRowId()); + } + + @Override + public ActionURL getRunGraphDetailURL(ExpRun run, @Nullable ExpData focus) + { + return getRunGraphDetailURL(run, focus, DotGraph.TYPECODE_DATA); + } + + public ActionURL getRunGraphDetailURL(ExpRun run, @Nullable ExpMaterial focus) + { + return getRunGraphDetailURL(run, focus, DotGraph.TYPECODE_MATERIAL); + } + + public ActionURL getRunGraphDetailURL(ExpRun run, @Nullable ExpProtocolApplication focus) + { + return getRunGraphDetailURL(run, focus, DotGraph.TYPECODE_PROT_APP); + } + + private ActionURL getRunGraphDetailURL(ExpRun run, @Nullable ExpObject focus, String typeCode) + { + ActionURL result = getShowRunGraphDetailURL(run.getContainer(), run.getRowId()); + result.addParameter("detail", "true"); + if (focus != null) + { + result.addParameter("focus", typeCode + focus.getRowId()); + } + return result; + } + + @Override + public ActionURL getRunGraphURL(Container container, long runId) + { + return ExperimentController.getRunGraphURL(container, runId); + } + + @Override + public ActionURL getRunGraphURL(ExpRun run) + { + return getRunGraphURL(run.getContainer(), run.getRowId()); + } + + @Override + public ActionURL getRunTextURL(Container c, long runId) + { + return new ActionURL(ShowRunTextAction.class, c).addParameter("rowId", runId); + } + + @Override + public ActionURL getRunTextURL(ExpRun run) + { + return getRunTextURL(run.getContainer(), run.getRowId()); + } + + @Override + public ActionURL getDeleteExperimentsURL(Container container, URLHelper returnUrl) + { + return new ActionURL(DeleteSelectedExperimentsAction.class, container).addReturnUrl(returnUrl); + } + + @Override + public ActionURL getDeleteProtocolURL(@NotNull ExpProtocol protocol, URLHelper returnUrl) + { + ActionURL result = new ActionURL(DeleteProtocolByRowIdsAction.class, protocol.getContainer()); + result.addParameter("singleObjectRowId", protocol.getRowId()); + if (returnUrl != null) + { + result.addReturnUrl(returnUrl); + } + return result; + } + + @Override + public ActionURL getAddRunsToExperimentURL(Container c, ExpExperiment exp) + { + return new ActionURL(AddRunsToExperimentAction.class, c).addParameter("expRowId", exp.getRowId()); + } + + @Override + public ActionURL getShowRunsURL(Container c, ExperimentRunType type) + { + ActionURL result = new ActionURL(ShowRunsAction.class, c); + result.addParameter("experimentRunFilter", type.getDescription()); + return result; + } + + public ActionURL getShowExperimentsURL(Container c) + { + return new ActionURL(ShowRunGroupsAction.class, c); + } + + @Override + public ActionURL getShowSampleTypeListURL(Container c) + { + return getShowSampleTypeListURL(c, null); + } + + @Override + public ActionURL getShowSampleTypeURL(ExpSampleType sampleType) + { + return getShowSampleTypeURL(sampleType, sampleType.getContainer()); + } + + @Override + public ActionURL getShowSampleTypeURL(ExpSampleType sampleType, Container container) + { + return new ActionURL(ShowSampleTypeAction.class, container).addParameter("rowId", sampleType.getRowId()); + } + + public ActionURL getExperimentListURL(Container container) + { + return new ActionURL(ShowRunGroupsAction.class, container); + } + + public ActionURL getShowSampleTypeListURL(Container c, String errorMessage) + { + ActionURL url = new ActionURL(ListSampleTypesAction.class, c); + if (errorMessage != null) + { + url.addParameter("errorMessage", errorMessage); + } + return url; + } + + @Override + public ActionURL getDataClassListURL(Container c) + { + return getDataClassListURL(c, null); + } + + public ActionURL getDataClassListURL(Container c, String errorMessage) + { + ActionURL url = new ActionURL(ListDataClassAction.class, c); + if (errorMessage != null) + { + url.addParameter("errorMessage", errorMessage); + } + return url; + } + + @Override + public ActionURL getDeleteDatasURL(Container c, URLHelper returnUrl) + { + ActionURL url = new ActionURL(DeleteSelectedDataAction.class, c); + if (returnUrl != null) + url.addReturnUrl(returnUrl); + return url; + } + + public ActionURL getDeleteSelectedExperimentsURL(Container c, URLHelper returnUrl) + { + ActionURL result = new ActionURL(DeleteSelectedExperimentsAction.class, c); + if (returnUrl != null) + result.addReturnUrl(returnUrl); + return result; + } + + @Override + public ActionURL getDeleteSelectedExpRunsURL(Container container, URLHelper returnUrl) + { + return new ActionURL(DeleteSelectedExpRunsAction.class, container).addReturnUrl(returnUrl); + } + + public ActionURL getShowUpdateURL(ExpExperiment experiment) + { + return new ActionURL(ShowUpdateAction.class, experiment.getContainer()).addParameter("rowId", experiment.getRowId()); + } + + @Override + public ActionURL getRemoveSelectedExpRunsURL(Container container, URLHelper returnUrl, ExpExperiment exp) + { + return new ActionURL(RemoveSelectedExpRunsAction.class, container).addReturnUrl(returnUrl).addParameter("expRowId", exp.getRowId()); + } + + @Override + public ActionURL getCreateRunGroupURL(Container container, URLHelper returnUrl, boolean addSelectedRuns) + { + ActionURL result = new ActionURL(CreateRunGroupAction.class, container); + if (returnUrl != null) + { + result.addReturnUrl(returnUrl); + } + if (addSelectedRuns) + { + result.addParameter("addSelectedRuns", "true"); + } + return result; + } + + + public static ExperimentUrlsImpl get() + { + return (ExperimentUrlsImpl) urlProvider(ExperimentUrls.class); + } + + public ActionURL getDownloadGraphURL(ExpRun run, boolean detail, String focus, String focusType) + { + ActionURL result = new ActionURL(DownloadGraphAction.class, run.getContainer()); + result.addParameter("rowId", run.getRowId()).addParameter("detail", detail); + if (focus != null) + { + result.addParameter("focus", focus); + } + if (focusType != null) + { + result.addParameter("focusType", focusType); + } + return result; + } + + public ActionURL getBeginURL(Container container) + { + return new ActionURL(BeginAction.class, container); + } + + @Override + public ActionURL getDomainEditorURL(Container container, String domainURI, boolean createOrEdit) + { + Domain domain = PropertyService.get().getDomain(container, domainURI); + if (domain != null) + return getDomainEditorURL(container, domain); + + ActionURL url = new ActionURL(PropertyController.EditDomainAction.class, container); + url.addParameter("domainURI", domainURI); + if (createOrEdit) + url.addParameter("createOrEdit", true); + return url; + } + + @Override + public ActionURL getDomainEditorURL(Container container, Domain domain) + { + ActionURL url = new ActionURL(PropertyController.EditDomainAction.class, container); + url.addParameter("domainId", domain.getTypeId()); + return url; + } + + @Override + public ActionURL getCreateDataClassURL(Container container) + { + return new ActionURL(EditDataClassAction.class, container); + } + + @Override + public ActionURL getShowDataClassURL(Container container, long rowId) + { + ActionURL url = new ActionURL(ShowDataClassAction.class, container); + url.addParameter("rowId", rowId); + return url; + } + + @Override + public ActionURL getShowFileURL(ExpData data, boolean inline) + { + ActionURL result = getShowFileURL(data.getContainer()).addParameter("rowId", data.getRowId()); + if (inline) + { + result.addParameter("inline", inline); + } + return result; + } + + @Override + public ActionURL getMaterialDetailsURL(ExpMaterial material) + { + return getMaterialDetailsURL(material.getContainer(), material.getRowId()); + } + + @Override + public ActionURL getMaterialDetailsURL(Container c, long materialRowId) + { + return getMaterialDetailsBaseURL(c, null).addParameter("rowId", materialRowId); + } + + @Override + public ActionURL getMaterialDetailsBaseURL(Container c, @Nullable String materialIdFieldKey) + { + return new ActionURL(ShowMaterialAction.class, c); + } + + @Override + public ActionURL getCreateSampleTypeURL(Container container) + { + return new ActionURL(EditSampleTypeAction.class, container); + } + + @Override + public ActionURL getImportSamplesURL(Container container, String sampleTypeName) + { + ActionURL url = new ActionURL(ImportSamplesAction.class, container); + url.addParameter("query.queryName", sampleTypeName); + url.addParameter("schemaName", "exp.materials"); + return url; + } + + @Override + public ActionURL getImportDataURL(Container container, String dataClassName) + { + ActionURL url = new ActionURL(ImportDataAction.class, container); + url.addParameter("query.queryName", dataClassName); + url.addParameter("schemaName", "exp.data"); + return url; + } + + @Override + public ActionURL getDataDetailsURL(ExpData data) + { + return new ActionURL(ShowDataAction.class, data.getContainer()).addParameter("rowId", data.getRowId()); + } + + @Override + public ActionURL getShowFileURL(Container c) + { + return new ActionURL(ShowFileAction.class, c); + } + + @Override + public ActionURL getSetFlagURL(Container container) + { + return new ActionURL(SetFlagAction.class, container); + } + + @Override + public ActionURL getShowRunGraphURL(ExpRun run) + { + return ExperimentController.getRunGraphURL(run.getContainer(), run.getRowId()); + } + + @Override + public ActionURL getRepairTypeURL(Container container) + { + return new ActionURL(TypesController.RepairAction.class, container); + } + + @Override + public ActionURL getUpdateMaterialQueryRowAction(Container c, TableInfo table) + { + ActionURL url = new ActionURL(UpdateMaterialQueryRowAction.class, c); + url.addParameter("schemaName", "samples"); + url.addParameter(QueryView.DATAREGIONNAME_DEFAULT + "." + QueryParam.queryName, table.getName()); + + return url; + } + + @Override + public ActionURL getInsertMaterialQueryRowAction(Container c, TableInfo table) + { + ActionURL url = new ActionURL(InsertMaterialQueryRowAction.class, c); + url.addParameter(QueryView.DATAREGIONNAME_DEFAULT + "." + QueryParam.queryName, table.getName()); + + return url; + } + + @Override + public ActionURL getDataClassAttachmentDownloadAction(Container c) + { + return new ActionURL(ExperimentController.DataClassAttachmentDownloadAction.class, c); + } + + } + + private static abstract class BaseResolveLsidApiAction extends ReadOnlyApiAction + { + protected Set _seeds; + + @Override + public void validateForm(F form, Errors errors) + { + if (null != form.getLsids()) + { + _seeds = new LinkedHashSet<>(form.getLsids().size()); + for (String lsid : form.getLsids()) + { + Identifiable id = LsidManager.get().getObject(lsid); + if (id == null) + throw new NotFoundException("Unable to resolve object: " + lsid); + + // ensure the user has read permission in the seed container + if (!getContainer().equals(id.getContainer())) + { + if (!id.getContainer().hasPermission(getUser(), ReadPermission.class)) + throw new UnauthorizedException("User does not have permission to read object: " + lsid); + } + + _seeds.add(id); + } + } + else + { + throw new ApiUsageException("Starting lsids required"); + } + } + } + + @RequiresPermission(ReadPermission.class) + public static class ResolveAction extends BaseResolveLsidApiAction + { + @Override + public Object execute(ResolveLsidsForm form, BindException errors) + { + var settings = new ExperimentJSONConverter.Settings(form.isIncludeProperties(), form.isIncludeInputsAndOutputs(), form.isIncludeRunSteps()); + var data = _seeds.stream().map(n -> ExperimentJSONConverter.serialize(n, getUser(), settings)).collect(toList()); + return new ApiSimpleResponse("data", data); + } + } + + @RequiresPermission(ReadPermission.class) + public static class LineageAction extends BaseResolveLsidApiAction + { + @Override + public Object execute(ExpLineageOptions options, BindException errors) throws Exception + { + ExpLineageServiceImpl.get().streamLineage(getContainer(), getUser(), getViewContext().getResponse(), _seeds, options); + return null; + } + } + + @Marshal(Marshaller.Jackson) + @RequiresPermission(AdminPermission.class) + public static class RebuildEdgesAction extends MutatingApiAction + { + @Override + public Object execute(ExperimentRunForm form, BindException errors) + { + if (form.getRowId() != 0 || form.getLsid() != null) + { + ExpRun run = form.lookupRun(); + if (!run.getContainer().hasPermission(getUser(), ReadPermission.class)) + throw new UnauthorizedException("Not permitted"); + + ExperimentServiceImpl.get().syncRunEdges(run); + } + else + { + // should this require site admin permissions? + ExperimentServiceImpl.get().rebuildAllRunEdges(); + } + return success(); + } + } + + private static class VerifyEdgesForm extends ExperimentRunForm + { + private Integer _limit; + + public Integer getLimit() + { + return _limit; + } + + public void setLimit(Integer limit) + { + _limit = limit; + } + } + + @Marshal(Marshaller.Jackson) + @RequiresPermission(AdminPermission.class) + public static class VerifyEdgesAction extends ReadOnlyApiAction + { + @Override + public Object execute(VerifyEdgesForm form, BindException errors) + { + if (form.getRowId() != 0 || form.getLsid() != null) + { + ExpRun run = form.lookupRun(); + if (!run.getContainer().hasPermission(getUser(), ReadPermission.class)) + throw new UnauthorizedException("Not permitted"); + + ExperimentServiceImpl.get().verifyRunEdges(run); + } + else + { + ExperimentServiceImpl.get().verifyAllEdges(getContainer(), form.getLimit()); + } + return success(); + } + } + + @Marshal(Marshaller.Jackson) + @RequiresPermission(AdminPermission.class) + public static class RebuildAncestorsAction extends MutatingApiAction + { + @Override + public Object execute(Object form, BindException errors) + { + ClosureQueryHelper.truncateAndRecreate(); + return success(); + } + } + + + @Marshal(Marshaller.Jackson) + @RequiresPermission(AdminPermission.class) + public static class CheckDataClassesIndexedAction extends ReadOnlyApiAction + { + @Override + public Object execute(Object o, BindException errors) throws Exception + { + List> notInIndex = new ArrayList<>(100); + + List list = ExperimentService.get().getDataClasses(getContainer(), getUser(), false); + for (ExpDataClass dc : list) + { + for (ExpData d : dc.getDatas()) + { + String docId = d.getDocumentId(); + if (docId != null) + { + SearchService.SearchHit hit = SearchService.get().find(docId); + if (hit == null) + { + JSONObject props = ExperimentJSONConverter.serializeData(d, getUser(), ExperimentJSONConverter.DEFAULT_SETTINGS); + props.put("docid", docId); + notInIndex.add(props.toMap()); + } + } + } + } + + return success(notInIndex); + } + } + + @Marshal(Marshaller.Jackson) + @RequiresPermission(AdminPermission.class) + public static class CheckEdgesAction extends ReadOnlyApiAction + { + @Override + public Object execute(Object o, BindException errors) throws Exception + { + List result; + DbSchema schema = ExperimentService.get().getSchema(); + TableInfo edgeTable = schema.getTable("Edge"); + + if (null != edgeTable.getColumn("fromObjectId")) + { + var edges = new SqlSelector(ExperimentService.get().getSchema(), "SELECT fromObjectId, toObjectId FROM exp.Edge") + .resultSetStream() + .map(r -> { try { return new Pair<>(r.getInt(1), r.getInt(2)); } catch (SQLException x) { throw new RuntimeException(x); } }) + .collect(toList()); + var cycles = (new GraphAlgorithms()).detectCycleInDirectedGraph(edges); + result = cycles.stream().map(e -> new Integer[]{e.first, e.second}).collect(toList()); + } + else + { + var edges = new SqlSelector(ExperimentService.get().getSchema(), "SELECT fromLsid, toLsid FROM exp.Edge") + .resultSetStream() + .map(r -> { try { return new Pair<>(r.getString(1), r.getString(2)); } catch (SQLException x) { throw new RuntimeException(x); } }) + .collect(toList()); + var cycles = (new GraphAlgorithms()).detectCycleInDirectedGraph(edges); + result = cycles.stream().map(e -> new String[]{e.first, e.second}).collect(toList()); + } + + JSONObject ret = new JSONObject(); + ret.put("result", result); + ret.put("success", true); + return ret; + } + } + + @RequiresPermission(UpdatePermission.class) + public static class UpdateMaterialQueryRowAction extends UserSchemaAction + { + @Override + protected QueryForm createQueryForm(ViewContext context) + { + QueryForm form = new QueryForm("samples", null); + form.setViewContext(getViewContext()); + form.bindParameters(getViewContext().getBindPropertyValues()); + return form; + } + + @Override + public BindException bindParameters(PropertyValues m) throws Exception + { + BindException bind = super.bindParameters(m); + + QueryUpdateForm tableForm = (QueryUpdateForm)bind.getTarget(); + + int sampleId; + try + { + sampleId = Integer.parseInt((String) tableForm.getPkVal()); + } + catch (NumberFormatException e) + { + throw new NotFoundException("Invalid RowId: " + tableForm.getPkVal()); + } + + ExpMaterial material = ExperimentService.get().getExpMaterial(sampleId); + if (material == null) + throw new NotFoundException("Invalid material: " + tableForm.getPkVal()); + + return bind; + } + + @Override + public ModelAndView getView(QueryUpdateForm tableForm, boolean reshow, BindException errors) + { + int sampleId = Integer.parseInt((String) tableForm.getPkVal()); + + ExpMaterial material = ExperimentService.get().getExpMaterial(sampleId); + if (material == null) + throw new NotFoundException("Invalid material: " + tableForm.getPkVal()); + + boolean isAliquot = !StringUtils.isEmpty(material.getAliquotedFromLSID()); + + TableInfo tableInfo = tableForm.getTable(); + Map scopedFields = new CaseInsensitiveHashMap<>(); + for (DomainProperty dp : tableInfo.getDomain().getProperties()) + { + if (!ExpSchema.DerivationDataScopeType.All.name().equalsIgnoreCase(dp.getDerivationDataScope())) + scopedFields.put(dp.getName(), ExpSchema.DerivationDataScopeType.ChildOnly.name().equalsIgnoreCase(dp.getDerivationDataScope())); + } + + for (var column : tableInfo.getColumns()) + { + String columnName = column.getName(); + if (scopedFields.containsKey(columnName)) + { + boolean isAliquotField = scopedFields.get(columnName); + boolean show = (isAliquot && isAliquotField) || (!isAliquot && !isAliquotField); + ((BaseColumnInfo)column).setUserEditable(show); + ((BaseColumnInfo)column).setHidden(!show); + } + } + + ButtonBar bb = createSubmitCancelButtonBar(tableForm); + UpdateView view = new UpdateView(tableForm, errors); + view.getDataRegion().setButtonBar(bb); + return view; + } + + @Override + public boolean handlePost(QueryUpdateForm tableForm, BindException errors) + { + doInsertUpdate(tableForm, errors, false); + return 0 == errors.getErrorCount(); + } + + @Override + public void addNavTrail(NavTree root) + { + super.addNavTrail(root); + root.addChild("Edit " + _form.getQueryName()); + } + } + + @RequiresPermission(InsertPermission.class) + public static class InsertMaterialQueryRowAction extends UserSchemaAction + { + @Override + protected QueryForm createQueryForm(ViewContext context) + { + QueryForm form = new QueryForm("samples", null); + form.setViewContext(getViewContext()); + form.bindParameters(getViewContext().getBindPropertyValues()); + + return form; + } + + @Override + public ModelAndView getView(QueryUpdateForm tableForm, boolean reshow, BindException errors) + { + TableInfo tableInfo = tableForm.getTable(); + Map propertyFields = new CaseInsensitiveHashMap<>(); + for (DomainProperty dp : tableInfo.getDomain().getProperties()) + { + propertyFields.put(dp.getName(), ExpSchema.DerivationDataScopeType.ChildOnly.name().equalsIgnoreCase(dp.getDerivationDataScope())); + } + + for (var column : tableInfo.getColumns()) + { + String columnName = column.getName(); + if (propertyFields.containsKey(columnName)) + { + boolean isAliquotField = propertyFields.get(columnName); + ((BaseColumnInfo)column).setUserEditable(!isAliquotField); + ((BaseColumnInfo)column).setHidden(isAliquotField); + } + } + + InsertView view = new InsertView(tableForm, errors); + view.getDataRegion().setButtonBar(createSubmitCancelButtonBar(tableForm)); + return view; + } + + @Override + public boolean handlePost(QueryUpdateForm tableForm, BindException errors) + { + doInsertUpdate(tableForm, errors, true); + return 0 == errors.getErrorCount(); + } + + @Override + public void addNavTrail(NavTree root) + { + super.addNavTrail(root); + root.addChild("Insert " + _form.getQueryName()); + } + } + + @Marshal(Marshaller.Jackson) + @RequiresPermission(ReadPermission.class) + public static class SaveFindIdsAction extends ReadOnlyApiAction + { + + public static final String FIND_BY_IDS_SESSION_KEY_PREFIX = "findByIds"; + + @Override + public Object execute(FindByIdsForm form, BindException errors) throws Exception + { + HttpServletRequest request = getViewContext().getRequest(); + String key = form.getSessionKey(); + boolean removePrevious = false; + + if (key == null) + { + removePrevious = true; + key = FIND_BY_IDS_SESSION_KEY_PREFIX + "_" + UniqueID.getServerSessionScopedUID(); + } + + if (request != null) + { + if (removePrevious) + SessionHelper.clearAttributesWithPrefix(request, FIND_BY_IDS_SESSION_KEY_PREFIX); + HttpSession session = request.getSession(false); + if (session != null) + { + @SuppressWarnings("unchecked") + List existingIds = (List) session.getAttribute(key); + + // deduplicate from existing ids + if (existingIds != null && form.getSessionKey() != null) + { + existingIds.addAll(form.getIds().stream().filter(id -> !existingIds.contains(id)).toList()); + session.setAttribute(key, existingIds); + } + else + { + session.setAttribute(key, form.getIds()); + } + return success("Saved ids to session key", key); + } + } + + return new SimpleResponse<>(false, "Unable to save to session. Session or request may be null."); + } + } + + @Marshal(Marshaller.Jackson) + @RequiresPermission(ReadPermission.class) + public static class SaveOrderedSamplesQueryAction extends ReadOnlyApiAction + { + private static final String SAMPLE_ID_PREFIX = "s:"; + private static final String UNIQUE_ID_PREFIX = "u:"; + + private List _ids; + private Map> _uniqueIdLsids; + + @Override + public void validateForm(FindByIdsForm form, Errors errors) + { + if (form.getSessionKey() == null) + errors.reject(ERROR_REQUIRED, "sessionKey must be provided"); + else + { + _ids = getFindIdsFromSession(form.getSessionKey()); + if (_ids == null || _ids.isEmpty()) + errors.reject(ERROR_REQUIRED, "No ids found corresponding to session key " + form.getSessionKey()); + } + } + + private void ensureUniqueIdLsids() + { + boolean hasUniqueId = _ids.stream().anyMatch(s -> s.startsWith(UNIQUE_ID_PREFIX)); + if (hasUniqueId && _uniqueIdLsids == null) + { + List uniqueIds = _ids.stream().map(s -> s.substring(UNIQUE_ID_PREFIX.length())).toList(); + _uniqueIdLsids = ExperimentService.get().getUniqueIdLsids(uniqueIds, getUser(), getContainer()); + } + } + + @Override + public Object execute(FindByIdsForm form, BindException errors) throws Exception + { + ensureUniqueIdLsids(); + + SQLFragment select = getOrderedRowsSql(); + // need to set the key field so selections are possible + // need the SampleTypeUnits so we will display using that unit + String metadata = + """ + + + + + true + true + + + true + + + true + + +
+
"""; + QueryDefinition def = QueryService.get().saveSessionQuery(getViewContext(), getContainer(), ExperimentServiceImpl.getExpSchema().getName(), select.getSQL(), metadata); + return success("Session query created", Map.of("queryName", def.getName(), "ids", _ids)); + } + + + private List getFindIdsFromSession(String sessionKey) + { + HttpServletRequest request = getViewContext().getRequest(); + List ids = new ArrayList<>(); + if (request != null) + { + HttpSession session = request.getSession(false); + if (session != null) + { + ids = (List) session.getAttribute(sessionKey); + } + } + return ids; + } + + private SQLFragment getOrderedRowsSql() + { + boolean isFMEnabled = InventoryService.isFreezerManagementEnabled(getContainer()); + String samplesTable = isFMEnabled ? "inventory.SampleItems" : "exp.materials"; + List orderedIdCols = new ArrayList<>(Arrays.asList("Id AS ProvidedID", "RowId", "Ordinal")); + List sampleColumns = new ArrayList<>(); + if (!isFMEnabled) + { + sampleColumns.addAll(Arrays.asList( + "S.Name AS SampleID", + "S.MaterialExpDate AS ExpirationDate", + "S.SampleSet as SampleType", + "S.SampleState", + "S.isAliquot", + "S.Created", + "S.CreatedBy" + )); + } + else + { + sampleColumns.addAll(Arrays.asList( + "S.Name AS SampleID", + "S.MaterialExpDate AS ExpirationDate", + "S.LabelColor", + "S.SampleSet", + "S.SampleState", + "S.StoredAmount", + "S.Units", + "S.SampleTypeUnits", + "S.FreezeThawCount", + "S.StorageStatus", + "S.CheckedOutBy", + "S.StorageLocation", + "S.StorageRow", + "S.StorageCol", + "S.StoragePositionNumber", + "S.IsAliquot", + "S.Created", + "S.CreatedBy" + )); + } + + + String sampleIdComma = ""; + String uniqueIdComma = ""; + int index = 1; + SQLFragment sampleIdValuesSql = new SQLFragment(); + SQLFragment uniqueIdValuesSql = new SQLFragment(); + for (String id : _ids) + { + if (id.startsWith(SAMPLE_ID_PREFIX)) + { + sampleIdValuesSql.append(sampleIdComma).append("\t(").appendValue(index); + sampleIdValuesSql.append(", "); + sampleIdValuesSql.append(LabKeySql.quoteString(id.substring(SAMPLE_ID_PREFIX.length()))); + sampleIdValuesSql.append(", "); + sampleIdValuesSql.append(LabKeySql.quoteString("null")); + sampleIdValuesSql.append(")"); + sampleIdComma = "\n,"; + } + else if (id.startsWith(UNIQUE_ID_PREFIX)) + { + String idClean = id.substring(UNIQUE_ID_PREFIX.length()); + + List lsids = _uniqueIdLsids.get(idClean); + if (lsids != null) + { + for (String lsid : lsids) + { + uniqueIdValuesSql.append(uniqueIdComma).append("\t(").appendValue(index); + uniqueIdValuesSql.append(", "); + uniqueIdValuesSql.append(LabKeySql.quoteString(idClean)); + uniqueIdValuesSql.append(", "); + uniqueIdValuesSql.append(LabKeySql.quoteString(lsid)); + uniqueIdValuesSql.append(")"); + uniqueIdComma = "\n,"; + } + } + } + index++; + } + + boolean haveData = !sampleIdValuesSql.isEmpty() || !_uniqueIdLsids.isEmpty(); + SQLFragment sql = new SQLFragment(); + if (!sampleIdValuesSql.isEmpty()) + { + sql.append("WITH _ordered_ids_ AS (\nSELECT * FROM (VALUES\n"); + sql.append(sampleIdValuesSql); + sql.append("\n) AS _values_ )\n"); // name of the alias here doesn't matter + } + if (!uniqueIdValuesSql.isEmpty()) + { + if (!sampleIdValuesSql.isEmpty()) + sql.append(",\n"); + else + sql.append("WITH "); + + sql.append("_ordered_unique_ids_ AS (\nSELECT * FROM (VALUES\n"); + sql.append(uniqueIdValuesSql); + sql.append("\n) AS _values_ )\n"); // name of the alias here doesn't matter + } + + sql.append("SELECT "); + sql.append("\n\tOID.").append(StringUtils.join(orderedIdCols, ",\n\tOID.")); + sql.append(",\n\t").append(StringUtils.join( sampleColumns, ",\n\t")); + sql.append("\nFROM\n("); + if (!sampleIdValuesSql.isEmpty()) + { + sql.append("SELECT\n\tM.RowId,\n\t_ordered_ids_.column1 as Ordinal,\n\t_ordered_ids_.column2 as Id,\n\t_ordered_ids_.column2 as lsid"); + sql.append("\nFROM _ordered_ids_\n"); + sql.append("INNER JOIN exp.materials M ON _ordered_ids_.column2 = M.Name"); + sql.append("\n"); + } + if (!uniqueIdValuesSql.isEmpty()) + { + if (!sampleIdValuesSql.isEmpty()) + sql.append("\nUNION ALL\n\n"); + + sql.append("SELECT\n\tM.RowId,\n\t_ordered_unique_ids_.column1 as Ordinal,\n\t_ordered_unique_ids_.column2 as Id,\n\t_ordered_unique_ids_.column3 as lsid"); + sql.append("\nFROM _ordered_unique_ids_\n"); + sql.append("INNER JOIN exp.materials M ON _ordered_unique_ids_.column3 = M.lsid"); + sql.append("\n"); + } + if (!haveData) // no data to return but return data in the expected shape. + { + sql = new SQLFragment("SELECT\n"); + sql.append(orderedIdCols.stream() + .map(col -> { + int asIndex = col.indexOf("AS"); + if (asIndex > 0) + return "NULL AS " + col.substring(asIndex+ 3); + else + return "NULL AS " + col; + }) + .collect(Collectors.joining(",\t\n"))); + sql.append(",\t\n").append(StringUtils.join(sampleColumns, ",\t\n")); + sql.append("\nFROM ").append(samplesTable).append(" S WHERE 1 = 2"); + return sql; + } + else + { + sql.append(") OID"); + if (isFMEnabled) + sql.append("\nLEFT JOIN inventory.SampleItems S on S.RowId = OID.RowId"); + else + sql.append("\nINNER JOIN exp.materials S on S.RowId = OID.RowId"); + sql.append("\n\nORDER BY Ordinal"); + return sql; + } + } + } + + public static class FindByIdsForm extends FindSessionKeyForm + { + List _ids; + + public List getIds() + { + return _ids; + } + + public void setIds(List ids) + { + _ids = ids; + } + } + + + public static class FindSessionKeyForm + { + private String _sessionKey; + + public String getSessionKey() + { + return _sessionKey; + } + + public void setSessionKey(String sessionKey) + { + _sessionKey = sessionKey; + } + } + + static void validateEntitySequenceForm(EntitySequenceForm form, Errors errors) + { + String kindName = form.getKindName(); + if (StringUtils.isEmpty(kindName) || form.getSeqType() == null) + { + errors.reject(ERROR_REQUIRED, "KindName and SeqType must be provided"); + return; + } + + if (form.getSeqType() == NameGenerator.EntityCounter.genId) + { + if (form.getRowId() == null) + errors.reject(ERROR_REQUIRED, "Data type RowId must be provided for genId"); + } + else if (!SampleTypeDomainKind.NAME.equalsIgnoreCase(kindName)) + { + errors.reject(ERROR_MSG, form.getSeqType() + " is not supported for " + kindName); + } + + if (!SampleTypeDomainKind.NAME.equalsIgnoreCase(kindName) && !DataClassDomainKind.NAME.equalsIgnoreCase(kindName)) + errors.reject(ERROR_MSG, "Invalid KindName. Should be either " + SampleTypeDomainKind.NAME + " or " + DataClassDomainKind.NAME + "."); + + } + + @RequiresPermission(ReadPermission.class) + public static class GetEntitySequenceAction extends ReadOnlyApiAction + { + @Override + public void validateForm(EntitySequenceForm form, Errors errors) + { + validateEntitySequenceForm(form, errors); + } + + @Override + public Object execute(EntitySequenceForm form, BindException errors) throws Exception + { + long value = -1; + if (SampleTypeDomainKind.NAME.equalsIgnoreCase(form.getKindName())) + { + if (form.getSeqType() == NameGenerator.EntityCounter.genId) + { + ExpSampleType sampleType = SampleTypeService.get().getSampleType(form.getRowId()); + if (sampleType != null) + value = sampleType.getCurrentGenId(); + } + else + { + value = SampleTypeService.get().getCurrentCount(form.getSeqType(), getContainer()); + } + + } + else if (DataClassDomainKind.NAME.equalsIgnoreCase(form.getKindName())) + { + ExpDataClass dataClass = ExperimentService.get().getDataClass(form.getRowId()); + if (dataClass != null) + value = dataClass.getCurrentGenId(); + } + + ApiSimpleResponse resp = new ApiSimpleResponse(); + resp.put("success", value > -1); + resp.put("value", value); + return resp; + } + } + + @RequiresPermission(ReadPermission.class) // actual permission checked later + public static class SetEntitySequenceAction extends MutatingApiAction + { + @Override + public void validateForm(EntitySequenceForm form, Errors errors) + { + validateEntitySequenceForm(form, errors); + + if (form.getNewValue() == null || form.getNewValue() < 0) + errors.reject(ERROR_MSG, "Invalid newValue."); + } + + @Override + public Object execute(EntitySequenceForm form, BindException errors) + { + ApiSimpleResponse resp = new ApiSimpleResponse(); + resp.put("success", true); + + try + { + Domain domain = null; + if (SampleTypeDomainKind.NAME.equalsIgnoreCase(form.getKindName())) + { + if (form.getSeqType() == NameGenerator.EntityCounter.genId) + { + if (!getContainer().hasPermission(getUser(), DesignSampleTypePermission.class)) + throw new UnauthorizedException("Insufficient permissions."); + + ExpSampleType sampleType = SampleTypeService.get().getSampleType(form.getRowId()); + if (sampleType != null) + { + sampleType.ensureMinGenId(form.getNewValue()); + domain = sampleType.getDomain(); + } + else + { + resp.put("success", false); + resp.put("error", "Sample type does not exist."); + } + } + else + { + if (!getContainer().hasPermission(getUser(), AdminPermission.class)) + throw new UnauthorizedException("Insufficient permissions."); + + SampleTypeService.get().ensureMinSampleCount(form.getNewValue(), form.getSeqType(), getContainer()); + } + } + else if (DataClassDomainKind.NAME.equalsIgnoreCase(form.getKindName())) + { + if (!getContainer().hasPermission(getUser(), DesignDataClassPermission.class)) + throw new BadRequestException("Insufficient permissions."); + + ExpDataClass dataClass = ExperimentService.get().getDataClass(form.getRowId()); + if (dataClass != null) + { + dataClass.ensureMinGenId(form.getNewValue(), getContainer()); + domain = dataClass.getDomain(); + } + else + { + resp.put("success", false); + resp.put("error", "DataClass does not exist."); + } + } + + if (domain != null) + { + DomainAuditProvider.DomainAuditEvent event = new DomainAuditProvider.DomainAuditEvent(getContainer(), "The genId for domain " + domain.getName() + " has been updated to " + form.getNewValue() + "."); + event.setDomainUri(domain.getTypeURI()); + event.setDomainName(domain.getName()); + AuditLogService.get().addEvent(getUser(), event); + } + } + catch (ExperimentException e) + { + resp.put("success", false); + resp.put("error", e.getMessage()); + } + + return resp; + } + } + + public static class EntitySequenceForm + { + private String _kindName; + private NameGenerator.EntityCounter _seqType; + private Integer _rowId; + private Long _newValue; + + public Integer getRowId() + { + return _rowId; + } + + public void setRowId(Integer rowId) + { + _rowId = rowId; + } + + public String getKindName() + { + return _kindName; + } + + public void setKindName(String kindName) + { + _kindName = kindName; + } + + public Long getNewValue() + { + return _newValue; + } + + public void setNewValue(Long newValue) + { + this._newValue = newValue; + } + + public NameGenerator.EntityCounter getSeqType() + { + return _seqType; + } + + public void setSeqType(String seqType) + { + _seqType = NameGenerator.EntityCounter.valueOf(seqType); + } + + } + + @Marshal(Marshaller.Jackson) + @RequiresPermission(ReadPermission.class) + public static class GetCrossFolderDataSelectionAction extends ReadOnlyApiAction + { + @Override + public void validateForm(CrossFolderSelectionForm form, Errors errors) + { + if (form.getDataRegionSelectionKey() == null && form.getRowIds() == null) + errors.reject(ERROR_REQUIRED, "You must provide either a set of rowIds or a dataRegionSelectionKey."); + if (!"samples".equalsIgnoreCase(form.getDataType()) && !"exp.data".equalsIgnoreCase(form.getDataType())&& !"assay".equalsIgnoreCase(form.getDataType())) + errors.reject(ERROR_REQUIRED, "Data type (sample, data or assayrun) must be specified."); + } + + @Override + public Object execute(CrossFolderSelectionForm form, BindException errors) + { + Pair result = ExperimentServiceImpl.getCurrentAndCrossFolderDataCount(form.getIds(false), form.getDataType(), getContainer()); + + ApiSimpleResponse resp = new ApiSimpleResponse(); + resp.put("success", true); + resp.put("currentFolderSelectionCount", result.first); + resp.put("crossFolderSelectionCount", result.second); + + return success(resp); + } + } + + public static class CrossFolderSelectionForm extends DataViewSnapshotSelectionForm + { + private String _dataType; + private String _picklistName; + + public String getDataType() + { + return _dataType; + } + + public void setDataType(String dataType) + { + _dataType = dataType; + } + + public String getPicklistName() + { + return _picklistName; + } + + public void setPicklistName(String picklistName) + { + _picklistName = picklistName; + } + + @Override + public Set getIds(boolean clear) + { + Set selectedIds; + + if (_rowIds != null) + selectedIds = _rowIds; + else if (isUseSnapshotSelection()) + selectedIds = new HashSet<>(DataRegionSelection.getSnapshotSelectedIntegers(getViewContext(), getDataRegionSelectionKey())); + else + selectedIds = DataRegionSelection.getSelectedIntegers(getViewContext(), getDataRegionSelectionKey(), clear); + + if (_picklistName != null) + { + User user = getViewContext().getUser(); + Container container = getViewContext().getContainer(); + UserSchema schema = ListService.get().getUserSchema(user, container); + TableInfo tInfo = schema.getTable(_picklistName); + if (tInfo != null) + { + SimpleFilter filter = SimpleFilter.createContainerFilter(container); + filter.addInClause(FieldKey.fromParts("id"), selectedIds); + TableSelector selector = new TableSelector(tInfo, Collections.singleton("SampleID"), filter, null); + return new HashSet<>(selector.getArrayList(Long.class)); + } + } + return selectedIds; + } + } + + @RequiresPermission(AdminPermission.class) + public static class RecomputeAliquotRollup extends SimpleViewAction + { + @Override + public void addNavTrail(NavTree root) + { + } + + @Override + public ModelAndView getView(Object o, BindException errors) throws SQLException + { + try (var ignore = SpringActionController.ignoreSqlUpdates()) + { + Container container = getContainer(); + User user = getUser(); + + List sampleTypes = SampleTypeService.get() + .getSampleTypes(container, user, true); + + HtmlStringBuilder builder = HtmlStringBuilder.of(); + builder.unsafeAppend(""); + + SampleTypeService service = SampleTypeService.get(); + for (ExpSampleType sampleType : sampleTypes) + { + int updatedCount; + updatedCount = service.recomputeSampleTypeRollup(sampleType, container); + // we could check "if (0 < updatedCount) refresh(rollup)", but since this is a "manual" usage lets just always refresh + SampleTypeServiceImpl.get().refreshSampleTypeMaterializedView(sampleType, update); + builder.unsafeAppend(""); + } + + builder.unsafeAppend("
Sample Type#Recomputed
") + .append(sampleType.getName()) + .unsafeAppend("") + .append(updatedCount) + .unsafeAppend("
"); + return new HtmlView("Aliquot Rollup Recalculation Result", builder); + } + } + } + + /* Also see API CheckEdgesAction */ + @RequiresPermission(TroubleshooterPermission.class) + public static class CycleCheckAction extends FormViewAction + { + List cycleObjectIds = null; + + @Override + public void validateCommand(Object target, Errors errors) + { + + } + + @Override + public ModelAndView getView(Object o, boolean reshow, BindException errors) + { + if (!reshow) + { + return new HtmlView( + DIV("This operation can use a lot of memory.", + LK.FORM(at(method,"POST"), + PageFlowUtil.button("Continue").submit(true))) + ); + } + + if (null == cycleObjectIds) + return new HtmlView(HtmlString.of("No cycles found")); + + Map map = new LongHashMap<>(); + var cf = new ContainerFilter.AllFolders(getUser()); + var materials = ExperimentServiceImpl.get().getExpMaterialsByObjectId(cf, cycleObjectIds); + materials.forEach( (m) -> map.put(m.getObjectId(), m)); + var datas = ExperimentServiceImpl.get().getExpDatasByObjectId(cf, cycleObjectIds); + datas.forEach( (d) -> map.put(d.getObjectId(), d)); + var runs = ExperimentServiceImpl.get().getRunsByObjectId(cf, cycleObjectIds); + runs.forEach( (r) -> map.put(r.getObjectId(), r)); + + ExperimentUrls urls = ExperimentUrls.get(); + return new HtmlView( + DIV("Cycle found involving these objects.", + UL(cycleObjectIds.stream().map((objectid) -> + { + ExpObject exp = map.get(objectid); + if (exp instanceof ExpMaterial mat) + return LI(A(at(target, "_blank", href, urls.getMaterialDetailsURL(mat)), objectid + " : material - " + mat.getName())); + else if (exp instanceof ExpRun run) + return LI(A(at(target, "_blank", href, urls.getRunTextURL(run)), objectid + " : run - " + run.getName())); + else if (exp instanceof ExpData data) + return LI(A(at(target, "_blank", href, urls.getDataDetailsURL(data)), objectid + " : run - " + data.getName())); + else + return LI(String.valueOf(objectid)); + })) + ) + ); + } + + @Override + public boolean handlePost(Object o, BindException errors) + { + var edges = new SqlSelector(ExperimentService.get().getSchema(), "SELECT fromObjectId, toObjectId FROM exp.Edge") + .resultSetStream() + .map(r -> { try { return new Pair<>(r.getInt(1), r.getInt(2)); } catch (SQLException x) { throw new RuntimeException(x); } }) + .collect(toList()); + var cyclesEdges = (new GraphAlgorithms()).detectCycleInDirectedGraph(edges); + + var set = new LinkedHashSet(); + cyclesEdges.forEach( (edge) -> { + set.add(edge.first); + set.add(edge.second); + }); + cycleObjectIds = set.stream().toList(); + return false; + } + + @Override + public URLHelper getSuccessURL(Object o) + { + return null; + } + + @Override + public void addNavTrail(NavTree root) + { + + } + } + + @RequiresPermission(AdminPermission.class) + public static class MissingFilesCheckAction extends ReadOnlyApiAction + { + @Override + public Object execute(Object o, BindException errors) throws Exception + { + Map> info = ExperimentServiceImpl.get().doMissingFilesCheck(getUser(), getContainer(), true); + JSONObject results = new JSONObject(); + for (String containerId : info.keySet()) + { + JSONObject containerResults = new JSONObject(); + for (String sourceName : info.get(containerId).keySet()) + containerResults.put(sourceName, info.get(containerId).get(sourceName).toJSON()); + results.put(containerId, containerResults); + } + + ApiSimpleResponse response = new ApiSimpleResponse(); + response.put("success", true); + response.put("result", results); + return response; + } + } + +} diff --git a/experiment/src/org/labkey/experiment/controllers/exp/ImportXarForm.java b/experiment/src/org/labkey/experiment/controllers/exp/ImportXarForm.java index f0ca5ddd765..306971e4a7c 100644 --- a/experiment/src/org/labkey/experiment/controllers/exp/ImportXarForm.java +++ b/experiment/src/org/labkey/experiment/controllers/exp/ImportXarForm.java @@ -23,6 +23,8 @@ import org.labkey.api.resource.Resource; import org.labkey.api.util.NetworkDrive; import org.labkey.api.view.NotFoundException; +import org.labkey.vfs.FileLike; +import org.labkey.vfs.FileSystemLike; import java.io.File; import java.util.ArrayList; @@ -51,7 +53,7 @@ public void setModule(String module) * default pipeline xar processing. */ @Override - public List getValidatedFiles(Container c, boolean allowNonExistentFiles) + public List getValidatedFiles(Container c, boolean allowNonExistentFiles) { if (_module == null) return super.getValidatedFiles(c, allowNonExistentFiles); @@ -70,7 +72,7 @@ public List getValidatedFiles(Container c, boolean allowNonExistentFiles) throw new NotFoundException("Could not find path " + getPath()); } - List files = new ArrayList<>(); + List files = new ArrayList<>(); for (String fileName : getFile()) { Resource rf = m.getModuleResource(getPath() + "/" + fileName); @@ -85,7 +87,7 @@ public List getValidatedFiles(Container c, boolean allowNonExistentFiles) { throw new NotFoundException("Could not find file '" + f + "'"); } - files.add(f); + files.add(FileSystemLike.wrapFile(f)); } return files; diff --git a/experiment/src/org/labkey/experiment/pipeline/ExperimentPipelineJob.java b/experiment/src/org/labkey/experiment/pipeline/ExperimentPipelineJob.java index 9df4b071384..76f8614fa64 100644 --- a/experiment/src/org/labkey/experiment/pipeline/ExperimentPipelineJob.java +++ b/experiment/src/org/labkey/experiment/pipeline/ExperimentPipelineJob.java @@ -1,184 +1,177 @@ -/* - * Copyright (c) 2008-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.labkey.experiment.pipeline; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.labkey.experiment.xar.CompressedXarSource; -import org.labkey.api.exp.FileXarSource; -import org.labkey.api.exp.XarSource; -import org.labkey.api.exp.api.ExpRun; -import org.labkey.api.exp.api.ExperimentService; -import org.labkey.api.exp.api.ExperimentUrls; -import org.labkey.api.pipeline.PipeRoot; -import org.labkey.api.pipeline.PipelineJob; -import org.labkey.api.util.PageFlowUtil; -import org.labkey.api.view.ActionURL; -import org.labkey.api.view.ViewBackgroundInfo; - -import java.io.File; -import java.io.IOException; -import java.nio.file.Path; -import java.sql.BatchUpdateException; -import java.util.List; - -/** - * User: jeckels - * Date: Oct 26, 2005 - */ -public class ExperimentPipelineJob extends PipelineJob -{ - private static final Logger _log = LogManager.getLogger(ExperimentPipelineJob.class); - - private static final Object _experimentLock = new Object(); - - private final Path _xarFile; - private final String _description; - private final boolean _deleteExistingRuns; - - private transient XarSource _xarSource; - - @JsonCreator - protected ExperimentPipelineJob(@JsonProperty("_xarFile") Path xarFile, - @JsonProperty("_description") String description, - @JsonProperty("_deleteExistingRuns") boolean deleteExistingRuns) - { - super(); - _xarFile = xarFile; - _description = description; - _deleteExistingRuns = deleteExistingRuns; - } - - @Deprecated //Prefer the Path version - public ExperimentPipelineJob(ViewBackgroundInfo info, File file, String description, boolean deleteExistingRuns, PipeRoot root) throws IOException - { - this(info, file.toPath(), description, deleteExistingRuns, root); - } - - public ExperimentPipelineJob(ViewBackgroundInfo info, Path file, String description, boolean deleteExistingRuns, PipeRoot root) throws IOException - { - super(ExperimentPipelineProvider.NAME, info, root); - _xarFile = file; - _description = description + " - " + file.getFileName().toString(); - _deleteExistingRuns = deleteExistingRuns; - - XarSource xarSource = getXarSource(); - header("XAR Import from " + xarSource.toString()); - } - - protected XarSource createXarSource(Path file) - { - String name = file.getFileName().toString().toLowerCase(); - if (name.endsWith(".xar") || name.endsWith(".zip")) - { - return new CompressedXarSource(file, this); - } - else - { - return new FileXarSource(file, this); - } - } - - private XarSource getXarSource() - { - if (_xarSource == null) - { - _xarSource = createXarSource(_xarFile); - - try - { - setLogFile(_xarSource.getLogFilePath()); - } - catch (IOException e) - { - _log.error("Failed to get log file for " + _xarFile, e); - } - } - return _xarSource; - } - - @Override - public ActionURL getStatusHref() - { - ExpRun run = getXarSource().getExperimentRun(); - if (run != null) - { - return PageFlowUtil.urlProvider(ExperimentUrls.class).getRunGraphURL(run); - } - return null; - } - - @Override - public String getDescription() - { - return _description; - } - - public static boolean loadExperiment(PipelineJob job, XarSource source, boolean deleteExistingRuns) - { - try - { - source.init(); - } - catch (Exception e) - { - job.getLogger().error("Failed to initialize XAR source", e); - return false; - } - - synchronized (_experimentLock) - { - job.getLogger().info("Starting to import XAR"); - - try - { - List runs = ExperimentService.get().importXar(source, job, deleteExistingRuns); - if (!runs.isEmpty()) - { - source.setExperimentRunRowId(runs.get(0).getRowId()); - } - - job.getLogger().info(""); - job.getLogger().info("XAR import completed successfully"); - } - catch (Throwable t) - { - job.getLogger().info(""); - job.getLogger().fatal("Exception during import", t); - job.getLogger().fatal("XAR import FAILED"); - if (t instanceof BatchUpdateException) - { - job.getLogger().fatal("Underlying exception", ((BatchUpdateException)t).getNextException()); - } - return false; - } - return true; - } - } - - @Override - public void run() - { - if (!setStatus("LOADING EXPERIMENT")) - return; - - if (loadExperiment(this, getXarSource(), _deleteExistingRuns)) - setStatus(TaskStatus.complete); - } -} +/* + * Copyright (c) 2008-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.labkey.experiment.pipeline; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.labkey.experiment.xar.CompressedXarSource; +import org.labkey.api.exp.FileXarSource; +import org.labkey.api.exp.XarSource; +import org.labkey.api.exp.api.ExpRun; +import org.labkey.api.exp.api.ExperimentService; +import org.labkey.api.exp.api.ExperimentUrls; +import org.labkey.api.pipeline.PipeRoot; +import org.labkey.api.pipeline.PipelineJob; +import org.labkey.api.util.PageFlowUtil; +import org.labkey.api.view.ActionURL; +import org.labkey.api.view.ViewBackgroundInfo; +import org.labkey.vfs.FileLike; + +import java.io.IOException; +import java.sql.BatchUpdateException; +import java.util.List; + +/** + * User: jeckels + * Date: Oct 26, 2005 + */ +public class ExperimentPipelineJob extends PipelineJob +{ + private static final Logger _log = LogManager.getLogger(ExperimentPipelineJob.class); + + private static final Object _experimentLock = new Object(); + + private final FileLike _xarFile; + private final String _description; + private final boolean _deleteExistingRuns; + + private transient XarSource _xarSource; + + @JsonCreator + protected ExperimentPipelineJob(@JsonProperty("_xarFile") FileLike xarFile, + @JsonProperty("_description") String description, + @JsonProperty("_deleteExistingRuns") boolean deleteExistingRuns) + { + super(); + _xarFile = xarFile; + _description = description; + _deleteExistingRuns = deleteExistingRuns; + } + + public ExperimentPipelineJob(ViewBackgroundInfo info, FileLike file, String description, boolean deleteExistingRuns, PipeRoot root) throws IOException + { + super(ExperimentPipelineProvider.NAME, info, root); + _xarFile = file; + _description = description + " - " + file.getName(); + _deleteExistingRuns = deleteExistingRuns; + + XarSource xarSource = getXarSource(); + header("XAR Import from " + xarSource.toString()); + } + + protected XarSource createXarSource(FileLike file) + { + String name = file.getName().toLowerCase(); + if (name.endsWith(".xar") || name.endsWith(".zip")) + { + return new CompressedXarSource(file, this); + } + else + { + return new FileXarSource(file, this); + } + } + + private XarSource getXarSource() + { + if (_xarSource == null) + { + _xarSource = createXarSource(_xarFile); + + try + { + setLogFile(_xarSource.getLogFilePath()); + } + catch (IOException e) + { + _log.error("Failed to get log file for " + _xarFile, e); + } + } + return _xarSource; + } + + @Override + public ActionURL getStatusHref() + { + ExpRun run = getXarSource().getExperimentRun(); + if (run != null) + { + return PageFlowUtil.urlProvider(ExperimentUrls.class).getRunGraphURL(run); + } + return null; + } + + @Override + public String getDescription() + { + return _description; + } + + public static boolean loadExperiment(PipelineJob job, XarSource source, boolean deleteExistingRuns) + { + try + { + source.init(); + } + catch (Exception e) + { + job.getLogger().error("Failed to initialize XAR source", e); + return false; + } + + synchronized (_experimentLock) + { + job.getLogger().info("Starting to import XAR"); + + try + { + List runs = ExperimentService.get().importXar(source, job, deleteExistingRuns); + if (!runs.isEmpty()) + { + source.setExperimentRunRowId(runs.get(0).getRowId()); + } + + job.getLogger().info(""); + job.getLogger().info("XAR import completed successfully"); + } + catch (Throwable t) + { + job.getLogger().info(""); + job.getLogger().fatal("Exception during import", t); + job.getLogger().fatal("XAR import FAILED"); + if (t instanceof BatchUpdateException) + { + job.getLogger().fatal("Underlying exception", ((BatchUpdateException)t).getNextException()); + } + return false; + } + return true; + } + } + + @Override + public void run() + { + if (!setStatus("LOADING EXPERIMENT")) + return; + + if (loadExperiment(this, getXarSource(), _deleteExistingRuns)) + setStatus(TaskStatus.complete); + } +} diff --git a/experiment/src/org/labkey/experiment/pipeline/MoveRunsTask.java b/experiment/src/org/labkey/experiment/pipeline/MoveRunsTask.java index acc6a3ec7ee..29fc75fb668 100644 --- a/experiment/src/org/labkey/experiment/pipeline/MoveRunsTask.java +++ b/experiment/src/org/labkey/experiment/pipeline/MoveRunsTask.java @@ -1,284 +1,286 @@ -/* - * Copyright (c) 2008-2018 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.experiment.pipeline; - -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.apache.xmlbeans.XmlException; -import org.fhcrc.cpas.exp.xml.ExperimentArchiveDocument; -import org.fhcrc.cpas.exp.xml.ExperimentArchiveType; -import org.jetbrains.annotations.NotNull; -import org.labkey.api.data.Container; -import org.labkey.api.data.DbScope; -import org.labkey.api.exp.ExperimentDataHandler; -import org.labkey.api.exp.ExperimentException; -import org.labkey.api.exp.XarSource; -import org.labkey.api.exp.api.ExpData; -import org.labkey.api.exp.api.ExperimentService; -import org.labkey.api.pipeline.PipelineJob; -import org.labkey.api.pipeline.RecordedActionSet; -import org.labkey.api.util.DateUtil; -import org.labkey.api.util.FileUtil; -import org.labkey.api.util.XmlBeansUtil; -import org.labkey.experiment.DataURLRelativizer; -import org.labkey.api.exp.xar.LSIDRelativizer; -import org.labkey.experiment.XarExporter; -import org.labkey.experiment.XarReader; -import org.labkey.experiment.api.ExpRunImpl; -import org.labkey.experiment.api.ExperimentServiceImpl; - -import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.IOException; -import java.net.URI; -import java.nio.file.Path; -import java.sql.BatchUpdateException; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -/** - * User: jeckels - * Date: Sep 8, 2008 - */ -public class MoveRunsTask extends PipelineJob.Task -{ - public MoveRunsTask(MoveRunsTaskFactory factory, PipelineJob job) - { - super(factory, job); - } - - @Override - @NotNull - public RecordedActionSet run() - { - MoveRunsPipelineJob job = (MoveRunsPipelineJob)getJob(); - - try - { - for (long runId : job.getRunIds()) - { - ExpRunImpl experimentRun = ExperimentServiceImpl.get().getExpRun(runId); - if (experimentRun != null) - { - moveRun(job, experimentRun); - } - else - { - job.info("Run with id " + runId + " is no longer available in the system"); - } - } - } - catch (Throwable t) - { - job.error("Exception during move", t); - job.error("Move FAILED"); - if (t instanceof BatchUpdateException) - { - job.error("Underlying exception", ((BatchUpdateException)t).getNextException()); - } - } - return new RecordedActionSet(); - } - - private void moveRun(MoveRunsPipelineJob job, ExpRunImpl experimentRun) throws ExperimentException, IOException - { - XarExporter exporter = new XarExporter(LSIDRelativizer.PARTIAL_FOLDER_RELATIVE, DataURLRelativizer.ORIGINAL_FILE_LOCATION.createURLRewriter(), getJob().getUser(), getJob().getContainer()); - exporter.addExperimentRun(experimentRun); - - ByteArrayOutputStream bOut = new ByteArrayOutputStream(); - exporter.dumpXML(bOut); - - try (DbScope.Transaction transaction = ExperimentService.get().getSchema().getScope().ensureTransaction()) - { - Map dataFiles = new HashMap<>(); - - for (ExpData oldData : experimentRun.getAllDataUsedByRun()) - { - ExperimentDataHandler handler = oldData.findDataHandler(); - handler.beforeMove(oldData, experimentRun.getContainer(), job.getUser()); - } - - for (ExpData data : ExperimentService.get().deleteExperimentRunForMove(experimentRun.getRowId(), job.getUser())) - { - if (data.getDataFileUrl() != null) - { - dataFiles.put(data.getDataFileUrl(), data.getRowId()); - } - } - - MoveRunsXarSource xarSource = new MoveRunsXarSource(bOut.toString(), experimentRun.getFilePathRootPath(), job); - XarReader reader = new XarReader(xarSource, job); - reader.parseAndLoad(false, null); - - List runLSIDs = reader.getProcessedRunsLSIDs(); - assert runLSIDs.size() == 1 : "Expected a single run to be loaded"; - - for (String dataURL : dataFiles.keySet()) - { - ExpData newData = ExperimentService.get().getExpDataByURL(xarSource.getCanonicalDataFileURL(dataURL), job.getSourceContainer()); - if (newData != null) - { - ExperimentDataHandler handler = newData.findDataHandler(); - handler.runMoved(newData, experimentRun.getContainer(), job.getContainer(), experimentRun.getLSID(), runLSIDs.get(0), job.getUser(), dataFiles.get(dataURL)); - } - } - transaction.commit(); - } - } - - public static class MoveRunsXarSource extends XarSource - { - private static final Logger _log = LogManager.getLogger(MoveRunsXarSource.class); - - private final String _xml; - private File _logFile; - private File _logFileDir; - - private final String _uploadTime; - - private String _experimentName; - private final String _root; - private final Container _sourceContainer; - - public MoveRunsXarSource(String xml, Path root, MoveRunsPipelineJob job) throws ExperimentException - { - super(job); - _xml = xml; - if (null == root) - throw new ExperimentException("File path root is null"); - - _root = FileUtil.getAbsolutePath(job.getSourceContainer(), root); - if (null == _root) - throw new ExperimentException("Unable to create absolute file path for root"); - - _sourceContainer = job.getSourceContainer(); - - int retry = 0; - while (_logFileDir == null) - { - try - { - _logFileDir = FileUtil.createTempFile("xarupload", ""); - } - catch (IOException e) - { - if (++retry > 10) - { - throw new ExperimentException("Unable to create a log file", e); - } - _log.warn("Failed to create log file, retrying...", e); - } - } - - _logFileDir.delete(); - try - { - FileUtil.mkdir(_logFileDir); - } - catch (IOException e) - { - throw new ExperimentException("Unable to create log file directory", e); - } - _logFileDir.deleteOnExit(); - _logFile = FileUtil.appendName(_logFileDir, "upload.xar.log"); - _logFile.deleteOnExit(); - _uploadTime = DateUtil.formatDateTime(job.getContainer()); - } - - @Override - public ExperimentArchiveDocument getDocument() throws XmlException - { - ExperimentArchiveDocument doc = ExperimentArchiveDocument.Factory.parse(_xml, XmlBeansUtil.getDefaultParseOptions()); - ExperimentArchiveType ea = doc.getExperimentArchive(); - if (ea != null) - { - if (ea.getExperimentArray() != null && ea.getExperimentArray().length > 0) - { - _experimentName = ea.getExperimentArray()[0].getName(); - } - } - return doc; - } - - @Override - public Path getRootPath() - { - return FileUtil.stringToPath(_sourceContainer, _root); - } - - @Override - public Path getJobRootPath() - { - var pipelineJob = getXarContext().getJob(); - return pipelineJob != null - ? pipelineJob.getPipeRoot().getRootFileLike().toNioPathForRead() - : super.getJobRootPath(); - } - - @Override - public boolean shouldIgnoreDataFiles() - { - return true; - } - - @Override - public String canonicalizeDataFileURL(String dataFileURL) - { - URI uri = FileUtil.createUri(dataFileURL); - Path dataFilePath; - if (!uri.isAbsolute()) - { - dataFilePath = getRootPath().resolve(dataFileURL); - } - else - { - dataFilePath = FileUtil.getPath(_sourceContainer, uri); - } - return FileUtil.getAbsoluteCaseSensitivePathString(_sourceContainer, dataFilePath.toUri()); - } - - @Override - public Path getLogFilePath() - { - return _logFile.toPath(); - } - - public String toString() - { - String result = "Uploaded file: " + _uploadTime; - if (_experimentName != null) - { - result += _experimentName; - } - return result; - } - - public void cleanup() - { - if (_logFile != null) - { - _logFile.delete(); - _logFile = null; - } - if (_logFileDir != null) - { - _logFileDir.delete(); - _logFileDir = null; - } - } - } -} +/* + * Copyright (c) 2008-2018 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.experiment.pipeline; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.xmlbeans.XmlException; +import org.fhcrc.cpas.exp.xml.ExperimentArchiveDocument; +import org.fhcrc.cpas.exp.xml.ExperimentArchiveType; +import org.jetbrains.annotations.NotNull; +import org.labkey.api.data.Container; +import org.labkey.api.data.DbScope; +import org.labkey.api.exp.ExperimentDataHandler; +import org.labkey.api.exp.ExperimentException; +import org.labkey.api.exp.XarSource; +import org.labkey.api.exp.api.ExpData; +import org.labkey.api.exp.api.ExperimentService; +import org.labkey.api.pipeline.PipelineJob; +import org.labkey.api.pipeline.RecordedActionSet; +import org.labkey.api.util.DateUtil; +import org.labkey.api.util.FileUtil; +import org.labkey.api.util.XmlBeansUtil; +import org.labkey.experiment.DataURLRelativizer; +import org.labkey.api.exp.xar.LSIDRelativizer; +import org.labkey.experiment.XarExporter; +import org.labkey.experiment.XarReader; +import org.labkey.experiment.api.ExpRunImpl; +import org.labkey.experiment.api.ExperimentServiceImpl; +import org.labkey.vfs.FileLike; +import org.labkey.vfs.FileSystemLike; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.nio.file.Path; +import java.sql.BatchUpdateException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * User: jeckels + * Date: Sep 8, 2008 + */ +public class MoveRunsTask extends PipelineJob.Task +{ + public MoveRunsTask(MoveRunsTaskFactory factory, PipelineJob job) + { + super(factory, job); + } + + @Override + @NotNull + public RecordedActionSet run() + { + MoveRunsPipelineJob job = (MoveRunsPipelineJob)getJob(); + + try + { + for (long runId : job.getRunIds()) + { + ExpRunImpl experimentRun = ExperimentServiceImpl.get().getExpRun(runId); + if (experimentRun != null) + { + moveRun(job, experimentRun); + } + else + { + job.info("Run with id " + runId + " is no longer available in the system"); + } + } + } + catch (Throwable t) + { + job.error("Exception during move", t); + job.error("Move FAILED"); + if (t instanceof BatchUpdateException) + { + job.error("Underlying exception", ((BatchUpdateException)t).getNextException()); + } + } + return new RecordedActionSet(); + } + + private void moveRun(MoveRunsPipelineJob job, ExpRunImpl experimentRun) throws ExperimentException, IOException + { + XarExporter exporter = new XarExporter(LSIDRelativizer.PARTIAL_FOLDER_RELATIVE, DataURLRelativizer.ORIGINAL_FILE_LOCATION.createURLRewriter(), getJob().getUser(), getJob().getContainer()); + exporter.addExperimentRun(experimentRun); + + ByteArrayOutputStream bOut = new ByteArrayOutputStream(); + exporter.dumpXML(bOut); + + try (DbScope.Transaction transaction = ExperimentService.get().getSchema().getScope().ensureTransaction()) + { + Map dataFiles = new HashMap<>(); + + for (ExpData oldData : experimentRun.getAllDataUsedByRun()) + { + ExperimentDataHandler handler = oldData.findDataHandler(); + handler.beforeMove(oldData, experimentRun.getContainer(), job.getUser()); + } + + for (ExpData data : ExperimentService.get().deleteExperimentRunForMove(experimentRun.getRowId(), job.getUser())) + { + if (data.getDataFileUrl() != null) + { + dataFiles.put(data.getDataFileUrl(), data.getRowId()); + } + } + + MoveRunsXarSource xarSource = new MoveRunsXarSource(bOut.toString(), experimentRun.getFilePathRootPath(), job); + XarReader reader = new XarReader(xarSource, job); + reader.parseAndLoad(false, null); + + List runLSIDs = reader.getProcessedRunsLSIDs(); + assert runLSIDs.size() == 1 : "Expected a single run to be loaded"; + + for (String dataURL : dataFiles.keySet()) + { + ExpData newData = ExperimentService.get().getExpDataByURL(xarSource.getCanonicalDataFileURL(dataURL), job.getSourceContainer()); + if (newData != null) + { + ExperimentDataHandler handler = newData.findDataHandler(); + handler.runMoved(newData, experimentRun.getContainer(), job.getContainer(), experimentRun.getLSID(), runLSIDs.get(0), job.getUser(), dataFiles.get(dataURL)); + } + } + transaction.commit(); + } + } + + public static class MoveRunsXarSource extends XarSource + { + private static final Logger _log = LogManager.getLogger(MoveRunsXarSource.class); + + private final String _xml; + private File _logFile; + private File _logFileDir; + + private final String _uploadTime; + + private String _experimentName; + private final String _root; + private final Container _sourceContainer; + + public MoveRunsXarSource(String xml, Path root, MoveRunsPipelineJob job) throws ExperimentException + { + super(job); + _xml = xml; + if (null == root) + throw new ExperimentException("File path root is null"); + + _root = FileUtil.getAbsolutePath(job.getSourceContainer(), root); + if (null == _root) + throw new ExperimentException("Unable to create absolute file path for root"); + + _sourceContainer = job.getSourceContainer(); + + int retry = 0; + while (_logFileDir == null) + { + try + { + _logFileDir = FileUtil.createTempFile("xarupload", ""); + } + catch (IOException e) + { + if (++retry > 10) + { + throw new ExperimentException("Unable to create a log file", e); + } + _log.warn("Failed to create log file, retrying...", e); + } + } + + _logFileDir.delete(); + try + { + FileUtil.mkdir(_logFileDir); + } + catch (IOException e) + { + throw new ExperimentException("Unable to create log file directory", e); + } + _logFileDir.deleteOnExit(); + _logFile = FileUtil.appendName(_logFileDir, "upload.xar.log"); + _logFile.deleteOnExit(); + _uploadTime = DateUtil.formatDateTime(job.getContainer()); + } + + @Override + public ExperimentArchiveDocument getDocument() throws XmlException + { + ExperimentArchiveDocument doc = ExperimentArchiveDocument.Factory.parse(_xml, XmlBeansUtil.getDefaultParseOptions()); + ExperimentArchiveType ea = doc.getExperimentArchive(); + if (ea != null) + { + if (ea.getExperimentArray() != null && ea.getExperimentArray().length > 0) + { + _experimentName = ea.getExperimentArray()[0].getName(); + } + } + return doc; + } + + @Override + public Path getRootPath() + { + return FileUtil.stringToPath(_sourceContainer, _root); + } + + @Override + public Path getJobRootPath() + { + var pipelineJob = getXarContext().getJob(); + return pipelineJob != null + ? pipelineJob.getPipeRoot().getRootFileLike().toNioPathForRead() + : super.getJobRootPath(); + } + + @Override + public boolean shouldIgnoreDataFiles() + { + return true; + } + + @Override + public String canonicalizeDataFileURL(String dataFileURL) + { + URI uri = FileUtil.createUri(dataFileURL); + Path dataFilePath; + if (!uri.isAbsolute()) + { + dataFilePath = getRootPath().resolve(dataFileURL); + } + else + { + dataFilePath = FileUtil.getPath(_sourceContainer, uri); + } + return FileUtil.getAbsoluteCaseSensitivePathString(_sourceContainer, dataFilePath.toUri()); + } + + @Override + public FileLike getLogFilePath() + { + return FileSystemLike.wrapFile(_logFile); + } + + public String toString() + { + String result = "Uploaded file: " + _uploadTime; + if (_experimentName != null) + { + result += _experimentName; + } + return result; + } + + public void cleanup() + { + if (_logFile != null) + { + _logFile.delete(); + _logFile = null; + } + if (_logFileDir != null) + { + _logFileDir.delete(); + _logFileDir = null; + } + } + } +} diff --git a/experiment/src/org/labkey/experiment/pipeline/XarGeneratorSource.java b/experiment/src/org/labkey/experiment/pipeline/XarGeneratorSource.java index dc7b4a4be83..f4af2612d61 100644 --- a/experiment/src/org/labkey/experiment/pipeline/XarGeneratorSource.java +++ b/experiment/src/org/labkey/experiment/pipeline/XarGeneratorSource.java @@ -1,47 +1,48 @@ -/* - * Copyright (c) 2008-2018 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.experiment.pipeline; - -import org.fhcrc.cpas.exp.xml.ExperimentArchiveDocument; -import org.labkey.api.exp.AbstractFileXarSource; -import org.labkey.api.pipeline.PipelineJob; - -import java.nio.file.Path; - -/* -* User: jeckels -* Date: Jul 30, 2008 -*/ -public class XarGeneratorSource extends AbstractFileXarSource -{ - public XarGeneratorSource(PipelineJob job, Path xarFile) - { - super(job); - _xmlFile = xarFile; - } - - @Override - public Path getLogFilePath() - { - throw new UnsupportedOperationException(); - } - - @Override - public ExperimentArchiveDocument getDocument() - { - throw new UnsupportedOperationException(); - } -} +/* + * Copyright (c) 2008-2018 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.experiment.pipeline; + +import org.fhcrc.cpas.exp.xml.ExperimentArchiveDocument; +import org.labkey.api.exp.AbstractFileXarSource; +import org.labkey.api.pipeline.PipelineJob; +import org.labkey.vfs.FileLike; + +import java.nio.file.Path; + +/* +* User: jeckels +* Date: Jul 30, 2008 +*/ +public class XarGeneratorSource extends AbstractFileXarSource +{ + public XarGeneratorSource(PipelineJob job, FileLike xarFile) + { + super(job); + _xmlFile = xarFile; + } + + @Override + public FileLike getLogFilePath() + { + throw new UnsupportedOperationException(); + } + + @Override + public ExperimentArchiveDocument getDocument() + { + throw new UnsupportedOperationException(); + } +} diff --git a/experiment/src/org/labkey/experiment/pipeline/XarGeneratorTask.java b/experiment/src/org/labkey/experiment/pipeline/XarGeneratorTask.java index f86c683d814..615540a7980 100644 --- a/experiment/src/org/labkey/experiment/pipeline/XarGeneratorTask.java +++ b/experiment/src/org/labkey/experiment/pipeline/XarGeneratorTask.java @@ -1,254 +1,253 @@ -/* - * Copyright (c) 2008-2018 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.experiment.pipeline; - -import org.jetbrains.annotations.NotNull; -import org.labkey.api.data.RuntimeSQLException; -import org.labkey.api.exp.ExperimentException; -import org.labkey.api.exp.FileXarSource; -import org.labkey.api.exp.PropertyDescriptor; -import org.labkey.api.exp.XarSource; -import org.labkey.api.exp.api.ExpRun; -import org.labkey.api.exp.api.ExperimentService; -import org.labkey.api.exp.pipeline.XarGeneratorFactorySettings; -import org.labkey.api.exp.pipeline.XarGeneratorId; -import org.labkey.api.pipeline.AbstractTaskFactory; -import org.labkey.api.pipeline.PipelineJob; -import org.labkey.api.pipeline.PipelineJobException; -import org.labkey.api.pipeline.PipelineService; -import org.labkey.api.pipeline.PipelineStatusFile; -import org.labkey.api.pipeline.PropertiesJobSupport; -import org.labkey.api.pipeline.RecordedActionSet; -import org.labkey.api.pipeline.file.FileAnalysisJobSupport; -import org.labkey.api.query.ValidationException; -import org.labkey.api.util.FileType; -import org.labkey.api.util.NetworkDrive; -import org.labkey.experiment.DataURLRelativizer; -import org.labkey.api.exp.xar.LSIDRelativizer; -import org.labkey.experiment.XarExporter; - -import java.io.BufferedOutputStream; -import java.io.IOException; -import java.io.OutputStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardCopyOption; -import java.nio.file.StandardOpenOption; -import java.util.Collections; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; - -/** - * Creates an experiment run to represent the work that the task's job has done so far. - * User: jeckels - * Date: Jul 25, 2008 -*/ -public class XarGeneratorTask extends PipelineJob.Task implements XarWriter -{ - public static class Factory extends AbstractTaskFactory - { - private FileType _outputType = XarGeneratorId.FT_PIPE_XAR_XML; - private boolean _loadFiles = true; - private String _statusName = "IMPORT RESULTS"; - - public Factory() - { - super(XarGeneratorId.class); - } - - @Override - public PipelineJob.Task createTask(PipelineJob job) - { - return new XarGeneratorTask(this, job); - } - - @Override - public List getInputTypes() - { - return Collections.emptyList(); - } - - public FileType getOutputType() - { - return _outputType; - } - - public boolean isLoadFiles() - { - return _loadFiles; - } - - @Override - public String getStatusName() - { - return _statusName; - } - - @Override - public List getProtocolActionNames() - { - return Collections.emptyList(); - } - - protected Path getXarFile(PipelineJob job) - { - FileAnalysisJobSupport jobSupport = job.getJobSupport(FileAnalysisJobSupport.class); - return getOutputType().newFile(jobSupport.getAnalysisDirectoryPath(), jobSupport.getBaseName()); - } - - @Override - public boolean isJobComplete(PipelineJob job) - { - // We can use an existing XAR file from disk if it's been generated, but we need to load it because - // there's no simple way to tell if it's already been imported or not, or if it's been subsequently deleted - return false; - } - - @Override - public void configure(XarGeneratorFactorySettings settings) - { - super.configure(settings); - - if (settings.getOutputExt() != null) - _outputType = new FileType(settings.getOutputExt()); - _loadFiles = settings.isLoadFiles(); - if (!_loadFiles) - _statusName = "GENERATING EXPERIMENT"; - } - } - - public XarGeneratorTask(Factory factory, PipelineJob job) - { - super(factory, job); - } - - /** - * The basic steps are: - * 1. Start a transaction. - * 2. Create a protocol and a run and insert them into the database, not loading the data files. - * 3. Export the run and protocol to a temporary XAR on the file system. - * 4. Commit the transaction. - * 5. Import the temporary XAR (not reloading the runs it references), which causes its referenced data files to load. - * 6. Rename the temporary XAR to its permanent name. - * - * This allows us to quickly tell if the task is already complete by checking for the XAR file. If it exists, we - * can simply reimport it. If the temporary file exists, we can skip directly to step 5 above. - */ - @Override - @NotNull - public RecordedActionSet run() throws PipelineJobException - { - try - { - // Keep track of all of the runs that have been created by this task - Set importedRuns = new HashSet<>(); - if (_factory.isLoadFiles()) - { - Path permanentXAR = _factory.getXarFile(getJob()); - if (NetworkDrive.exists(permanentXAR)) - { - // Be sure that it's been imported (and not already deleted from the database) - importedRuns.addAll(ExperimentService.get().importXar(new FileXarSource(permanentXAR, getJob()), getJob(), false)); - } - else - { - if (!NetworkDrive.exists(getLoadingXarFile())) - { - XarSource source = new XarGeneratorSource(getJob(), _factory.getXarFile(getJob())); - importedRuns.add(ExpGeneratorHelper.insertRun(getJob(), source, this)); - } - - // Load the data files for this run - importedRuns.addAll(ExperimentService.get().importXar(new FileXarSource(getLoadingXarFile(), getJob()), getJob(), false)); - - Files.move(getLoadingXarFile(), permanentXAR); - } - } - else - { - importedRuns.add(ExpGeneratorHelper.insertRun(getJob(), null, null)); - } - - // save any job-level custom properties from the run - if (getJob() instanceof PropertiesJobSupport) - { - PropertiesJobSupport jobSupport = getJob().getJobSupport(PropertiesJobSupport.class); - for (Map.Entry prop : jobSupport.getProps().entrySet()) - { - for (ExpRun importedRun : importedRuns) - importedRun.setProperty(getJob().getUser(), prop.getKey(), prop.getValue()); - } - } - - // Check if we've been cancelled. If so, delete any newly created runs from the database - PipelineStatusFile statusFile = PipelineService.get().getStatusFile(getJob().getLogFile()); - if (statusFile != null && (PipelineJob.TaskStatus.cancelled.matches(statusFile.getStatus()) || PipelineJob.TaskStatus.cancelling.matches(statusFile.getStatus()))) - { - for (ExpRun importedRun : importedRuns) - { - getJob().info("Deleting run " + importedRun.getName() + " due to cancellation request"); - importedRun.delete(getJob().getUser()); - } - } - } - catch (RuntimeSQLException | ValidationException e) - { - throw new PipelineJobException("Failed to save experiment run in the database", e); - } - catch (ExperimentException e) - { - throw new PipelineJobException("Failed to import data files", e); - } - catch (IOException e) - { - throw new PipelineJobException("Unable to read data files", e); - } - return new RecordedActionSet(); - } - - // XarWriter interface - @Override - public void writeToDisk(ExpRun run) throws PipelineJobException - { - Path f = getLoadingXarFile(); - Path tempFile = f.getParent().resolve(f.getFileName().toString() + ".temp"); - - try - { - XarExporter exporter = new XarExporter(LSIDRelativizer.FOLDER_RELATIVE, DataURLRelativizer.RUN_RELATIVE_LOCATION.createURLRewriter(), getJob().getUser(), getJob().getContainer()); - exporter.addExperimentRun(run); - - try (OutputStream fOut = new BufferedOutputStream(Files.newOutputStream(tempFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE))) - { - exporter.dumpXML(fOut); - fOut.close(); - Files.move(tempFile, f, StandardCopyOption.ATOMIC_MOVE); - } - } - catch (ExperimentException | IOException e) - { - throw new PipelineJobException("Failed to write XAR to disk", e); - } - } - - private Path getLoadingXarFile() - { - Path xarPath = _factory.getXarFile(getJob()); - return xarPath.resolve(xarPath + ".loading"); - } -} +/* + * Copyright (c) 2008-2018 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.experiment.pipeline; + +import org.jetbrains.annotations.NotNull; +import org.labkey.api.data.RuntimeSQLException; +import org.labkey.api.exp.ExperimentException; +import org.labkey.api.exp.FileXarSource; +import org.labkey.api.exp.PropertyDescriptor; +import org.labkey.api.exp.XarSource; +import org.labkey.api.exp.api.ExpRun; +import org.labkey.api.exp.api.ExperimentService; +import org.labkey.api.exp.pipeline.XarGeneratorFactorySettings; +import org.labkey.api.exp.pipeline.XarGeneratorId; +import org.labkey.api.pipeline.AbstractTaskFactory; +import org.labkey.api.pipeline.PipelineJob; +import org.labkey.api.pipeline.PipelineJobException; +import org.labkey.api.pipeline.PipelineService; +import org.labkey.api.pipeline.PipelineStatusFile; +import org.labkey.api.pipeline.PropertiesJobSupport; +import org.labkey.api.pipeline.RecordedActionSet; +import org.labkey.api.pipeline.file.FileAnalysisJobSupport; +import org.labkey.api.query.ValidationException; +import org.labkey.api.util.FileType; +import org.labkey.api.util.NetworkDrive; +import org.labkey.experiment.DataURLRelativizer; +import org.labkey.api.exp.xar.LSIDRelativizer; +import org.labkey.experiment.XarExporter; +import org.labkey.vfs.FileLike; + +import java.io.BufferedOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Creates an experiment run to represent the work that the task's job has done so far. + * User: jeckels + * Date: Jul 25, 2008 +*/ +public class XarGeneratorTask extends PipelineJob.Task implements XarWriter +{ + public static class Factory extends AbstractTaskFactory + { + private FileType _outputType = XarGeneratorId.FT_PIPE_XAR_XML; + private boolean _loadFiles = true; + private String _statusName = "IMPORT RESULTS"; + + public Factory() + { + super(XarGeneratorId.class); + } + + @Override + public PipelineJob.Task createTask(PipelineJob job) + { + return new XarGeneratorTask(this, job); + } + + @Override + public List getInputTypes() + { + return Collections.emptyList(); + } + + public FileType getOutputType() + { + return _outputType; + } + + public boolean isLoadFiles() + { + return _loadFiles; + } + + @Override + public String getStatusName() + { + return _statusName; + } + + @Override + public List getProtocolActionNames() + { + return Collections.emptyList(); + } + + protected FileLike getXarFile(PipelineJob job) + { + FileAnalysisJobSupport jobSupport = job.getJobSupport(FileAnalysisJobSupport.class); + return getOutputType().newFile(jobSupport.getAnalysisDirectoryFileLike(), jobSupport.getBaseName()); + } + + @Override + public boolean isJobComplete(PipelineJob job) + { + // We can use an existing XAR file from disk if it's been generated, but we need to load it because + // there's no simple way to tell if it's already been imported or not, or if it's been subsequently deleted + return false; + } + + @Override + public void configure(XarGeneratorFactorySettings settings) + { + super.configure(settings); + + if (settings.getOutputExt() != null) + _outputType = new FileType(settings.getOutputExt()); + _loadFiles = settings.isLoadFiles(); + if (!_loadFiles) + _statusName = "GENERATING EXPERIMENT"; + } + } + + public XarGeneratorTask(Factory factory, PipelineJob job) + { + super(factory, job); + } + + /** + * The basic steps are: + * 1. Start a transaction. + * 2. Create a protocol and a run and insert them into the database, not loading the data files. + * 3. Export the run and protocol to a temporary XAR on the file system. + * 4. Commit the transaction. + * 5. Import the temporary XAR (not reloading the runs it references), which causes its referenced data files to load. + * 6. Rename the temporary XAR to its permanent name. + * + * This allows us to quickly tell if the task is already complete by checking for the XAR file. If it exists, we + * can simply reimport it. If the temporary file exists, we can skip directly to step 5 above. + */ + @Override + @NotNull + public RecordedActionSet run() throws PipelineJobException + { + try + { + // Keep track of all of the runs that have been created by this task + Set importedRuns = new HashSet<>(); + if (_factory.isLoadFiles()) + { + FileLike permanentXAR = _factory.getXarFile(getJob()); + if (NetworkDrive.exists(permanentXAR)) + { + // Be sure that it's been imported (and not already deleted from the database) + importedRuns.addAll(ExperimentService.get().importXar(new FileXarSource(permanentXAR, getJob()), getJob(), false)); + } + else + { + if (!NetworkDrive.exists(getLoadingXarFile())) + { + XarSource source = new XarGeneratorSource(getJob(), _factory.getXarFile(getJob())); + importedRuns.add(ExpGeneratorHelper.insertRun(getJob(), source, this)); + } + + // Load the data files for this run + importedRuns.addAll(ExperimentService.get().importXar(new FileXarSource(getLoadingXarFile(), getJob()), getJob(), false)); + + Files.move(getLoadingXarFile().toNioPathForWrite(), permanentXAR.toNioPathForWrite()); + } + } + else + { + importedRuns.add(ExpGeneratorHelper.insertRun(getJob(), null, null)); + } + + // save any job-level custom properties from the run + if (getJob() instanceof PropertiesJobSupport) + { + PropertiesJobSupport jobSupport = getJob().getJobSupport(PropertiesJobSupport.class); + for (Map.Entry prop : jobSupport.getProps().entrySet()) + { + for (ExpRun importedRun : importedRuns) + importedRun.setProperty(getJob().getUser(), prop.getKey(), prop.getValue()); + } + } + + // Check if we've been cancelled. If so, delete any newly created runs from the database + PipelineStatusFile statusFile = PipelineService.get().getStatusFile(getJob().getLogFile()); + if (statusFile != null && (PipelineJob.TaskStatus.cancelled.matches(statusFile.getStatus()) || PipelineJob.TaskStatus.cancelling.matches(statusFile.getStatus()))) + { + for (ExpRun importedRun : importedRuns) + { + getJob().info("Deleting run " + importedRun.getName() + " due to cancellation request"); + importedRun.delete(getJob().getUser()); + } + } + } + catch (RuntimeSQLException | ValidationException e) + { + throw new PipelineJobException("Failed to save experiment run in the database", e); + } + catch (ExperimentException e) + { + throw new PipelineJobException("Failed to import data files", e); + } + catch (IOException e) + { + throw new PipelineJobException("Unable to read data files", e); + } + return new RecordedActionSet(); + } + + // XarWriter interface + @Override + public void writeToDisk(ExpRun run) throws PipelineJobException + { + FileLike f = getLoadingXarFile(); + FileLike tempFile = f.getParent().resolveChild(f.getName() + ".temp"); + + try + { + XarExporter exporter = new XarExporter(LSIDRelativizer.FOLDER_RELATIVE, DataURLRelativizer.RUN_RELATIVE_LOCATION.createURLRewriter(), getJob().getUser(), getJob().getContainer()); + exporter.addExperimentRun(run); + + try (OutputStream fOut = new BufferedOutputStream(tempFile.openOutputStream())) + { + exporter.dumpXML(fOut); + fOut.close(); + Files.move(tempFile.toNioPathForWrite(), f.toNioPathForWrite(), StandardCopyOption.ATOMIC_MOVE); + } + } + catch (ExperimentException | IOException e) + { + throw new PipelineJobException("Failed to write XAR to disk", e); + } + } + + private FileLike getLoadingXarFile() + { + FileLike xarPath = _factory.getXarFile(getJob()); + return xarPath.resolveChild(xarPath + ".loading"); + } +} diff --git a/experiment/src/org/labkey/experiment/samples/AbstractExpFolderImporter.java b/experiment/src/org/labkey/experiment/samples/AbstractExpFolderImporter.java index 750b80d1628..433d240354a 100644 --- a/experiment/src/org/labkey/experiment/samples/AbstractExpFolderImporter.java +++ b/experiment/src/org/labkey/experiment/samples/AbstractExpFolderImporter.java @@ -46,10 +46,11 @@ import org.labkey.experiment.api.SampleTypeServiceImpl; import org.labkey.experiment.xar.FolderXarImporterFactory; import org.labkey.experiment.xar.XarImportContext; +import org.labkey.vfs.FileLike; +import org.labkey.vfs.FileSystemLike; import java.io.IOException; import java.io.InputStream; -import java.nio.file.Files; import java.nio.file.Path; import java.sql.SQLException; import java.util.Collections; @@ -90,9 +91,9 @@ public void process(@Nullable PipelineJob job, FolderImportContext ctx, VirtualF if (xarDir != null) { // #44384 Generate a relative Path object for the folder's VirtualFile - Path xarDirPath = Path.of(xarDir.getLocation()); - Path typesXarFile = null; - Path runsXarFile = null; + FileLike xarDirPath = FileSystemLike.wrapFile(Path.of(xarDir.getLocation())); + FileLike typesXarFile = null; + FileLike runsXarFile = null; Logger log = ctx.getLogger(); if (null != job) @@ -104,14 +105,14 @@ public void process(@Nullable PipelineJob job, FolderImportContext ctx, VirtualF if (isXarTypesFile(file)) { if (typesXarFile == null) - typesXarFile = xarDirPath.resolve(file); + typesXarFile = xarDirPath.resolveChild(file); else log.error("More than one types XAR file found in the sample type directory: ", file); } else if (file.equalsIgnoreCase(XAR_RUNS_NAME) || file.equalsIgnoreCase(XAR_RUNS_XML_NAME)) { if (runsXarFile == null) - runsXarFile = xarDirPath.resolve(file); + runsXarFile = xarDirPath.resolveChild(file); else log.error("More than one runs XAR file found in the sample type directory: ", file); } @@ -126,8 +127,8 @@ else if (file.equalsIgnoreCase(XAR_RUNS_NAME) || file.equalsIgnoreCase(XAR_RUNS_ { if (typesXarFile != null) { - Path logFile = null; - if (Files.exists(typesXarFile)) + FileLike logFile = null; + if (typesXarFile.exists()) logFile = CompressedInputStreamXarSource.getLogFileFor(typesXarFile); XarReader typesReader = getXarReader(job, ctx, root, typesXarFile); XarContext xarContext = typesReader.getXarSource().getXarContext(); @@ -142,10 +143,10 @@ else if (file.equalsIgnoreCase(XAR_RUNS_NAME) || file.equalsIgnoreCase(XAR_RUNS_ if (runsXarFile != null) { XarSource runsXarSource; - if (runsXarFile.getFileName().toString().toLowerCase().endsWith(".xar.xml")) + if (runsXarFile.getName().toLowerCase().endsWith(".xar.xml")) runsXarSource = new FileXarSource(runsXarFile, job, ctx.getContainer(), ctx.getXarJobIdContext()); else - runsXarSource = new CompressedInputStreamXarSource(xarDir.getInputStream(runsXarFile.getFileName().toString()), runsXarFile, logFile, job, ctx.getUser(), ctx.getContainer(), ctx.getXarJobIdContext()); + runsXarSource = new CompressedInputStreamXarSource(xarDir.getInputStream(runsXarFile.getName()), runsXarFile, logFile, job, ctx.getUser(), ctx.getContainer(), ctx.getXarJobIdContext()); try { runsXarSource.init(); @@ -155,7 +156,7 @@ else if (file.equalsIgnoreCase(XAR_RUNS_NAME) || file.equalsIgnoreCase(XAR_RUNS_ log.error("Failed to initialize runs XAR source", e); throw(e); } - log.info("Importing the runs XAR file: " + runsXarFile.getFileName().toString()); + log.info("Importing the runs XAR file: " + runsXarFile.getName()); XarReader runsReader = new FolderXarImporterFactory.FolderExportXarReader(runsXarSource, job); runsReader.setStrictValidateExistingSampleType(xarCtx.isStrictValidateExistingSampleType()); runsReader.parseAndLoad(false, ctx.getAuditBehaviorType()); @@ -188,14 +189,14 @@ else if (file.equalsIgnoreCase(XAR_RUNS_NAME) || file.equalsIgnoreCase(XAR_RUNS_ } } - protected XarReader getXarReader(@Nullable PipelineJob job, FolderImportContext ctx, VirtualFile root, Path typesXarFile) throws IOException, ExperimentException + protected XarReader getXarReader(@Nullable PipelineJob job, FolderImportContext ctx, VirtualFile root, FileLike typesXarFile) throws IOException, ExperimentException { VirtualFile xarDir = getXarDir(root); Logger log = ctx.getLogger(); - Path logFile = null; + FileLike logFile = null; // we don't need the log file in cases where the xarFile is a virtual file and not in the file system - if (Files.exists(typesXarFile)) + if (typesXarFile.exists()) logFile = CompressedInputStreamXarSource.getLogFileFor(typesXarFile); if (job == null) @@ -206,10 +207,10 @@ protected XarReader getXarReader(@Nullable PipelineJob job, FolderImportContext XarSource typesXarSource; - if (typesXarFile.getFileName().toString().toLowerCase().endsWith(".xar.xml")) + if (typesXarFile.getName().toLowerCase().endsWith(".xar.xml")) typesXarSource = new FileXarSource(typesXarFile, job, ctx.getContainer(), ctx.getXarJobIdContext()); else - typesXarSource = new CompressedInputStreamXarSource(xarDir.getInputStream(typesXarFile.getFileName().toString()), typesXarFile, logFile, job, ctx.getUser(), ctx.getContainer(), ctx.getXarJobIdContext()); + typesXarSource = new CompressedInputStreamXarSource(xarDir.getInputStream(typesXarFile.getName()), typesXarFile, logFile, job, ctx.getUser(), ctx.getContainer(), ctx.getXarJobIdContext()); try { typesXarSource.init(); diff --git a/experiment/src/org/labkey/experiment/samples/SampleStatusFolderImporter.java b/experiment/src/org/labkey/experiment/samples/SampleStatusFolderImporter.java index 7ade7bb4675..3b28543aebc 100644 --- a/experiment/src/org/labkey/experiment/samples/SampleStatusFolderImporter.java +++ b/experiment/src/org/labkey/experiment/samples/SampleStatusFolderImporter.java @@ -15,6 +15,8 @@ import org.labkey.api.util.FileUtil; import org.labkey.api.writer.VirtualFile; import org.labkey.experiment.XarReader; +import org.labkey.vfs.FileLike; +import org.labkey.vfs.FileSystemLike; import java.nio.file.Path; import java.util.HashMap; @@ -52,8 +54,8 @@ public void process(@Nullable PipelineJob job, FolderImportContext ctx, VirtualF if (xarDir != null) { // #44384 Generate a relative Path object for the folder's VirtualFile - Path xarDirPath = Path.of(xarDir.getLocation()); - Path typesXarFile = null; + FileLike xarDirPath = FileSystemLike.wrapFile(Path.of(xarDir.getLocation())); + FileLike typesXarFile = null; Map sampleStatusDataFiles = new HashMap<>(); Logger log = ctx.getLogger(); @@ -66,7 +68,7 @@ public void process(@Nullable PipelineJob job, FolderImportContext ctx, VirtualF if (file.equalsIgnoreCase(XAR_TYPES_NAME) || file.equalsIgnoreCase(XAR_TYPES_XML_NAME)) { if (typesXarFile == null) - typesXarFile = xarDirPath.resolve(file); + typesXarFile = xarDirPath.resolveChild(file); else log.error("More than one types XAR file found in the sample type directory: ", file); } diff --git a/experiment/src/org/labkey/experiment/xar/CompressedXarSource.java b/experiment/src/org/labkey/experiment/xar/CompressedXarSource.java index 9463c79c15d..e38db3f8585 100644 --- a/experiment/src/org/labkey/experiment/xar/CompressedXarSource.java +++ b/experiment/src/org/labkey/experiment/xar/CompressedXarSource.java @@ -24,6 +24,7 @@ import org.labkey.api.pipeline.PipelineJob; import org.labkey.api.util.FileUtil; import org.labkey.api.writer.ZipUtil; +import org.labkey.vfs.FileLike; import java.io.IOException; import java.nio.file.Files; @@ -38,9 +39,9 @@ */ public class CompressedXarSource extends AbstractFileXarSource { - private final Path _xarFile; + private final FileLike _xarFile; - public CompressedXarSource(Path xarFile, PipelineJob job) + public CompressedXarSource(FileLike xarFile, PipelineJob job) { super(job); _xarFile = xarFile; @@ -53,13 +54,13 @@ public CompressedXarSource(Path xarFile, PipelineJob job) * This may not be the same as the the Container returned by job.getContainer(). * @param substitutions Additional context substitutions */ - public CompressedXarSource(Path xarFile, PipelineJob job, Container targetContainer, @Nullable Map substitutions) + public CompressedXarSource(FileLike xarFile, PipelineJob job, Container targetContainer, @Nullable Map substitutions) { super(job.getDescription(), targetContainer, job.getUser(), job, substitutions); _xarFile = xarFile; } - public CompressedXarSource(Path xarFile, PipelineJob job, Container targetContainer) + public CompressedXarSource(FileLike xarFile, PipelineJob job, Container targetContainer) { this(xarFile, job, targetContainer, null); } @@ -67,19 +68,19 @@ public CompressedXarSource(Path xarFile, PipelineJob job, Container targetContai @Override public void init() throws ExperimentException, IOException { - Path outputDir = _xarFile.resolve(_xarFile + ".exploded"); + FileLike outputDir = _xarFile.resolveChild(_xarFile + ".exploded"); FileUtil.deleteDir(outputDir); - if (Files.exists(outputDir)) + if (outputDir.exists()) { throw new ExperimentException("Failed to clean up old directory " + outputDir); } FileUtil.createDirectories(outputDir); - if (!Files.isDirectory(outputDir)) + if (!outputDir.isDirectory()) { throw new ExperimentException("Failed to create directory " + outputDir); } - List xarContents; + List xarContents; try { xarContents = ZipUtil.unzipToDirectory(_xarFile, outputDir); @@ -89,7 +90,7 @@ public void init() throws ExperimentException, IOException throw new ExperimentException("Failed to extract XAR file: " + _xarFile, e); } - List xarFiles = xarContents.stream().filter(f -> f.getFileName().toString().toLowerCase().endsWith(".xar.xml")).collect(Collectors.toList()); + List xarFiles = xarContents.stream().filter(f -> f.getName().toLowerCase().endsWith(".xar.xml")).toList(); if (xarFiles.isEmpty()) { @@ -106,7 +107,7 @@ else if (xarFiles.size() > 1) } @Override - public Path getLogFilePath() + public FileLike getLogFilePath() { try { diff --git a/experiment/src/org/labkey/experiment/xar/FolderXarImporterFactory.java b/experiment/src/org/labkey/experiment/xar/FolderXarImporterFactory.java index b430a42a436..86e804d7c0a 100644 --- a/experiment/src/org/labkey/experiment/xar/FolderXarImporterFactory.java +++ b/experiment/src/org/labkey/experiment/xar/FolderXarImporterFactory.java @@ -1,249 +1,249 @@ -/* - * Copyright (c) 2014-2018 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.experiment.xar; - -import org.labkey.api.admin.AbstractFolderImportFactory; -import org.labkey.api.admin.FolderArchiveDataTypes; -import org.labkey.api.admin.FolderImportContext; -import org.labkey.api.admin.FolderImporter; -import org.labkey.api.admin.ImportException; -import org.labkey.api.data.Container; -import org.labkey.api.exp.FileXarSource; -import org.labkey.api.exp.XarSource; -import org.labkey.api.pipeline.PipeRoot; -import org.labkey.api.pipeline.PipelineJob; -import org.labkey.api.pipeline.PipelineService; -import org.labkey.api.util.FileUtil; -import org.labkey.api.view.NotFoundException; -import org.labkey.api.view.ViewBackgroundInfo; -import org.labkey.api.writer.VirtualFile; -import org.labkey.experiment.XarReader; -import org.labkey.experiment.pipeline.ExperimentPipelineJob; - -import java.nio.file.Path; - -/** - * User: vsharma - * Date: 6/4/14 - * Time: 9:52 AM - */ -public class FolderXarImporterFactory extends AbstractFolderImportFactory -{ - @Override - public FolderImporter create() - { - return new FolderXarImporter(); - } - - @Override - public int getPriority() - { - return 70; - } - - public static class FolderXarImporter implements FolderImporter - { - @Override - public String getDataType() - { - return FolderArchiveDataTypes.EXPERIMENTS_AND_RUNS; - } - - @Override - public String getDescription() - { - return "xar"; - } - - @Override - public void process(PipelineJob job, FolderImportContext ctx, VirtualFile root) throws Exception - { - if (!isValidForImportArchive(ctx)) - { - ctx.getLogger().info("xar directory not found in folder " + ctx.getContainer().getPath()); - return; - } - - VirtualFile xarDir = ctx.getDir(FolderXarWriterFactory.XAR_DIRECTORY); - - if (job != null) - job.setStatus("IMPORT " + getDescription()); - ctx.getLogger().info("Loading " + getDescription()); - - PipeRoot pipeRoot = PipelineService.get().findPipelineRoot(ctx.getContainer()); - if (pipeRoot == null) - { - throw new NotFoundException("PipelineRoot not found for container " + ctx.getContainer().getPath()); - } - - final FolderExportXarSourceWrapper xarSourceWrapper = new FolderExportXarSourceWrapper(xarDir, ctx); - try - { - xarSourceWrapper.init(); - } - catch (Exception e) - { - ctx.getLogger().info("Failed to initialize xar source.", e); - throw e; - } - - Path xarFile = xarSourceWrapper.getXarFile(); - if (xarFile == null) - { - ctx.getLogger().error("Could not find a xar file in the xar directory."); - throw new NotFoundException("Could not find a xar file in the xar directory."); - } - - if (job == null) - { - // Create a new job, if we were not given one. This will happen if we are creating a new folder - // from a template folder. - ViewBackgroundInfo bgInfo = new ViewBackgroundInfo(ctx.getContainer(), ctx.getUser(), null); - - // This will create a new job in the folder. - // If subfolders are being imported, a job will be created in each subfolder that has a xar file. - // TODO: Is there a way to create a single job that will import all the files to - // their respective folders? - job = new ExperimentPipelineJob(bgInfo, xarFile, "Xar import", false, pipeRoot) - { - @Override - protected XarSource createXarSource(Path file) - { - // Assume this is a .xar or a .zip file - return xarSourceWrapper.getXarSource(this); - } - }; - PipelineService.get().queueJob(job); - } - else - { - XarSource xarSource = xarSourceWrapper.getXarSource(job); - try - { - xarSource.init(); - } - catch (Exception e) - { - ctx.getLogger().error("Failed to initialize XAR source", e); - throw(e); - } - - FolderExportXarReader reader = new FolderExportXarReader(xarSource, job); - XarImportContext xarCtx = ctx.getContext(XarImportContext.class); - if (xarCtx != null) - { - reader.setStrictValidateExistingSampleType(xarCtx.isStrictValidateExistingSampleType()); - } - reader.parseAndLoad(false, ctx.getAuditBehaviorType()); - } - - ctx.getLogger().info("Done importing " + getDescription()); - } - - @Override - public boolean isValidForImportArchive(FolderImportContext ctx) throws ImportException - { - return ctx.getDir(FolderXarWriterFactory.XAR_DIRECTORY) != null; - } - } - - private static class FolderExportXarSourceWrapper - { - private final VirtualFile _xarDir; - private final FolderImportContext _importContext; - - private Path _xarFile; - private XarSource _xarSource; - - public FolderExportXarSourceWrapper(VirtualFile xarDir, FolderImportContext ctx) - { - _xarDir = xarDir; - _importContext = ctx; - } - - public void init() - { - if (_xarDir == null) - { - throw new IllegalStateException("Xar directory is null"); - } - - for (String file: _xarDir.list()) - { - if (file.toLowerCase().endsWith(".xar") || file.toLowerCase().endsWith(".xar.xml")) - { - _xarFile = FileUtil.getPath(_importContext.getContainer(), FileUtil.createUri(_xarDir.getLocation())).resolve(file); - break; - } - } - } - - public Path getXarFile() - { - return _xarFile; - } - - public XarSource getXarSource(PipelineJob job) - { - if (_xarSource == null) - { - if (getXarFile().getFileName().toString().toLowerCase().endsWith(".xar.xml")) - { - _xarSource = new FileXarSource( - getXarFile(), - job, - // Initialize the XarSource with the container from the ImportContext instead of the job - // so that a XarContext with the correct folder gets created, and runs imported to subfolders - // get assigned to the subfolder instead of the parent container. - // If we were given a non-null job in FolderXarImporter.process(), job.getContainer() will - // return the parent container. - _importContext.getContainer(), - _importContext.getXarJobIdContext()); - } - else - { - _xarSource = new CompressedXarSource( - getXarFile(), - job, - // Initialize the XarSource with the container from the ImportContext instead of the job - // so that a XarContext with the correct folder gets created, and runs imported to subfolders - // get assigned to the subfolder instead of the parent container. - // If we were given a non-null job in FolderXarImporter.process(), job.getContainer() will - // return the parent container. - _importContext.getContainer(), - _importContext.getXarJobIdContext()); - } - } - return _xarSource; - } - } - - public static class FolderExportXarReader extends XarReader - { - public FolderExportXarReader(XarSource source, PipelineJob job) - { - super(source, job); - } - - @Override - protected Container getContainer() - { - // XarReader.getContainer() returns job.getContainer(). - // We want to return the container from the XarContext instead. - return _xarSource.getXarContext().getContainer(); - } - } -} +/* + * Copyright (c) 2014-2018 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.experiment.xar; + +import org.labkey.api.admin.AbstractFolderImportFactory; +import org.labkey.api.admin.FolderArchiveDataTypes; +import org.labkey.api.admin.FolderImportContext; +import org.labkey.api.admin.FolderImporter; +import org.labkey.api.admin.ImportException; +import org.labkey.api.data.Container; +import org.labkey.api.exp.FileXarSource; +import org.labkey.api.exp.XarSource; +import org.labkey.api.pipeline.PipeRoot; +import org.labkey.api.pipeline.PipelineJob; +import org.labkey.api.pipeline.PipelineService; +import org.labkey.api.util.FileUtil; +import org.labkey.api.view.NotFoundException; +import org.labkey.api.view.ViewBackgroundInfo; +import org.labkey.api.writer.VirtualFile; +import org.labkey.experiment.XarReader; +import org.labkey.experiment.pipeline.ExperimentPipelineJob; +import org.labkey.vfs.FileLike; +import org.labkey.vfs.FileSystemLike; + +/** + * User: vsharma + * Date: 6/4/14 + * Time: 9:52 AM + */ +public class FolderXarImporterFactory extends AbstractFolderImportFactory +{ + @Override + public FolderImporter create() + { + return new FolderXarImporter(); + } + + @Override + public int getPriority() + { + return 70; + } + + public static class FolderXarImporter implements FolderImporter + { + @Override + public String getDataType() + { + return FolderArchiveDataTypes.EXPERIMENTS_AND_RUNS; + } + + @Override + public String getDescription() + { + return "xar"; + } + + @Override + public void process(PipelineJob job, FolderImportContext ctx, VirtualFile root) throws Exception + { + if (!isValidForImportArchive(ctx)) + { + ctx.getLogger().info("xar directory not found in folder " + ctx.getContainer().getPath()); + return; + } + + VirtualFile xarDir = ctx.getDir(FolderXarWriterFactory.XAR_DIRECTORY); + + if (job != null) + job.setStatus("IMPORT " + getDescription()); + ctx.getLogger().info("Loading " + getDescription()); + + PipeRoot pipeRoot = PipelineService.get().findPipelineRoot(ctx.getContainer()); + if (pipeRoot == null) + { + throw new NotFoundException("PipelineRoot not found for container " + ctx.getContainer().getPath()); + } + + final FolderExportXarSourceWrapper xarSourceWrapper = new FolderExportXarSourceWrapper(xarDir, ctx); + try + { + xarSourceWrapper.init(); + } + catch (Exception e) + { + ctx.getLogger().info("Failed to initialize xar source.", e); + throw e; + } + + FileLike xarFile = xarSourceWrapper.getXarFile(); + if (xarFile == null) + { + ctx.getLogger().error("Could not find a xar file in the xar directory."); + throw new NotFoundException("Could not find a xar file in the xar directory."); + } + + if (job == null) + { + // Create a new job, if we were not given one. This will happen if we are creating a new folder + // from a template folder. + ViewBackgroundInfo bgInfo = new ViewBackgroundInfo(ctx.getContainer(), ctx.getUser(), null); + + // This will create a new job in the folder. + // If subfolders are being imported, a job will be created in each subfolder that has a xar file. + // TODO: Is there a way to create a single job that will import all the files to + // their respective folders? + job = new ExperimentPipelineJob(bgInfo, xarFile, "Xar import", false, pipeRoot) + { + @Override + protected XarSource createXarSource(FileLike file) + { + // Assume this is a .xar or a .zip file + return xarSourceWrapper.getXarSource(this); + } + }; + PipelineService.get().queueJob(job); + } + else + { + XarSource xarSource = xarSourceWrapper.getXarSource(job); + try + { + xarSource.init(); + } + catch (Exception e) + { + ctx.getLogger().error("Failed to initialize XAR source", e); + throw(e); + } + + FolderExportXarReader reader = new FolderExportXarReader(xarSource, job); + XarImportContext xarCtx = ctx.getContext(XarImportContext.class); + if (xarCtx != null) + { + reader.setStrictValidateExistingSampleType(xarCtx.isStrictValidateExistingSampleType()); + } + reader.parseAndLoad(false, ctx.getAuditBehaviorType()); + } + + ctx.getLogger().info("Done importing " + getDescription()); + } + + @Override + public boolean isValidForImportArchive(FolderImportContext ctx) throws ImportException + { + return ctx.getDir(FolderXarWriterFactory.XAR_DIRECTORY) != null; + } + } + + private static class FolderExportXarSourceWrapper + { + private final VirtualFile _xarDir; + private final FolderImportContext _importContext; + + private FileLike _xarFile; + private XarSource _xarSource; + + public FolderExportXarSourceWrapper(VirtualFile xarDir, FolderImportContext ctx) + { + _xarDir = xarDir; + _importContext = ctx; + } + + public void init() + { + if (_xarDir == null) + { + throw new IllegalStateException("Xar directory is null"); + } + + for (String file: _xarDir.list()) + { + if (file.toLowerCase().endsWith(".xar") || file.toLowerCase().endsWith(".xar.xml")) + { + _xarFile = FileSystemLike.wrapFile(FileUtil.getPath(_importContext.getContainer(), FileUtil.createUri(_xarDir.getLocation())).resolve(file)); + break; + } + } + } + + public FileLike getXarFile() + { + return _xarFile; + } + + public XarSource getXarSource(PipelineJob job) + { + if (_xarSource == null) + { + if (getXarFile().getName().toLowerCase().endsWith(".xar.xml")) + { + _xarSource = new FileXarSource( + getXarFile(), + job, + // Initialize the XarSource with the container from the ImportContext instead of the job + // so that a XarContext with the correct folder gets created, and runs imported to subfolders + // get assigned to the subfolder instead of the parent container. + // If we were given a non-null job in FolderXarImporter.process(), job.getContainer() will + // return the parent container. + _importContext.getContainer(), + _importContext.getXarJobIdContext()); + } + else + { + _xarSource = new CompressedXarSource( + getXarFile(), + job, + // Initialize the XarSource with the container from the ImportContext instead of the job + // so that a XarContext with the correct folder gets created, and runs imported to subfolders + // get assigned to the subfolder instead of the parent container. + // If we were given a non-null job in FolderXarImporter.process(), job.getContainer() will + // return the parent container. + _importContext.getContainer(), + _importContext.getXarJobIdContext()); + } + } + return _xarSource; + } + } + + public static class FolderExportXarReader extends XarReader + { + public FolderExportXarReader(XarSource source, PipelineJob job) + { + super(source, job); + } + + @Override + protected Container getContainer() + { + // XarReader.getContainer() returns job.getContainer(). + // We want to return the container from the XarContext instead. + return _xarSource.getXarContext().getContainer(); + } + } +} diff --git a/filecontent/src/org/labkey/filecontent/FileContentServiceImpl.java b/filecontent/src/org/labkey/filecontent/FileContentServiceImpl.java index da9fe34706a..1b3c35ee263 100644 --- a/filecontent/src/org/labkey/filecontent/FileContentServiceImpl.java +++ b/filecontent/src/org/labkey/filecontent/FileContentServiceImpl.java @@ -1,1960 +1,1968 @@ -/* - * Copyright (c) 2009-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.labkey.filecontent; - -import org.apache.commons.io.FilenameUtils; -import org.apache.commons.lang3.StringUtils; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.junit.After; -import org.junit.Assert; -import org.junit.Test; -import org.labkey.api.action.SpringActionController; -import org.labkey.api.admin.AdminUrls; -import org.labkey.api.attachments.AttachmentDirectory; -import org.labkey.api.cache.Cache; -import org.labkey.api.cache.CacheManager; -import org.labkey.api.cloud.CloudStoreService; -import org.labkey.api.collections.CaseInsensitiveHashMap; -import org.labkey.api.data.CompareType; -import org.labkey.api.data.Container; -import org.labkey.api.data.ContainerManager; -import org.labkey.api.data.ContainerManager.ContainerListener; -import org.labkey.api.data.ContainerType; -import org.labkey.api.data.CoreSchema; -import org.labkey.api.data.SQLFragment; -import org.labkey.api.data.SimpleFilter; -import org.labkey.api.data.TabContainerType; -import org.labkey.api.data.Table; -import org.labkey.api.data.TableInfo; -import org.labkey.api.data.TableSelector; -import org.labkey.api.data.WorkbookContainerType; -import org.labkey.api.exp.Lsid; -import org.labkey.api.exp.api.ExpData; -import org.labkey.api.exp.api.ExpProtocol; -import org.labkey.api.exp.api.ExpRun; -import org.labkey.api.exp.api.ExperimentService; -import org.labkey.api.exp.query.ExpDataTable; -import org.labkey.api.files.DirectoryPattern; -import org.labkey.api.files.FileContentService; -import org.labkey.api.files.FileListener; -import org.labkey.api.files.FileRoot; -import org.labkey.api.files.FilesAdminOptions; -import org.labkey.api.files.MissingRootDirectoryException; -import org.labkey.api.files.UnsetRootDirectoryException; -import org.labkey.api.files.view.FilesWebPart; -import org.labkey.api.module.Module; -import org.labkey.api.module.ModuleLoader; -import org.labkey.api.pipeline.PipeRoot; -import org.labkey.api.pipeline.PipelineService; -import org.labkey.api.pipeline.PipelineUrls; -import org.labkey.api.query.BatchValidationException; -import org.labkey.api.query.FieldKey; -import org.labkey.api.query.QueryUpdateService; -import org.labkey.api.security.User; -import org.labkey.api.security.permissions.AdminOperationsPermission; -import org.labkey.api.settings.AppProps; -import org.labkey.api.settings.RandomSiteSettingsPropertyHandler; -import org.labkey.api.settings.StartupPropertyEntry; -import org.labkey.api.settings.WriteableAppProps; -import org.labkey.api.test.TestWhen; -import org.labkey.api.util.ContainerUtil; -import org.labkey.api.util.DOM; -import org.labkey.api.util.FileUtil; -import org.labkey.api.util.GUID; -import org.labkey.api.util.HtmlString; -import org.labkey.api.util.NetworkDrive; -import org.labkey.api.util.PageFlowUtil; -import org.labkey.api.util.Path; -import org.labkey.api.util.TestContext; -import org.labkey.api.util.URIUtil; -import org.labkey.api.view.ActionURL; -import org.labkey.api.view.HttpView; -import org.labkey.api.view.ViewBackgroundInfo; -import org.labkey.api.view.ViewContext; -import org.labkey.api.view.template.WarningProvider; -import org.labkey.api.view.template.WarningService; -import org.labkey.api.view.template.Warnings; -import org.labkey.api.webdav.WebdavResource; -import org.labkey.api.webdav.WebdavService; - -import java.beans.PropertyChangeEvent; -import java.io.BufferedWriter; -import java.io.File; -import java.io.IOException; -import java.net.URI; -import java.net.URISyntaxException; -import java.nio.file.Files; -import java.nio.file.InvalidPathException; -import java.nio.file.StandardOpenOption; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.Date; -import java.util.HashMap; -import java.util.HashSet; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.regex.Pattern; -import java.util.stream.Stream; - -import static org.labkey.api.settings.AppProps.SCOPE_SITE_SETTINGS; -import static org.labkey.api.util.DOM.Attribute.href; -import static org.labkey.api.util.DOM.at; - -public class FileContentServiceImpl implements FileContentService, WarningProvider -{ - private static final Logger _log = LogManager.getLogger(FileContentServiceImpl.class); - private static final String UPLOAD_LOG = ".upload.log"; - private static final FileContentServiceImpl INSTANCE = new FileContentServiceImpl(); - - private final ContainerListener _containerListener = new FileContentServiceContainerListener(); - private final List _fileListeners = new CopyOnWriteArrayList<>(); - - private final List _ziploaderPattern = new CopyOnWriteArrayList<>(); - - private volatile boolean _fileRootSetViaStartupProperty = false; - private String _problematicFileRootMessage; - - enum FileAction - { - UPLOAD, - DELETE - } - - static FileContentServiceImpl getInstance() - { - return INSTANCE; - } - - private FileContentServiceImpl() - { - WarningService.get().register(this); - } - - @Override - @NotNull - public List getContainersForFilePath(java.nio.file.Path path) - { - // Ignore cloud files for now - if (FileUtil.hasCloudScheme(path)) - return Collections.emptyList(); - - // If the path is under the default root, do optimistic simple match for containers under the default root - File defaultRoot = getSiteDefaultRoot(); - java.nio.file.Path defaultRootPath = defaultRoot.toPath(); - if (path.startsWith(defaultRootPath)) - { - java.nio.file.Path rel = defaultRootPath.relativize(path); - if (rel.getNameCount() > 0) - { - Container root = ContainerManager.getRoot(); - Container next = root; - while (rel.getNameCount() > 0) - { - // check if there exists a child container that matches the next path segment - java.nio.file.Path top = rel.subpath(0, 1); - assert top != null; - Container child = next.getChild(top.getFileName().toString()); - if (child == null) - break; - - next = child; - - if(rel.getNameCount() > 1) - { - rel = rel.subpath(1, rel.getNameCount()); - } - else - { - break; - } - } - - if (next != null && !next.equals(root)) - { - // verify our naive file path is correct for the container -- it may have a file root other than the default - java.nio.file.Path fileRoot = getFileRootPath(next); - if (fileRoot != null && path.startsWith(fileRoot)) - return Collections.singletonList(next); - } - } - } - - // TODO: Create cache of file root and pipeline root paths -> list of containers - - return Collections.emptyList(); - } - - @Override - public @Nullable File getFileRoot(@NotNull Container c, @NotNull ContentType type) - { - switch (type) - { - case files: - case assayfiles: - String folderName = getFolderName(type); - if (folderName == null) - folderName = ""; - - java.nio.file.Path dir = getFileRootPath(c); - return dir != null ? dir.resolve(folderName).toFile() : null; - - case pipeline: - PipeRoot root = PipelineService.get().findPipelineRoot(c); - return root != null ? root.getRootPath() : null; - } - return null; - } - - @Override - public @Nullable java.nio.file.Path getFileRootPath(@NotNull Container c, @NotNull ContentType type) - { - switch (type) - { - case files: - case assayfiles: - java.nio.file.Path fileRootPath = getFileRootPath(c); - if (null != fileRootPath && !FileUtil.hasCloudScheme(fileRootPath)) // Don't add @files when we're in the cloud - fileRootPath = fileRootPath.resolve(getFolderName(type)); - return fileRootPath; - - case pipeline: - PipeRoot root = PipelineService.get().findPipelineRoot(c); - return root != null ? root.getRootNioPath() : null; - } - return null; - } - - // Returns full uri to file root for this container. filePath is optional relative path to a file under the file root - @Override - public @Nullable URI getFileRootUri(@NotNull Container c, @NotNull ContentType type, @Nullable String filePath) - { - java.nio.file.Path root = FileContentService.get().getFileRootPath(c, FileContentService.ContentType.files); - if (root != null) - { - String path = root.toString(); - if (filePath != null) { - path += filePath; - } - - // non-unix needs a leading slash - if (!path.startsWith("/") && !path.startsWith("\\")) - { - path = "/" + path; - } - return FileUtil.createUri(path); - } - - return null; - } - - @Override - public @Nullable File getFileRoot(@NotNull Container c) - { - java.nio.file.Path path = getFileRootPath(c); - throwIfPathNotFile(path, c); - return path.toFile(); - } - - @Override - public @Nullable java.nio.file.Path getFileRootPath(@NotNull Container c) - { - if (c == null) - return null; - - if (c.isRoot()) - { - return getSiteDefaultRootPath(); - } - - if (!isFileRootDisabled(c)) - { - FileRoot root = FileRootManager.get().getFileRoot(c); - - // check if there is a site wide file root - if (root.getPath() == null || isUseDefaultRoot(c)) - { - return getDefaultRootPath(c, true); - } - else - return getNioPath(c, root.getPath()); - } - return null; - } - - @Override - public File getDefaultRoot(Container c, boolean createDir) - { - return getDefaultRootPath(c, createDir).toFile(); - } - - @Override - public java.nio.file.Path getDefaultRootPath(@NotNull Container c, boolean createDir) - { - Container firstOverride = getFirstAncestorWithOverride(c); - - java.nio.file.Path parentRoot; - if (firstOverride == null) - { - parentRoot = getSiteDefaultRoot().toPath(); - firstOverride = ContainerManager.getRoot(); - } - else - { - parentRoot = getFileRootPath(firstOverride); - } - - if (parentRoot != null && firstOverride != null) - { - java.nio.file.Path fileRootPath; - if (FileUtil.hasCloudScheme(parentRoot)) - { - // For cloud root, we don't have to create directories for this path - fileRootPath = CloudStoreService.get().getPathForOtherContainer(firstOverride, c, FileUtil.pathToString(parentRoot), new Path("")); - } - else - { - // For local, the path may be several directories deep (since it matches the LK folder path), so we should create the directories for that path - fileRootPath = FileUtil.appendPath(parentRoot.toFile(), Path.parse(getRelativePath(c, firstOverride))).toPath(); - - try - { - if (createDir && !NetworkDrive.exists(fileRootPath)) - FileUtil.createDirectories(fileRootPath); - } - catch (IOException e) - { - return null; // throw new RuntimeException(e); TODO: does returning null make certain tests, like TargetedMSQCGuideSetTest pass on Windows? - } - } - - return fileRootPath; - } - return null; - } - - // Return pretty path string for defaultFileRoot and boolean true if defaultFileRoot is cloud - @Override - public DefaultRootInfo getDefaultRootInfo(Container container) - { - String defaultRoot = ""; - boolean isDefaultRootCloud = false; - java.nio.file.Path defaultRootPath = getDefaultRootPath(container, false); - String cloudName = null; - if (defaultRootPath != null) - { - isDefaultRootCloud = FileUtil.hasCloudScheme(defaultRootPath); - if (isDefaultRootCloud && !container.isProject()) - { - FileRoot fileRoot = getDefaultFileRoot(container); - if (null != fileRoot) - defaultRoot = fileRoot.getPath(); - if (null != defaultRoot) - cloudName = getCloudRootName(defaultRoot); - } - else - { - defaultRoot = FileUtil.getAbsolutePath(container, defaultRootPath.toUri()); - } - } - return new DefaultRootInfo(defaultRootPath, defaultRoot, isDefaultRootCloud, cloudName); - } - - @Nullable - // Get FileRoot associated with path returned form getDefaultRootPath() - public FileRoot getDefaultFileRoot(Container c) - { - Container firstOverride = getFirstAncestorWithOverride(c); - - if (firstOverride == null) - firstOverride = ContainerManager.getRoot(); - - if (null != firstOverride) - return FileRootManager.get().getFileRoot(firstOverride); - return null; - } - - private @NotNull String getRelativePath(Container c, Container ancestor) - { - return c.getPath().replaceAll("^" + Pattern.quote(ancestor.getPath()), ""); - } - - //returns the first parent container that has a custom file root, or NULL if none have overrides - private Container getFirstAncestorWithOverride(Container c) - { - Container toTest = c.getParent(); - if (toTest == null) - return null; - - while (isUseDefaultRoot(toTest)) - { - if (toTest == null || toTest.equals(ContainerManager.getRoot())) - return null; - - toTest = toTest.getParent(); - } - - return toTest; - } - - private java.nio.file.Path getNioPath(Container c, @NotNull String fileRootPath) - { - if (isCloudFileRoot(fileRootPath)) - return CloudStoreService.get().getPath(c, getCloudRootName(fileRootPath), new org.labkey.api.util.Path("")); - - return FileUtil.stringToPath(c, fileRootPath, false); // fileRootPath is unencoded - } - - private boolean isCloudFileRoot(String fileRootPseudoPath) - { - return StringUtils.startsWith(fileRootPseudoPath, FileContentService.CLOUD_ROOT_PREFIX); - } - - private String getCloudRootName(@NotNull String fileRootPseudoPath) - { - return fileRootPseudoPath.substring(fileRootPseudoPath.indexOf(FileContentService.CLOUD_ROOT_PREFIX) + FileContentService.CLOUD_ROOT_PREFIX.length() + 1); - } - - @Override - public boolean isCloudRoot(Container c) - { - if (null != c) - { - java.nio.file.Path fileRootPath = getFileRootPath(c); - return null != fileRootPath && FileUtil.hasCloudScheme(fileRootPath); - } - return false; - } - - @Override - @NotNull - public String getCloudRootName(Container c) - { - if (null != c) - { - if (isCloudRoot(c)) - { - FileRoot root = FileRootManager.get().getFileRoot(c); - if (null == root.getPath() || isUseDefaultRoot(c)) - { - Container firstOverride = getFirstAncestorWithOverride(c); - if (null == firstOverride) - firstOverride = ContainerManager.getRoot(); - root = FileRootManager.get().getFileRoot(firstOverride); - if (null == root.getPath()) - return ""; - } - return getCloudRootName(root.getPath()); - } - } - return ""; - } - - @Override - public void setCloudRoot(@NotNull Container c, String cloudRootName) - { - _setFileRoot(c, FileContentService.CLOUD_ROOT_PREFIX + "/" + cloudRootName); - } - - @Override - public void setFileRoot(@NotNull Container c, @Nullable File path) - { - _setFileRoot(c, (null != path ? FileUtil.getAbsoluteCaseSensitiveFile(path).getAbsolutePath() : null)); - } - - @Override - public void setFileRootPath(@NotNull Container c, @Nullable String strPath) - { - String absolutePath = null; - if (strPath != null) - { - URI uri = FileUtil.createUri(strPath, false); // strPath is unencoded - if (FileUtil.hasCloudScheme(uri)) - absolutePath = FileUtil.getAbsolutePath(c, uri); - else - absolutePath = FileUtil.getAbsoluteCaseSensitiveFile(new File(uri)).getAbsolutePath(); - } - _setFileRoot(c, absolutePath); - } - - private void _setFileRoot(@NotNull Container c, @Nullable String absolutePath) - { - if (!c.isContainerFor(ContainerType.DataType.fileRoot)) - throw new IllegalArgumentException("File roots cannot be set for containers of type " + c.getContainerType().getName()); - - FileRoot root = FileRootManager.get().getFileRoot(c); - root.setEnabled(true); - - String oldValue = root.getPath(); - String newValue = null; - - // clear out the root - if (absolutePath == null) - root.setPath(null); - else - { - root.setPath(absolutePath); - newValue = root.getPath(); - } - - FileRootManager.get().saveFileRoot(null, root); - ContainerManager.ContainerPropertyChangeEvent evt = new ContainerManager.ContainerPropertyChangeEvent( - c, ContainerManager.Property.WebRoot, oldValue, newValue); - ContainerManager.firePropertyChangeEvent(evt); - } - - @Override - public void disableFileRoot(Container container) - { - if (container == null || container.isRoot()) - throw new IllegalArgumentException("Disabling either a null project or the root project is not allowed."); - - Container effective = container.getContainerFor(ContainerType.DataType.fileRoot); - if (effective != null) - { - FileRoot root = FileRootManager.get().getFileRoot(effective); - String oldValue = root.getPath(); - root.setEnabled(false); - FileRootManager.get().saveFileRoot(null, root); - - ContainerManager.ContainerPropertyChangeEvent evt = new ContainerManager.ContainerPropertyChangeEvent( - container, ContainerManager.Property.WebRoot, oldValue, null); - ContainerManager.firePropertyChangeEvent(evt); - } - } - - @Override - public boolean isFileRootDisabled(Container c) - { - Container effective = c.getContainerFor(ContainerType.DataType.fileRoot); - if (null == effective) - return false; - - FileRoot root = FileRootManager.get().getFileRoot(effective); - return !root.isEnabled(); - } - - @Override - public boolean isUseDefaultRoot(Container c) - { - if (c == null) - return true; - - Container effective = c.getContainerFor(ContainerType.DataType.fileRoot); - if (null == effective) - return true; - - FileRoot root = FileRootManager.get().getFileRoot(effective); - return root.isUseDefault() || StringUtils.isEmpty(root.getPath()); - } - - @Override - public void setIsUseDefaultRoot(Container c, boolean useDefaultRoot) - { - Container effective = c.getContainerFor(ContainerType.DataType.fileRoot); - if (effective != null) - { - FileRoot root = FileRootManager.get().getFileRoot(effective); - String oldValue = root.getPath(); - root.setEnabled(true); - root.setUseDefault(useDefaultRoot); - if (useDefaultRoot) - root.setPath(null); - FileRootManager.get().saveFileRoot(null, root); - - ContainerManager.ContainerPropertyChangeEvent evt = new ContainerManager.ContainerPropertyChangeEvent( - effective, ContainerManager.Property.WebRoot, oldValue, null); - ContainerManager.firePropertyChangeEvent(evt); - } - } - - @Override - public @NotNull java.nio.file.Path getSiteDefaultRootPath() - { - return getSiteDefaultRoot().toPath(); - } - - @Override - public @NotNull File getSiteDefaultRoot() - { - // Site default is always on file system - File root = AppProps.getInstance().getFileSystemRoot(); - - try - { - if (!NetworkDrive.exists(root)) - { - File configuredRoot = root; - root = getDefaultRoot(); - if (configuredRoot != null && !configuredRoot.equals(root)) - { - String message = "The configured site-wide file root " + configuredRoot + " does not exist. Falling back to " + root; - if (!message.equals(_problematicFileRootMessage)) - { - _problematicFileRootMessage = message; - _log.error(_problematicFileRootMessage); - } - } - } - else - { - _problematicFileRootMessage = null; - } - - if (!NetworkDrive.exists(root)) - { - if (FileUtil.mkdirs(root)) - { - _log.info("Created site-wide file root " + root); - } - else - { - _log.error("Failed when attempting to create site-wide file root " + root); - } - } - } - catch (IOException e) - { - throw new RuntimeException("Unable to create file root directory", e); - } - - return root; - } - - @Override - public String getProblematicFileRootMessage() - { - return _problematicFileRootMessage; - } - - private @NotNull File getDefaultRoot() throws IOException - { - File explodedPath = ModuleLoader.getInstance().getCoreModule().getExplodedPath(); - - File root = explodedPath.getParentFile(); - if (root != null) - { - if (root.getParentFile() != null) - root = root.getParentFile(); - } - File defaultRoot = new File(root, "files"); - if (!NetworkDrive.exists(defaultRoot)) - FileUtil.mkdirs(defaultRoot); - - return defaultRoot; - } - - @Override - public void setSiteDefaultRoot(File root, User user) - { - if (root == null) - throw new IllegalArgumentException("Invalid site root: specified root is null"); - - if (!NetworkDrive.exists(root)) - throw new IllegalArgumentException("Invalid site root: " + root.getAbsolutePath() + " does not exist"); - - File prevRoot = getSiteDefaultRoot(); - WriteableAppProps props = AppProps.getWriteableInstance(); - - props.setFileSystemRoot(root.getAbsolutePath()); - props.save(user); - - FileRootManager.get().clearCache(); - ContainerManager.ContainerPropertyChangeEvent evt = new ContainerManager.ContainerPropertyChangeEvent( - ContainerManager.getRoot(), ContainerManager.Property.SiteRoot, prevRoot, root); - ContainerManager.firePropertyChangeEvent(evt); - } - - @Override - public void setWebfilesEnabled(boolean enabled, User user) - { - WriteableAppProps props = AppProps.getWriteableInstance(); - props.setWebfilesEnabled(enabled); - props.save(user); - } - - @Override - public FileSystemAttachmentParent registerDirectory(Container c, String name, String path, boolean relative) - { - FileSystemAttachmentParent parent = new FileSystemAttachmentParent(); - parent.setContainer(c); - if (null == name) - name = path; - parent.setName(name); - parent.setPath(path); - parent.setRelative(relative); - //We do this because insert does not return new fields - parent.setEntityid(GUID.makeGUID()); - - FileSystemAttachmentParent ret = Table.insert(HttpView.currentContext().getUser(), CoreSchema.getInstance().getMappedDirectories(), parent); - ContainerManager.ContainerPropertyChangeEvent evt = new ContainerManager.ContainerPropertyChangeEvent( - c, ContainerManager.Property.AttachmentDirectory, null, ret); - ContainerManager.firePropertyChangeEvent(evt); - return ret; - } - - @Override - public void unregisterDirectory(Container c, String name) - { - FileSystemAttachmentParent parent = getRegisteredDirectory(c, name); - SimpleFilter filter = SimpleFilter.createContainerFilter(c); - filter.addCondition(FieldKey.fromParts("Name"), name); - Table.delete(CoreSchema.getInstance().getMappedDirectories(), filter); - ContainerManager.ContainerPropertyChangeEvent evt = new ContainerManager.ContainerPropertyChangeEvent( - c, ContainerManager.Property.AttachmentDirectory, parent, null); - ContainerManager.firePropertyChangeEvent(evt); - } - - @Override - public @Nullable AttachmentDirectory getMappedAttachmentDirectory(Container c, boolean createDir) throws UnsetRootDirectoryException, MissingRootDirectoryException - { - return getMappedAttachmentDirectory(c, ContentType.files, createDir); - } - - @Override - @Nullable - public AttachmentDirectory getMappedAttachmentDirectory(Container c, ContentType contentType, boolean createDir) throws UnsetRootDirectoryException - { - try - { - if (createDir) //force create - getMappedDirectory(c, true); - else if (null == getMappedDirectory(c, false)) - return null; - - return new FileSystemAttachmentParent(c, contentType); - } - catch (IOException e) - { - _log.error("Cannot get mapped directory for " + c.getPath(), e); - return null; - } - } - - public java.nio.file.Path getMappedDirectory(Container c, boolean create) throws UnsetRootDirectoryException, IOException - { - java.nio.file.Path root = getFileRootPath(c); - if (!FileUtil.hasCloudScheme(root)) - { - if (null == root) - { - if (create) - throw new UnsetRootDirectoryException(c.isRoot() ? c : c.getProject()); - else - return null; - } - - if (!NetworkDrive.exists(root)) - { - if (create) - throw new MissingRootDirectoryException(c.isRoot() ? c : c.getProject(), root); - else - return null; - - } - } - return root; - } - - @Override - public FileSystemAttachmentParent getRegisteredDirectory(Container c, String name) - { - SimpleFilter filter = SimpleFilter.createContainerFilter(c); - filter.addCondition(FieldKey.fromParts("Name"), name); - - return new TableSelector(CoreSchema.getInstance().getMappedDirectories(), filter, null).getObject(FileSystemAttachmentParent.class); - } - - @Override - public FileSystemAttachmentParent getRegisteredDirectoryFromEntityId(Container c, String entityId) - { - SimpleFilter filter = SimpleFilter.createContainerFilter(c); - filter.addCondition(FieldKey.fromParts("EntityId"), entityId); - - return new TableSelector(CoreSchema.getInstance().getMappedDirectories(), filter, null).getObject(FileSystemAttachmentParent.class); - } - - @Override - public @NotNull Collection getRegisteredDirectories(Container c) - { - SimpleFilter filter = SimpleFilter.createContainerFilter(c); - - return Collections.unmodifiableCollection(new TableSelector(CoreSchema.getInstance().getMappedDirectories(), filter, null).getCollection(FileSystemAttachmentParent.class)); - } - - private class FileContentServiceContainerListener implements ContainerListener - { - @Override - public void containerCreated(Container c, User user) - { - try - { - // Will create directory if it's a default dir - getMappedDirectory(c, false); - } - catch (IOException ex) - { - /* */ - } - } - - @Override - public void containerDeleted(Container c, User user) - { - java.nio.file.Path dir = null; - try - { - // don't delete the file contents if they have a project override - if (isUseDefaultRoot(c) && !isCloudRoot(c)) // Don't do anything for cloud root here. CloudContainerListener will handle - dir = getMappedDirectory(c, false); - - if (null != dir) - { - FileUtil.deleteDir(dir); - } - } - catch (Exception e) - { - _log.error("containerDeleted", e); - } - - ContainerUtil.purgeTable(CoreSchema.getInstance().getMappedDirectories(), c, null); - } - - @Override - public void containerMoved(Container c, Container oldParent, User user) - { - /* **** Cases: - SRC DEST - specific local path same -- no work - specific cloud path same -- no work - local default local default -- move tree - local default cloud default -- move tree - cloud default local default -- move tree - cloud default cloud default -- if change bucket, move tree - *************************************************************/ - if (isUseDefaultRoot(c)) - { - java.nio.file.Path srcParent = getFileRootPath(oldParent); - java.nio.file.Path dest = getFileRootPath(c); - if (null != srcParent && null != dest) - { - if (!FileUtil.hasCloudScheme(srcParent)) - { - File src = new File(srcParent.toFile(), c.getName()); - if (NetworkDrive.exists(src)) - { - if (!FileUtil.hasCloudScheme(dest)) - { - // local -> local - moveFileRoot(src, dest.toFile(), user, c); - } - else - { - // local -> cloud; source starts under @files - File filesSrc = FileUtil.appendName(src, FILES_LINK); - if (NetworkDrive.exists(filesSrc)) - moveFileRoot(filesSrc.toPath(), dest, user, c); - FileUtil.deleteDir(src); // moveFileRoot will delete @files, but we need to delete its parent - } - } - } - else - { - // Get source path using moving container and parent's config (cloudRoot), because that config must be the source config - java.nio.file.Path src = CloudStoreService.get().getPath(c, getCloudRootName(oldParent), new Path("")); - if (!FileUtil.hasCloudScheme(dest)) - { - // cloud -> local; destination is under @files - dest = dest.resolve(FILES_LINK); - moveFileRoot(src, dest, user, c); - } - else - { - // cloud -> cloud - if (!getCloudRootName(oldParent).equals(getCloudRootName(c))) - { - // Different configs - moveFileRoot(src, dest, user, c); - } - } - } - } - } - } - - @NotNull - @Override - public Collection canMove(Container c, Container newParent, User user) - { - return Collections.emptyList(); - } - - @Override - public void propertyChange(PropertyChangeEvent propertyChangeEvent) - { - ContainerManager.ContainerPropertyChangeEvent evt = (ContainerManager.ContainerPropertyChangeEvent)propertyChangeEvent; - Container c = evt.container; - - switch (evt.property) - { - case Name: // container rename event - { - String oldValue = (String) propertyChangeEvent.getOldValue(); - String newValue = (String) propertyChangeEvent.getNewValue(); - - java.nio.file.Path location; - try - { - location = getMappedDirectory(c, false); - if (location != null && !FileUtil.hasCloudScheme(location)) // If cloud, folder name for container not dependent on Name - { - //Don't rely on container object. Seems not to point to the - //new location even AFTER rename. Just construct new file paths - File locationFile = location.toFile(); - File parentDir = locationFile.getParentFile(); - File oldLocation = new File(parentDir, oldValue); - File newLocation = new File(parentDir, newValue); - if (NetworkDrive.exists(newLocation)) - moveToDeleted(newLocation); - - if (NetworkDrive.exists(oldLocation)) - { - oldLocation.renameTo(newLocation); - fireFileMoveEvent(oldLocation, newLocation, evt.user, evt.container); - } - } - } - catch (IOException ex) - { - _log.error(ex); - } - - break; - } - } - } - } - - - @Override - public @Nullable String getFolderName(FileContentService.ContentType type) - { - if (type != null) - return "@" + type.name(); - return null; - } - - - /** - * Move the file or directory into a ".deleted" directory under the parent directory. - * @return True if successfully moved. - */ - private static boolean moveToDeleted(File fileToMove) throws IOException - { - if (!NetworkDrive.exists(fileToMove)) - return false; - - File parent = fileToMove.getParentFile(); - - File deletedDir = new File(parent, ".deleted"); - if (!NetworkDrive.exists(deletedDir)) - if (!FileUtil.mkdir(deletedDir)) - return false; - - File newLocation = new File(deletedDir, fileToMove.getName()); - if (NetworkDrive.exists(newLocation)) - FileUtil.deleteDir(newLocation); - - return fileToMove.renameTo(newLocation); - } - - static void logFileAction(java.nio.file.Path directory, String fileName, FileAction action, User user) - { - try (BufferedWriter fw = Files.newBufferedWriter(directory.resolve(UPLOAD_LOG), StandardOpenOption.APPEND, StandardOpenOption.CREATE)) - { - fw.write(action.toString() + "\t" + fileName + "\t" + new Date() + "\t" + (user == null ? "(unknown)" : user.getEmail()) + "\n"); - } - catch (Exception x) - { - //Just log it. - _log.error(x); - } - } - - @Override - public FilesAdminOptions getAdminOptions(Container c) - { - FileRoot root = FileRootManager.get().getFileRoot(c); - String xml = null; - - if (!StringUtils.isBlank(root.getProperties())) - { - xml = root.getProperties(); - } - return new FilesAdminOptions(c, xml); - } - - @Override - public void setAdminOptions(Container c, FilesAdminOptions options) - { - if (options != null) - { - setAdminOptions(c, options.serialize()); - } - } - - @Override - public void setAdminOptions(Container c, String properties) - { - FileRoot root = FileRootManager.get().getFileRoot(c); - - root.setProperties(properties); - FileRootManager.get().saveFileRoot(null, root); - } - - public static final String NAMESPACE_PREFIX = "FileProperties"; - public static final String PROPERTIES_DOMAIN = "File Properties"; - public static final String TYPE_PROPERTIES = "FileProperties"; - - @Override - public String getDomainURI(Container container) - { - return getDomainURI(container, getAdminOptions(container).getFileConfig()); - } - - @Override - public String getDomainURI(Container container, FilesAdminOptions.fileConfig config) - { - while (config == FilesAdminOptions.fileConfig.useParent && container != container.getParent()) - { - container = container.getParent(); - config = getAdminOptions(container).getFileConfig(); - } - - //String typeURI = "urn:lsid:" + AppProps.getInstance().getDefaultLsidAuthority() + ":List" + ".Folder-" + container.getRowId() + ":" + name; - - return new Lsid("urn:lsid:labkey.com:" + NAMESPACE_PREFIX + ".Folder-" + container.getRowId() + ':' + TYPE_PROPERTIES).toString(); - } - - @Override @Nullable - public ExpData getDataObject(WebdavResource resource, Container c) - { - return getDataObject(resource, c, null, false); - } - - @Nullable - private static ExpData getDataObject(WebdavResource resource, Container c, User user, boolean create) - { - // TODO: S3: seems to only be called from Search and currently we're not searching in cloud. SaveCustomPropsAction seems unused - if (resource != null) - { - File file = resource.getFile(); - if (file != null) - { - ExpData data = ExperimentService.get().getExpDataByURL(file, c); - - if (data == null && create) - { - data = ExperimentService.get().createData(c, FileContentService.UPLOADED_FILE); - data.setName(file.getName()); - data.setDataFileURI(file.toURI()); - data.save(user); - } - return data; - } - } - return null; - } - - @Override - public QueryUpdateService getFilePropsUpdateService(TableInfo tinfo, Container container) - { - return new FileQueryUpdateService(tinfo, container); - } - - @Override - public boolean isValidProjectRoot(String root) - { - File f = new File(root); - return NetworkDrive.exists(f) && f.isDirectory(); - } - - @Override - public void moveFileRoot(java.nio.file.Path prev, java.nio.file.Path dest, @Nullable User user, @Nullable Container container) - { - if (!FileUtil.hasCloudScheme(prev) && !FileUtil.hasCloudScheme(dest)) - { - moveFileRoot(prev.toFile(), dest.toFile(), user, container); // Both files; try rename - } - else - { - try - { - // At least one is in the cloud - FileUtil.copyDirectory(prev, dest); - FileUtil.deleteDir(prev); // TODO use more efficient delete - fireFileMoveEvent(prev, dest, user, container); - } - catch (IOException e) - { - _log.error("error occurred moving the file root", e); - } - } - } - - @Override - public void moveFileRoot(File prev, File dest, @Nullable User user, @Nullable Container container) - { - try - { - _log.info("moving " + prev.getPath() + " to " + dest.getPath()); - boolean doRename = true; - - // Our best bet for perf is to do a rename, which doesn't require creating an actual copy. - // If it exists, try deleting the target directory, which will only succeed if it's empty, but would - // enable using renameTo() method. Don't delete if it's a symbolic link, since it wouldn't be recreated - // in the same way. - if (NetworkDrive.exists(dest) && !Files.isSymbolicLink(dest.toPath())) - doRename = dest.delete(); - - if (doRename && !prev.renameTo(dest)) - { - _log.info("rename failed, attempting to copy"); - - //listFiles can return null, which could cause a NPE - File[] children = prev.listFiles(); - if (children != null) - { - for (File file : children) - FileUtil.copyBranch(file, dest); - } - FileUtil.deleteDir(prev); - } - fireFileMoveEvent(prev, dest, user, container); - } - catch (IOException e) - { - _log.error("error occurred moving the file root", e); - } - } - - @Override - public void fireFileCreateEvent(@NotNull File created, @Nullable User user, @Nullable Container container) - { - fireFileCreateEvent(created.toPath(), user, container); - } - - @Override - public void fireFileCreateEvent(@NotNull java.nio.file.Path created, @Nullable User user, @Nullable Container container) - { - java.nio.file.Path absPath = FileUtil.getAbsoluteCaseSensitivePath(container, created); - for (FileListener fileListener : _fileListeners) - { - fileListener.fileCreated(absPath, user, container); - } - } - - @Override - public void fireFileReplacedEvent(@NotNull java.nio.file.Path replaced, @Nullable User user, @Nullable Container container) - { - java.nio.file.Path absPath = FileUtil.getAbsoluteCaseSensitivePath(container, replaced); - for (FileListener fileListener : _fileListeners) - { - fileListener.fileReplaced(absPath, user, container); - } - } - - @Override - public void fireFileDeletedEvent(@NotNull java.nio.file.Path deleted, @Nullable User user, @Nullable Container container) - { - java.nio.file.Path absPath = FileUtil.getAbsoluteCaseSensitivePath(container, deleted); - for (FileListener fileListener : _fileListeners) - { - fileListener.fileDeleted(absPath, user, container); - } - } - - @Override - public int fireFileMoveEvent(@NotNull File src, @NotNull File dest, @Nullable User user, @Nullable Container container) - { - return fireFileMoveEvent(src.toPath(), dest.toPath(), user, container); - } - - @Override - public int fireFileMoveEvent(@NotNull java.nio.file.Path src, @NotNull java.nio.file.Path dest, @Nullable User user, @Nullable Container container) - { - return fireFileMoveEvent(src, dest, user, container, null); - } - - @Override - public int fireFileMoveEvent(@NotNull java.nio.file.Path src, @NotNull java.nio.file.Path dest, @Nullable User user, @Nullable Container sourceContainer, @Nullable Container targetContainer) - { - // Make sure that we've got the best representation of the file that we can - java.nio.file.Path absSrc = FileUtil.getAbsoluteCaseSensitivePath(sourceContainer, src); - java.nio.file.Path absDest = FileUtil.getAbsoluteCaseSensitivePath(targetContainer != null ? targetContainer : sourceContainer, dest); - int result = 0; - for (FileListener fileListener : _fileListeners) - { - result += fileListener.fileMoved(absSrc, absDest, user, sourceContainer, targetContainer); - } - return result; - } - - @Override - public void addFileListener(FileListener listener) - { - _fileListeners.add(listener); - } - - @Override - public Map> listFiles(@NotNull Container container) - { - Map> files = new LinkedHashMap<>(); - for (FileListener fileListener : _fileListeners) - { - files.put(fileListener.getSourceName(), new HashSet<>(fileListener.listFiles(container))); - } - return files; - } - - @Override - public SQLFragment listFilesQuery(@NotNull User currentUser) - { - SQLFragment frag = new SQLFragment(); - if (currentUser == null || !currentUser.hasSiteAdminPermission()) - { - frag.append("SELECT\n"); - frag.append(" CAST(NULL AS VARCHAR) AS Container,\n"); - frag.append(" NULL AS Created,\n"); - frag.append(" NULL AS CreatedBy,\n"); - frag.append(" NULL AS Modified,\n"); - frag.append(" NULL AS ModifiedBy,\n"); - frag.append(" NULL AS FilePath,\n"); - frag.append(" NULL AS SourceKey,\n"); - frag.append(" NULL AS SourceName\n"); - frag.append("WHERE 1 = 0"); - } - else - { - String union = ""; - frag.append("("); - for (FileListener fileListener : _fileListeners) - { - SQLFragment subselect = fileListener.listFilesQuery(); - if (subselect != null) - { - frag.append(union); - frag.append(subselect); - union = "UNION\n"; - } - } - frag.append(")"); - } - return frag; - } - - @Override - public void setFileRootSetViaStartupProperty(boolean fileRootSetViaStartupProperty) - { - _fileRootSetViaStartupProperty = fileRootSetViaStartupProperty; - } - - @Override - public boolean isFileRootSetViaStartupProperty() - { - return _fileRootSetViaStartupProperty; - } - - public ContainerListener getContainerListener() - { - return _containerListener; - } - - public Set> getNodes(boolean isShowOverridesOnly, @Nullable String browseUrl, Container c) - { - Set> children = new LinkedHashSet<>(); - - try { - java.nio.file.Path assayFilesRoot = getFileRootPath(c, ContentType.assayfiles); - if (NetworkDrive.exists(assayFilesRoot)) - { - Map node = createFileSetNode(c, ASSAY_FILES, assayFilesRoot); - node.put("default", false); - node.put("webdavURL", FilesWebPart.getRootPath(c, ASSAY_FILES).toString()); - children.add(node); - } - - AttachmentDirectory root = getMappedAttachmentDirectory(c, false); - if (root != null) - { - boolean isDefault = isUseDefaultRoot(c); - if (!isDefault || !isShowOverridesOnly) - { - ActionURL config = PageFlowUtil.urlProvider(AdminUrls.class).getProjectSettingsFileURL(c); - Map node = createFileSetNode(c, FILES_LINK, root.getFileSystemDirectoryPath()); - node.put("default", isUseDefaultRoot(c)); - node.put("configureURL", config.getEncodedLocalURIString()); - node.put("browseURL", browseUrl); - node.put("webdavURL", FilesWebPart.getRootPath(c, FILES_LINK).toString()); - - children.add(node); - } - } - - for (AttachmentDirectory fileSet : getRegisteredDirectories(c)) - { - ActionURL config = new ActionURL(FileContentController.ShowAdminAction.class, c); - Map node = createFileSetNode(c, fileSet.getName(), fileSet.getFileSystemDirectoryPath()); - node.put("configureURL", config.getEncodedLocalURIString()); - node.put("browseURL", browseUrl); - node.put("webdavURL", FilesWebPart.getRootPath(c, FILE_SETS_LINK, fileSet.getName()).toString()); - node.put("rootType", "fileset"); - - children.add(node); - } - - PipeRoot pipeRoot = PipelineService.get().findPipelineRoot(c); - if (pipeRoot != null) - { - boolean isDefault = PipelineService.get().hasSiteDefaultRoot(c); - if (!isDefault || !isShowOverridesOnly) - { - ActionURL config = PageFlowUtil.urlProvider(PipelineUrls.class).urlSetup(c); - ActionURL pipelineBrowse = PageFlowUtil.urlProvider(PipelineUrls.class).urlBrowse(c, null); - Map node = createFileSetNode(c, PIPELINE_LINK, pipeRoot.getRootNioPath()); - node.put("default", isDefault ); - node.put("configureURL", config.getEncodedLocalURIString()); - node.put("browseURL", pipelineBrowse.getEncodedLocalURIString()); - node.put("webdavURL", FilesWebPart.getRootPath(c, PIPELINE_LINK).toString()); - - children.add(node); - } - } - } - catch (IOException | UnsetRootDirectoryException ignored) {} - return children; - } - - protected Map createFileSetNode(Container container, String name, java.nio.file.Path dir) - { - Map node = new HashMap<>(); - if (dir != null) - { - node.put("name", name); - node.put("path", FileUtil.getAbsolutePath(container, dir)); - node.put("leaf", true); - } - return node; - } - - public String getAbsolutePathFromDataFileUrl(String dataFileUrl, Container container) - { - return FileUtil.getAbsolutePath(container, FileUtil.createUri(dataFileUrl)); - } - - @Nullable - @Override - public URI getWebDavUrl(@NotNull java.nio.file.Path path, @NotNull Container container, @NotNull PathType type) - { - PipeRoot root = PipelineService.get().getPipelineRootSetting(container); - java.nio.file.Path assayFilesPath = getFileRootPath(container, ContentType.assayfiles); - path = path.toAbsolutePath(); - String relPath = null; - URI rootWebDavUrl = null; - - try - { - // currently, only report if the file is under the parent container - if (root != null && root.isUnderRoot(path)) - { - relPath = root.relativePath(path); - rootWebDavUrl = root.getWebdavURL(); - } - else if (assayFilesPath != null && URIUtil.isDescendant(assayFilesPath.toUri(), path.toUri())) - { - relPath = assayFilesPath.relativize(path).toString(); - rootWebDavUrl = FilesWebPart.getRootPath(container, ASSAY_FILES); - } - - if (relPath != null) - { - relPath = Path.parse(FilenameUtils.separatorsToUnix(relPath)).encode(); - - return switch (type) - { - case folderRelative -> new URI(relPath); - case serverRelative -> new URI(rootWebDavUrl + (rootWebDavUrl.getPath().endsWith("/") ? "" : "/") + relPath); - case full -> new URI(AppProps.getInstance().getBaseServerUrl() + rootWebDavUrl + (rootWebDavUrl.getPath().endsWith("/") ? "" : "/") + relPath); - }; - } - } - catch (InvalidPathException | URISyntaxException e) - { - _log.error("Invalid WebDav URL from: " + path, e); - } - - return null; - } - - @Override - public String getDataFileRelativeFileRootPath(@NotNull String dataFileUrl, Container container) - { - Set> children = getNodes(false, null, container); - String filesRoot = null; // the path for @files - for (Map child : children) - { - String rootName = (String) child.get("name"); - String rootPath = (String) child.get("path"); - - // skip default @pipeline, which is the same as @files - if (PIPELINE_LINK.equals(rootName)) - { - if((boolean) child.get("default") || rootPath.equals(filesRoot)) - continue; - } - - if (FILES_LINK.equals(rootName)) - filesRoot = rootPath; - - String absoluteFilePath = getAbsolutePathFromDataFileUrl(dataFileUrl, container); - if (StringUtils.startsWith(absoluteFilePath, rootPath)) - { - String offset = absoluteFilePath.replace(rootPath, "").replace("\\", "/"); - int lastSlash = offset.lastIndexOf("/"); - if (lastSlash <= 0) - return "/"; - else - return offset.substring(0, lastSlash); - } - } - return null; - } - - @Override - public void ensureFileData(@NotNull ExpDataTable table) - { - Container container = table.getUserSchema().getContainer(); - // The current user may not have insert permission, and they didn't necessarily upload the files anyway - User user = User.getAdminServiceUser(); - QueryUpdateService qus = table.getUpdateService(); - if (qus == null) - { - throw new IllegalArgumentException("getUpdateServer() returned null from " + table); - } - - synchronized (_fileDataUpToDateCache) - { - if (_fileDataUpToDateCache.get(container) != null) // already synced in the past 5 minutes, skip - return; - - _fileDataUpToDateCache.put(container, true); - } - - List existingDataFileUrls = getDataFileUrls(container); - Collection filesets = getRegisteredDirectories(container); - Set> children = getNodes(false, null, container); - String filesRoot = null; // the path for @files - for (Map child : children) - { - String rootName = (String) child.get("name"); - String rootPathVal = (String) child.get("path"); - - // skip default @pipeline, which is the same as @files - if (PIPELINE_LINK.equals(rootName)) - { - if((boolean) child.get("default") || rootPathVal.equals(filesRoot)) - continue; - } - - if (FILES_LINK.equals(rootName)) - filesRoot = rootPathVal; - - String rootDavUrl = (String) child.get("webdavURL"); - - WebdavResource resource = getResource(rootDavUrl); - if (resource == null) - continue; - - List> rows = new ArrayList<>(); - BatchValidationException errors = new BatchValidationException(); - File file = resource.getFile(); - - if (file == null) - { - String rootType = (String) child.get("rootType"); - if ("fileset".equals(rootType)) - { - for (AttachmentDirectory fileset : filesets) - { - if (fileset.getName().equals(rootName)) - { - try - { - file = fileset.getFileSystemDirectory(); - } - catch (MissingRootDirectoryException e) - { - _log.error("Unable to list files for fileset: " + rootName, e); - } - break; - } - } - } - } - - if (file == null) - return; - - try (var ignore = SpringActionController.ignoreSqlUpdates()) - { - java.nio.file.Path rootPath = file.toPath(); - - try (Stream pathStream = Files.walk(rootPath, 100)) // prevent symlink loop - { - pathStream - .filter(path -> !Files.isSymbolicLink(path) && path.compareTo(rootPath) != 0) // exclude symlink & root - .forEach(path -> { - if (!containsUrlOrVariation(existingDataFileUrls, path)) - rows.add(new CaseInsensitiveHashMap<>(Collections.singletonMap("DataFileUrl", path.toUri().toString()))); - }); - } - - qus.insertRows(user, container, rows, errors, null, null); - } - catch (Exception e) - { - _log.error("Error listing content of directory: " + file.getAbsolutePath(), e); - } - } - } - - - @Override - public void addZiploaderPattern(DirectoryPattern directoryPattern) - { - _ziploaderPattern.add(directoryPattern); - } - - @Override - public List getZiploaderPatterns(Container container) - { - List registeredPatterns = new ArrayList<>(); - for(Module module : container.getActiveModules()) - { - _ziploaderPattern.forEach(p -> { - if(p.getModule().getName().equalsIgnoreCase(module.getName())) - registeredPatterns.add(p); - }); - } - return registeredPatterns; - } - - public List getDataFileUrls(Container container) - { - SimpleFilter filter = SimpleFilter.createContainerFilter(container); - filter.addCondition(FieldKey.fromParts("DataFileUrl"), null, CompareType.NONBLANK); - TableSelector selector = new TableSelector(ExperimentService.get().getTinfoData(), Collections.singleton("DataFileUrl"), filter, null); - return selector.getArrayList(String.class); - } - - public Path getPath(String uri) - { - Path path = Path.decode(uri); - - if (!path.startsWith(WebdavService.getPath()) && path.contains(WebdavService.getPath().getName())) - { - String newPath = path.toString(); - int idx = newPath.indexOf(WebdavService.getPath().toString()); - - if (idx != -1) - { - newPath = newPath.substring(idx); - path = Path.parse(newPath); - } - } - return path; - } - - @Nullable - public WebdavResource getResource(String uri) - { - Path path = getPath(uri); - return WebdavService.get().getResolver().lookup(path); - } - - public static void throwIfPathNotFile(java.nio.file.Path path, Container container) - { - if (null == path) - { - throw new RuntimeException("No path to evaluate in " + container.getPath()); - } - if (FileUtil.hasCloudScheme(path)) - { - throw new RuntimeException("Cannot get File object from Cloud File Root in " + container.getPath()); - } - } - - private boolean containsUrlOrVariation(List existingUrls, java.nio.file.Path path) - { - String url = path.toUri().toString(); - if (existingUrls.contains(url)) - return true; - - boolean urlHasTrailingSlash = (Files.isDirectory(path) && (url.endsWith("/") || url.endsWith(File.pathSeparator))); - if (urlHasTrailingSlash && existingUrls.contains(url.substring(0, url.length() - 1))) - return true; - - if (!FileUtil.hasCloudScheme(path)) - { - File file = path.toFile(); - String legacyUrl = file.toURI().toString(); - if (existingUrls.contains(legacyUrl)) // Legacy URI format (file:/users/...) - return true; - - return existingUrls.contains(file.getPath()); - } - return false; - } - - @Override - public File getMoveTargetFile(String absoluteFilePath, @NotNull Container sourceContainer, @NotNull Container targetContainer) - { - if (absoluteFilePath == null) - return null; - - File file = new File(absoluteFilePath); - if (!NetworkDrive.exists(file)) - { - _log.warn("File '" + absoluteFilePath + "' not found and cannot be moved"); - return null; - } - - File sourceFileRoot = getFileRoot(sourceContainer); - if (sourceFileRoot == null) - return null; - - String sourceRootPath = sourceFileRoot.getAbsolutePath(); - if (!absoluteFilePath.startsWith(sourceRootPath)) - { - _log.warn("File '" + absoluteFilePath + "' not currently located in source folder '" + sourceRootPath + "'. Not moving."); - return null; - } - File targetFileRoot = getFileRoot(targetContainer); - if (targetFileRoot == null) - return null; - - String targetPath = absoluteFilePath.replace(sourceRootPath, targetFileRoot.getAbsolutePath()); - File targetFile = new File(targetPath); - return FileUtil.findUniqueFileName(file.getName(), targetFile.getParentFile()); - } - - @Override - public void addDynamicWarnings(@NotNull Warnings warnings, @Nullable ViewContext context, boolean showAllWarnings) - { - if (_problematicFileRootMessage != null && context != null && ContainerManager.getRoot().hasPermission(context.getUser(), AdminOperationsPermission.class)) - { - warnings.add(DOM.createHtmlFragment(_problematicFileRootMessage, " ", DOM.A(at(href, PageFlowUtil.urlProvider(AdminUrls.class).getFilesSiteSettingsURL()), "Configure File System Access"))); - } - else if (showAllWarnings) - { - try - { - warnings.add(HtmlString.of("Configured site-wide file root " + getDefaultRoot() + " does not exist. Falling back to " + getDefaultRoot())); - } - catch (IOException ignored) {} - } - } - - // Cache with short-lived entries so that exp.files can perform reasonably - private static final Cache _fileDataUpToDateCache = CacheManager.getCache(CacheManager.UNLIMITED, 5 * CacheManager.MINUTE, "Files"); - - @TestWhen(TestWhen.When.BVT) - public static class TestCase extends AssertionError - { - private static final String TRICKY_CHARACTERS_FOR_PROJECT_NAMES = "\u2603~!@$&()_+{}-=[],.#\u00E4\u00F6\u00FC"; - - private static final String PROJECT1 = "FileRootTestProject1" + TRICKY_CHARACTERS_FOR_PROJECT_NAMES; - private static final String PROJECT1_SUBFOLDER1 = "Subfolder1"; - private static final String PROJECT1_SUBFOLDER2 = "Subfolder2" + TRICKY_CHARACTERS_FOR_PROJECT_NAMES; - private static final String PROJECT1_SUBSUBFOLDER = "SubSubfolder"; - private static final String PROJECT1_SUBSUBFOLDER_SIBLING = "SubSubfolderSibling"; - private static final String PROJECT2 = "FileRootTestProject2"; - - private static final String FILE_ROOT_SUFFIX = "_FileRootTest"; - private static final String TXT_FILE = "FileContentTestFile.txt"; - - private Map _expectedPaths; - - @Test - public void fileRootsTest() - { - //pre-clean - cleanup(); - - _expectedPaths = new HashMap<>(); - - FileContentService svc = FileContentService.get(); - Assert.assertNotNull(svc); - - Container project1 = ContainerManager.createContainer(ContainerManager.getRoot(), PROJECT1, TestContext.get().getUser()); - _expectedPaths.put(project1, null); - - Container project2 = ContainerManager.createContainer(ContainerManager.getRoot(), PROJECT2, TestContext.get().getUser()); - _expectedPaths.put(project2, null); - - Container subfolder1 = ContainerManager.createContainer(project1, PROJECT1_SUBFOLDER1, TestContext.get().getUser()); - _expectedPaths.put(subfolder1, null); - - Container subfolder2 = ContainerManager.createContainer(project1, PROJECT1_SUBFOLDER2, TestContext.get().getUser()); - _expectedPaths.put(subfolder2, null); - - Container subsubfolder = ContainerManager.createContainer(subfolder1, PROJECT1_SUBSUBFOLDER, TestContext.get().getUser()); - _expectedPaths.put(subsubfolder, null); - - //set custom root on project, then expect children to inherit - File testRoot = getTestRoot(); - - svc.setFileRoot(project1, testRoot); - _expectedPaths.put(project1, testRoot); - - //the subfolder should inherit from the parent - _expectedPaths.put(subfolder1, new File(testRoot, subfolder1.getName())); - assertPathsEqual("Incorrect values returned by getDefaultRoot", _expectedPaths.get(subfolder1), svc.getDefaultRoot(subfolder1, false)); - assertPathsEqual("Subfolder1 has incorrect root", _expectedPaths.get(subfolder1), svc.getFileRoot(subfolder1)); - - _expectedPaths.put(subfolder2, new File(testRoot, subfolder2.getName())); - assertPathsEqual("Incorrect values returned by getDefaultRoot", _expectedPaths.get(subfolder2), svc.getDefaultRoot(subfolder2, false)); - assertPathsEqual("Subfolder2 has incorrect root", _expectedPaths.get(subfolder2), svc.getFileRoot(subfolder2)); - - _expectedPaths.put(subsubfolder, new File(_expectedPaths.get(subfolder1), subsubfolder.getName())); - assertPathsEqual("Incorrect values returned by getDefaultRoot", _expectedPaths.get(subsubfolder), svc.getDefaultRoot(subsubfolder, false)); - assertPathsEqual("SubSubfolder has incorrect root", _expectedPaths.get(subsubfolder), svc.getFileRoot(subsubfolder)); - - //override root on 1st child, expect children of that folder to inherit - _expectedPaths.put(subfolder1, new File(testRoot, "CustomSubfolder")); - _expectedPaths.get(subfolder1).mkdirs(); - svc.setFileRoot(subfolder1, _expectedPaths.get(subfolder1)); - assertPathsEqual("SubSubfolder has incorrect root", new File(_expectedPaths.get(subfolder1), subsubfolder.getName()), svc.getFileRoot(subsubfolder)); - - //reset project, we assume overridden child roots to remain the same - svc.setFileRoot(project1, null); - assertPathsEqual("Subfolder1 has incorrect root", _expectedPaths.get(subfolder1), svc.getFileRoot(subfolder1)); - assertPathsEqual("SubSubfolder has incorrect root", new File(_expectedPaths.get(subfolder1), subsubfolder.getName()), svc.getFileRoot(subsubfolder)); - - } - - private void assertPathsEqual(String msg, File expected, File actual) - { - String expectedPath = FileUtil.getAbsoluteCaseSensitiveFile(expected).getPath(); - String actualPath = FileUtil.getAbsoluteCaseSensitiveFile(actual).getPath(); - Assert.assertEquals(msg, expectedPath, actualPath); - } - - private File getTestRoot() - { - FileContentService svc = FileContentService.get(); - File siteRoot = svc.getSiteDefaultRoot(); - File testRoot = new File(siteRoot, FILE_ROOT_SUFFIX); - testRoot.mkdirs(); - Assert.assertTrue("Unable to create test file root", NetworkDrive.exists(testRoot)); - - return testRoot; - } - - @Test - //when we move a folder, we expect child files to follow, and expect - // any file paths stored in the DB to also get updated - public void testFolderMove() throws Exception - { - //pre-clean - cleanup(); - - _expectedPaths = new HashMap<>(); - - FileContentService svc = FileContentService.get(); - Assert.assertNotNull(svc); - - Container project1 = ContainerManager.createContainer(ContainerManager.getRoot(), PROJECT1, TestContext.get().getUser()); - _expectedPaths.put(project1, null); - - Container project2 = ContainerManager.createContainer(ContainerManager.getRoot(), PROJECT2, TestContext.get().getUser()); - _expectedPaths.put(project2, null); - - Container subfolder1 = ContainerManager.createContainer(project1, PROJECT1_SUBFOLDER1, TestContext.get().getUser()); - _expectedPaths.put(subfolder1, null); - - Container subfolder2 = ContainerManager.createContainer(project1, PROJECT1_SUBFOLDER2, TestContext.get().getUser()); - _expectedPaths.put(subfolder2, null); - - Container subsubfolder = ContainerManager.createContainer(subfolder1, PROJECT1_SUBSUBFOLDER, TestContext.get().getUser()); - Container subsubfolderSibling = ContainerManager.createContainer(subfolder1, PROJECT1_SUBSUBFOLDER_SIBLING, TestContext.get().getUser()); - _expectedPaths.put(subsubfolder, null); - - //create a test file that we will follow - File fileRoot = svc.getFileRoot(subsubfolder, ContentType.files); - fileRoot.mkdirs(); - - File childFile = new File(fileRoot, TXT_FILE); - childFile.createNewFile(); - - ExpData data = ExperimentService.get().createData(subsubfolder, UPLOADED_FILE); - data.setDataFileURI(childFile.toPath().toUri()); - data.save(TestContext.get().getUser()); - - ExpProtocol protocol = ExperimentService.get().createExpProtocol(subsubfolder, ExpProtocol.ApplicationType.ProtocolApplication, "DummyProtocol"); - protocol = ExperimentService.get().insertSimpleProtocol(protocol, TestContext.get().getUser()); - - ExpRun expRun = ExperimentService.get().createExperimentRun(subsubfolder, "DummyRun"); - expRun.setProtocol(protocol); - expRun.setFilePathRootPath(childFile.getParentFile().toPath()); - - ViewBackgroundInfo info = new ViewBackgroundInfo(subsubfolder, TestContext.get().getUser(), null); - ExpRun run = ExperimentService.get().saveSimpleExperimentRun( - expRun, - Collections.emptyMap(), - Collections.singletonMap(data, "Data"), - Collections.emptyMap(), - Collections.emptyMap(), - Collections.emptyMap(), - info, - _log, - false); - - Assert.assertTrue("File not found: " + childFile.getPath(), NetworkDrive.exists(childFile)); - ContainerManager.move(subsubfolder, subfolder2, TestContext.get().getUser()); - Container movedSubfolder = ContainerManager.getChild(subfolder2, subsubfolder.getName()); - - _expectedPaths.put(movedSubfolder, new File(svc.getFileRoot(subfolder2), movedSubfolder.getName())); - assertPathsEqual("Incorrect values returned by getDefaultRoot", _expectedPaths.get(movedSubfolder), svc.getDefaultRoot(movedSubfolder, false)); - assertPathsEqual("SubSubfolder has incorrect root", _expectedPaths.get(movedSubfolder), svc.getFileRoot(movedSubfolder)); - - File expectedFile = new File(svc.getFileRoot(movedSubfolder, ContentType.files), TXT_FILE); - Assert.assertTrue("File was not moved, expected: " + expectedFile.getPath(), NetworkDrive.exists(expectedFile)); - - ExpData movedData = ExperimentService.get().getExpData(data.getRowId()); - Assert.assertNotNull(movedData); - - // Reload the run after it's path has hopefully been updated - expRun = ExperimentService.get().getExpRun(expRun.getRowId()); - - assertPathsEqual("Incorrect data file path", expectedFile, FileUtil.stringToPath(movedSubfolder, movedData.getDataFileUrl()).toFile()); - assertPathsEqual("Incorrect run root path", expectedFile.getParentFile(), expRun.getFilePathRoot()); - - // Issue 38206 - file paths get mangled with multiple folder moves - ContainerManager.move(subsubfolderSibling, subfolder2, TestContext.get().getUser()); - - // Reload the run after it's path has hopefully NOT been updated - expRun = ExperimentService.get().getExpRun(expRun.getRowId()); - assertPathsEqual("Incorrect run root path", expectedFile.getParentFile(), expRun.getFilePathRoot()); - } - - @Test - public void testWorkbooksAndTabs() - { - //pre-clean - cleanup(); - - FileContentService svc = FileContentService.get(); - Assert.assertNotNull(svc); - - Container project1 = ContainerManager.createContainer(ContainerManager.getRoot(), PROJECT1, TestContext.get().getUser()); - - Container workbook = ContainerManager.createContainer(project1, null, null, null, WorkbookContainerType.NAME, TestContext.get().getUser()); - File expectedWorkbookRoot = new File(svc.getFileRoot(project1), workbook.getName()); - assertPathsEqual("Workbook has incorrect file root", expectedWorkbookRoot, svc.getFileRoot(workbook)); - - Container tab = ContainerManager.createContainer(project1, "tab", null, null, TabContainerType.NAME, TestContext.get().getUser()); - File expectedTabRoot = new File(svc.getFileRoot(project1), tab.getName()); - assertPathsEqual("Folder tab has incorrect file root", expectedTabRoot, svc.getFileRoot(tab)); - } - - /** - * Test that the Site Settings can be configured from startup properties - */ - @Test - public void testStartupPropertiesForSiteRootSettings() throws IOException - { - // save the original Site Root File settings so that we can restore them when this test is done - File originalSiteRootFile = FileContentService.get().getSiteDefaultRoot(); - - // create the new site root file to test with as a child of the current site root file so that we know it is in a dir that exist - String originalSiteRootFilePath = originalSiteRootFile.getAbsolutePath(); - File testSiteRootFile = new File(originalSiteRootFilePath, "testSiteRootFile"); - testSiteRootFile.createNewFile(); - - ModuleLoader.getInstance().handleStartupProperties(new RandomSiteSettingsPropertyHandler(){ - @Override - public @NotNull Collection getStartupPropertyEntries() - { - return List.of(new StartupPropertyEntry("siteFileRoot", testSiteRootFile.getAbsolutePath(), "startup", SCOPE_SITE_SETTINGS)); - } - - @Override - public boolean performChecks() - { - return false; - } - }); - - // now check that the expected changes occurred to the Site Root File settings on the server - File newSiteRootFile = FileContentService.get().getSiteDefaultRoot(); - Assert.assertEquals("The expected change in Site Root File was not found", testSiteRootFile.getAbsolutePath(), newSiteRootFile.getAbsolutePath()); - - // restore the Site Root File server settings to how they were originally - FileContentService.get().setSiteDefaultRoot(originalSiteRootFile, null); - testSiteRootFile.delete(); - } - - @After - public void cleanup() - { - FileContentService svc = FileContentService.get(); - Assert.assertNotNull(svc); - - deleteContainerAndFiles(svc, ContainerManager.getForPath(PROJECT1)); - deleteContainerAndFiles(svc, ContainerManager.getForPath(PROJECT2)); - - File testRoot = getTestRoot(); - if (NetworkDrive.exists(testRoot)) - { - FileUtil.deleteDir(testRoot); - } - } - - private void deleteContainerAndFiles(FileContentService svc, @Nullable Container c) - { - if (c != null) - { - ContainerManager.deleteAll(c, TestContext.get().getUser()); - - File file1 = svc.getFileRoot(c); - if (NetworkDrive.exists(file1)) - { - FileUtil.deleteDir(file1); - } - } - } - } -} +/* + * Copyright (c) 2009-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.labkey.filecontent; + +import org.apache.commons.io.FilenameUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.junit.After; +import org.junit.Assert; +import org.junit.Test; +import org.labkey.api.action.SpringActionController; +import org.labkey.api.admin.AdminUrls; +import org.labkey.api.attachments.AttachmentDirectory; +import org.labkey.api.cache.Cache; +import org.labkey.api.cache.CacheManager; +import org.labkey.api.cloud.CloudStoreService; +import org.labkey.api.collections.CaseInsensitiveHashMap; +import org.labkey.api.data.CompareType; +import org.labkey.api.data.Container; +import org.labkey.api.data.ContainerManager; +import org.labkey.api.data.ContainerManager.ContainerListener; +import org.labkey.api.data.ContainerType; +import org.labkey.api.data.CoreSchema; +import org.labkey.api.data.SQLFragment; +import org.labkey.api.data.SimpleFilter; +import org.labkey.api.data.TabContainerType; +import org.labkey.api.data.Table; +import org.labkey.api.data.TableInfo; +import org.labkey.api.data.TableSelector; +import org.labkey.api.data.WorkbookContainerType; +import org.labkey.api.exp.Lsid; +import org.labkey.api.exp.api.ExpData; +import org.labkey.api.exp.api.ExpProtocol; +import org.labkey.api.exp.api.ExpRun; +import org.labkey.api.exp.api.ExperimentService; +import org.labkey.api.exp.query.ExpDataTable; +import org.labkey.api.files.DirectoryPattern; +import org.labkey.api.files.FileContentService; +import org.labkey.api.files.FileListener; +import org.labkey.api.files.FileRoot; +import org.labkey.api.files.FilesAdminOptions; +import org.labkey.api.files.MissingRootDirectoryException; +import org.labkey.api.files.UnsetRootDirectoryException; +import org.labkey.api.files.view.FilesWebPart; +import org.labkey.api.module.Module; +import org.labkey.api.module.ModuleLoader; +import org.labkey.api.pipeline.PipeRoot; +import org.labkey.api.pipeline.PipelineService; +import org.labkey.api.pipeline.PipelineUrls; +import org.labkey.api.query.BatchValidationException; +import org.labkey.api.query.FieldKey; +import org.labkey.api.query.QueryUpdateService; +import org.labkey.api.security.User; +import org.labkey.api.security.permissions.AdminOperationsPermission; +import org.labkey.api.settings.AppProps; +import org.labkey.api.settings.RandomSiteSettingsPropertyHandler; +import org.labkey.api.settings.StartupPropertyEntry; +import org.labkey.api.settings.WriteableAppProps; +import org.labkey.api.test.TestWhen; +import org.labkey.api.util.ContainerUtil; +import org.labkey.api.util.DOM; +import org.labkey.api.util.FileUtil; +import org.labkey.api.util.GUID; +import org.labkey.api.util.HtmlString; +import org.labkey.api.util.NetworkDrive; +import org.labkey.api.util.PageFlowUtil; +import org.labkey.api.util.Path; +import org.labkey.api.util.TestContext; +import org.labkey.api.util.URIUtil; +import org.labkey.api.view.ActionURL; +import org.labkey.api.view.HttpView; +import org.labkey.api.view.ViewBackgroundInfo; +import org.labkey.api.view.ViewContext; +import org.labkey.api.view.template.WarningProvider; +import org.labkey.api.view.template.WarningService; +import org.labkey.api.view.template.Warnings; +import org.labkey.api.webdav.WebdavResource; +import org.labkey.api.webdav.WebdavService; +import org.labkey.vfs.FileLike; + +import java.beans.PropertyChangeEvent; +import java.io.BufferedWriter; +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import static org.labkey.api.settings.AppProps.SCOPE_SITE_SETTINGS; +import static org.labkey.api.util.DOM.Attribute.href; +import static org.labkey.api.util.DOM.at; + +public class FileContentServiceImpl implements FileContentService, WarningProvider +{ + private static final Logger _log = LogManager.getLogger(FileContentServiceImpl.class); + private static final String UPLOAD_LOG = ".upload.log"; + private static final FileContentServiceImpl INSTANCE = new FileContentServiceImpl(); + + private final ContainerListener _containerListener = new FileContentServiceContainerListener(); + private final List _fileListeners = new CopyOnWriteArrayList<>(); + + private final List _ziploaderPattern = new CopyOnWriteArrayList<>(); + + private volatile boolean _fileRootSetViaStartupProperty = false; + private String _problematicFileRootMessage; + + enum FileAction + { + UPLOAD, + DELETE + } + + static FileContentServiceImpl getInstance() + { + return INSTANCE; + } + + private FileContentServiceImpl() + { + WarningService.get().register(this); + } + + @Override + @NotNull + public List getContainersForFilePath(java.nio.file.Path path) + { + // Ignore cloud files for now + if (FileUtil.hasCloudScheme(path)) + return Collections.emptyList(); + + // If the path is under the default root, do optimistic simple match for containers under the default root + File defaultRoot = getSiteDefaultRoot(); + java.nio.file.Path defaultRootPath = defaultRoot.toPath(); + if (path.startsWith(defaultRootPath)) + { + java.nio.file.Path rel = defaultRootPath.relativize(path); + if (rel.getNameCount() > 0) + { + Container root = ContainerManager.getRoot(); + Container next = root; + while (rel.getNameCount() > 0) + { + // check if there exists a child container that matches the next path segment + java.nio.file.Path top = rel.subpath(0, 1); + assert top != null; + Container child = next.getChild(top.getFileName().toString()); + if (child == null) + break; + + next = child; + + if(rel.getNameCount() > 1) + { + rel = rel.subpath(1, rel.getNameCount()); + } + else + { + break; + } + } + + if (next != null && !next.equals(root)) + { + // verify our naive file path is correct for the container -- it may have a file root other than the default + java.nio.file.Path fileRoot = getFileRootPath(next); + if (fileRoot != null && path.startsWith(fileRoot)) + return Collections.singletonList(next); + } + } + } + + // TODO: Create cache of file root and pipeline root paths -> list of containers + + return Collections.emptyList(); + } + + @Override + public @Nullable File getFileRoot(@NotNull Container c, @NotNull ContentType type) + { + switch (type) + { + case files: + case assayfiles: + String folderName = getFolderName(type); + if (folderName == null) + folderName = ""; + + java.nio.file.Path dir = getFileRootPath(c); + return dir != null ? dir.resolve(folderName).toFile() : null; + + case pipeline: + PipeRoot root = PipelineService.get().findPipelineRoot(c); + return root != null ? root.getRootPath() : null; + } + return null; + } + + @Override + public @Nullable java.nio.file.Path getFileRootPath(@NotNull Container c, @NotNull ContentType type) + { + switch (type) + { + case files: + case assayfiles: + java.nio.file.Path fileRootPath = getFileRootPath(c); + if (null != fileRootPath && !FileUtil.hasCloudScheme(fileRootPath)) // Don't add @files when we're in the cloud + fileRootPath = fileRootPath.resolve(getFolderName(type)); + return fileRootPath; + + case pipeline: + PipeRoot root = PipelineService.get().findPipelineRoot(c); + return root != null ? root.getRootNioPath() : null; + } + return null; + } + + // Returns full uri to file root for this container. filePath is optional relative path to a file under the file root + @Override + public @Nullable URI getFileRootUri(@NotNull Container c, @NotNull ContentType type, @Nullable String filePath) + { + java.nio.file.Path root = FileContentService.get().getFileRootPath(c, FileContentService.ContentType.files); + if (root != null) + { + String path = root.toString(); + if (filePath != null) { + path += filePath; + } + + // non-unix needs a leading slash + if (!path.startsWith("/") && !path.startsWith("\\")) + { + path = "/" + path; + } + return FileUtil.createUri(path); + } + + return null; + } + + @Override + public @Nullable File getFileRoot(@NotNull Container c) + { + java.nio.file.Path path = getFileRootPath(c); + throwIfPathNotFile(path, c); + return path.toFile(); + } + + @Override + public @Nullable java.nio.file.Path getFileRootPath(@NotNull Container c) + { + if (c == null) + return null; + + if (c.isRoot()) + { + return getSiteDefaultRootPath(); + } + + if (!isFileRootDisabled(c)) + { + FileRoot root = FileRootManager.get().getFileRoot(c); + + // check if there is a site wide file root + if (root.getPath() == null || isUseDefaultRoot(c)) + { + return getDefaultRootPath(c, true); + } + else + return getNioPath(c, root.getPath()); + } + return null; + } + + @Override + public File getDefaultRoot(Container c, boolean createDir) + { + return getDefaultRootPath(c, createDir).toFile(); + } + + @Override + public java.nio.file.Path getDefaultRootPath(@NotNull Container c, boolean createDir) + { + Container firstOverride = getFirstAncestorWithOverride(c); + + java.nio.file.Path parentRoot; + if (firstOverride == null) + { + parentRoot = getSiteDefaultRoot().toPath(); + firstOverride = ContainerManager.getRoot(); + } + else + { + parentRoot = getFileRootPath(firstOverride); + } + + if (parentRoot != null && firstOverride != null) + { + java.nio.file.Path fileRootPath; + if (FileUtil.hasCloudScheme(parentRoot)) + { + // For cloud root, we don't have to create directories for this path + fileRootPath = CloudStoreService.get().getPathForOtherContainer(firstOverride, c, FileUtil.pathToString(parentRoot), new Path("")); + } + else + { + // For local, the path may be several directories deep (since it matches the LK folder path), so we should create the directories for that path + fileRootPath = FileUtil.appendPath(parentRoot.toFile(), Path.parse(getRelativePath(c, firstOverride))).toPath(); + + try + { + if (createDir && !NetworkDrive.exists(fileRootPath)) + FileUtil.createDirectories(fileRootPath); + } + catch (IOException e) + { + return null; // throw new RuntimeException(e); TODO: does returning null make certain tests, like TargetedMSQCGuideSetTest pass on Windows? + } + } + + return fileRootPath; + } + return null; + } + + // Return pretty path string for defaultFileRoot and boolean true if defaultFileRoot is cloud + @Override + public DefaultRootInfo getDefaultRootInfo(Container container) + { + String defaultRoot = ""; + boolean isDefaultRootCloud = false; + java.nio.file.Path defaultRootPath = getDefaultRootPath(container, false); + String cloudName = null; + if (defaultRootPath != null) + { + isDefaultRootCloud = FileUtil.hasCloudScheme(defaultRootPath); + if (isDefaultRootCloud && !container.isProject()) + { + FileRoot fileRoot = getDefaultFileRoot(container); + if (null != fileRoot) + defaultRoot = fileRoot.getPath(); + if (null != defaultRoot) + cloudName = getCloudRootName(defaultRoot); + } + else + { + defaultRoot = FileUtil.getAbsolutePath(container, defaultRootPath.toUri()); + } + } + return new DefaultRootInfo(defaultRootPath, defaultRoot, isDefaultRootCloud, cloudName); + } + + @Nullable + // Get FileRoot associated with path returned form getDefaultRootPath() + public FileRoot getDefaultFileRoot(Container c) + { + Container firstOverride = getFirstAncestorWithOverride(c); + + if (firstOverride == null) + firstOverride = ContainerManager.getRoot(); + + if (null != firstOverride) + return FileRootManager.get().getFileRoot(firstOverride); + return null; + } + + private @NotNull String getRelativePath(Container c, Container ancestor) + { + return c.getPath().replaceAll("^" + Pattern.quote(ancestor.getPath()), ""); + } + + //returns the first parent container that has a custom file root, or NULL if none have overrides + private Container getFirstAncestorWithOverride(Container c) + { + Container toTest = c.getParent(); + if (toTest == null) + return null; + + while (isUseDefaultRoot(toTest)) + { + if (toTest == null || toTest.equals(ContainerManager.getRoot())) + return null; + + toTest = toTest.getParent(); + } + + return toTest; + } + + private java.nio.file.Path getNioPath(Container c, @NotNull String fileRootPath) + { + if (isCloudFileRoot(fileRootPath)) + return CloudStoreService.get().getPath(c, getCloudRootName(fileRootPath), new org.labkey.api.util.Path("")); + + return FileUtil.stringToPath(c, fileRootPath, false); // fileRootPath is unencoded + } + + private boolean isCloudFileRoot(String fileRootPseudoPath) + { + return StringUtils.startsWith(fileRootPseudoPath, FileContentService.CLOUD_ROOT_PREFIX); + } + + private String getCloudRootName(@NotNull String fileRootPseudoPath) + { + return fileRootPseudoPath.substring(fileRootPseudoPath.indexOf(FileContentService.CLOUD_ROOT_PREFIX) + FileContentService.CLOUD_ROOT_PREFIX.length() + 1); + } + + @Override + public boolean isCloudRoot(Container c) + { + if (null != c) + { + java.nio.file.Path fileRootPath = getFileRootPath(c); + return null != fileRootPath && FileUtil.hasCloudScheme(fileRootPath); + } + return false; + } + + @Override + @NotNull + public String getCloudRootName(Container c) + { + if (null != c) + { + if (isCloudRoot(c)) + { + FileRoot root = FileRootManager.get().getFileRoot(c); + if (null == root.getPath() || isUseDefaultRoot(c)) + { + Container firstOverride = getFirstAncestorWithOverride(c); + if (null == firstOverride) + firstOverride = ContainerManager.getRoot(); + root = FileRootManager.get().getFileRoot(firstOverride); + if (null == root.getPath()) + return ""; + } + return getCloudRootName(root.getPath()); + } + } + return ""; + } + + @Override + public void setCloudRoot(@NotNull Container c, String cloudRootName) + { + _setFileRoot(c, FileContentService.CLOUD_ROOT_PREFIX + "/" + cloudRootName); + } + + @Override + public void setFileRoot(@NotNull Container c, @Nullable File path) + { + _setFileRoot(c, (null != path ? FileUtil.getAbsoluteCaseSensitiveFile(path).getAbsolutePath() : null)); + } + + @Override + public void setFileRootPath(@NotNull Container c, @Nullable String strPath) + { + String absolutePath = null; + if (strPath != null) + { + URI uri = FileUtil.createUri(strPath, false); // strPath is unencoded + if (FileUtil.hasCloudScheme(uri)) + absolutePath = FileUtil.getAbsolutePath(c, uri); + else + absolutePath = FileUtil.getAbsoluteCaseSensitiveFile(new File(uri)).getAbsolutePath(); + } + _setFileRoot(c, absolutePath); + } + + private void _setFileRoot(@NotNull Container c, @Nullable String absolutePath) + { + if (!c.isContainerFor(ContainerType.DataType.fileRoot)) + throw new IllegalArgumentException("File roots cannot be set for containers of type " + c.getContainerType().getName()); + + FileRoot root = FileRootManager.get().getFileRoot(c); + root.setEnabled(true); + + String oldValue = root.getPath(); + String newValue = null; + + // clear out the root + if (absolutePath == null) + root.setPath(null); + else + { + root.setPath(absolutePath); + newValue = root.getPath(); + } + + FileRootManager.get().saveFileRoot(null, root); + ContainerManager.ContainerPropertyChangeEvent evt = new ContainerManager.ContainerPropertyChangeEvent( + c, ContainerManager.Property.WebRoot, oldValue, newValue); + ContainerManager.firePropertyChangeEvent(evt); + } + + @Override + public void disableFileRoot(Container container) + { + if (container == null || container.isRoot()) + throw new IllegalArgumentException("Disabling either a null project or the root project is not allowed."); + + Container effective = container.getContainerFor(ContainerType.DataType.fileRoot); + if (effective != null) + { + FileRoot root = FileRootManager.get().getFileRoot(effective); + String oldValue = root.getPath(); + root.setEnabled(false); + FileRootManager.get().saveFileRoot(null, root); + + ContainerManager.ContainerPropertyChangeEvent evt = new ContainerManager.ContainerPropertyChangeEvent( + container, ContainerManager.Property.WebRoot, oldValue, null); + ContainerManager.firePropertyChangeEvent(evt); + } + } + + @Override + public boolean isFileRootDisabled(Container c) + { + Container effective = c.getContainerFor(ContainerType.DataType.fileRoot); + if (null == effective) + return false; + + FileRoot root = FileRootManager.get().getFileRoot(effective); + return !root.isEnabled(); + } + + @Override + public boolean isUseDefaultRoot(Container c) + { + if (c == null) + return true; + + Container effective = c.getContainerFor(ContainerType.DataType.fileRoot); + if (null == effective) + return true; + + FileRoot root = FileRootManager.get().getFileRoot(effective); + return root.isUseDefault() || StringUtils.isEmpty(root.getPath()); + } + + @Override + public void setIsUseDefaultRoot(Container c, boolean useDefaultRoot) + { + Container effective = c.getContainerFor(ContainerType.DataType.fileRoot); + if (effective != null) + { + FileRoot root = FileRootManager.get().getFileRoot(effective); + String oldValue = root.getPath(); + root.setEnabled(true); + root.setUseDefault(useDefaultRoot); + if (useDefaultRoot) + root.setPath(null); + FileRootManager.get().saveFileRoot(null, root); + + ContainerManager.ContainerPropertyChangeEvent evt = new ContainerManager.ContainerPropertyChangeEvent( + effective, ContainerManager.Property.WebRoot, oldValue, null); + ContainerManager.firePropertyChangeEvent(evt); + } + } + + @Override + public @NotNull java.nio.file.Path getSiteDefaultRootPath() + { + return getSiteDefaultRoot().toPath(); + } + + @Override + public @NotNull File getSiteDefaultRoot() + { + // Site default is always on file system + File root = AppProps.getInstance().getFileSystemRoot(); + + try + { + if (!NetworkDrive.exists(root)) + { + File configuredRoot = root; + root = getDefaultRoot(); + if (configuredRoot != null && !configuredRoot.equals(root)) + { + String message = "The configured site-wide file root " + configuredRoot + " does not exist. Falling back to " + root; + if (!message.equals(_problematicFileRootMessage)) + { + _problematicFileRootMessage = message; + _log.error(_problematicFileRootMessage); + } + } + } + else + { + _problematicFileRootMessage = null; + } + + if (!NetworkDrive.exists(root)) + { + if (FileUtil.mkdirs(root)) + { + _log.info("Created site-wide file root " + root); + } + else + { + _log.error("Failed when attempting to create site-wide file root " + root); + } + } + } + catch (IOException e) + { + throw new RuntimeException("Unable to create file root directory", e); + } + + return root; + } + + @Override + public String getProblematicFileRootMessage() + { + return _problematicFileRootMessage; + } + + private @NotNull File getDefaultRoot() throws IOException + { + File explodedPath = ModuleLoader.getInstance().getCoreModule().getExplodedPath(); + + File root = explodedPath.getParentFile(); + if (root != null) + { + if (root.getParentFile() != null) + root = root.getParentFile(); + } + File defaultRoot = new File(root, "files"); + if (!NetworkDrive.exists(defaultRoot)) + FileUtil.mkdirs(defaultRoot); + + return defaultRoot; + } + + @Override + public void setSiteDefaultRoot(File root, User user) + { + if (root == null) + throw new IllegalArgumentException("Invalid site root: specified root is null"); + + if (!NetworkDrive.exists(root)) + throw new IllegalArgumentException("Invalid site root: " + root.getAbsolutePath() + " does not exist"); + + File prevRoot = getSiteDefaultRoot(); + WriteableAppProps props = AppProps.getWriteableInstance(); + + props.setFileSystemRoot(root.getAbsolutePath()); + props.save(user); + + FileRootManager.get().clearCache(); + ContainerManager.ContainerPropertyChangeEvent evt = new ContainerManager.ContainerPropertyChangeEvent( + ContainerManager.getRoot(), ContainerManager.Property.SiteRoot, prevRoot, root); + ContainerManager.firePropertyChangeEvent(evt); + } + + @Override + public void setWebfilesEnabled(boolean enabled, User user) + { + WriteableAppProps props = AppProps.getWriteableInstance(); + props.setWebfilesEnabled(enabled); + props.save(user); + } + + @Override + public FileSystemAttachmentParent registerDirectory(Container c, String name, String path, boolean relative) + { + FileSystemAttachmentParent parent = new FileSystemAttachmentParent(); + parent.setContainer(c); + if (null == name) + name = path; + parent.setName(name); + parent.setPath(path); + parent.setRelative(relative); + //We do this because insert does not return new fields + parent.setEntityid(GUID.makeGUID()); + + FileSystemAttachmentParent ret = Table.insert(HttpView.currentContext().getUser(), CoreSchema.getInstance().getMappedDirectories(), parent); + ContainerManager.ContainerPropertyChangeEvent evt = new ContainerManager.ContainerPropertyChangeEvent( + c, ContainerManager.Property.AttachmentDirectory, null, ret); + ContainerManager.firePropertyChangeEvent(evt); + return ret; + } + + @Override + public void unregisterDirectory(Container c, String name) + { + FileSystemAttachmentParent parent = getRegisteredDirectory(c, name); + SimpleFilter filter = SimpleFilter.createContainerFilter(c); + filter.addCondition(FieldKey.fromParts("Name"), name); + Table.delete(CoreSchema.getInstance().getMappedDirectories(), filter); + ContainerManager.ContainerPropertyChangeEvent evt = new ContainerManager.ContainerPropertyChangeEvent( + c, ContainerManager.Property.AttachmentDirectory, parent, null); + ContainerManager.firePropertyChangeEvent(evt); + } + + @Override + public @Nullable AttachmentDirectory getMappedAttachmentDirectory(Container c, boolean createDir) throws UnsetRootDirectoryException, MissingRootDirectoryException + { + return getMappedAttachmentDirectory(c, ContentType.files, createDir); + } + + @Override + @Nullable + public AttachmentDirectory getMappedAttachmentDirectory(Container c, ContentType contentType, boolean createDir) throws UnsetRootDirectoryException + { + try + { + if (createDir) //force create + getMappedDirectory(c, true); + else if (null == getMappedDirectory(c, false)) + return null; + + return new FileSystemAttachmentParent(c, contentType); + } + catch (IOException e) + { + _log.error("Cannot get mapped directory for " + c.getPath(), e); + return null; + } + } + + public java.nio.file.Path getMappedDirectory(Container c, boolean create) throws UnsetRootDirectoryException, IOException + { + java.nio.file.Path root = getFileRootPath(c); + if (!FileUtil.hasCloudScheme(root)) + { + if (null == root) + { + if (create) + throw new UnsetRootDirectoryException(c.isRoot() ? c : c.getProject()); + else + return null; + } + + if (!NetworkDrive.exists(root)) + { + if (create) + throw new MissingRootDirectoryException(c.isRoot() ? c : c.getProject(), root); + else + return null; + + } + } + return root; + } + + @Override + public FileSystemAttachmentParent getRegisteredDirectory(Container c, String name) + { + SimpleFilter filter = SimpleFilter.createContainerFilter(c); + filter.addCondition(FieldKey.fromParts("Name"), name); + + return new TableSelector(CoreSchema.getInstance().getMappedDirectories(), filter, null).getObject(FileSystemAttachmentParent.class); + } + + @Override + public FileSystemAttachmentParent getRegisteredDirectoryFromEntityId(Container c, String entityId) + { + SimpleFilter filter = SimpleFilter.createContainerFilter(c); + filter.addCondition(FieldKey.fromParts("EntityId"), entityId); + + return new TableSelector(CoreSchema.getInstance().getMappedDirectories(), filter, null).getObject(FileSystemAttachmentParent.class); + } + + @Override + public @NotNull Collection getRegisteredDirectories(Container c) + { + SimpleFilter filter = SimpleFilter.createContainerFilter(c); + + return Collections.unmodifiableCollection(new TableSelector(CoreSchema.getInstance().getMappedDirectories(), filter, null).getCollection(FileSystemAttachmentParent.class)); + } + + private class FileContentServiceContainerListener implements ContainerListener + { + @Override + public void containerCreated(Container c, User user) + { + try + { + // Will create directory if it's a default dir + getMappedDirectory(c, false); + } + catch (IOException ex) + { + /* */ + } + } + + @Override + public void containerDeleted(Container c, User user) + { + java.nio.file.Path dir = null; + try + { + // don't delete the file contents if they have a project override + if (isUseDefaultRoot(c) && !isCloudRoot(c)) // Don't do anything for cloud root here. CloudContainerListener will handle + dir = getMappedDirectory(c, false); + + if (null != dir) + { + FileUtil.deleteDir(dir); + } + } + catch (Exception e) + { + _log.error("containerDeleted", e); + } + + ContainerUtil.purgeTable(CoreSchema.getInstance().getMappedDirectories(), c, null); + } + + @Override + public void containerMoved(Container c, Container oldParent, User user) + { + /* **** Cases: + SRC DEST + specific local path same -- no work + specific cloud path same -- no work + local default local default -- move tree + local default cloud default -- move tree + cloud default local default -- move tree + cloud default cloud default -- if change bucket, move tree + *************************************************************/ + if (isUseDefaultRoot(c)) + { + java.nio.file.Path srcParent = getFileRootPath(oldParent); + java.nio.file.Path dest = getFileRootPath(c); + if (null != srcParent && null != dest) + { + if (!FileUtil.hasCloudScheme(srcParent)) + { + File src = new File(srcParent.toFile(), c.getName()); + if (NetworkDrive.exists(src)) + { + if (!FileUtil.hasCloudScheme(dest)) + { + // local -> local + moveFileRoot(src, dest.toFile(), user, c); + } + else + { + // local -> cloud; source starts under @files + File filesSrc = FileUtil.appendName(src, FILES_LINK); + if (NetworkDrive.exists(filesSrc)) + moveFileRoot(filesSrc.toPath(), dest, user, c); + FileUtil.deleteDir(src); // moveFileRoot will delete @files, but we need to delete its parent + } + } + } + else + { + // Get source path using moving container and parent's config (cloudRoot), because that config must be the source config + java.nio.file.Path src = CloudStoreService.get().getPath(c, getCloudRootName(oldParent), new Path("")); + if (!FileUtil.hasCloudScheme(dest)) + { + // cloud -> local; destination is under @files + dest = dest.resolve(FILES_LINK); + moveFileRoot(src, dest, user, c); + } + else + { + // cloud -> cloud + if (!getCloudRootName(oldParent).equals(getCloudRootName(c))) + { + // Different configs + moveFileRoot(src, dest, user, c); + } + } + } + } + } + } + + @NotNull + @Override + public Collection canMove(Container c, Container newParent, User user) + { + return Collections.emptyList(); + } + + @Override + public void propertyChange(PropertyChangeEvent propertyChangeEvent) + { + ContainerManager.ContainerPropertyChangeEvent evt = (ContainerManager.ContainerPropertyChangeEvent)propertyChangeEvent; + Container c = evt.container; + + switch (evt.property) + { + case Name: // container rename event + { + String oldValue = (String) propertyChangeEvent.getOldValue(); + String newValue = (String) propertyChangeEvent.getNewValue(); + + java.nio.file.Path location; + try + { + location = getMappedDirectory(c, false); + if (location != null && !FileUtil.hasCloudScheme(location)) // If cloud, folder name for container not dependent on Name + { + //Don't rely on container object. Seems not to point to the + //new location even AFTER rename. Just construct new file paths + File locationFile = location.toFile(); + File parentDir = locationFile.getParentFile(); + File oldLocation = new File(parentDir, oldValue); + File newLocation = new File(parentDir, newValue); + if (NetworkDrive.exists(newLocation)) + moveToDeleted(newLocation); + + if (NetworkDrive.exists(oldLocation)) + { + oldLocation.renameTo(newLocation); + fireFileMoveEvent(oldLocation, newLocation, evt.user, evt.container); + } + } + } + catch (IOException ex) + { + _log.error(ex); + } + + break; + } + } + } + } + + + @Override + public @Nullable String getFolderName(FileContentService.ContentType type) + { + if (type != null) + return "@" + type.name(); + return null; + } + + + /** + * Move the file or directory into a ".deleted" directory under the parent directory. + * @return True if successfully moved. + */ + private static boolean moveToDeleted(File fileToMove) throws IOException + { + if (!NetworkDrive.exists(fileToMove)) + return false; + + File parent = fileToMove.getParentFile(); + + File deletedDir = new File(parent, ".deleted"); + if (!NetworkDrive.exists(deletedDir)) + if (!FileUtil.mkdir(deletedDir)) + return false; + + File newLocation = new File(deletedDir, fileToMove.getName()); + if (NetworkDrive.exists(newLocation)) + FileUtil.deleteDir(newLocation); + + return fileToMove.renameTo(newLocation); + } + + static void logFileAction(java.nio.file.Path directory, String fileName, FileAction action, User user) + { + try (BufferedWriter fw = Files.newBufferedWriter(directory.resolve(UPLOAD_LOG), StandardOpenOption.APPEND, StandardOpenOption.CREATE)) + { + fw.write(action.toString() + "\t" + fileName + "\t" + new Date() + "\t" + (user == null ? "(unknown)" : user.getEmail()) + "\n"); + } + catch (Exception x) + { + //Just log it. + _log.error(x); + } + } + + @Override + public FilesAdminOptions getAdminOptions(Container c) + { + FileRoot root = FileRootManager.get().getFileRoot(c); + String xml = null; + + if (!StringUtils.isBlank(root.getProperties())) + { + xml = root.getProperties(); + } + return new FilesAdminOptions(c, xml); + } + + @Override + public void setAdminOptions(Container c, FilesAdminOptions options) + { + if (options != null) + { + setAdminOptions(c, options.serialize()); + } + } + + @Override + public void setAdminOptions(Container c, String properties) + { + FileRoot root = FileRootManager.get().getFileRoot(c); + + root.setProperties(properties); + FileRootManager.get().saveFileRoot(null, root); + } + + public static final String NAMESPACE_PREFIX = "FileProperties"; + public static final String PROPERTIES_DOMAIN = "File Properties"; + public static final String TYPE_PROPERTIES = "FileProperties"; + + @Override + public String getDomainURI(Container container) + { + return getDomainURI(container, getAdminOptions(container).getFileConfig()); + } + + @Override + public String getDomainURI(Container container, FilesAdminOptions.fileConfig config) + { + while (config == FilesAdminOptions.fileConfig.useParent && container != container.getParent()) + { + container = container.getParent(); + config = getAdminOptions(container).getFileConfig(); + } + + //String typeURI = "urn:lsid:" + AppProps.getInstance().getDefaultLsidAuthority() + ":List" + ".Folder-" + container.getRowId() + ":" + name; + + return new Lsid("urn:lsid:labkey.com:" + NAMESPACE_PREFIX + ".Folder-" + container.getRowId() + ':' + TYPE_PROPERTIES).toString(); + } + + @Override @Nullable + public ExpData getDataObject(WebdavResource resource, Container c) + { + return getDataObject(resource, c, null, false); + } + + @Nullable + private static ExpData getDataObject(WebdavResource resource, Container c, User user, boolean create) + { + // TODO: S3: seems to only be called from Search and currently we're not searching in cloud. SaveCustomPropsAction seems unused + if (resource != null) + { + File file = resource.getFile(); + if (file != null) + { + ExpData data = ExperimentService.get().getExpDataByURL(file, c); + + if (data == null && create) + { + data = ExperimentService.get().createData(c, FileContentService.UPLOADED_FILE); + data.setName(file.getName()); + data.setDataFileURI(file.toURI()); + data.save(user); + } + return data; + } + } + return null; + } + + @Override + public QueryUpdateService getFilePropsUpdateService(TableInfo tinfo, Container container) + { + return new FileQueryUpdateService(tinfo, container); + } + + @Override + public boolean isValidProjectRoot(String root) + { + File f = new File(root); + return NetworkDrive.exists(f) && f.isDirectory(); + } + + @Override + public void moveFileRoot(java.nio.file.Path prev, java.nio.file.Path dest, @Nullable User user, @Nullable Container container) + { + if (!FileUtil.hasCloudScheme(prev) && !FileUtil.hasCloudScheme(dest)) + { + moveFileRoot(prev.toFile(), dest.toFile(), user, container); // Both files; try rename + } + else + { + try + { + // At least one is in the cloud + FileUtil.copyDirectory(prev, dest); + FileUtil.deleteDir(prev); // TODO use more efficient delete + fireFileMoveEvent(prev, dest, user, container); + } + catch (IOException e) + { + _log.error("error occurred moving the file root", e); + } + } + } + + @Override + public void moveFileRoot(File prev, File dest, @Nullable User user, @Nullable Container container) + { + try + { + _log.info("moving " + prev.getPath() + " to " + dest.getPath()); + boolean doRename = true; + + // Our best bet for perf is to do a rename, which doesn't require creating an actual copy. + // If it exists, try deleting the target directory, which will only succeed if it's empty, but would + // enable using renameTo() method. Don't delete if it's a symbolic link, since it wouldn't be recreated + // in the same way. + if (NetworkDrive.exists(dest) && !Files.isSymbolicLink(dest.toPath())) + doRename = dest.delete(); + + if (doRename && !prev.renameTo(dest)) + { + _log.info("rename failed, attempting to copy"); + + //listFiles can return null, which could cause a NPE + File[] children = prev.listFiles(); + if (children != null) + { + for (File file : children) + FileUtil.copyBranch(file, dest); + } + FileUtil.deleteDir(prev); + } + fireFileMoveEvent(prev, dest, user, container); + } + catch (IOException e) + { + _log.error("error occurred moving the file root", e); + } + } + + @Override + public void fireFileCreateEvent(@NotNull File created, @Nullable User user, @Nullable Container container) + { + fireFileCreateEvent(created.toPath(), user, container); + } + + @Override + public void fireFileCreateEvent(@NotNull java.nio.file.Path created, @Nullable User user, @Nullable Container container) + { + java.nio.file.Path absPath = FileUtil.getAbsoluteCaseSensitivePath(container, created); + for (FileListener fileListener : _fileListeners) + { + fileListener.fileCreated(absPath, user, container); + } + } + + @Override + public void fireFileReplacedEvent(@NotNull java.nio.file.Path replaced, @Nullable User user, @Nullable Container container) + { + java.nio.file.Path absPath = FileUtil.getAbsoluteCaseSensitivePath(container, replaced); + for (FileListener fileListener : _fileListeners) + { + fileListener.fileReplaced(absPath, user, container); + } + } + + @Override + public void fireFileDeletedEvent(@NotNull java.nio.file.Path deleted, @Nullable User user, @Nullable Container container) + { + java.nio.file.Path absPath = FileUtil.getAbsoluteCaseSensitivePath(container, deleted); + for (FileListener fileListener : _fileListeners) + { + fileListener.fileDeleted(absPath, user, container); + } + } + + @Override + public int fireFileMoveEvent(@NotNull File src, @NotNull File dest, @Nullable User user, @Nullable Container container) + { + return fireFileMoveEvent(src.toPath(), dest.toPath(), user, container); + } + + @Override + public int fireFileMoveEvent(@NotNull java.nio.file.Path src, @NotNull java.nio.file.Path dest, @Nullable User user, @Nullable Container container) + { + return fireFileMoveEvent(src, dest, user, container, null); + } + + @Override + public int fireFileMoveEvent(@NotNull java.nio.file.Path src, @NotNull java.nio.file.Path dest, @Nullable User user, @Nullable Container sourceContainer, @Nullable Container targetContainer) + { + // Make sure that we've got the best representation of the file that we can + java.nio.file.Path absSrc = FileUtil.getAbsoluteCaseSensitivePath(sourceContainer, src); + java.nio.file.Path absDest = FileUtil.getAbsoluteCaseSensitivePath(targetContainer != null ? targetContainer : sourceContainer, dest); + int result = 0; + for (FileListener fileListener : _fileListeners) + { + result += fileListener.fileMoved(absSrc, absDest, user, sourceContainer, targetContainer); + } + return result; + } + + @Override + public void addFileListener(FileListener listener) + { + _fileListeners.add(listener); + } + + @Override + public Map> listFiles(@NotNull Container container) + { + Map> files = new LinkedHashMap<>(); + for (FileListener fileListener : _fileListeners) + { + files.put(fileListener.getSourceName(), new HashSet<>(fileListener.listFiles(container))); + } + return files; + } + + @Override + public SQLFragment listFilesQuery(@NotNull User currentUser) + { + SQLFragment frag = new SQLFragment(); + if (currentUser == null || !currentUser.hasSiteAdminPermission()) + { + frag.append("SELECT\n"); + frag.append(" CAST(NULL AS VARCHAR) AS Container,\n"); + frag.append(" NULL AS Created,\n"); + frag.append(" NULL AS CreatedBy,\n"); + frag.append(" NULL AS Modified,\n"); + frag.append(" NULL AS ModifiedBy,\n"); + frag.append(" NULL AS FilePath,\n"); + frag.append(" NULL AS SourceKey,\n"); + frag.append(" NULL AS SourceName\n"); + frag.append("WHERE 1 = 0"); + } + else + { + String union = ""; + frag.append("("); + for (FileListener fileListener : _fileListeners) + { + SQLFragment subselect = fileListener.listFilesQuery(); + if (subselect != null) + { + frag.append(union); + frag.append(subselect); + union = "UNION\n"; + } + } + frag.append(")"); + } + return frag; + } + + @Override + public void setFileRootSetViaStartupProperty(boolean fileRootSetViaStartupProperty) + { + _fileRootSetViaStartupProperty = fileRootSetViaStartupProperty; + } + + @Override + public boolean isFileRootSetViaStartupProperty() + { + return _fileRootSetViaStartupProperty; + } + + public ContainerListener getContainerListener() + { + return _containerListener; + } + + public Set> getNodes(boolean isShowOverridesOnly, @Nullable String browseUrl, Container c) + { + Set> children = new LinkedHashSet<>(); + + try { + java.nio.file.Path assayFilesRoot = getFileRootPath(c, ContentType.assayfiles); + if (NetworkDrive.exists(assayFilesRoot)) + { + Map node = createFileSetNode(c, ASSAY_FILES, assayFilesRoot); + node.put("default", false); + node.put("webdavURL", FilesWebPart.getRootPath(c, ASSAY_FILES).toString()); + children.add(node); + } + + AttachmentDirectory root = getMappedAttachmentDirectory(c, false); + if (root != null) + { + boolean isDefault = isUseDefaultRoot(c); + if (!isDefault || !isShowOverridesOnly) + { + ActionURL config = PageFlowUtil.urlProvider(AdminUrls.class).getProjectSettingsFileURL(c); + Map node = createFileSetNode(c, FILES_LINK, root.getFileSystemDirectoryPath()); + node.put("default", isUseDefaultRoot(c)); + node.put("configureURL", config.getEncodedLocalURIString()); + node.put("browseURL", browseUrl); + node.put("webdavURL", FilesWebPart.getRootPath(c, FILES_LINK).toString()); + + children.add(node); + } + } + + for (AttachmentDirectory fileSet : getRegisteredDirectories(c)) + { + ActionURL config = new ActionURL(FileContentController.ShowAdminAction.class, c); + Map node = createFileSetNode(c, fileSet.getName(), fileSet.getFileSystemDirectoryPath()); + node.put("configureURL", config.getEncodedLocalURIString()); + node.put("browseURL", browseUrl); + node.put("webdavURL", FilesWebPart.getRootPath(c, FILE_SETS_LINK, fileSet.getName()).toString()); + node.put("rootType", "fileset"); + + children.add(node); + } + + PipeRoot pipeRoot = PipelineService.get().findPipelineRoot(c); + if (pipeRoot != null) + { + boolean isDefault = PipelineService.get().hasSiteDefaultRoot(c); + if (!isDefault || !isShowOverridesOnly) + { + ActionURL config = PageFlowUtil.urlProvider(PipelineUrls.class).urlSetup(c); + ActionURL pipelineBrowse = PageFlowUtil.urlProvider(PipelineUrls.class).urlBrowse(c, null); + Map node = createFileSetNode(c, PIPELINE_LINK, pipeRoot.getRootNioPath()); + node.put("default", isDefault ); + node.put("configureURL", config.getEncodedLocalURIString()); + node.put("browseURL", pipelineBrowse.getEncodedLocalURIString()); + node.put("webdavURL", FilesWebPart.getRootPath(c, PIPELINE_LINK).toString()); + + children.add(node); + } + } + } + catch (IOException | UnsetRootDirectoryException ignored) {} + return children; + } + + protected Map createFileSetNode(Container container, String name, java.nio.file.Path dir) + { + Map node = new HashMap<>(); + if (dir != null) + { + node.put("name", name); + node.put("path", FileUtil.getAbsolutePath(container, dir)); + node.put("leaf", true); + } + return node; + } + + public String getAbsolutePathFromDataFileUrl(String dataFileUrl, Container container) + { + return FileUtil.getAbsolutePath(container, FileUtil.createUri(dataFileUrl)); + } + + @Nullable + @Override + public URI getWebDavUrl(@NotNull FileLike path, @NotNull Container container, @NotNull PathType type) + { + return getWebDavUrl(path.toNioPathForRead(), container, type); + } + + @Nullable + @Override + public URI getWebDavUrl(@NotNull java.nio.file.Path path, @NotNull Container container, @NotNull PathType type) + { + PipeRoot root = PipelineService.get().getPipelineRootSetting(container); + java.nio.file.Path assayFilesPath = getFileRootPath(container, ContentType.assayfiles); + path = path.toAbsolutePath(); + String relPath = null; + URI rootWebDavUrl = null; + + try + { + // currently, only report if the file is under the parent container + if (root != null && root.isUnderRoot(path)) + { + relPath = root.relativePath(path); + rootWebDavUrl = root.getWebdavURL(); + } + else if (assayFilesPath != null && URIUtil.isDescendant(assayFilesPath.toUri(), path.toUri())) + { + relPath = assayFilesPath.relativize(path).toString(); + rootWebDavUrl = FilesWebPart.getRootPath(container, ASSAY_FILES); + } + + if (relPath != null) + { + relPath = Path.parse(FilenameUtils.separatorsToUnix(relPath)).encode(); + + return switch (type) + { + case folderRelative -> new URI(relPath); + case serverRelative -> new URI(rootWebDavUrl + (rootWebDavUrl.getPath().endsWith("/") ? "" : "/") + relPath); + case full -> new URI(AppProps.getInstance().getBaseServerUrl() + rootWebDavUrl + (rootWebDavUrl.getPath().endsWith("/") ? "" : "/") + relPath); + }; + } + } + catch (InvalidPathException | URISyntaxException e) + { + _log.error("Invalid WebDav URL from: " + path, e); + } + + return null; + } + + @Override + public String getDataFileRelativeFileRootPath(@NotNull String dataFileUrl, Container container) + { + Set> children = getNodes(false, null, container); + String filesRoot = null; // the path for @files + for (Map child : children) + { + String rootName = (String) child.get("name"); + String rootPath = (String) child.get("path"); + + // skip default @pipeline, which is the same as @files + if (PIPELINE_LINK.equals(rootName)) + { + if((boolean) child.get("default") || rootPath.equals(filesRoot)) + continue; + } + + if (FILES_LINK.equals(rootName)) + filesRoot = rootPath; + + String absoluteFilePath = getAbsolutePathFromDataFileUrl(dataFileUrl, container); + if (StringUtils.startsWith(absoluteFilePath, rootPath)) + { + String offset = absoluteFilePath.replace(rootPath, "").replace("\\", "/"); + int lastSlash = offset.lastIndexOf("/"); + if (lastSlash <= 0) + return "/"; + else + return offset.substring(0, lastSlash); + } + } + return null; + } + + @Override + public void ensureFileData(@NotNull ExpDataTable table) + { + Container container = table.getUserSchema().getContainer(); + // The current user may not have insert permission, and they didn't necessarily upload the files anyway + User user = User.getAdminServiceUser(); + QueryUpdateService qus = table.getUpdateService(); + if (qus == null) + { + throw new IllegalArgumentException("getUpdateServer() returned null from " + table); + } + + synchronized (_fileDataUpToDateCache) + { + if (_fileDataUpToDateCache.get(container) != null) // already synced in the past 5 minutes, skip + return; + + _fileDataUpToDateCache.put(container, true); + } + + List existingDataFileUrls = getDataFileUrls(container); + Collection filesets = getRegisteredDirectories(container); + Set> children = getNodes(false, null, container); + String filesRoot = null; // the path for @files + for (Map child : children) + { + String rootName = (String) child.get("name"); + String rootPathVal = (String) child.get("path"); + + // skip default @pipeline, which is the same as @files + if (PIPELINE_LINK.equals(rootName)) + { + if((boolean) child.get("default") || rootPathVal.equals(filesRoot)) + continue; + } + + if (FILES_LINK.equals(rootName)) + filesRoot = rootPathVal; + + String rootDavUrl = (String) child.get("webdavURL"); + + WebdavResource resource = getResource(rootDavUrl); + if (resource == null) + continue; + + List> rows = new ArrayList<>(); + BatchValidationException errors = new BatchValidationException(); + File file = resource.getFile(); + + if (file == null) + { + String rootType = (String) child.get("rootType"); + if ("fileset".equals(rootType)) + { + for (AttachmentDirectory fileset : filesets) + { + if (fileset.getName().equals(rootName)) + { + try + { + file = fileset.getFileSystemDirectory(); + } + catch (MissingRootDirectoryException e) + { + _log.error("Unable to list files for fileset: " + rootName, e); + } + break; + } + } + } + } + + if (file == null) + return; + + try (var ignore = SpringActionController.ignoreSqlUpdates()) + { + java.nio.file.Path rootPath = file.toPath(); + + try (Stream pathStream = Files.walk(rootPath, 100)) // prevent symlink loop + { + pathStream + .filter(path -> !Files.isSymbolicLink(path) && path.compareTo(rootPath) != 0) // exclude symlink & root + .forEach(path -> { + if (!containsUrlOrVariation(existingDataFileUrls, path)) + rows.add(new CaseInsensitiveHashMap<>(Collections.singletonMap("DataFileUrl", path.toUri().toString()))); + }); + } + + qus.insertRows(user, container, rows, errors, null, null); + } + catch (Exception e) + { + _log.error("Error listing content of directory: " + file.getAbsolutePath(), e); + } + } + } + + + @Override + public void addZiploaderPattern(DirectoryPattern directoryPattern) + { + _ziploaderPattern.add(directoryPattern); + } + + @Override + public List getZiploaderPatterns(Container container) + { + List registeredPatterns = new ArrayList<>(); + for(Module module : container.getActiveModules()) + { + _ziploaderPattern.forEach(p -> { + if(p.getModule().getName().equalsIgnoreCase(module.getName())) + registeredPatterns.add(p); + }); + } + return registeredPatterns; + } + + public List getDataFileUrls(Container container) + { + SimpleFilter filter = SimpleFilter.createContainerFilter(container); + filter.addCondition(FieldKey.fromParts("DataFileUrl"), null, CompareType.NONBLANK); + TableSelector selector = new TableSelector(ExperimentService.get().getTinfoData(), Collections.singleton("DataFileUrl"), filter, null); + return selector.getArrayList(String.class); + } + + public Path getPath(String uri) + { + Path path = Path.decode(uri); + + if (!path.startsWith(WebdavService.getPath()) && path.contains(WebdavService.getPath().getName())) + { + String newPath = path.toString(); + int idx = newPath.indexOf(WebdavService.getPath().toString()); + + if (idx != -1) + { + newPath = newPath.substring(idx); + path = Path.parse(newPath); + } + } + return path; + } + + @Nullable + public WebdavResource getResource(String uri) + { + Path path = getPath(uri); + return WebdavService.get().getResolver().lookup(path); + } + + public static void throwIfPathNotFile(java.nio.file.Path path, Container container) + { + if (null == path) + { + throw new RuntimeException("No path to evaluate in " + container.getPath()); + } + if (FileUtil.hasCloudScheme(path)) + { + throw new RuntimeException("Cannot get File object from Cloud File Root in " + container.getPath()); + } + } + + private boolean containsUrlOrVariation(List existingUrls, java.nio.file.Path path) + { + String url = path.toUri().toString(); + if (existingUrls.contains(url)) + return true; + + boolean urlHasTrailingSlash = (Files.isDirectory(path) && (url.endsWith("/") || url.endsWith(File.pathSeparator))); + if (urlHasTrailingSlash && existingUrls.contains(url.substring(0, url.length() - 1))) + return true; + + if (!FileUtil.hasCloudScheme(path)) + { + File file = path.toFile(); + String legacyUrl = file.toURI().toString(); + if (existingUrls.contains(legacyUrl)) // Legacy URI format (file:/users/...) + return true; + + return existingUrls.contains(file.getPath()); + } + return false; + } + + @Override + public File getMoveTargetFile(String absoluteFilePath, @NotNull Container sourceContainer, @NotNull Container targetContainer) + { + if (absoluteFilePath == null) + return null; + + File file = new File(absoluteFilePath); + if (!NetworkDrive.exists(file)) + { + _log.warn("File '" + absoluteFilePath + "' not found and cannot be moved"); + return null; + } + + File sourceFileRoot = getFileRoot(sourceContainer); + if (sourceFileRoot == null) + return null; + + String sourceRootPath = sourceFileRoot.getAbsolutePath(); + if (!absoluteFilePath.startsWith(sourceRootPath)) + { + _log.warn("File '" + absoluteFilePath + "' not currently located in source folder '" + sourceRootPath + "'. Not moving."); + return null; + } + File targetFileRoot = getFileRoot(targetContainer); + if (targetFileRoot == null) + return null; + + String targetPath = absoluteFilePath.replace(sourceRootPath, targetFileRoot.getAbsolutePath()); + File targetFile = new File(targetPath); + return FileUtil.findUniqueFileName(file.getName(), targetFile.getParentFile()); + } + + @Override + public void addDynamicWarnings(@NotNull Warnings warnings, @Nullable ViewContext context, boolean showAllWarnings) + { + if (_problematicFileRootMessage != null && context != null && ContainerManager.getRoot().hasPermission(context.getUser(), AdminOperationsPermission.class)) + { + warnings.add(DOM.createHtmlFragment(_problematicFileRootMessage, " ", DOM.A(at(href, PageFlowUtil.urlProvider(AdminUrls.class).getFilesSiteSettingsURL()), "Configure File System Access"))); + } + else if (showAllWarnings) + { + try + { + warnings.add(HtmlString.of("Configured site-wide file root " + getDefaultRoot() + " does not exist. Falling back to " + getDefaultRoot())); + } + catch (IOException ignored) {} + } + } + + // Cache with short-lived entries so that exp.files can perform reasonably + private static final Cache _fileDataUpToDateCache = CacheManager.getCache(CacheManager.UNLIMITED, 5 * CacheManager.MINUTE, "Files"); + + @TestWhen(TestWhen.When.BVT) + public static class TestCase extends AssertionError + { + private static final String TRICKY_CHARACTERS_FOR_PROJECT_NAMES = "\u2603~!@$&()_+{}-=[],.#\u00E4\u00F6\u00FC"; + + private static final String PROJECT1 = "FileRootTestProject1" + TRICKY_CHARACTERS_FOR_PROJECT_NAMES; + private static final String PROJECT1_SUBFOLDER1 = "Subfolder1"; + private static final String PROJECT1_SUBFOLDER2 = "Subfolder2" + TRICKY_CHARACTERS_FOR_PROJECT_NAMES; + private static final String PROJECT1_SUBSUBFOLDER = "SubSubfolder"; + private static final String PROJECT1_SUBSUBFOLDER_SIBLING = "SubSubfolderSibling"; + private static final String PROJECT2 = "FileRootTestProject2"; + + private static final String FILE_ROOT_SUFFIX = "_FileRootTest"; + private static final String TXT_FILE = "FileContentTestFile.txt"; + + private Map _expectedPaths; + + @Test + public void fileRootsTest() + { + //pre-clean + cleanup(); + + _expectedPaths = new HashMap<>(); + + FileContentService svc = FileContentService.get(); + Assert.assertNotNull(svc); + + Container project1 = ContainerManager.createContainer(ContainerManager.getRoot(), PROJECT1, TestContext.get().getUser()); + _expectedPaths.put(project1, null); + + Container project2 = ContainerManager.createContainer(ContainerManager.getRoot(), PROJECT2, TestContext.get().getUser()); + _expectedPaths.put(project2, null); + + Container subfolder1 = ContainerManager.createContainer(project1, PROJECT1_SUBFOLDER1, TestContext.get().getUser()); + _expectedPaths.put(subfolder1, null); + + Container subfolder2 = ContainerManager.createContainer(project1, PROJECT1_SUBFOLDER2, TestContext.get().getUser()); + _expectedPaths.put(subfolder2, null); + + Container subsubfolder = ContainerManager.createContainer(subfolder1, PROJECT1_SUBSUBFOLDER, TestContext.get().getUser()); + _expectedPaths.put(subsubfolder, null); + + //set custom root on project, then expect children to inherit + File testRoot = getTestRoot(); + + svc.setFileRoot(project1, testRoot); + _expectedPaths.put(project1, testRoot); + + //the subfolder should inherit from the parent + _expectedPaths.put(subfolder1, new File(testRoot, subfolder1.getName())); + assertPathsEqual("Incorrect values returned by getDefaultRoot", _expectedPaths.get(subfolder1), svc.getDefaultRoot(subfolder1, false)); + assertPathsEqual("Subfolder1 has incorrect root", _expectedPaths.get(subfolder1), svc.getFileRoot(subfolder1)); + + _expectedPaths.put(subfolder2, new File(testRoot, subfolder2.getName())); + assertPathsEqual("Incorrect values returned by getDefaultRoot", _expectedPaths.get(subfolder2), svc.getDefaultRoot(subfolder2, false)); + assertPathsEqual("Subfolder2 has incorrect root", _expectedPaths.get(subfolder2), svc.getFileRoot(subfolder2)); + + _expectedPaths.put(subsubfolder, new File(_expectedPaths.get(subfolder1), subsubfolder.getName())); + assertPathsEqual("Incorrect values returned by getDefaultRoot", _expectedPaths.get(subsubfolder), svc.getDefaultRoot(subsubfolder, false)); + assertPathsEqual("SubSubfolder has incorrect root", _expectedPaths.get(subsubfolder), svc.getFileRoot(subsubfolder)); + + //override root on 1st child, expect children of that folder to inherit + _expectedPaths.put(subfolder1, new File(testRoot, "CustomSubfolder")); + _expectedPaths.get(subfolder1).mkdirs(); + svc.setFileRoot(subfolder1, _expectedPaths.get(subfolder1)); + assertPathsEqual("SubSubfolder has incorrect root", new File(_expectedPaths.get(subfolder1), subsubfolder.getName()), svc.getFileRoot(subsubfolder)); + + //reset project, we assume overridden child roots to remain the same + svc.setFileRoot(project1, null); + assertPathsEqual("Subfolder1 has incorrect root", _expectedPaths.get(subfolder1), svc.getFileRoot(subfolder1)); + assertPathsEqual("SubSubfolder has incorrect root", new File(_expectedPaths.get(subfolder1), subsubfolder.getName()), svc.getFileRoot(subsubfolder)); + + } + + private void assertPathsEqual(String msg, File expected, File actual) + { + String expectedPath = FileUtil.getAbsoluteCaseSensitiveFile(expected).getPath(); + String actualPath = FileUtil.getAbsoluteCaseSensitiveFile(actual).getPath(); + Assert.assertEquals(msg, expectedPath, actualPath); + } + + private File getTestRoot() + { + FileContentService svc = FileContentService.get(); + File siteRoot = svc.getSiteDefaultRoot(); + File testRoot = new File(siteRoot, FILE_ROOT_SUFFIX); + testRoot.mkdirs(); + Assert.assertTrue("Unable to create test file root", NetworkDrive.exists(testRoot)); + + return testRoot; + } + + @Test + //when we move a folder, we expect child files to follow, and expect + // any file paths stored in the DB to also get updated + public void testFolderMove() throws Exception + { + //pre-clean + cleanup(); + + _expectedPaths = new HashMap<>(); + + FileContentService svc = FileContentService.get(); + Assert.assertNotNull(svc); + + Container project1 = ContainerManager.createContainer(ContainerManager.getRoot(), PROJECT1, TestContext.get().getUser()); + _expectedPaths.put(project1, null); + + Container project2 = ContainerManager.createContainer(ContainerManager.getRoot(), PROJECT2, TestContext.get().getUser()); + _expectedPaths.put(project2, null); + + Container subfolder1 = ContainerManager.createContainer(project1, PROJECT1_SUBFOLDER1, TestContext.get().getUser()); + _expectedPaths.put(subfolder1, null); + + Container subfolder2 = ContainerManager.createContainer(project1, PROJECT1_SUBFOLDER2, TestContext.get().getUser()); + _expectedPaths.put(subfolder2, null); + + Container subsubfolder = ContainerManager.createContainer(subfolder1, PROJECT1_SUBSUBFOLDER, TestContext.get().getUser()); + Container subsubfolderSibling = ContainerManager.createContainer(subfolder1, PROJECT1_SUBSUBFOLDER_SIBLING, TestContext.get().getUser()); + _expectedPaths.put(subsubfolder, null); + + //create a test file that we will follow + File fileRoot = svc.getFileRoot(subsubfolder, ContentType.files); + fileRoot.mkdirs(); + + File childFile = new File(fileRoot, TXT_FILE); + childFile.createNewFile(); + + ExpData data = ExperimentService.get().createData(subsubfolder, UPLOADED_FILE); + data.setDataFileURI(childFile.toPath().toUri()); + data.save(TestContext.get().getUser()); + + ExpProtocol protocol = ExperimentService.get().createExpProtocol(subsubfolder, ExpProtocol.ApplicationType.ProtocolApplication, "DummyProtocol"); + protocol = ExperimentService.get().insertSimpleProtocol(protocol, TestContext.get().getUser()); + + ExpRun expRun = ExperimentService.get().createExperimentRun(subsubfolder, "DummyRun"); + expRun.setProtocol(protocol); + expRun.setFilePathRootPath(childFile.getParentFile().toPath()); + + ViewBackgroundInfo info = new ViewBackgroundInfo(subsubfolder, TestContext.get().getUser(), null); + ExpRun run = ExperimentService.get().saveSimpleExperimentRun( + expRun, + Collections.emptyMap(), + Collections.singletonMap(data, "Data"), + Collections.emptyMap(), + Collections.emptyMap(), + Collections.emptyMap(), + info, + _log, + false); + + Assert.assertTrue("File not found: " + childFile.getPath(), NetworkDrive.exists(childFile)); + ContainerManager.move(subsubfolder, subfolder2, TestContext.get().getUser()); + Container movedSubfolder = ContainerManager.getChild(subfolder2, subsubfolder.getName()); + + _expectedPaths.put(movedSubfolder, new File(svc.getFileRoot(subfolder2), movedSubfolder.getName())); + assertPathsEqual("Incorrect values returned by getDefaultRoot", _expectedPaths.get(movedSubfolder), svc.getDefaultRoot(movedSubfolder, false)); + assertPathsEqual("SubSubfolder has incorrect root", _expectedPaths.get(movedSubfolder), svc.getFileRoot(movedSubfolder)); + + File expectedFile = new File(svc.getFileRoot(movedSubfolder, ContentType.files), TXT_FILE); + Assert.assertTrue("File was not moved, expected: " + expectedFile.getPath(), NetworkDrive.exists(expectedFile)); + + ExpData movedData = ExperimentService.get().getExpData(data.getRowId()); + Assert.assertNotNull(movedData); + + // Reload the run after it's path has hopefully been updated + expRun = ExperimentService.get().getExpRun(expRun.getRowId()); + + assertPathsEqual("Incorrect data file path", expectedFile, FileUtil.stringToPath(movedSubfolder, movedData.getDataFileUrl()).toFile()); + assertPathsEqual("Incorrect run root path", expectedFile.getParentFile(), expRun.getFilePathRoot()); + + // Issue 38206 - file paths get mangled with multiple folder moves + ContainerManager.move(subsubfolderSibling, subfolder2, TestContext.get().getUser()); + + // Reload the run after it's path has hopefully NOT been updated + expRun = ExperimentService.get().getExpRun(expRun.getRowId()); + assertPathsEqual("Incorrect run root path", expectedFile.getParentFile(), expRun.getFilePathRoot()); + } + + @Test + public void testWorkbooksAndTabs() + { + //pre-clean + cleanup(); + + FileContentService svc = FileContentService.get(); + Assert.assertNotNull(svc); + + Container project1 = ContainerManager.createContainer(ContainerManager.getRoot(), PROJECT1, TestContext.get().getUser()); + + Container workbook = ContainerManager.createContainer(project1, null, null, null, WorkbookContainerType.NAME, TestContext.get().getUser()); + File expectedWorkbookRoot = new File(svc.getFileRoot(project1), workbook.getName()); + assertPathsEqual("Workbook has incorrect file root", expectedWorkbookRoot, svc.getFileRoot(workbook)); + + Container tab = ContainerManager.createContainer(project1, "tab", null, null, TabContainerType.NAME, TestContext.get().getUser()); + File expectedTabRoot = new File(svc.getFileRoot(project1), tab.getName()); + assertPathsEqual("Folder tab has incorrect file root", expectedTabRoot, svc.getFileRoot(tab)); + } + + /** + * Test that the Site Settings can be configured from startup properties + */ + @Test + public void testStartupPropertiesForSiteRootSettings() throws IOException + { + // save the original Site Root File settings so that we can restore them when this test is done + File originalSiteRootFile = FileContentService.get().getSiteDefaultRoot(); + + // create the new site root file to test with as a child of the current site root file so that we know it is in a dir that exist + String originalSiteRootFilePath = originalSiteRootFile.getAbsolutePath(); + File testSiteRootFile = new File(originalSiteRootFilePath, "testSiteRootFile"); + testSiteRootFile.createNewFile(); + + ModuleLoader.getInstance().handleStartupProperties(new RandomSiteSettingsPropertyHandler(){ + @Override + public @NotNull Collection getStartupPropertyEntries() + { + return List.of(new StartupPropertyEntry("siteFileRoot", testSiteRootFile.getAbsolutePath(), "startup", SCOPE_SITE_SETTINGS)); + } + + @Override + public boolean performChecks() + { + return false; + } + }); + + // now check that the expected changes occurred to the Site Root File settings on the server + File newSiteRootFile = FileContentService.get().getSiteDefaultRoot(); + Assert.assertEquals("The expected change in Site Root File was not found", testSiteRootFile.getAbsolutePath(), newSiteRootFile.getAbsolutePath()); + + // restore the Site Root File server settings to how they were originally + FileContentService.get().setSiteDefaultRoot(originalSiteRootFile, null); + testSiteRootFile.delete(); + } + + @After + public void cleanup() + { + FileContentService svc = FileContentService.get(); + Assert.assertNotNull(svc); + + deleteContainerAndFiles(svc, ContainerManager.getForPath(PROJECT1)); + deleteContainerAndFiles(svc, ContainerManager.getForPath(PROJECT2)); + + File testRoot = getTestRoot(); + if (NetworkDrive.exists(testRoot)) + { + FileUtil.deleteDir(testRoot); + } + } + + private void deleteContainerAndFiles(FileContentService svc, @Nullable Container c) + { + if (c != null) + { + ContainerManager.deleteAll(c, TestContext.get().getUser()); + + File file1 = svc.getFileRoot(c); + if (NetworkDrive.exists(file1)) + { + FileUtil.deleteDir(file1); + } + } + } + } +} diff --git a/pipeline/src/org/labkey/pipeline/analysis/AnalysisController.java b/pipeline/src/org/labkey/pipeline/analysis/AnalysisController.java index a59f1ba0ce4..5fb8f55ff11 100644 --- a/pipeline/src/org/labkey/pipeline/analysis/AnalysisController.java +++ b/pipeline/src/org/labkey/pipeline/analysis/AnalysisController.java @@ -1,789 +1,789 @@ -/* - * Copyright (c) 2008-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.pipeline.analysis; - -import com.google.common.base.Function; -import com.google.common.collect.Collections2; -import org.apache.commons.beanutils.BeanUtils; -import org.apache.commons.io.input.ReaderInputStream; -import org.apache.commons.lang3.StringUtils; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.jetbrains.annotations.Nullable; -import org.json.JSONArray; -import org.json.JSONObject; -import org.labkey.api.action.ApiResponse; -import org.labkey.api.action.ApiSimpleResponse; -import org.labkey.api.action.ApiUsageException; -import org.labkey.api.action.FormHandlerAction; -import org.labkey.api.action.MutatingApiAction; -import org.labkey.api.action.ReturnUrlForm; -import org.labkey.api.action.SimpleViewAction; -import org.labkey.api.action.SpringActionController; -import org.labkey.api.admin.AdminUrls; -import org.labkey.api.audit.AuditLogService; -import org.labkey.api.data.Container; -import org.labkey.api.data.DataRegionSelection; -import org.labkey.api.pipeline.AnalyzeForm; -import org.labkey.api.pipeline.ParamParser; -import org.labkey.api.pipeline.PipeRoot; -import org.labkey.api.pipeline.PipelineJobService; -import org.labkey.api.pipeline.PipelineProtocolFactory; -import org.labkey.api.pipeline.PipelineService; -import org.labkey.api.pipeline.PipelineValidationException; -import org.labkey.api.pipeline.TaskFactory; -import org.labkey.api.pipeline.TaskId; -import org.labkey.api.pipeline.TaskPipeline; -import org.labkey.api.pipeline.file.AbstractFileAnalysisProtocol; -import org.labkey.api.pipeline.file.AbstractFileAnalysisProtocolFactory; -import org.labkey.api.security.RequiresPermission; -import org.labkey.api.security.permissions.AdminPermission; -import org.labkey.api.security.permissions.DeletePermission; -import org.labkey.api.security.permissions.InsertPermission; -import org.labkey.api.security.permissions.ReadPermission; -import org.labkey.api.util.DOM; -import org.labkey.api.util.DotRunner; -import org.labkey.api.util.FileUtil; -import org.labkey.api.util.HtmlString; -import org.labkey.api.util.NetworkDrive; -import org.labkey.api.util.PageFlowUtil; -import org.labkey.api.util.ReturnURLString; -import org.labkey.api.util.URLHelper; -import org.labkey.api.view.ActionURL; -import org.labkey.api.view.HtmlView; -import org.labkey.api.view.JspView; -import org.labkey.api.view.NavTree; -import org.labkey.api.view.NotFoundException; -import org.labkey.api.view.ViewForm; -import org.labkey.api.writer.ContainerUser; -import org.springframework.validation.BindException; -import org.springframework.validation.Errors; -import org.springframework.web.servlet.ModelAndView; - -import java.io.File; -import java.io.IOException; -import java.io.StringReader; -import java.nio.charset.Charset; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.TreeMap; - -import static org.labkey.api.util.DOM.TD; -import static org.labkey.api.util.DOM.TR; -import static org.labkey.api.util.DOM.at; -import static org.labkey.api.util.DOM.cl; - -/** - * AnalysisController - */ -public class AnalysisController extends SpringActionController -{ - private static final Logger LOG = LogManager.getLogger(AnalysisController.class); - private static final DefaultActionResolver _resolver = new DefaultActionResolver(AnalysisController.class); - - public AnalysisController() - { - setActionResolver(_resolver); - } - - public static ActionURL urlAnalyze(Container container, TaskId tid, String path, @Nullable ReturnURLString returnUrl) - { - ActionURL result = new ActionURL(AnalyzeAction.class, container) - .addParameter(AnalyzeForm.Params.taskId, tid.toString()) - .addParameter(AnalyzeForm.Params.path, path); - if (returnUrl != null) - { - result.addParameter(ActionURL.Param.returnUrl, returnUrl.toString()); - } - return result; - } - - @RequiresPermission(InsertPermission.class) - public static class AnalyzeAction extends SimpleViewAction - { - private TaskPipeline _taskPipeline; - - @Override - public ModelAndView getView(AnalyzeForm analyzeForm, BindException errors) - { - try - { - getPageConfig().setIncludePostParameters(true); - if (analyzeForm.getTaskId() == null || "".equals(analyzeForm.getTaskId())) - throw new NotFoundException("taskId required"); - - _taskPipeline = PipelineJobService.get().getTaskPipeline(new TaskId(analyzeForm.getTaskId())); - if (_taskPipeline == null) - throw new NotFoundException("Task pipeline not found: " + analyzeForm.getTaskId()); - - return new JspView<>("/org/labkey/pipeline/analysis/analyze.jsp", getViewContext().getActionURL()); - } - catch (ClassNotFoundException e) - { - throw new NotFoundException("Could not find task pipeline: " + analyzeForm.getTaskId()); - } - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild(_taskPipeline.getDescription()); - } - } - - private static TaskPipeline getTaskPipeline(String taskIdString) - { - return PipelineJobService.get().getTaskPipeline(taskIdString); - } - - private static AbstractFileAnalysisProtocolFactory getProtocolFactory(TaskPipeline taskPipeline) - { - return PipelineJobService.get().getProtocolFactory(taskPipeline); - } - - /** - * Called from LABKEY.Pipeline.startAnalysis() - */ - @RequiresPermission(InsertPermission.class) - public static class StartAnalysisAction extends MutatingApiAction - { - @Override - public ApiResponse execute(AnalyzeForm form, BindException errors) - { - try - { - String jobGUID = PipelineService.get().startFileAnalysis(form, null, getViewContext()); - Map resultProperties = new HashMap<>(); - resultProperties.put("status", "success"); - resultProperties.put("jobGUID", jobGUID); - - return new ApiSimpleResponse(resultProperties); - } - catch (IOException | PipelineValidationException e) - { - throw new ApiUsageException(e); - } - } - } - - /** - * Called from LABKEY.Pipeline.getFileStatus(). - */ - @RequiresPermission(ReadPermission.class) - public static class GetFileStatusAction extends MutatingApiAction - { - @Override - public ApiResponse execute(AnalyzeForm form, BindException errors) - { - if (form.getProtocolName() == null || "".equals(form.getProtocolName())) - { - throw new NotFoundException("No protocol specified"); - } - PipelineService.PathAnalysisProperties props = PipelineService.get().getFileAnalysisProperties(getContainer(), form.getTaskId(), form.getPath()); - AbstractFileAnalysisProtocol protocol = props.getFactory().getProtocol(props.getPipeRoot(), props.getDirData(), form.getProtocolName(), false); - //NOTE: if protocol if null, initFileStatus() will return a result of UNKNOWN - Path dirAnalysis = props.getFactory().getAnalysisDir(props.getDirData(), form.getProtocolName(), props.getPipeRoot()); - form.initStatus(protocol, props.getDirData(), dirAnalysis); - - boolean isRetry = false; - - JSONArray files = new JSONArray(); - for (int i = 0; i < form.getFile().length; i++) - { - JSONObject o = new JSONObject(); - o.put("name", form.getFile()[i]); - o.put("status", JSONObject.wrap(form.getFileInputStatus()[i])); // Wrap to allow 'null' status - isRetry |= form.getFileInputStatus()[i] != null; - files.put(o); - } - JSONObject result = new JSONObject(); - result.put("files", files); - if (!form.isActiveJobs()) - { - result.put("submitType", isRetry ? "Retry" : "Analyze"); - } - return new ApiSimpleResponse(result); - } - } - - /** - * Called from LABKEY.Pipeline.getProtocols(). - */ - @RequiresPermission(ReadPermission.class) - public static class GetSavedProtocolsAction extends MutatingApiAction - { - @Override - public ApiResponse execute(AnalyzeForm form, BindException errors) - { - PipelineService.PathAnalysisProperties props = PipelineService.get().getFileAnalysisProperties(getContainer(), form.getTaskId(), form.getPath()); - JSONArray protocols = new JSONArray(); - for (String protocolName : props.getFactory().getProtocolNames(props.getPipeRoot(), props.getDirData(), false)) - { - protocols.put(getProtocolJson(protocolName, props.getPipeRoot(), props.getDirData(), props.getFactory())); - } - - if (form.getIncludeWorkbooks()) - { - for (Container c : getContainer().getChildren()) - { - if (c.isWorkbook()) - { - PipeRoot wbRoot = PipelineService.get().findPipelineRoot(c); - if (wbRoot == null || !wbRoot.isValid()) - continue; - - File wbDirData = null; - if (form.getPath() != null) - { - wbDirData = wbRoot.resolvePath(form.getPath()); - if (!NetworkDrive.exists(wbDirData)) - continue; - } - - for (String protocolName : props.getFactory().getProtocolNames(wbRoot, wbDirData.toPath(), false)) - { - protocols.put(getProtocolJson(protocolName, wbRoot, wbDirData.toPath(), props.getFactory())); - } - } - } - } - - JSONObject result = new JSONObject(); - result.put("protocols", protocols); - result.put("defaultProtocolName", PipelineService.get().getLastProtocolSetting(props.getFactory(), getContainer(), getUser())); - return new ApiSimpleResponse(result); - } - - protected JSONObject getProtocolJson(String protocolName, PipeRoot root, @Nullable Path dirData, AbstractFileAnalysisProtocolFactory factory) throws NotFoundException - { - JSONObject protocol = new JSONObject(); - AbstractFileAnalysisProtocol pipelineProtocol = factory.getProtocol(root, dirData, protocolName, false); - if (pipelineProtocol == null) - { - throw new NotFoundException("Protocol not found: " + protocolName); - } - - protocol.put("name", protocolName); - protocol.put("description", pipelineProtocol.getDescription()); - protocol.put("xmlParameters", pipelineProtocol.getXml()); - protocol.put("containerPath", root.getContainer().getPath()); - ParamParser parser = PipelineJobService.get().createParamParser(); - parser.parse(new ReaderInputStream(new StringReader(pipelineProtocol.getXml()), Charset.defaultCharset())); - if (parser.getErrors() == null || parser.getErrors().length == 0) - { - protocol.put("jsonParameters", new JSONObject(parser.getInputParameters())); - } - - return protocol; - } - } - - /** - * For management of protocol files - */ - public enum ProtocolTask - { - delete - { - @Override - boolean doIt(PipeRoot root, PipelineProtocolFactory factory, String name) - { - return factory.deleteProtocolFile(root, name); - } - }, - archive - { - @Override - boolean doIt(PipeRoot root, PipelineProtocolFactory factory, String name) throws IOException - { - return factory.changeArchiveStatus(root, name, true); - } - }, - unarchive - { - @Override - boolean doIt(PipeRoot root, PipelineProtocolFactory factory, String name) throws IOException - { - return factory.changeArchiveStatus(root, name, false); - } - }; - - abstract boolean doIt(PipeRoot root, PipelineProtocolFactory factory, String name) throws IOException; - - String pastTense() - { - return this + "d"; - } - - boolean run(ContainerUser cu, Map> selected) - { - PipeRoot root = PipelineService.get().findPipelineRoot(cu.getContainer()); - - // selected is a map of taskId -> list of protocol names. - // Find the correct factory for each taskId, then perform operation on list of names. Fail and return on first error - return selected.entrySet().stream().allMatch( entry -> { - PipelineProtocolFactory factory = getProtocolFactory(getTaskPipeline(entry.getKey())); - return entry.getValue().stream().allMatch( name -> { - try - { - if (doIt(root, factory, name)) - { - AuditLogService.get().addEvent(cu.getUser(), - new ProtocolManagementAuditProvider.ProtocolManagementEvent(ProtocolManagementAuditProvider.EVENT, - cu.getContainer(), factory.getName(), name, this.pastTense())); - return true; - } - else - return false; - } - catch (IOException e) - { - throw new RuntimeException("Error during protocol execution", e); - } - }); - }); - } - - public static boolean isInEnum(String value) { - return Arrays.stream(ProtocolTask.values()).anyMatch(e -> e.name().equals(value)); - } - } - - @RequiresPermission(DeletePermission.class) - public static class ProtocolManagementAction extends FormHandlerAction - { - - @Override - public void validateCommand(ProtocolManagementForm form, Errors errors) - { - if (!ProtocolTask.isInEnum(form.getAction())) - errors.reject("An invalid action was passed: " + form.getAction()); - try - { - form.getSelected(); - } - catch (Exception e) - { - errors.reject("Invalid selection"); - } - } - - @Override - public boolean handlePost(ProtocolManagementForm form, BindException errors) - { - try - { - return ProtocolTask.valueOf(form.getAction()).run(getViewContext(), form.getSelected()); - } - catch (Exception e) - { - LOG.error("Error processing protocol management action.", e); - errors.reject("Error processing action. See server log for more details."); - return false; - } - } - - @Override - public URLHelper getSuccessURL(ProtocolManagementForm form) - { - return form.getReturnActionURL(); - } - - - } - - public static class ProtocolManagementForm extends ViewForm - { - String action; - Map> selected = null; - String taskId = null; - String name = null; - - public String getAction() - { - return action; - } - - public void setAction(String action) - { - this.action = action; - } - - public void setTaskId(String taskId) - { - this.taskId = taskId; - } - - public void setName(String name) - { - this.name = name; - } - - public Map> getSelected() - { - if (selected == null) - { - if (null != taskId && null != name) // came in from the Details page, not the grid view - { - selected = new HashMap<>(); - selected.put(taskId, Collections.singletonList(name)); - } - else - { - selected = parseSelected(DataRegionSelection.getSelected(getViewContext(), true)); - } - } - return selected; - } - - /** - * The select set are comma separated pairs of taskId, protocol name - * Split into a map of taskId -> list of names - */ - private Map> parseSelected(Set selected) - { - Map> parsedSelected = new HashMap<>(); - for (String pair : selected) - { - String[] split = pair.split(",", 2); - if (split.length == 2) // silently ignore malformed input - { - List names = parsedSelected.computeIfAbsent(split[0], k -> new ArrayList<>()); - names.add(split[1]); - } - } - return parsedSelected; - } - } - - @RequiresPermission(ReadPermission.class) - public static class ProtocolDetailsAction extends SimpleViewAction - { - private String _protocolName; - - @Override - public void addNavTrail(NavTree root) - { - root.addChild("Protocol: " + _protocolName); - } - - @Override - public ModelAndView getView(ProtocolDetailsForm form, BindException errors) - { - _protocolName = form.getName(); - PipeRoot root = PipelineService.get().findPipelineRoot(getViewContext().getContainer()); - AbstractFileAnalysisProtocolFactory factory = getProtocolFactory(getTaskPipeline(form.getTaskId())); - AbstractFileAnalysisProtocol protocol = factory.getProtocol(root, null, _protocolName, form.isArchived()); - if (null != protocol) - form.setXml(protocol.getXml()); - return new JspView<>("/org/labkey/pipeline/analysis/protocolDetail.jsp", form); - } - } - - public static class ProtocolDetailsForm extends ReturnUrlForm - { - private String _taskId; - private String _name; - private boolean _archived; - private String _xml = null; - - public String getTaskId() - { - return _taskId; - } - - public void setTaskId(String taskId) - { - _taskId = taskId; - } - - public String getName() - { - return _name; - } - - public void setName(String name) - { - _name = name; - } - - public boolean isArchived() - { - return _archived; - } - - public void setArchived(boolean archived) - { - _archived = archived; - } - - public String getXml() - { - return _xml; - } - - public void setXml(String xml) - { - _xml = xml; - } - } - - /** - * Used for debugging task registration. - */ - @RequiresPermission(AdminPermission.class) - public static class InternalListTasksAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object o, BindException errors) - { - return new JspView<>("/org/labkey/pipeline/analysis/internalListTasks.jsp", null, errors); - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild("Internal List Tasks"); - } - } - - /** - * Used for debugging pipeline registration. - */ - @RequiresPermission(AdminPermission.class) - public static class InternalListPipelinesAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object o, BindException errors) - { - return new JspView<>("/org/labkey/pipeline/analysis/internalListPipelines.jsp", null, errors); - } - - @Override - public void addNavTrail(NavTree root) - { - urlProvider(AdminUrls.class).addAdminNavTrail(root, "Internal List Pipelines", getClass(), getContainer()); - } - } - - public static class TaskForm - { - private String _taskId; - - public String getTaskId() - { - return _taskId; - } - - public void setTaskId(String taskId) - { - _taskId = taskId; - } - } - - /** - * Used for debugging task registration. - */ - @RequiresPermission(AdminPermission.class) - public class InternalDetailsAction extends SimpleViewAction - { - @Override - public ModelAndView getView(TaskForm form, BindException errors) throws Exception - { - String id = form.getTaskId(); - if (id == null) - throw new NotFoundException("taskId required"); - - TaskFactory factory = null; - TaskPipeline pipeline = null; - - Map map = Collections.emptyMap(); - TaskId taskId = TaskId.valueOf(id); - if (taskId.getType() == TaskId.Type.task || taskId.getType() == null) - { - factory = PipelineJobService.get().getTaskFactory(taskId); - map = BeanUtils.describe(factory); - } - - if (factory == null) - { - pipeline = PipelineJobService.get().getTaskPipeline(taskId); - map = BeanUtils.describe(pipeline); - } - - if (map.isEmpty()) - { - return new HtmlView(HtmlString.of("no task or pipeline found")); - } - // Sort the properties alphabetically - map = new TreeMap<>(map); - - return new HtmlView(DOM.DIV( - DOM.TABLE(at(cl("labkey-data-region-legacy", "labkey-show-borders")), map.entrySet().stream().map(e -> TR(TD(e.getKey()), TD(e.getValue())))), - generateGraph(pipeline))); - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild("Internal Details"); - } - } - - @Nullable - private DOM.Renderable generateGraph(@Nullable TaskPipeline pipeline) - { - if (pipeline == null) - { - return null; - } - - File svgFile = null; - try - { - File dir = FileUtil.getTempDirectory(); - String dot = buildDigraph(pipeline); - svgFile = FileUtil.createTempFile("pipeline", ".svg", dir); - DotRunner runner = new DotRunner(dir, dot); - runner.addSvgOutput(svgFile); - runner.execute(); - return HtmlString.unsafe(PageFlowUtil.getFileContentsAsString(svgFile)); - } - catch (Exception e) - { - LOG.error("Error running dot", e); - } - finally - { - if (svgFile != null) - svgFile.delete(); - } - return null; - } - - /** - * Generate a dot graph of the pipeline. - * Each task is drawn as a box with inputs on the left and outputs on the right: - *
-     * +--------------------+
-     * |      task id       |
-     * +---------+----------+
-     * | in1.xls | out1.txt |
-     * | in2.xls |          |
-     * +---------+----------+
-     * 
- */ - private String buildDigraph(TaskPipeline pipeline) - { - TaskId[] progression = pipeline.getTaskProgression(); - if (progression == null) - return null; - - StringBuilder sb = new StringBuilder(); - sb.append("digraph pipeline {\n"); - - // First, add all the nodes - for (TaskId taskId : progression) - { - String name = taskId.getName(); - if (name == null) - name = taskId.getNamespaceClass().getSimpleName(); - - TaskFactory factory = PipelineJobService.get().getTaskFactory(taskId); - - if (factory == null) - { - // not found - sb.append("\t\"").append(taskId).append("\""); - sb.append(" [label=\"").append(name).append("\""); - sb.append(" color=red"); - sb.append("];"); - } - else - { - sb.append("\t\"").append(taskId).append("\""); - sb.append(" [shape=record label=\"{"); - sb.append(name).append(" | {"); - - // inputs - // TODO: include parameters as inputs - sb.append("{"); - if (factory instanceof CommandTaskImpl.Factory f) - { - sb.append(StringUtils.join( - Collections2.transform(f.getInputPaths().keySet(), (Function) input -> escapeDotFieldLabel(input) + "\\l"), - " | ")); - } - else - { - StringUtils.join(factory.getInputTypes(), " | "); - } - sb.append("}"); // end inputs - - sb.append(" | "); - - // outputs - sb.append("{"); - if (factory instanceof CommandTaskImpl.Factory f) - { - - sb.append(StringUtils.join( - Collections2.transform(f.getOutputPaths().keySet(), (Function) input -> escapeDotFieldLabel(input) + "\\r"), - " | ")); - } - else - { - // CONSIDER: can other tasks have outputs? - } - sb.append("}"); // end outputs - - sb.append("}"); // end body - sb.append("}\""); // end label - sb.append("];"); - } - - sb.append("\n\n"); - } - - sb.append("\n"); - - // Now draw edges - // For now, we draw just a sequence from a->b->c. Eventually, we should connect outputs to inputs and draw splits/joins. - sb.append("\t"); - sb.append(StringUtils.join( - Collections2.transform(Arrays.asList(progression), task -> "\"" + task.toString() + "\""), - " -> ")); - - sb.append("}"); - return sb.toString(); - } - - // Escape a field within a dot record node: - // - backslash escape [] {} <> - // - spaces with '\' - private String escapeDotFieldLabel(String field) - { - field = field.replaceAll("[\\[\\]{}<>]", "\\\\$0"); - return field.replaceAll("\\s", "\"); - } - -} +/* + * Copyright (c) 2008-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.pipeline.analysis; + +import com.google.common.base.Function; +import com.google.common.collect.Collections2; +import org.apache.commons.beanutils.BeanUtils; +import org.apache.commons.io.input.ReaderInputStream; +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.Nullable; +import org.json.JSONArray; +import org.json.JSONObject; +import org.labkey.api.action.ApiResponse; +import org.labkey.api.action.ApiSimpleResponse; +import org.labkey.api.action.ApiUsageException; +import org.labkey.api.action.FormHandlerAction; +import org.labkey.api.action.MutatingApiAction; +import org.labkey.api.action.ReturnUrlForm; +import org.labkey.api.action.SimpleViewAction; +import org.labkey.api.action.SpringActionController; +import org.labkey.api.admin.AdminUrls; +import org.labkey.api.audit.AuditLogService; +import org.labkey.api.data.Container; +import org.labkey.api.data.DataRegionSelection; +import org.labkey.api.pipeline.AnalyzeForm; +import org.labkey.api.pipeline.ParamParser; +import org.labkey.api.pipeline.PipeRoot; +import org.labkey.api.pipeline.PipelineJobService; +import org.labkey.api.pipeline.PipelineProtocolFactory; +import org.labkey.api.pipeline.PipelineService; +import org.labkey.api.pipeline.PipelineValidationException; +import org.labkey.api.pipeline.TaskFactory; +import org.labkey.api.pipeline.TaskId; +import org.labkey.api.pipeline.TaskPipeline; +import org.labkey.api.pipeline.file.AbstractFileAnalysisProtocol; +import org.labkey.api.pipeline.file.AbstractFileAnalysisProtocolFactory; +import org.labkey.api.security.RequiresPermission; +import org.labkey.api.security.permissions.AdminPermission; +import org.labkey.api.security.permissions.DeletePermission; +import org.labkey.api.security.permissions.InsertPermission; +import org.labkey.api.security.permissions.ReadPermission; +import org.labkey.api.util.DOM; +import org.labkey.api.util.DotRunner; +import org.labkey.api.util.FileUtil; +import org.labkey.api.util.HtmlString; +import org.labkey.api.util.NetworkDrive; +import org.labkey.api.util.PageFlowUtil; +import org.labkey.api.util.ReturnURLString; +import org.labkey.api.util.URLHelper; +import org.labkey.api.view.ActionURL; +import org.labkey.api.view.HtmlView; +import org.labkey.api.view.JspView; +import org.labkey.api.view.NavTree; +import org.labkey.api.view.NotFoundException; +import org.labkey.api.view.ViewForm; +import org.labkey.api.writer.ContainerUser; +import org.labkey.vfs.FileLike; +import org.springframework.validation.BindException; +import org.springframework.validation.Errors; +import org.springframework.web.servlet.ModelAndView; + +import java.io.File; +import java.io.IOException; +import java.io.StringReader; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +import static org.labkey.api.util.DOM.TD; +import static org.labkey.api.util.DOM.TR; +import static org.labkey.api.util.DOM.at; +import static org.labkey.api.util.DOM.cl; + +/** + * AnalysisController + */ +public class AnalysisController extends SpringActionController +{ + private static final Logger LOG = LogManager.getLogger(AnalysisController.class); + private static final DefaultActionResolver _resolver = new DefaultActionResolver(AnalysisController.class); + + public AnalysisController() + { + setActionResolver(_resolver); + } + + public static ActionURL urlAnalyze(Container container, TaskId tid, String path, @Nullable ReturnURLString returnUrl) + { + ActionURL result = new ActionURL(AnalyzeAction.class, container) + .addParameter(AnalyzeForm.Params.taskId, tid.toString()) + .addParameter(AnalyzeForm.Params.path, path); + if (returnUrl != null) + { + result.addParameter(ActionURL.Param.returnUrl, returnUrl.toString()); + } + return result; + } + + @RequiresPermission(InsertPermission.class) + public static class AnalyzeAction extends SimpleViewAction + { + private TaskPipeline _taskPipeline; + + @Override + public ModelAndView getView(AnalyzeForm analyzeForm, BindException errors) + { + try + { + getPageConfig().setIncludePostParameters(true); + if (analyzeForm.getTaskId() == null || "".equals(analyzeForm.getTaskId())) + throw new NotFoundException("taskId required"); + + _taskPipeline = PipelineJobService.get().getTaskPipeline(new TaskId(analyzeForm.getTaskId())); + if (_taskPipeline == null) + throw new NotFoundException("Task pipeline not found: " + analyzeForm.getTaskId()); + + return new JspView<>("/org/labkey/pipeline/analysis/analyze.jsp", getViewContext().getActionURL()); + } + catch (ClassNotFoundException e) + { + throw new NotFoundException("Could not find task pipeline: " + analyzeForm.getTaskId()); + } + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild(_taskPipeline.getDescription()); + } + } + + private static TaskPipeline getTaskPipeline(String taskIdString) + { + return PipelineJobService.get().getTaskPipeline(taskIdString); + } + + private static AbstractFileAnalysisProtocolFactory getProtocolFactory(TaskPipeline taskPipeline) + { + return PipelineJobService.get().getProtocolFactory(taskPipeline); + } + + /** + * Called from LABKEY.Pipeline.startAnalysis() + */ + @RequiresPermission(InsertPermission.class) + public static class StartAnalysisAction extends MutatingApiAction + { + @Override + public ApiResponse execute(AnalyzeForm form, BindException errors) + { + try + { + String jobGUID = PipelineService.get().startFileAnalysis(form, null, getViewContext()); + Map resultProperties = new HashMap<>(); + resultProperties.put("status", "success"); + resultProperties.put("jobGUID", jobGUID); + + return new ApiSimpleResponse(resultProperties); + } + catch (IOException | PipelineValidationException e) + { + throw new ApiUsageException(e); + } + } + } + + /** + * Called from LABKEY.Pipeline.getFileStatus(). + */ + @RequiresPermission(ReadPermission.class) + public static class GetFileStatusAction extends MutatingApiAction + { + @Override + public ApiResponse execute(AnalyzeForm form, BindException errors) + { + if (form.getProtocolName() == null || "".equals(form.getProtocolName())) + { + throw new NotFoundException("No protocol specified"); + } + PipelineService.PathAnalysisProperties props = PipelineService.get().getFileAnalysisProperties(getContainer(), form.getTaskId(), form.getPath()); + AbstractFileAnalysisProtocol protocol = props.getFactory().getProtocol(props.getPipeRoot(), props.getDirData(), form.getProtocolName(), false); + //NOTE: if protocol if null, initFileStatus() will return a result of UNKNOWN + FileLike dirAnalysis = props.getFactory().getAnalysisDir(props.getDirData(), form.getProtocolName(), props.getPipeRoot()); + form.initStatus(protocol, props.getDirData(), dirAnalysis); + + boolean isRetry = false; + + JSONArray files = new JSONArray(); + for (int i = 0; i < form.getFile().length; i++) + { + JSONObject o = new JSONObject(); + o.put("name", form.getFile()[i]); + o.put("status", JSONObject.wrap(form.getFileInputStatus()[i])); // Wrap to allow 'null' status + isRetry |= form.getFileInputStatus()[i] != null; + files.put(o); + } + JSONObject result = new JSONObject(); + result.put("files", files); + if (!form.isActiveJobs()) + { + result.put("submitType", isRetry ? "Retry" : "Analyze"); + } + return new ApiSimpleResponse(result); + } + } + + /** + * Called from LABKEY.Pipeline.getProtocols(). + */ + @RequiresPermission(ReadPermission.class) + public static class GetSavedProtocolsAction extends MutatingApiAction + { + @Override + public ApiResponse execute(AnalyzeForm form, BindException errors) + { + PipelineService.PathAnalysisProperties props = PipelineService.get().getFileAnalysisProperties(getContainer(), form.getTaskId(), form.getPath()); + JSONArray protocols = new JSONArray(); + for (String protocolName : props.getFactory().getProtocolNames(props.getPipeRoot(), props.getDirData(), false)) + { + protocols.put(getProtocolJson(protocolName, props.getPipeRoot(), props.getDirData(), props.getFactory())); + } + + if (form.getIncludeWorkbooks()) + { + for (Container c : getContainer().getChildren()) + { + if (c.isWorkbook()) + { + PipeRoot wbRoot = PipelineService.get().findPipelineRoot(c); + if (wbRoot == null || !wbRoot.isValid()) + continue; + + FileLike wbDirData = null; + if (form.getPath() != null) + { + wbDirData = wbRoot.resolvePathToFileLike(form.getPath()); + if (!NetworkDrive.exists(wbDirData)) + continue; + } + + for (String protocolName : props.getFactory().getProtocolNames(wbRoot, wbDirData, false)) + { + protocols.put(getProtocolJson(protocolName, wbRoot, wbDirData, props.getFactory())); + } + } + } + } + + JSONObject result = new JSONObject(); + result.put("protocols", protocols); + result.put("defaultProtocolName", PipelineService.get().getLastProtocolSetting(props.getFactory(), getContainer(), getUser())); + return new ApiSimpleResponse(result); + } + + protected JSONObject getProtocolJson(String protocolName, PipeRoot root, @Nullable FileLike dirData, AbstractFileAnalysisProtocolFactory factory) throws NotFoundException + { + JSONObject protocol = new JSONObject(); + AbstractFileAnalysisProtocol pipelineProtocol = factory.getProtocol(root, dirData, protocolName, false); + if (pipelineProtocol == null) + { + throw new NotFoundException("Protocol not found: " + protocolName); + } + + protocol.put("name", protocolName); + protocol.put("description", pipelineProtocol.getDescription()); + protocol.put("xmlParameters", pipelineProtocol.getXml()); + protocol.put("containerPath", root.getContainer().getPath()); + ParamParser parser = PipelineJobService.get().createParamParser(); + parser.parse(new ReaderInputStream(new StringReader(pipelineProtocol.getXml()), Charset.defaultCharset())); + if (parser.getErrors() == null || parser.getErrors().length == 0) + { + protocol.put("jsonParameters", new JSONObject(parser.getInputParameters())); + } + + return protocol; + } + } + + /** + * For management of protocol files + */ + public enum ProtocolTask + { + delete + { + @Override + boolean doIt(PipeRoot root, PipelineProtocolFactory factory, String name) + { + return factory.deleteProtocolFile(root, name); + } + }, + archive + { + @Override + boolean doIt(PipeRoot root, PipelineProtocolFactory factory, String name) throws IOException + { + return factory.changeArchiveStatus(root, name, true); + } + }, + unarchive + { + @Override + boolean doIt(PipeRoot root, PipelineProtocolFactory factory, String name) throws IOException + { + return factory.changeArchiveStatus(root, name, false); + } + }; + + abstract boolean doIt(PipeRoot root, PipelineProtocolFactory factory, String name) throws IOException; + + String pastTense() + { + return this + "d"; + } + + boolean run(ContainerUser cu, Map> selected) + { + PipeRoot root = PipelineService.get().findPipelineRoot(cu.getContainer()); + + // selected is a map of taskId -> list of protocol names. + // Find the correct factory for each taskId, then perform operation on list of names. Fail and return on first error + return selected.entrySet().stream().allMatch( entry -> { + PipelineProtocolFactory factory = getProtocolFactory(getTaskPipeline(entry.getKey())); + return entry.getValue().stream().allMatch( name -> { + try + { + if (doIt(root, factory, name)) + { + AuditLogService.get().addEvent(cu.getUser(), + new ProtocolManagementAuditProvider.ProtocolManagementEvent(ProtocolManagementAuditProvider.EVENT, + cu.getContainer(), factory.getName(), name, this.pastTense())); + return true; + } + else + return false; + } + catch (IOException e) + { + throw new RuntimeException("Error during protocol execution", e); + } + }); + }); + } + + public static boolean isInEnum(String value) { + return Arrays.stream(ProtocolTask.values()).anyMatch(e -> e.name().equals(value)); + } + } + + @RequiresPermission(DeletePermission.class) + public static class ProtocolManagementAction extends FormHandlerAction + { + + @Override + public void validateCommand(ProtocolManagementForm form, Errors errors) + { + if (!ProtocolTask.isInEnum(form.getAction())) + errors.reject("An invalid action was passed: " + form.getAction()); + try + { + form.getSelected(); + } + catch (Exception e) + { + errors.reject("Invalid selection"); + } + } + + @Override + public boolean handlePost(ProtocolManagementForm form, BindException errors) + { + try + { + return ProtocolTask.valueOf(form.getAction()).run(getViewContext(), form.getSelected()); + } + catch (Exception e) + { + LOG.error("Error processing protocol management action.", e); + errors.reject("Error processing action. See server log for more details."); + return false; + } + } + + @Override + public URLHelper getSuccessURL(ProtocolManagementForm form) + { + return form.getReturnActionURL(); + } + + + } + + public static class ProtocolManagementForm extends ViewForm + { + String action; + Map> selected = null; + String taskId = null; + String name = null; + + public String getAction() + { + return action; + } + + public void setAction(String action) + { + this.action = action; + } + + public void setTaskId(String taskId) + { + this.taskId = taskId; + } + + public void setName(String name) + { + this.name = name; + } + + public Map> getSelected() + { + if (selected == null) + { + if (null != taskId && null != name) // came in from the Details page, not the grid view + { + selected = new HashMap<>(); + selected.put(taskId, Collections.singletonList(name)); + } + else + { + selected = parseSelected(DataRegionSelection.getSelected(getViewContext(), true)); + } + } + return selected; + } + + /** + * The select set are comma separated pairs of taskId, protocol name + * Split into a map of taskId -> list of names + */ + private Map> parseSelected(Set selected) + { + Map> parsedSelected = new HashMap<>(); + for (String pair : selected) + { + String[] split = pair.split(",", 2); + if (split.length == 2) // silently ignore malformed input + { + List names = parsedSelected.computeIfAbsent(split[0], k -> new ArrayList<>()); + names.add(split[1]); + } + } + return parsedSelected; + } + } + + @RequiresPermission(ReadPermission.class) + public static class ProtocolDetailsAction extends SimpleViewAction + { + private String _protocolName; + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("Protocol: " + _protocolName); + } + + @Override + public ModelAndView getView(ProtocolDetailsForm form, BindException errors) + { + _protocolName = form.getName(); + PipeRoot root = PipelineService.get().findPipelineRoot(getViewContext().getContainer()); + AbstractFileAnalysisProtocolFactory factory = getProtocolFactory(getTaskPipeline(form.getTaskId())); + AbstractFileAnalysisProtocol protocol = factory.getProtocol(root, null, _protocolName, form.isArchived()); + if (null != protocol) + form.setXml(protocol.getXml()); + return new JspView<>("/org/labkey/pipeline/analysis/protocolDetail.jsp", form); + } + } + + public static class ProtocolDetailsForm extends ReturnUrlForm + { + private String _taskId; + private String _name; + private boolean _archived; + private String _xml = null; + + public String getTaskId() + { + return _taskId; + } + + public void setTaskId(String taskId) + { + _taskId = taskId; + } + + public String getName() + { + return _name; + } + + public void setName(String name) + { + _name = name; + } + + public boolean isArchived() + { + return _archived; + } + + public void setArchived(boolean archived) + { + _archived = archived; + } + + public String getXml() + { + return _xml; + } + + public void setXml(String xml) + { + _xml = xml; + } + } + + /** + * Used for debugging task registration. + */ + @RequiresPermission(AdminPermission.class) + public static class InternalListTasksAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object o, BindException errors) + { + return new JspView<>("/org/labkey/pipeline/analysis/internalListTasks.jsp", null, errors); + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("Internal List Tasks"); + } + } + + /** + * Used for debugging pipeline registration. + */ + @RequiresPermission(AdminPermission.class) + public static class InternalListPipelinesAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object o, BindException errors) + { + return new JspView<>("/org/labkey/pipeline/analysis/internalListPipelines.jsp", null, errors); + } + + @Override + public void addNavTrail(NavTree root) + { + urlProvider(AdminUrls.class).addAdminNavTrail(root, "Internal List Pipelines", getClass(), getContainer()); + } + } + + public static class TaskForm + { + private String _taskId; + + public String getTaskId() + { + return _taskId; + } + + public void setTaskId(String taskId) + { + _taskId = taskId; + } + } + + /** + * Used for debugging task registration. + */ + @RequiresPermission(AdminPermission.class) + public class InternalDetailsAction extends SimpleViewAction + { + @Override + public ModelAndView getView(TaskForm form, BindException errors) throws Exception + { + String id = form.getTaskId(); + if (id == null) + throw new NotFoundException("taskId required"); + + TaskFactory factory = null; + TaskPipeline pipeline = null; + + Map map = Collections.emptyMap(); + TaskId taskId = TaskId.valueOf(id); + if (taskId.getType() == TaskId.Type.task || taskId.getType() == null) + { + factory = PipelineJobService.get().getTaskFactory(taskId); + map = BeanUtils.describe(factory); + } + + if (factory == null) + { + pipeline = PipelineJobService.get().getTaskPipeline(taskId); + map = BeanUtils.describe(pipeline); + } + + if (map.isEmpty()) + { + return new HtmlView(HtmlString.of("no task or pipeline found")); + } + // Sort the properties alphabetically + map = new TreeMap<>(map); + + return new HtmlView(DOM.DIV( + DOM.TABLE(at(cl("labkey-data-region-legacy", "labkey-show-borders")), map.entrySet().stream().map(e -> TR(TD(e.getKey()), TD(e.getValue())))), + generateGraph(pipeline))); + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("Internal Details"); + } + } + + @Nullable + private DOM.Renderable generateGraph(@Nullable TaskPipeline pipeline) + { + if (pipeline == null) + { + return null; + } + + File svgFile = null; + try + { + File dir = FileUtil.getTempDirectory(); + String dot = buildDigraph(pipeline); + svgFile = FileUtil.createTempFile("pipeline", ".svg", dir); + DotRunner runner = new DotRunner(dir, dot); + runner.addSvgOutput(svgFile); + runner.execute(); + return HtmlString.unsafe(PageFlowUtil.getFileContentsAsString(svgFile)); + } + catch (Exception e) + { + LOG.error("Error running dot", e); + } + finally + { + if (svgFile != null) + svgFile.delete(); + } + return null; + } + + /** + * Generate a dot graph of the pipeline. + * Each task is drawn as a box with inputs on the left and outputs on the right: + *
+     * +--------------------+
+     * |      task id       |
+     * +---------+----------+
+     * | in1.xls | out1.txt |
+     * | in2.xls |          |
+     * +---------+----------+
+     * 
+ */ + private String buildDigraph(TaskPipeline pipeline) + { + TaskId[] progression = pipeline.getTaskProgression(); + if (progression == null) + return null; + + StringBuilder sb = new StringBuilder(); + sb.append("digraph pipeline {\n"); + + // First, add all the nodes + for (TaskId taskId : progression) + { + String name = taskId.getName(); + if (name == null) + name = taskId.getNamespaceClass().getSimpleName(); + + TaskFactory factory = PipelineJobService.get().getTaskFactory(taskId); + + if (factory == null) + { + // not found + sb.append("\t\"").append(taskId).append("\""); + sb.append(" [label=\"").append(name).append("\""); + sb.append(" color=red"); + sb.append("];"); + } + else + { + sb.append("\t\"").append(taskId).append("\""); + sb.append(" [shape=record label=\"{"); + sb.append(name).append(" | {"); + + // inputs + // TODO: include parameters as inputs + sb.append("{"); + if (factory instanceof CommandTaskImpl.Factory f) + { + sb.append(StringUtils.join( + Collections2.transform(f.getInputPaths().keySet(), (Function) input -> escapeDotFieldLabel(input) + "\\l"), + " | ")); + } + else + { + StringUtils.join(factory.getInputTypes(), " | "); + } + sb.append("}"); // end inputs + + sb.append(" | "); + + // outputs + sb.append("{"); + if (factory instanceof CommandTaskImpl.Factory f) + { + + sb.append(StringUtils.join( + Collections2.transform(f.getOutputPaths().keySet(), (Function) input -> escapeDotFieldLabel(input) + "\\r"), + " | ")); + } + else + { + // CONSIDER: can other tasks have outputs? + } + sb.append("}"); // end outputs + + sb.append("}"); // end body + sb.append("}\""); // end label + sb.append("];"); + } + + sb.append("\n\n"); + } + + sb.append("\n"); + + // Now draw edges + // For now, we draw just a sequence from a->b->c. Eventually, we should connect outputs to inputs and draw splits/joins. + sb.append("\t"); + sb.append(StringUtils.join( + Collections2.transform(Arrays.asList(progression), task -> "\"" + task.toString() + "\""), + " -> ")); + + sb.append("}"); + return sb.toString(); + } + + // Escape a field within a dot record node: + // - backslash escape [] {} <> + // - spaces with '\' + private String escapeDotFieldLabel(String field) + { + field = field.replaceAll("[\\[\\]{}<>]", "\\\\$0"); + return field.replaceAll("\\s", "\"); + } + +} diff --git a/pipeline/src/org/labkey/pipeline/analysis/FileAnalysisJob.java b/pipeline/src/org/labkey/pipeline/analysis/FileAnalysisJob.java index 76239b6bfff..dcaa823d0af 100644 --- a/pipeline/src/org/labkey/pipeline/analysis/FileAnalysisJob.java +++ b/pipeline/src/org/labkey/pipeline/analysis/FileAnalysisJob.java @@ -1,222 +1,221 @@ -/* - * Copyright (c) 2008-2018 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.pipeline.analysis; - -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.jetbrains.annotations.Nullable; -import org.labkey.api.pipeline.PipeRoot; -import org.labkey.api.pipeline.TaskId; -import org.labkey.api.pipeline.TaskPipeline; -import org.labkey.api.pipeline.file.AbstractFileAnalysisJob; -import org.labkey.api.pipeline.file.FileAnalysisTaskPipeline; -import org.labkey.api.util.FileType; -import org.labkey.api.util.NetworkDrive; -import org.labkey.api.view.ViewBackgroundInfo; - -import java.io.File; -import java.io.IOException; -import java.nio.file.Path; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -/** - * FileAnalysisJob - */ -public class FileAnalysisJob extends AbstractFileAnalysisJob -{ - private TaskId _taskPipelineId; - private Map _variableMap; - - private static final Logger LOG = LogManager.getLogger(FileAnalysisJob.class); - - // For serialization - protected FileAnalysisJob() {} - - public FileAnalysisJob(FileAnalysisProtocol protocol, - String providerName, - ViewBackgroundInfo info, - PipeRoot root, - TaskId taskPipelineId, - String protocolName, - Path fileParameters, - List filesInput, - @Nullable Map variableMap, - boolean splittable, - boolean writeJobInfoFile) throws IOException - { - super(protocol, providerName, info, root, protocolName, fileParameters, filesInput, splittable, writeJobInfoFile); - - _taskPipelineId = taskPipelineId; - _variableMap = variableMap; - } - - public FileAnalysisJob(FileAnalysisJob job, File fileInput) - { - super(job, fileInput); - - _taskPipelineId = job._taskPipelineId; - _variableMap = job._variableMap; - } - - @Override - public String getDescription() - { - String description = getParameters().get("pipelineDescription"); - if(description != null) - return description; - - return super.getDescription(); - } - - @Override - public Map getParameters() - { - Map parameters = new HashMap<>(super.getParameters()); - if (_variableMap != null && !_variableMap.isEmpty()) - parameters.putAll(_variableMap); - - return Collections.unmodifiableMap(parameters); - } - - @Override - public TaskId getTaskPipelineId() - { - return _taskPipelineId; - } - - @Override - public AbstractFileAnalysisJob createSingleFileJob(File file) - { - return new FileAnalysisJob(this, file); - } - - @Override - public FileAnalysisTaskPipeline getTaskPipeline() - { - TaskPipeline tp = super.getTaskPipeline(); - if (tp == null) - { - LOG.warn("Task pipeline " + _taskPipelineId + " not found."); - } - return (FileAnalysisTaskPipeline) tp; - } - - @Override - public File findInputFile(String name) - { - return findFile(name); - } - - @Override - public File findOutputFile(String name) - { - return findFile(name); - } - - /** - * Look at the specified type hierarchy to see if the requested file is an - * ancestor to this processing job, residing outside the analysis directory. - * - * @param name The name of the file to be located - * @return The file location outside the analysis directory, or null, if no such match is found. - */ - public File findFile(String name) - { - File dirAnalysis = getAnalysisDirectory(); - - for (Map.Entry> entry : getTaskPipeline().getTypeHierarchy().entrySet()) - { - if (entry.getKey().isType(name)) - { - // TODO: Eventually we will need to actually consult the parameters files - // in order to find files. - - // First try to go two directories up - File dir = dirAnalysis.getParentFile(); - if (dir != null) - { - dir = dir.getParentFile(); - } - - List derivedTypes = entry.getValue(); - for (int i = derivedTypes.size() - 1; i >= 0; i--) - { - // Go two directories up for each level of derivation - if (dir != null) - { - dir = dir.getParentFile(); - } - if (dir != null) - { - dir = dir.getParentFile(); - } - } - - String relativePath = getPipeRoot().relativePath(dir); - File expectedFile = getPipeRoot().resolvePath(relativePath + "/" + name); - - if (!NetworkDrive.exists(expectedFile)) - { - // If the file isn't where we would expect it, check other directories in the same hierarchy - File alternateFile = findFileInAlternateDirectory(expectedFile.getParentFile(), dirAnalysis, name); - if (alternateFile != null) - { - // If we found a file that matches, use it - return alternateFile; - } - } - return expectedFile; - } - } - - // Path of last resort is always to look in the current directory. - return new File(dirAnalysis, name); - } - - /** - * Starting from the expectedDir, look up the chain until getting to the final directory. Return the first - * file that matches by name. - * @param expectedDir where we would have expected the file to be, but it wasn't there - * @param dir must be a descendant of expectedDir, this is the deepest directory that will be inspected - * @param name name of the file to look for - * @return matching file, or null if nothing was found - */ - private File findFileInAlternateDirectory(File expectedDir, File dir, String name) - { - // Bail out if we've gotten all the way down to the originally expected file location - if (dir == null || dir.equals(expectedDir)) - { - return null; - } - // Recurse through the parent directories to find it in the place closest to the expected directory - File result = findFileInAlternateDirectory(expectedDir, dir.getParentFile(), name); - if (result != null) - { - // If we found a match, use it - return result; - } - - result = new File(dir, name); - if (NetworkDrive.exists(result)) - { - return result; - } - return null; - } -} +/* + * Copyright (c) 2008-2018 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.pipeline.analysis; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.Nullable; +import org.labkey.api.pipeline.PipeRoot; +import org.labkey.api.pipeline.TaskId; +import org.labkey.api.pipeline.TaskPipeline; +import org.labkey.api.pipeline.file.AbstractFileAnalysisJob; +import org.labkey.api.pipeline.file.FileAnalysisTaskPipeline; +import org.labkey.api.util.FileType; +import org.labkey.api.util.NetworkDrive; +import org.labkey.api.view.ViewBackgroundInfo; +import org.labkey.vfs.FileLike; + +import java.io.File; +import java.io.IOException; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * FileAnalysisJob + */ +public class FileAnalysisJob extends AbstractFileAnalysisJob +{ + private TaskId _taskPipelineId; + private Map _variableMap; + + private static final Logger LOG = LogManager.getLogger(FileAnalysisJob.class); + + // For serialization + protected FileAnalysisJob() {} + + public FileAnalysisJob(FileAnalysisProtocol protocol, + String providerName, + ViewBackgroundInfo info, + PipeRoot root, + TaskId taskPipelineId, + String protocolName, + FileLike fileParameters, + List filesInput, + @Nullable Map variableMap, + boolean splittable) throws IOException + { + super(protocol, providerName, info, root, protocolName, fileParameters, filesInput, splittable); + + _taskPipelineId = taskPipelineId; + _variableMap = variableMap; + } + + public FileAnalysisJob(FileAnalysisJob job, FileLike fileInput) + { + super(job, fileInput); + + _taskPipelineId = job._taskPipelineId; + _variableMap = job._variableMap; + } + + @Override + public String getDescription() + { + String description = getParameters().get("pipelineDescription"); + if(description != null) + return description; + + return super.getDescription(); + } + + @Override + public Map getParameters() + { + Map parameters = new HashMap<>(super.getParameters()); + if (_variableMap != null && !_variableMap.isEmpty()) + parameters.putAll(_variableMap); + + return Collections.unmodifiableMap(parameters); + } + + @Override + public TaskId getTaskPipelineId() + { + return _taskPipelineId; + } + + @Override + public AbstractFileAnalysisJob createSingleFileJob(FileLike file) + { + return new FileAnalysisJob(this, file); + } + + @Override + public FileAnalysisTaskPipeline getTaskPipeline() + { + TaskPipeline tp = super.getTaskPipeline(); + if (tp == null) + { + LOG.warn("Task pipeline " + _taskPipelineId + " not found."); + } + return (FileAnalysisTaskPipeline) tp; + } + + @Override + public File findInputFile(String name) + { + return findFile(name); + } + + @Override + public File findOutputFile(String name) + { + return findFile(name); + } + + /** + * Look at the specified type hierarchy to see if the requested file is an + * ancestor to this processing job, residing outside the analysis directory. + * + * @param name The name of the file to be located + * @return The file location outside the analysis directory, or null, if no such match is found. + */ + public File findFile(String name) + { + File dirAnalysis = getAnalysisDirectory(); + + for (Map.Entry> entry : getTaskPipeline().getTypeHierarchy().entrySet()) + { + if (entry.getKey().isType(name)) + { + // TODO: Eventually we will need to actually consult the parameters files + // in order to find files. + + // First try to go two directories up + File dir = dirAnalysis.getParentFile(); + if (dir != null) + { + dir = dir.getParentFile(); + } + + List derivedTypes = entry.getValue(); + for (int i = derivedTypes.size() - 1; i >= 0; i--) + { + // Go two directories up for each level of derivation + if (dir != null) + { + dir = dir.getParentFile(); + } + if (dir != null) + { + dir = dir.getParentFile(); + } + } + + String relativePath = getPipeRoot().relativePath(dir); + File expectedFile = getPipeRoot().resolvePath(relativePath + "/" + name); + + if (!NetworkDrive.exists(expectedFile)) + { + // If the file isn't where we would expect it, check other directories in the same hierarchy + File alternateFile = findFileInAlternateDirectory(expectedFile.getParentFile(), dirAnalysis, name); + if (alternateFile != null) + { + // If we found a file that matches, use it + return alternateFile; + } + } + return expectedFile; + } + } + + // Path of last resort is always to look in the current directory. + return new File(dirAnalysis, name); + } + + /** + * Starting from the expectedDir, look up the chain until getting to the final directory. Return the first + * file that matches by name. + * @param expectedDir where we would have expected the file to be, but it wasn't there + * @param dir must be a descendant of expectedDir, this is the deepest directory that will be inspected + * @param name name of the file to look for + * @return matching file, or null if nothing was found + */ + private File findFileInAlternateDirectory(File expectedDir, File dir, String name) + { + // Bail out if we've gotten all the way down to the originally expected file location + if (dir == null || dir.equals(expectedDir)) + { + return null; + } + // Recurse through the parent directories to find it in the place closest to the expected directory + File result = findFileInAlternateDirectory(expectedDir, dir.getParentFile(), name); + if (result != null) + { + // If we found a match, use it + return result; + } + + result = new File(dir, name); + if (NetworkDrive.exists(result)) + { + return result; + } + return null; + } +} diff --git a/pipeline/src/org/labkey/pipeline/analysis/FileAnalysisProtocol.java b/pipeline/src/org/labkey/pipeline/analysis/FileAnalysisProtocol.java index 9eae9d0db3f..97350c89b17 100644 --- a/pipeline/src/org/labkey/pipeline/analysis/FileAnalysisProtocol.java +++ b/pipeline/src/org/labkey/pipeline/analysis/FileAnalysisProtocol.java @@ -1,75 +1,74 @@ -/* - * Copyright (c) 2008-2017 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.pipeline.analysis; - -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.labkey.api.pipeline.PipeRoot; -import org.labkey.api.pipeline.TaskId; -import org.labkey.api.pipeline.file.AbstractFileAnalysisJob; -import org.labkey.api.pipeline.file.AbstractFileAnalysisProtocol; -import org.labkey.api.util.FileType; -import org.labkey.api.view.ViewBackgroundInfo; - -import java.io.IOException; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; - -/** - * FileAnalysisProtocol - */ -public class FileAnalysisProtocol extends AbstractFileAnalysisProtocol -{ - private FileAnalysisProtocolFactory _factory; - - public FileAnalysisProtocol(String name, String description, String xml) - { - super(name, description, xml); - } - - @Override - @NotNull - public List getInputTypes() - { - return _factory.getPipeline().getInitialFileTypes(); - } - - @Override - public FileAnalysisProtocolFactory getFactory() - { - return _factory; - } - - public void setFactory(FileAnalysisProtocolFactory factory) - { - _factory = factory; - } - - @Override - public AbstractFileAnalysisJob createPipelineJob(ViewBackgroundInfo info, PipeRoot root, List filesInput, - Path fileParameters, @Nullable Map variableMap - ) throws IOException - { - TaskId id = _factory.getPipeline().getId(); - - boolean splittable = _factory.getPipeline().isSplittable(); - boolean writeJobInfoFile = _factory.getPipeline().isWriteJobInfoFile(); - - return new FileAnalysisJob(this, FileAnalysisPipelineProvider.name, info, root, - id, getName(), fileParameters, filesInput, variableMap, splittable, writeJobInfoFile); - } -} +/* + * Copyright (c) 2008-2017 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.pipeline.analysis; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.labkey.api.pipeline.PipeRoot; +import org.labkey.api.pipeline.TaskId; +import org.labkey.api.pipeline.file.AbstractFileAnalysisJob; +import org.labkey.api.pipeline.file.AbstractFileAnalysisProtocol; +import org.labkey.api.util.FileType; +import org.labkey.api.view.ViewBackgroundInfo; +import org.labkey.vfs.FileLike; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +/** + * FileAnalysisProtocol + */ +public class FileAnalysisProtocol extends AbstractFileAnalysisProtocol +{ + private FileAnalysisProtocolFactory _factory; + + public FileAnalysisProtocol(String name, String description, String xml) + { + super(name, description, xml); + } + + @Override + @NotNull + public List getInputTypes() + { + return _factory.getPipeline().getInitialFileTypes(); + } + + @Override + public FileAnalysisProtocolFactory getFactory() + { + return _factory; + } + + public void setFactory(FileAnalysisProtocolFactory factory) + { + _factory = factory; + } + + @Override + public AbstractFileAnalysisJob createPipelineJob(ViewBackgroundInfo info, PipeRoot root, List filesInput, + FileLike fileParameters, @Nullable Map variableMap + ) throws IOException + { + TaskId id = _factory.getPipeline().getId(); + + boolean splittable = _factory.getPipeline().isSplittable(); + + return new FileAnalysisJob(this, FileAnalysisPipelineProvider.name, info, root, + id, getName(), fileParameters, filesInput, variableMap, splittable); + } +} diff --git a/pipeline/src/org/labkey/pipeline/api/PipelineServiceImpl.java b/pipeline/src/org/labkey/pipeline/api/PipelineServiceImpl.java index 3f2e1db3837..1a0fc0f7bc0 100644 --- a/pipeline/src/org/labkey/pipeline/api/PipelineServiceImpl.java +++ b/pipeline/src/org/labkey/pipeline/api/PipelineServiceImpl.java @@ -89,6 +89,7 @@ import org.labkey.pipeline.mule.ResumableDescriptor; import org.labkey.pipeline.status.PipelineQueryView; import org.labkey.pipeline.trigger.PipelineTriggerManager; +import org.labkey.vfs.FileLike; import org.mule.MuleManager; import org.mule.umo.UMODescriptor; import org.mule.umo.UMOException; @@ -848,10 +849,10 @@ public PathAnalysisProperties getFileAnalysisProperties(Container c, String task if (pr == null || !pr.isValid()) throw new NotFoundException(); - Path dirData = null; + FileLike dirData = null; if (path != null) { - dirData = pr.resolveToNioPath(path); + dirData = pr.resolvePathToFileLike(path); if (!NetworkDrive.exists(dirData)) throw new NotFoundException("Could not resolve path: " + path); } @@ -887,7 +888,7 @@ public String startFileAnalysis(AnalyzeForm form, @Nullable Map TaskPipeline taskPipeline = PipelineJobService.get().getTaskPipeline(form.getTaskId()); PathAnalysisProperties props = getFileAnalysisProperties(context.getContainer(), form.getTaskId(), form.getPath()); PipeRoot root = props.getPipeRoot(); - Path dirData = props.getDirData(); + FileLike dirData = props.getDirData(); AbstractFileAnalysisProtocolFactory factory = props.getFactory(); if (dirData == null) @@ -897,10 +898,10 @@ public String startFileAnalysis(AnalyzeForm form, @Nullable Map if (taskPipeline.isUseUniqueAnalysisDirectory()) { - dirData = FileUtil.appendName(dirData, form.getProtocolName() + "_" + FileUtil.getTimestamp()); - if (!Files.exists(FileUtil.createDirectories(dirData))) + dirData = dirData.resolveChild(form.getProtocolName() + "_" + FileUtil.getTimestamp()); + if (!FileUtil.createDirectory(dirData).exists()) { - throw new IOException("Failed to create unique analysis directory: " + FileUtil.getAbsoluteCaseSensitiveFile(dirData.toFile()).getAbsolutePath()); + throw new IOException("Failed to create unique analysis directory: " + FileUtil.getAbsoluteCaseSensitiveFile(dirData)); } } AbstractFileAnalysisProtocol protocol = factory.getProtocol(root, dirData, form.getProtocolName(), false); @@ -964,16 +965,16 @@ public String startFileAnalysis(AnalyzeForm form, @Nullable Map protocol.getFactory().ensureDefaultParameters(root); - Path fileParameters = protocol.getParametersFile(dirData, root); + FileLike fileParameters = protocol.getParametersFile(dirData, root); // Make sure configure.xml file exists for the job when it runs. - if (fileParameters != null && !Files.exists(fileParameters)) + if (fileParameters != null && !fileParameters.exists()) { protocol.setEmail(context.getUser().getEmail()); protocol.saveInstance(fileParameters, context.getContainer()); } boolean allowNonExistentFiles = form.isAllowNonExistentFiles() != null ? form.isAllowNonExistentFiles() : false; - List filesInputList = form.getValidatedPaths(context.getContainer(), allowNonExistentFiles); + List filesInputList = form.getValidatedFiles(context.getContainer(), allowNonExistentFiles); if (form.isActiveJobs()) { @@ -982,17 +983,17 @@ public String startFileAnalysis(AnalyzeForm form, @Nullable Map if (taskPipeline.isUseUniqueAnalysisDirectory()) { - for (Path inputFile : filesInputList) + for (FileLike inputFile : filesInputList) { try { - Files.move(inputFile, FileUtil.appendName(dirData, inputFile.getFileName().toString())); + Files.move(inputFile.toNioPathForWrite(), dirData.resolveChild(inputFile.getName()).toNioPathForWrite()); } catch (IOException e) { if (!allowNonExistentFiles) { - throw new IOException("Failed to move input file into unique directory: " + FileUtil.getAbsoluteCaseSensitivePath(context.getContainer(), inputFile).toAbsolutePath()); + throw new IOException("Failed to move input file into unique directory: " + FileUtil.getAbsoluteCaseSensitiveFile(inputFile)); } } } diff --git a/query/package-lock.json b/query/package-lock.json new file mode 100644 index 00000000000..001e6eec59f --- /dev/null +++ b/query/package-lock.json @@ -0,0 +1,10541 @@ +{ + "name": "puppeteer", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "puppeteer", + "version": "0.0.0", + "dependencies": { + "@labkey/components": "6.64.0" + }, + "devDependencies": { + "@labkey/build": "8.6.0", + "@types/jest": "30.0.0", + "@types/react": "18.3.23", + "@types/react-dom": "18.3.7" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.3.tgz", + "integrity": "sha512-VQKMkwriZbaOgVCby1UDY/LDk5fIjhQicCvVPFqfe+69fWaPWydbWJ3wRt59/YzIwda1I81loas3oCoHxnqvdA==", + "license": "MIT" + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", + "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz", + "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.0", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.27.3", + "@babel/helpers": "^7.27.6", + "@babel/parser": "^7.28.0", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.0", + "@babel/types": "^7.28.0", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz", + "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.0", + "@babel/types": "^7.28.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.1.tgz", + "integrity": "sha512-QwGAmuvM17btKU5VqXfb+Giw4JcN0hjuufz3DYnpeVDvZLAObloM77bhMXiqry3Iio+Ai4phVRDwl6WU10+r5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-member-expression-to-functions": "^7.27.1", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.27.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.27.1.tgz", + "integrity": "sha512-uVDC72XVf8UbrH5qQTc18Agb8emwjTiZrQE11Nv3CuBEZmVvTwwE9CBUEvHku06gQCAyYf8Nv6ja1IN+6LMbxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "regexpu-core": "^6.2.0", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.5.tgz", + "integrity": "sha512-uJnGFcPsWQK8fvjgGP5LZUZZsYGIoPeRjSF5PGwrelYgq7Q15/Ft9NGFp1zglwgIv//W0uG4BevRuSJRyylZPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-plugin-utils": "^7.27.1", + "debug": "^4.4.1", + "lodash.debounce": "^4.0.8", + "resolve": "^1.22.10" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz", + "integrity": "sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz", + "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", + "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-wrap-function": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.27.1.tgz", + "integrity": "sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.27.1", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.27.1.tgz", + "integrity": "sha512-NFJK2sHUvrjo8wAU/nQTWU890/zB2jj0qBcCbZbbf+005cAsv6tMjXz31fBign6M5ov1o0Bllu+9nbqkfsjjJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.1", + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz", + "integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.27.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", + "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.27.1.tgz", + "integrity": "sha512-QPG3C9cCVRQLxAVwmefEmwdTanECuUBMQZ/ym5kiw3XKCGA7qkuQLcjWWHcrD/GKbn/WmJwaezfuuAOcyKlRPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz", + "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz", + "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz", + "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-transform-optional-chaining": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.13.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.27.1.tgz", + "integrity": "sha512-6BpaYGDavZqkI6yT+KSPdpZFfpnd68UKXbcjI9pJ13pvHhPrCKWOOLp+ysvMeA+DxnhuPpgIaRpxRxo5A9t5jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0-placeholder-for-preset-env.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.27.1.tgz", + "integrity": "sha512-UT/Jrhw57xg4ILHLFnzFpPDlMbcdEicaAtjPQpbj9wa8T4r5KVWCimHcL/460g8Ht0DMxDyjsLgiWSkVjnwPFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", + "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", + "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", + "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-unicode-sets-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", + "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", + "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.28.0.tgz", + "integrity": "sha512-BEOdvX4+M765icNPZeidyADIvQ1m1gmunXufXxvRESy/jNNyfovIqUyE7MVgGBjWktCoJlzvFA1To2O4ymIO3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-remap-async-to-generator": "^7.27.1", + "@babel/traverse": "^7.28.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.27.1.tgz", + "integrity": "sha512-NREkZsZVJS4xmTr8qzE5y8AfIPqsdQfRuUiLRTEzb7Qii8iFWCyDKaUV2c0rCuh4ljDZ98ALHP/PetiBV2nddA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-remap-async-to-generator": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoped-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz", + "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.0.tgz", + "integrity": "sha512-gKKnwjpdx5sER/wl0WN0efUBFzF/56YZO0RJrSYP4CljXnP31ByY7fol89AzomdlLNzI36AvOTmYHsnZTCkq8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.27.1.tgz", + "integrity": "sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.27.1.tgz", + "integrity": "sha512-s734HmYU78MVzZ++joYM+NkJusItbdRcbm+AGRgJCt3iA+yux0QpD9cBVdz3tKyrjVYWRl7j0mHSmv4lhV0aoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.0.tgz", + "integrity": "sha512-IjM1IoJNw72AZFlj33Cu8X0q2XK/6AaVC3jQu+cgQ5lThWD5ajnuUAml80dqRmOhmPkTH8uAwnpMu9Rvj0LTRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-globals": "^7.28.0", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1", + "@babel/traverse": "^7.28.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.27.1.tgz", + "integrity": "sha512-lj9PGWvMTVksbWiDT2tW68zGS/cyo4AkZ/QTp0sQT0mjPopCmrSkzxeXkznjqBxzDI6TclZhOJbBmbBLjuOZUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/template": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.0.tgz", + "integrity": "sha512-v1nrSMBiKcodhsyJ4Gf+Z0U/yawmJDBOTpEB3mcQY52r9RIyPneGyAS/yM6seP/8I+mWI3elOMtT5dB8GJVs+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dotall-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.27.1.tgz", + "integrity": "sha512-gEbkDVGRvjj7+T1ivxrfgygpT7GUd4vmODtYpbs0gZATdkX8/iSnOtZSxiZnsgm1YjTgjI6VKBGSJJevkrclzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-keys": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz", + "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.27.1.tgz", + "integrity": "sha512-hkGcueTEzuhB30B3eJCbCYeCaaEQOmQR0AdvzpD4LoN0GXMWzzGSuRrxR2xTnCrvNbVwK9N6/jQ92GSLfiZWoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-dynamic-import": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz", + "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-explicit-resource-management": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.0.tgz", + "integrity": "sha512-K8nhUcn3f6iB+P3gwCv/no7OdzOZQcKchW6N389V6PD8NUWKZHzndOd9sPDVbMoBsbmjMqlB4L9fm+fEFNVlwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-transform-destructuring": "^7.28.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-exponentiation-operator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.27.1.tgz", + "integrity": "sha512-uspvXnhHvGKf2r4VVtBpeFnuDWsJLQ6MF6lGJLC89jBR1uoVeqM416AZtTuhTezOfgHicpJQmoD5YUakO/YmXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", + "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", + "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz", + "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-json-strings": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.27.1.tgz", + "integrity": "sha512-6WVLVJiTjqcQauBhn1LkICsR2H+zm62I3h9faTDKt1qP4jn2o72tSvqMwtGFKGTpojce0gJs+76eZ2uCHRZh0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", + "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.27.1.tgz", + "integrity": "sha512-SJvDs5dXxiae4FbSL1aBJlG4wvl594N6YEVVn9e3JGulwioy6z3oPjx/sQBO3Y4NwUu5HNix6KJ3wBZoewcdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-member-expression-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz", + "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-amd": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz", + "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.27.1.tgz", + "integrity": "sha512-OJguuwlTYlN0gBZFRPqwOGNWssZjfIUdS7HMYtN8c1KmwpwHFBwTeFZrg9XZa+DFTitWOW5iTAG7tyCUPsCCyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-systemjs": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.27.1.tgz", + "integrity": "sha512-w5N1XzsRbc0PQStASMksmUeqECuzKuTJer7kFagK8AXgpCMkeDMO5S+aaFb7A51ZYDF7XI34qsTX+fkHiIm5yA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-umd": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz", + "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.27.1.tgz", + "integrity": "sha512-SstR5JYy8ddZvD6MhV0tM/j16Qds4mIpJTOd1Yu9J9pJjH93bxHECF7pgtc28XvkzTD6Pxcm/0Z73Hvk7kb3Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-new-target": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz", + "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.27.1.tgz", + "integrity": "sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-numeric-separator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.27.1.tgz", + "integrity": "sha512-fdPKAcujuvEChxDBJ5c+0BTaS6revLV7CJL08e4m3de8qJfNIuCc2nc7XJYOjBoTMJeqSmwXJ0ypE14RCjLwaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.0.tgz", + "integrity": "sha512-9VNGikXxzu5eCiQjdE4IZn8sb9q7Xsk5EXLDBKUYg1e/Tve8/05+KJEtcxGxAgCY5t/BpKQM+JEL/yT4tvgiUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-transform-destructuring": "^7.28.0", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/traverse": "^7.28.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-super": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz", + "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.27.1.tgz", + "integrity": "sha512-txEAEKzYrHEX4xSZN4kJ+OfKXFVSWKB2ZxM9dpcE3wT7smwkNmXo5ORRlVzMVdJbD+Q8ILTgSD7959uj+3Dm3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.27.1.tgz", + "integrity": "sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.27.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz", + "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.27.1.tgz", + "integrity": "sha512-10FVt+X55AjRAYI9BrdISN9/AQWHqldOeZDUoLyif1Kn05a56xVBXb8ZouL8pZ9jem8QpXaOt8TS7RHUIS+GPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.27.1.tgz", + "integrity": "sha512-5J+IhqTi1XPa0DXF83jYOaARrX+41gOewWbkPyjMNRDqgOCqdffGh8L3f/Ek5utaEBZExjSAzcyjmV9SSAWObQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-property-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz", + "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-display-name": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.28.0.tgz", + "integrity": "sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.27.1.tgz", + "integrity": "sha512-2KH4LWGSrJIkVf5tSiBFYuXDAoWRq2MMwgivCf+93dd0GQi8RXLjKA/0EvRnVV5G0hrHczsquXuD01L8s6dmBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.27.1.tgz", + "integrity": "sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-pure-annotations": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.27.1.tgz", + "integrity": "sha512-JfuinvDOsD9FVMTHpzA/pBLisxpv1aSf+OIV8lgH3MuWrks19R27e6a6DipIg4aX1Zm9Wpb04p8wljfKrVSnPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.28.0.tgz", + "integrity": "sha512-LOAozRVbqxEVjSKfhGnuLoE4Kz4Oc5UJzuvFUhSsQzdCdaAQu06mG8zDv2GFSerM62nImUZ7K92vxnQcLSDlCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regexp-modifiers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.27.1.tgz", + "integrity": "sha512-TtEciroaiODtXvLZv4rmfMhkCv8jx3wgKpL68PuiPh2M4fvz5jhsA7697N1gMvkvr/JTF13DrFYyEbY9U7cVPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz", + "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", + "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-spread": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.27.1.tgz", + "integrity": "sha512-kpb3HUqaILBJcRFVhFUs6Trdd4mkrzcGXss+6/mxUd273PfbWqSDHRzMT2234gIg2QYfAjvXLSquP1xECSg09Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz", + "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", + "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typeof-symbol": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz", + "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.0.tgz", + "integrity": "sha512-4AEiDEBPIZvLQaWlc9liCavE0xRM0dNca41WtBeM3jgFptfUOSG9z0uteLhq6+3rq+WB6jIvUwKDTpXEHPJ2Vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-escapes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", + "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-property-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.27.1.tgz", + "integrity": "sha512-uW20S39PnaTImxp39O5qFlHLS9LJEmANjMG7SxIhap8rCHqu0Ik+tLEPX5DKmHn6CsWQ7j3lix2tFOa5YtL12Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", + "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-sets-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.27.1.tgz", + "integrity": "sha512-EtkOujbc4cgvb0mlpQefi4NTPBzhSIevblFevACNLUspmrALgmEBdL/XfnyyITfd8fKBZrZys92zOWcik7j9Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/preset-env": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.28.0.tgz", + "integrity": "sha512-VmaxeGOwuDqzLl5JUkIRM1X2Qu2uKGxHEQWh+cvvbl7JuJRgKGJSfsEF/bUaxFhJl/XAyxBe7q7qSuTbKFuCyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.0", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.27.1", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.27.1", + "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", + "@babel/plugin-syntax-import-assertions": "^7.27.1", + "@babel/plugin-syntax-import-attributes": "^7.27.1", + "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", + "@babel/plugin-transform-arrow-functions": "^7.27.1", + "@babel/plugin-transform-async-generator-functions": "^7.28.0", + "@babel/plugin-transform-async-to-generator": "^7.27.1", + "@babel/plugin-transform-block-scoped-functions": "^7.27.1", + "@babel/plugin-transform-block-scoping": "^7.28.0", + "@babel/plugin-transform-class-properties": "^7.27.1", + "@babel/plugin-transform-class-static-block": "^7.27.1", + "@babel/plugin-transform-classes": "^7.28.0", + "@babel/plugin-transform-computed-properties": "^7.27.1", + "@babel/plugin-transform-destructuring": "^7.28.0", + "@babel/plugin-transform-dotall-regex": "^7.27.1", + "@babel/plugin-transform-duplicate-keys": "^7.27.1", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.27.1", + "@babel/plugin-transform-dynamic-import": "^7.27.1", + "@babel/plugin-transform-explicit-resource-management": "^7.28.0", + "@babel/plugin-transform-exponentiation-operator": "^7.27.1", + "@babel/plugin-transform-export-namespace-from": "^7.27.1", + "@babel/plugin-transform-for-of": "^7.27.1", + "@babel/plugin-transform-function-name": "^7.27.1", + "@babel/plugin-transform-json-strings": "^7.27.1", + "@babel/plugin-transform-literals": "^7.27.1", + "@babel/plugin-transform-logical-assignment-operators": "^7.27.1", + "@babel/plugin-transform-member-expression-literals": "^7.27.1", + "@babel/plugin-transform-modules-amd": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.27.1", + "@babel/plugin-transform-modules-systemjs": "^7.27.1", + "@babel/plugin-transform-modules-umd": "^7.27.1", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.27.1", + "@babel/plugin-transform-new-target": "^7.27.1", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1", + "@babel/plugin-transform-numeric-separator": "^7.27.1", + "@babel/plugin-transform-object-rest-spread": "^7.28.0", + "@babel/plugin-transform-object-super": "^7.27.1", + "@babel/plugin-transform-optional-catch-binding": "^7.27.1", + "@babel/plugin-transform-optional-chaining": "^7.27.1", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/plugin-transform-private-methods": "^7.27.1", + "@babel/plugin-transform-private-property-in-object": "^7.27.1", + "@babel/plugin-transform-property-literals": "^7.27.1", + "@babel/plugin-transform-regenerator": "^7.28.0", + "@babel/plugin-transform-regexp-modifiers": "^7.27.1", + "@babel/plugin-transform-reserved-words": "^7.27.1", + "@babel/plugin-transform-shorthand-properties": "^7.27.1", + "@babel/plugin-transform-spread": "^7.27.1", + "@babel/plugin-transform-sticky-regex": "^7.27.1", + "@babel/plugin-transform-template-literals": "^7.27.1", + "@babel/plugin-transform-typeof-symbol": "^7.27.1", + "@babel/plugin-transform-unicode-escapes": "^7.27.1", + "@babel/plugin-transform-unicode-property-regex": "^7.27.1", + "@babel/plugin-transform-unicode-regex": "^7.27.1", + "@babel/plugin-transform-unicode-sets-regex": "^7.27.1", + "@babel/preset-modules": "0.1.6-no-external-plugins", + "babel-plugin-polyfill-corejs2": "^0.4.14", + "babel-plugin-polyfill-corejs3": "^0.13.0", + "babel-plugin-polyfill-regenerator": "^0.6.5", + "core-js-compat": "^3.43.0", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-modules": { + "version": "0.1.6-no-external-plugins", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", + "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/preset-react": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.27.1.tgz", + "integrity": "sha512-oJHWh2gLhU9dW9HHr42q0cI0/iHHXTLGe39qvpAZZzagHy0MzYLCnCVV0symeRvzmjHyVU7mw2K06E6u/JwbhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-transform-react-display-name": "^7.27.1", + "@babel/plugin-transform-react-jsx": "^7.27.1", + "@babel/plugin-transform-react-jsx-development": "^7.27.1", + "@babel/plugin-transform-react-pure-annotations": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.1.tgz", + "integrity": "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.27.1", + "@babel/plugin-transform-typescript": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", + "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz", + "integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.0", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.0.tgz", + "integrity": "sha512-jYnje+JyZG5YThjHiF28oT4SIZLnYOcSBb6+SDaFIyzDVSkXQmQQYclJ2R+YxcdmK0AX6x1E5OQNtuh3jHDrUg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@discoveryjs/json-ext": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", + "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/babel-plugin/node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "license": "MIT" + }, + "node_modules/@emotion/babel-plugin/node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/core": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@emotion/core/-/core-10.3.1.tgz", + "integrity": "sha512-447aUEjPIm0MnE6QYIaFz9VQOHSXf4Iu6EWOIqq11EAPqinkSZmfymPTmlOE3QjLv846lH4JVZBUOtwGbuQoww==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.5.5", + "@emotion/cache": "^10.0.27", + "@emotion/css": "^10.0.27", + "@emotion/serialize": "^0.11.15", + "@emotion/sheet": "0.9.4", + "@emotion/utils": "0.11.3" + }, + "peerDependencies": { + "react": ">=16.3.0" + } + }, + "node_modules/@emotion/core/node_modules/@emotion/cache": { + "version": "10.0.29", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-10.0.29.tgz", + "integrity": "sha512-fU2VtSVlHiF27empSbxi1O2JFdNWZO+2NFHfwO0pxgTep6Xa3uGb+3pVKfLww2l/IBGLNEZl5Xf/++A4wAYDYQ==", + "license": "MIT", + "dependencies": { + "@emotion/sheet": "0.9.4", + "@emotion/stylis": "0.8.5", + "@emotion/utils": "0.11.3", + "@emotion/weak-memoize": "0.2.5" + } + }, + "node_modules/@emotion/core/node_modules/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", + "license": "MIT" + }, + "node_modules/@emotion/core/node_modules/@emotion/memoize": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz", + "integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==", + "license": "MIT" + }, + "node_modules/@emotion/core/node_modules/@emotion/serialize": { + "version": "0.11.16", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-0.11.16.tgz", + "integrity": "sha512-G3J4o8by0VRrO+PFeSc3js2myYNOXVJ3Ya+RGVxnshRYgsvErfAOglKAiy1Eo1vhzxqtUvjCyS5gtewzkmvSSg==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "0.8.0", + "@emotion/memoize": "0.7.4", + "@emotion/unitless": "0.7.5", + "@emotion/utils": "0.11.3", + "csstype": "^2.5.7" + } + }, + "node_modules/@emotion/core/node_modules/@emotion/sheet": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-0.9.4.tgz", + "integrity": "sha512-zM9PFmgVSqBw4zL101Q0HrBVTGmpAxFZH/pYx/cjJT5advXguvcgjHFTCaIO3enL/xr89vK2bh0Mfyj9aa0ANA==", + "license": "MIT" + }, + "node_modules/@emotion/core/node_modules/@emotion/unitless": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", + "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", + "license": "MIT" + }, + "node_modules/@emotion/core/node_modules/@emotion/utils": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-0.11.3.tgz", + "integrity": "sha512-0o4l6pZC+hI88+bzuaX/6BgOvQVhbt2PfmxauVaYOGgbsAw14wdKyvMCZXnsnsHys94iadcF+RG/wZyx6+ZZBw==", + "license": "MIT" + }, + "node_modules/@emotion/core/node_modules/@emotion/weak-memoize": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.2.5.tgz", + "integrity": "sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA==", + "license": "MIT" + }, + "node_modules/@emotion/core/node_modules/csstype": { + "version": "2.6.21", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.21.tgz", + "integrity": "sha512-Z1PhmomIfypOpoMjRQB70jfvy/wxT50qW08YXO5lMIJkrdq4yOTR+AW7FqutScmB9NkLwxo+jU+kZLbofZZq/w==", + "license": "MIT" + }, + "node_modules/@emotion/css": { + "version": "10.0.27", + "resolved": "https://registry.npmjs.org/@emotion/css/-/css-10.0.27.tgz", + "integrity": "sha512-6wZjsvYeBhyZQYNrGoR5yPMYbMBNEnanDrqmsqS1mzDm1cOTu12shvl2j4QHNS36UaTE0USIJawCH9C8oW34Zw==", + "license": "MIT", + "dependencies": { + "@emotion/serialize": "^0.11.15", + "@emotion/utils": "0.11.3", + "babel-plugin-emotion": "^10.0.27" + } + }, + "node_modules/@emotion/css/node_modules/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", + "license": "MIT" + }, + "node_modules/@emotion/css/node_modules/@emotion/memoize": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz", + "integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==", + "license": "MIT" + }, + "node_modules/@emotion/css/node_modules/@emotion/serialize": { + "version": "0.11.16", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-0.11.16.tgz", + "integrity": "sha512-G3J4o8by0VRrO+PFeSc3js2myYNOXVJ3Ya+RGVxnshRYgsvErfAOglKAiy1Eo1vhzxqtUvjCyS5gtewzkmvSSg==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "0.8.0", + "@emotion/memoize": "0.7.4", + "@emotion/unitless": "0.7.5", + "@emotion/utils": "0.11.3", + "csstype": "^2.5.7" + } + }, + "node_modules/@emotion/css/node_modules/@emotion/unitless": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", + "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", + "license": "MIT" + }, + "node_modules/@emotion/css/node_modules/@emotion/utils": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-0.11.3.tgz", + "integrity": "sha512-0o4l6pZC+hI88+bzuaX/6BgOvQVhbt2PfmxauVaYOGgbsAw14wdKyvMCZXnsnsHys94iadcF+RG/wZyx6+ZZBw==", + "license": "MIT" + }, + "node_modules/@emotion/css/node_modules/csstype": { + "version": "2.6.21", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.21.tgz", + "integrity": "sha512-Z1PhmomIfypOpoMjRQB70jfvy/wxT50qW08YXO5lMIJkrdq4yOTR+AW7FqutScmB9NkLwxo+jU+kZLbofZZq/w==", + "license": "MIT" + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/is-prop-valid": { + "version": "0.8.8", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz", + "integrity": "sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "0.7.4" + } + }, + "node_modules/@emotion/is-prop-valid/node_modules/@emotion/memoize": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz", + "integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==", + "license": "MIT" + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT" + }, + "node_modules/@emotion/styled": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-10.3.0.tgz", + "integrity": "sha512-GgcUpXBBEU5ido+/p/mCT2/Xx+Oqmp9JzQRuC+a4lYM4i4LBBn/dWvc0rQ19N9ObA8/T4NWMrPNe79kMBDJqoQ==", + "license": "MIT", + "dependencies": { + "@emotion/styled-base": "^10.3.0", + "babel-plugin-emotion": "^10.0.27" + }, + "peerDependencies": { + "@emotion/core": "^10.0.27", + "react": ">=16.3.0" + } + }, + "node_modules/@emotion/styled-base": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@emotion/styled-base/-/styled-base-10.3.0.tgz", + "integrity": "sha512-PBRqsVKR7QRNkmfH78hTSSwHWcwDpecH9W6heujWAcyp2wdz/64PP73s7fWS1dIPm8/Exc8JAzYS8dEWXjv60w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.5.5", + "@emotion/is-prop-valid": "0.8.8", + "@emotion/serialize": "^0.11.15", + "@emotion/utils": "0.11.3" + }, + "peerDependencies": { + "@emotion/core": "^10.0.28", + "react": ">=16.3.0" + } + }, + "node_modules/@emotion/styled-base/node_modules/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", + "license": "MIT" + }, + "node_modules/@emotion/styled-base/node_modules/@emotion/memoize": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz", + "integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==", + "license": "MIT" + }, + "node_modules/@emotion/styled-base/node_modules/@emotion/serialize": { + "version": "0.11.16", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-0.11.16.tgz", + "integrity": "sha512-G3J4o8by0VRrO+PFeSc3js2myYNOXVJ3Ya+RGVxnshRYgsvErfAOglKAiy1Eo1vhzxqtUvjCyS5gtewzkmvSSg==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "0.8.0", + "@emotion/memoize": "0.7.4", + "@emotion/unitless": "0.7.5", + "@emotion/utils": "0.11.3", + "csstype": "^2.5.7" + } + }, + "node_modules/@emotion/styled-base/node_modules/@emotion/unitless": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", + "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", + "license": "MIT" + }, + "node_modules/@emotion/styled-base/node_modules/@emotion/utils": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-0.11.3.tgz", + "integrity": "sha512-0o4l6pZC+hI88+bzuaX/6BgOvQVhbt2PfmxauVaYOGgbsAw14wdKyvMCZXnsnsHys94iadcF+RG/wZyx6+ZZBw==", + "license": "MIT" + }, + "node_modules/@emotion/styled-base/node_modules/csstype": { + "version": "2.6.21", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.21.tgz", + "integrity": "sha512-Z1PhmomIfypOpoMjRQB70jfvy/wxT50qW08YXO5lMIJkrdq4yOTR+AW7FqutScmB9NkLwxo+jU+kZLbofZZq/w==", + "license": "MIT" + }, + "node_modules/@emotion/stylis": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/@emotion/stylis/-/stylis-0.8.5.tgz", + "integrity": "sha512-h6KtPihKFn3T9fuIrwvXXUOwlx3rfUvfZIcP5a6rh8Y7zjE3O06hT5Ss4S/YI1AYhuZ1kjaE/5EaOOI2NqSylQ==", + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" + }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.26.28", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz", + "integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.2", + "@floating-ui/utils": "^0.2.8", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", + "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.4" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, + "node_modules/@hello-pangea/dnd": { + "version": "18.0.1", + "resolved": "https://registry.npmjs.org/@hello-pangea/dnd/-/dnd-18.0.1.tgz", + "integrity": "sha512-xojVWG8s/TGrKT1fC8K2tIWeejJYTAeJuj36zM//yEm/ZrnZUSFGS15BpO+jGZT1ybWvyXmeDJwPYb4dhWlbZQ==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime": "^7.26.7", + "css-box-model": "^1.2.1", + "raf-schd": "^4.0.3", + "react-redux": "^9.2.0", + "redux": "^5.0.1" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@icons/material": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@icons/material/-/material-0.2.4.tgz", + "integrity": "sha512-QPcGmICAPbGLGb6F/yNf/KzKqvFx8z5qx3D1yFqVAjoFmXK35EgyW+cJ57Te3CNsmzblwtzakLGFqHPqrfb4Tw==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", + "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@jest/diff-sequences": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz", + "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "30.0.4", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.0.4.tgz", + "integrity": "sha512-EgXecHDNfANeqOkcak0DxsoVI4qkDUsR7n/Lr2vtmTBjwLPBnnPOF71S11Q8IObWzxm2QgQoY6f9hzrRD3gHRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/get-type": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.0.1.tgz", + "integrity": "sha512-AyYdemXCptSRFirI5EPazNxyPwAL0jXt3zceFjaj8NFiKP9pOi0bfXonf6qkf82z2t3QWPeLCWWw4stPBzctLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", + "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/schemas": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.1.tgz", + "integrity": "sha512-+g/1TKjFuGrf1Hh0QPCv0gISwBxJ+MQSNXmG9zjHy7BmFhtoJ9fdNhWJp3qUKRi93AOZHXtdxZgJ1vAtz6z65w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/types": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.0.1.tgz", + "integrity": "sha512-HGwoYRVF0QSKJu1ZQX0o5ZrUrrhj0aOOFA8hXrumD7SIzjouevhawbTjmXdwOmURdGluU9DM/XvGm3NyFoiQjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", + "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.10.tgz", + "integrity": "sha512-0pPkgz9dY+bijgistcTTJ5mR+ocqRXLuhXHYdzoMmmoJ2C9S46RCm2GMUbatPEUK9Yjy26IrAy8D/M00lLkv+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", + "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.29", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", + "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jsonjoy.com/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/json-pack": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.2.0.tgz", + "integrity": "sha512-io1zEbbYcElht3tdlqEOFxZ0dMTYrHz9iMf0gqn1pPjZFTCgM5R4R5IMA20Chb2UPYYsxjzs8CgZ7Nb5n2K2rA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/base64": "^1.1.1", + "@jsonjoy.com/util": "^1.1.2", + "hyperdyperid": "^1.2.0", + "thingies": "^1.20.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/util": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.6.0.tgz", + "integrity": "sha512-sw/RMbehRhN68WRtcKCpQOPfnH6lLP4GJfqzi3iYej8tnzpZUDr6UkZYJjcjjC0FWEJOJbyM3PTIwxucUmDG2A==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@labkey/api": { + "version": "1.43.0", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/api/-/@labkey/api-1.43.0.tgz", + "integrity": "sha512-4hOQz+pM/QaCey6ooJEmEbElnR9+TDEzWG+8caFfeIX1iAg1335NXW3+/Xzs6a+L9ysRKds8bNgFPu2sxjPzfg==", + "license": "Apache-2.0" + }, + "node_modules/@labkey/build": { + "version": "8.6.0", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/build/-/@labkey/build-8.6.0.tgz", + "integrity": "sha512-rAb/cQomhlL4GamJkI8/V2lHUGwUNRh7n1uXFMeHfUmuiXP/adp9uqhCC+yTAcaAK0kdq6Z3vU/n7VvCEdjtMQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@babel/core": "~7.28.0", + "@babel/plugin-transform-class-properties": "~7.27.1", + "@babel/plugin-transform-object-rest-spread": "~7.28.0", + "@babel/preset-env": "~7.28.0", + "@babel/preset-react": "~7.27.1", + "@babel/preset-typescript": "~7.27.1", + "@pmmmwh/react-refresh-webpack-plugin": "~0.6.1", + "ajv": "~8.17.1", + "babel-loader": "~10.0.0", + "bootstrap-sass": "~3.4.3", + "copy-webpack-plugin": "~13.0.0", + "cross-env": "~7.0.3", + "css-loader": "~7.1.2", + "fork-ts-checker-webpack-plugin": "~9.1.0", + "html-webpack-plugin": "~5.6.3", + "mini-css-extract-plugin": "~2.9.2", + "react-refresh": "~0.17.0", + "resolve-url-loader": "~5.0.0", + "rimraf": "~6.0.1", + "sass": "~1.79.6", + "sass-loader": "~16.0.5", + "source-map-loader": "~5.0.0", + "style-loader": "~4.0.0", + "typescript": "~5.8.3", + "webpack": "~5.100.0", + "webpack-bundle-analyzer": "~4.10.2", + "webpack-cli": "~6.0.1", + "webpack-dev-server": "~5.2.2" + } + }, + "node_modules/@labkey/components": { + "version": "6.64.0", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-6.64.0.tgz", + "integrity": "sha512-PvaxxI03mJ64L/F0FFWrtHDFwrFiyYm+/w/uyCTjFv/RZ/A+CIjkb5+v4iaQyAzJPUr1HzVPUfDlNxWkd3r2OQ==", + "license": "SEE LICENSE IN LICENSE.txt", + "dependencies": { + "@hello-pangea/dnd": "18.0.1", + "@labkey/api": "1.43.0", + "@testing-library/dom": "~10.4.0", + "@testing-library/jest-dom": "~6.6.3", + "@testing-library/react": "~16.3.0", + "@testing-library/user-event": "~14.6.1", + "bootstrap": "~3.4.1", + "classnames": "~2.5.1", + "date-fns": "~3.6.0", + "date-fns-tz": "~3.2.0", + "font-awesome": "~4.7.0", + "immer": "~10.1.1", + "immutable": "~3.8.2", + "normalizr": "~3.6.2", + "numeral": "~2.0.6", + "react": "~18.3.1", + "react-color": "~2.19.3", + "react-datepicker": "~7.5.0", + "react-dom": "~18.3.1", + "react-router-dom": "~6.30.1", + "react-select": "~5.10.1", + "react-treebeard": "~3.2.4", + "vis-data": "~7.1.10", + "vis-network": "~9.1.13" + } + }, + "node_modules/@leichtgewicht/ip-codec": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", + "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@parcel/watcher": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", + "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.1", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-freebsd-x64": "2.5.1", + "@parcel/watcher-linux-arm-glibc": "2.5.1", + "@parcel/watcher-linux-arm-musl": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-ia32": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", + "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", + "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", + "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", + "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", + "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", + "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", + "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", + "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", + "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", + "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@pmmmwh/react-refresh-webpack-plugin": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.6.1.tgz", + "integrity": "sha512-95DXXJxNkpYu+sqmpDp7vbw9JCyiNpHuCsvuMuOgVFrKQlwEIn9Y1+NNIQJq+zFL+eWyxw6htthB5CtdwJupNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "anser": "^2.1.1", + "core-js-pure": "^3.23.3", + "error-stack-parser": "^2.0.6", + "html-entities": "^2.1.0", + "schema-utils": "^4.2.0", + "source-map": "^0.7.3" + }, + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "@types/webpack": "5.x", + "react-refresh": ">=0.10.0 <1.0.0", + "sockjs-client": "^1.4.0", + "type-fest": ">=0.17.0 <5.0.0", + "webpack": "^5.0.0", + "webpack-dev-server": "^4.8.0 || 5.x", + "webpack-hot-middleware": "2.x", + "webpack-plugin-serve": "1.x" + }, + "peerDependenciesMeta": { + "@types/webpack": { + "optional": true + }, + "sockjs-client": { + "optional": true + }, + "type-fest": { + "optional": true + }, + "webpack-dev-server": { + "optional": true + }, + "webpack-hot-middleware": { + "optional": true + }, + "webpack-plugin-serve": { + "optional": true + } + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@remix-run/router": { + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz", + "integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.34.37", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.37.tgz", + "integrity": "sha512-2TRuQVgQYfy+EzHRTIvkhv2ADEouJ2xNS/Vq+W5EuuewBdOrvATvljZTxHWZSTYr2sTjTHpGvucaGAt67S2akw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/dom": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", + "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.6.3.tgz", + "integrity": "sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==", + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "chalk": "^3.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "lodash": "^4.17.21", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz", + "integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "license": "MIT" + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/bonjour": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@types/bonjour/-/bonjour-3.5.13.tgz", + "integrity": "sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/connect-history-api-fallback": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.5.4.tgz", + "integrity": "sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express-serve-static-core": "*", + "@types/node": "*" + } + }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz", + "integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.6", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", + "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/html-minifier-terser": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", + "integrity": "sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/http-proxy": { + "version": "1.17.16", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.16.tgz", + "integrity": "sha512-sdWoUajOB1cd0A8cRRQ1cfyWNbmFKLAqBB89Y8x5iYyG/mkJHc0YUH8pdWBy2omi9qtCpiIgGjuwO0dQST2l5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", + "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^30.0.0", + "pretty-format": "^30.0.0" + } + }, + "node_modules/@types/jest/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@types/jest/node_modules/pretty-format": { + "version": "30.0.2", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.2.tgz", + "integrity": "sha512-yC5/EBSOrTtqhCKfLHqoUIAXVRZnukHPwWBJWR7h84Q3Be1DRQZLncwcfLoPA5RPQ65qfiCMqgYwdUuQ//eVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.1", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@types/jest/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.0.12", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.12.tgz", + "integrity": "sha512-LtOrbvDf5ndC9Xi+4QZjVL0woFymF/xSTKZKPgrrl7H7XoeDvnD+E2IclKVDyaK9UM756W/3BXqSU+JEHopA9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.8.0" + } + }, + "node_modules/@types/node-forge": { + "version": "1.3.12", + "resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.12.tgz", + "integrity": "sha512-a0ToKlRVnUw3aXKQq2F+krxZKq7B8LEQijzPn5RdFAMatARD2JX9o8FBpMXOOrjob0uc13aN+V/AXniOXW4d9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.23", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz", + "integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/retry": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz", + "integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "0.17.5", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", + "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/serve-index": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@types/serve-index/-/serve-index-1.9.4.tgz", + "integrity": "sha512-qLpGZ/c2fhSs5gnYsQxtDEq3Oy8SXPClIXkW5ghvAvsNuVSA8k+gCONcUCS/UjLEYvYps+e8uBtfgXgvhwfNug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.8", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.8.tgz", + "integrity": "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "*" + } + }, + "node_modules/@types/sockjs": { + "version": "0.3.36", + "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.36.tgz", + "integrity": "sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/yargs": { + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webpack-cli/configtest": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-3.0.1.tgz", + "integrity": "sha512-u8d0pJ5YFgneF/GuvEiDA61Tf1VDomHHYMjv/wc9XzYj7nopltpG96nXN5dJRstxZhcNpV1g+nT6CydO7pHbjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12.0" + }, + "peerDependencies": { + "webpack": "^5.82.0", + "webpack-cli": "6.x.x" + } + }, + "node_modules/@webpack-cli/info": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@webpack-cli/info/-/info-3.0.1.tgz", + "integrity": "sha512-coEmDzc2u/ffMvuW9aCjoRzNSPDl/XLuhPdlFRpT9tZHmJ/039az33CE7uH+8s0uL1j5ZNtfdv0HkfaKRBGJsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12.0" + }, + "peerDependencies": { + "webpack": "^5.82.0", + "webpack-cli": "6.x.x" + } + }, + "node_modules/@webpack-cli/serve": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-3.0.1.tgz", + "integrity": "sha512-sbgw03xQaCLiT6gcY/6u3qBDn01CWw/nbaXl3gTdTFuJJ75Gffv3E3DBpgvY2fkkrdS1fpjaXNOmJlnbtKauKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12.0" + }, + "peerDependencies": { + "webpack": "^5.82.0", + "webpack-cli": "6.x.x" + }, + "peerDependenciesMeta": { + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-phases": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.3.tgz", + "integrity": "sha512-jtKLnfoOzm28PazuQ4dVBcE9Jeo6ha1GAJvq3N0LlNOszmTfx+wSycBehn+FN0RnyeR77IBxN/qVYMw0Rlj0Xw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/adjust-sourcemap-loader": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/adjust-sourcemap-loader/-/adjust-sourcemap-loader-4.0.0.tgz", + "integrity": "sha512-OXwN5b9pCUXNQHJpwwD2qP40byEmSgzj8B4ydSN0uMNYWiFmJ6x6KwUllMmfk8Rwu/HJDFR7U8ubsWBoN0Xp0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "loader-utils": "^2.0.0", + "regex-parser": "^2.2.11" + }, + "engines": { + "node": ">=8.9" + } + }, + "node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/anser": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/anser/-/anser-2.3.2.tgz", + "integrity": "sha512-PMqBCBvrOVDRqLGooQb+z+t1Q0PiPyurUQeZRR5uHBOVZcW8B04KMmnT12USnhpNX2wCPagWzLVppQMUG3u0Dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-html-community": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz", + "integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==", + "dev": true, + "engines": [ + "node >= 0.8.0" + ], + "license": "Apache-2.0", + "bin": { + "ansi-html": "bin/ansi-html" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/babel-loader": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-10.0.0.tgz", + "integrity": "sha512-z8jt+EdS61AMw22nSfoNJAZ0vrtmhPRVi6ghL3rCeRZI8cdNYFiV5xeV3HbE7rlZZNmGH8BVccwWt8/ED0QOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^5.0.0" + }, + "engines": { + "node": "^18.20.0 || ^20.10.0 || >=22.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0", + "webpack": ">=5.61.0" + } + }, + "node_modules/babel-plugin-emotion": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/babel-plugin-emotion/-/babel-plugin-emotion-10.2.2.tgz", + "integrity": "sha512-SMSkGoqTbTyUTDeuVuPIWifPdUGkTk1Kf9BWRiXIOIcuyMfsdp2EjeiiFvOzX8NOBvEh/ypKYvUh2rkgAJMCLA==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.0.0", + "@emotion/hash": "0.8.0", + "@emotion/memoize": "0.7.4", + "@emotion/serialize": "^0.11.16", + "babel-plugin-macros": "^2.0.0", + "babel-plugin-syntax-jsx": "^6.18.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^1.0.5", + "find-root": "^1.1.0", + "source-map": "^0.5.7" + } + }, + "node_modules/babel-plugin-emotion/node_modules/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", + "license": "MIT" + }, + "node_modules/babel-plugin-emotion/node_modules/@emotion/memoize": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz", + "integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==", + "license": "MIT" + }, + "node_modules/babel-plugin-emotion/node_modules/@emotion/serialize": { + "version": "0.11.16", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-0.11.16.tgz", + "integrity": "sha512-G3J4o8by0VRrO+PFeSc3js2myYNOXVJ3Ya+RGVxnshRYgsvErfAOglKAiy1Eo1vhzxqtUvjCyS5gtewzkmvSSg==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "0.8.0", + "@emotion/memoize": "0.7.4", + "@emotion/unitless": "0.7.5", + "@emotion/utils": "0.11.3", + "csstype": "^2.5.7" + } + }, + "node_modules/babel-plugin-emotion/node_modules/@emotion/unitless": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", + "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", + "license": "MIT" + }, + "node_modules/babel-plugin-emotion/node_modules/@emotion/utils": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-0.11.3.tgz", + "integrity": "sha512-0o4l6pZC+hI88+bzuaX/6BgOvQVhbt2PfmxauVaYOGgbsAw14wdKyvMCZXnsnsHys94iadcF+RG/wZyx6+ZZBw==", + "license": "MIT" + }, + "node_modules/babel-plugin-emotion/node_modules/babel-plugin-macros": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-2.8.0.tgz", + "integrity": "sha512-SEP5kJpfGYqYKpBrj5XU3ahw5p5GOHJ0U5ssOSQ/WBVdwkD2Dzlce95exQTs3jOVWPPKLBN2rlEWkCK7dSmLvg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.7.2", + "cosmiconfig": "^6.0.0", + "resolve": "^1.12.0" + } + }, + "node_modules/babel-plugin-emotion/node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "license": "MIT" + }, + "node_modules/babel-plugin-emotion/node_modules/cosmiconfig": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-6.0.0.tgz", + "integrity": "sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg==", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.1.0", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.7.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-emotion/node_modules/csstype": { + "version": "2.6.21", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.21.tgz", + "integrity": "sha512-Z1PhmomIfypOpoMjRQB70jfvy/wxT50qW08YXO5lMIJkrdq4yOTR+AW7FqutScmB9NkLwxo+jU+kZLbofZZq/w==", + "license": "MIT" + }, + "node_modules/babel-plugin-emotion/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/babel-plugin-emotion/node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/babel-plugin-macros/node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.14", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz", + "integrity": "sha512-Co2Y9wX854ts6U8gAAPXfn0GmAyctHuK8n0Yhfjd6t30g7yvKjspvvOo9yG+z52PZRgFErt7Ka2pYnXCjLKEpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.7", + "@babel/helper-define-polyfill-provider": "^0.6.5", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz", + "integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.5", + "core-js-compat": "^3.43.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.5.tgz", + "integrity": "sha512-ISqQ2frbiNU9vIJkzg7dlPpznPZ4jOiUQ1uSmB0fEHeowtN3COYRsXr/xexn64NpU13P06jc/L5TgiJXOgrbEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.5" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-syntax-jsx": { + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz", + "integrity": "sha512-qrPaCSo9c8RHNRHIotaufGbuOBN8rtdC4QrrFFc43vyWCCz7Kl7GL1PGaXtMGQZUXrkCjNEgxDfmAuAabr/rlw==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/batch": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", + "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/big.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/bonjour-service": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.3.0.tgz", + "integrity": "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "multicast-dns": "^7.2.5" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/bootstrap": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-3.4.1.tgz", + "integrity": "sha512-yN5oZVmRCwe5aKwzRj6736nSmKDX7pLYwsXiCj/EYmo16hODaBiT4En5btW/jhBF/seV+XMx3aYwukYC3A49DA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/bootstrap-sass": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/bootstrap-sass/-/bootstrap-sass-3.4.3.tgz", + "integrity": "sha512-vPgFnGMp1jWZZupOND65WS6mkR8rxhJxndT/AcMbqcq1hHMdkcH4sMPhznLzzoHOHkSCrd6J9F8pWBriPCKP2Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.25.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", + "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001726", + "electron-to-chromium": "^1.5.173", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camel-case": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", + "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pascal-case": "^3.1.2", + "tslib": "^2.0.3" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001727", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz", + "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/ci-info": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.0.tgz", + "integrity": "sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, + "node_modules/clean-css": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz", + "integrity": "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "source-map": "~0.6.0" + }, + "engines": { + "node": ">= 10.0" + } + }, + "node_modules/clean-css/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.0.tgz", + "integrity": "sha512-k6WLKfunuqCYD3t6AsuPGvQWaKwuLLh2/xHNcX4qE+vIfDNXpSqnrhwA7O53R7WVQUnt8dVAIW+YHr7xTgOgGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.0.2", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/connect-history-api-fallback": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz", + "integrity": "sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/copy-webpack-plugin": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-13.0.0.tgz", + "integrity": "sha512-FgR/h5a6hzJqATDGd9YG41SeDViH+0bkHn6WNXCi5zKAZkeESeSxLySSsFLHqLEVCh0E+rITmCf0dusXWYukeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob-parent": "^6.0.1", + "normalize-path": "^3.0.0", + "schema-utils": "^4.2.0", + "serialize-javascript": "^6.0.2", + "tinyglobby": "^0.2.12" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + } + }, + "node_modules/core-js-compat": { + "version": "3.44.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.44.0.tgz", + "integrity": "sha512-JepmAj2zfl6ogy34qfWtcE7nHKAJnKsQFRn++scjVS2bZFllwptzw61BZcZFYBPpUznLfAvh0LGhxKppk04ClA==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.25.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/core-js-pure": { + "version": "3.44.0", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.44.0.tgz", + "integrity": "sha512-gvMQAGB4dfVUxpYD0k3Fq8J+n5bB6Ytl15lqlZrOIXFzxOhtPaObfkQGHtMRdyjIf7z2IeNULwi1jEwyS+ltKQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-box-model": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz", + "integrity": "sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==", + "license": "MIT", + "dependencies": { + "tiny-invariant": "^1.0.6" + } + }, + "node_modules/css-loader": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-7.1.2.tgz", + "integrity": "sha512-6WvYYn7l/XEGN8Xu2vWFt9nVzrCn39vKyTEFf/ExEyoksJjjSZV/0/35XPlMbpnr6VGhZIUg5yJrL8tGfes/FA==", + "dev": true, + "license": "MIT", + "dependencies": { + "icss-utils": "^5.1.0", + "postcss": "^8.4.33", + "postcss-modules-extract-imports": "^3.1.0", + "postcss-modules-local-by-default": "^4.0.5", + "postcss-modules-scope": "^3.2.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || 1.x", + "webpack": "^5.27.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/css-loader/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/css-select": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz", + "integrity": "sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.0.1", + "domhandler": "^4.3.1", + "domutils": "^2.8.0", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, + "node_modules/date-fns": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/date-fns-tz": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-3.2.0.tgz", + "integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==", + "license": "MIT", + "peerDependencies": { + "date-fns": "^3.0.0 || ^4.0.0" + } + }, + "node_modules/debounce": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", + "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-equal": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz", + "integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==", + "license": "MIT", + "dependencies": { + "is-arguments": "^1.1.1", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "regexp.prototype.flags": "^1.5.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/default-browser": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz", + "integrity": "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.0.tgz", + "integrity": "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "license": "MIT" + }, + "node_modules/dns-packet": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", + "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@leichtgewicht/ip-codec": "^2.0.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "license": "MIT" + }, + "node_modules/dom-converter": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz", + "integrity": "sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "utila": "~0.4" + } + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dom-serializer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", + "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.2.0", + "entities": "^2.0.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", + "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.2.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", + "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.2.0", + "domhandler": "^4.2.0" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dot-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", + "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexer": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", + "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", + "dev": true, + "license": "MIT" + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.181", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.181.tgz", + "integrity": "sha512-+ISMj8OIQ+0qEeDj14Rt8WwcTOiqHyAB+5bnK1K7xNNLjBJ4hRCQfUkw8RWtcLbfBzDwc15ZnKH0c7SNOfwiyA==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/emojis-list": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.18.2", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.2.tgz", + "integrity": "sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "dev": true, + "license": "BSD-2-Clause", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/envinfo": { + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.14.0.tgz", + "integrity": "sha512-CO40UI41xDQzhLB1hWyqUKgFhs250pNcGbyGKe1l/e4FSaI/+YE4IMG76GDt0In67WLPACIITC+sOi08x4wIvg==", + "dev": true, + "license": "MIT", + "bin": { + "envinfo": "dist/cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/error-stack-parser": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", + "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "stackframe": "^1.3.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/expect": { + "version": "30.0.4", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.0.4.tgz", + "integrity": "sha512-dDLGjnP2cKbEppxVICxI/Uf4YemmGMPNy0QytCbfafbpYk9AFQsxb8Uyrxii0RPK7FWgLGlSem+07WirwS3cFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "30.0.4", + "@jest/get-type": "30.0.1", + "jest-matcher-utils": "30.0.4", + "jest-message-util": "30.0.2", + "jest-mock": "30.0.2", + "jest-util": "30.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/express": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", + "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/fdir": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", + "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "license": "MIT" + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/font-awesome": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/font-awesome/-/font-awesome-4.7.0.tgz", + "integrity": "sha512-U6kGnykA/6bFmg1M/oT9EkFeIYv7JlX3bozwQJWiiLz6L0w3F5vBVPxHlwyX/vtNq1ckcpRKOB9f2Qal/VtFpg==", + "license": "(OFL-1.1 AND MIT)", + "engines": { + "node": ">=0.10.3" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fork-ts-checker-webpack-plugin": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-9.1.0.tgz", + "integrity": "sha512-mpafl89VFPJmhnJ1ssH+8wmM2b50n+Rew5x42NeI2U78aRWgtkEtGmctp7iT16UjquJTjorEmIfESj3DxdW84Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.16.7", + "chalk": "^4.1.2", + "chokidar": "^4.0.1", + "cosmiconfig": "^8.2.0", + "deepmerge": "^4.2.2", + "fs-extra": "^10.0.0", + "memfs": "^3.4.1", + "minimatch": "^3.0.4", + "node-abort-controller": "^3.0.1", + "schema-utils": "^3.1.1", + "semver": "^7.3.5", + "tapable": "^2.2.1" + }, + "engines": { + "node": ">=14.21.3" + }, + "peerDependencies": { + "typescript": ">3.6.0", + "webpack": "^5.11.0" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs-monkey": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.6.tgz", + "integrity": "sha512-b1FMfwetIKymC0eioW7mTywihSQE4oLzQn1dB6rZB5fx/3NpNEdAWeCSMB+60/AeT0TCXsxzAlcYVEFCTAksWg==", + "dev": true, + "license": "Unlicense" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz", + "integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.0.3", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/glob/node_modules/minimatch": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz", + "integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@isaacs/brace-expansion": "^5.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/gzip-size": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", + "integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "duplexer": "^0.1.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/handle-thing": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", + "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/hpack.js": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", + "integrity": "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.1", + "obuf": "^1.0.0", + "readable-stream": "^2.0.1", + "wbuf": "^1.1.0" + } + }, + "node_modules/hpack.js/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/hpack.js/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/hpack.js/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/html-minifier-terser": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", + "integrity": "sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "camel-case": "^4.1.2", + "clean-css": "^5.2.2", + "commander": "^8.3.0", + "he": "^1.2.0", + "param-case": "^3.0.4", + "relateurl": "^0.2.7", + "terser": "^5.10.0" + }, + "bin": { + "html-minifier-terser": "cli.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/html-webpack-plugin": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.6.3.tgz", + "integrity": "sha512-QSf1yjtSAsmf7rYBV7XX86uua4W/vkhIt0xNXKbsi2foEeW7vjJQz4bhnpL3xH+l1ryl1680uNv968Z+X6jSYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/html-minifier-terser": "^6.0.0", + "html-minifier-terser": "^6.0.2", + "lodash": "^4.17.21", + "pretty-error": "^4.0.0", + "tapable": "^2.0.0" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/html-webpack-plugin" + }, + "peerDependencies": { + "@rspack/core": "0.x || 1.x", + "webpack": "^5.20.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/htmlparser2": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz", + "integrity": "sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.0.0", + "domutils": "^2.5.2", + "entities": "^2.0.0" + } + }, + "node_modules/http-deceiver": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", + "integrity": "sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-proxy-middleware": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz", + "integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-proxy": "^1.17.8", + "http-proxy": "^1.18.1", + "is-glob": "^4.0.1", + "is-plain-obj": "^3.0.0", + "micromatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "@types/express": "^4.17.13" + }, + "peerDependenciesMeta": { + "@types/express": { + "optional": true + } + } + }, + "node_modules/hyperdyperid": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", + "integrity": "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.18" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/immer": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz", + "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/immutable": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-3.8.2.tgz", + "integrity": "sha512-15gZoQ38eYjEjxkorfbcgBKBL6R7T459OuK+CpcWt7O3KF4uPCx2tD0uFETlUDIyo+1789crbMhTvQBSR5yBMg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/interpret": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz", + "integrity": "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/ipaddr.js": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz", + "integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-network-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.1.0.tgz", + "integrity": "sha512-tUdRRAnhT+OtCZR/LxZelH/C7QtjtFrTu5tXCA8pl55eTUElUHT+GPYV8MBMBvea/j+NxQqVt3LbWMRir7Gx9g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-obj": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz", + "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-wsl": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", + "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jackspeak": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", + "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-diff": { + "version": "30.0.4", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.0.4.tgz", + "integrity": "sha512-TSjceIf6797jyd+R64NXqicttROD+Qf98fex7CowmlSn7f8+En0da1Dglwr1AXxDtVizoxXYZBlUQwNhoOXkNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/diff-sequences": "30.0.1", + "@jest/get-type": "30.0.1", + "chalk": "^4.1.2", + "pretty-format": "30.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-diff/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-diff/node_modules/pretty-format": { + "version": "30.0.2", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.2.tgz", + "integrity": "sha512-yC5/EBSOrTtqhCKfLHqoUIAXVRZnukHPwWBJWR7h84Q3Be1DRQZLncwcfLoPA5RPQ65qfiCMqgYwdUuQ//eVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.1", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-diff/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-matcher-utils": { + "version": "30.0.4", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.0.4.tgz", + "integrity": "sha512-ubCewJ54YzeAZ2JeHHGVoU+eDIpQFsfPQs0xURPWoNiO42LGJ+QGgfSf+hFIRplkZDkhH5MOvuxHKXRTUU3dUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.0.1", + "chalk": "^4.1.2", + "jest-diff": "30.0.4", + "pretty-format": "30.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-matcher-utils/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-matcher-utils/node_modules/pretty-format": { + "version": "30.0.2", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.2.tgz", + "integrity": "sha512-yC5/EBSOrTtqhCKfLHqoUIAXVRZnukHPwWBJWR7h84Q3Be1DRQZLncwcfLoPA5RPQ65qfiCMqgYwdUuQ//eVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.1", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-matcher-utils/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-message-util": { + "version": "30.0.2", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.0.2.tgz", + "integrity": "sha512-vXywcxmr0SsKXF/bAD7t7nMamRvPuJkras00gqYeB1V0WllxZrbZ0paRr3XqpFU2sYYjD0qAaG2fRyn/CGZ0aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.0.1", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "micromatch": "^4.0.8", + "pretty-format": "30.0.2", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-message-util/node_modules/pretty-format": { + "version": "30.0.2", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.2.tgz", + "integrity": "sha512-yC5/EBSOrTtqhCKfLHqoUIAXVRZnukHPwWBJWR7h84Q3Be1DRQZLncwcfLoPA5RPQ65qfiCMqgYwdUuQ//eVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.1", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-mock": { + "version": "30.0.2", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.0.2.tgz", + "integrity": "sha512-PnZOHmqup/9cT/y+pXIVbbi8ID6U1XHRmbvR7MvUy4SLqhCbwpkmXhLbsWbGewHrV5x/1bF7YDjs+x24/QSvFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.1", + "@types/node": "*", + "jest-util": "30.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-regex-util": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", + "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-util": { + "version": "30.0.2", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.2.tgz", + "integrity": "sha512-8IyqfKS4MqprBuUpZNlFB5l+WFehc8bfCe1HSZFHzft2mOuND8Cvi9r1musli+u6F3TqanCZ/Ik4H4pXUolZIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.1", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/launch-editor": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.10.0.tgz", + "integrity": "sha512-D7dBRJo/qcGX9xlvt/6wUYzQxjh5G1RvZPgPv8vi4KRU99DVQL/oW7tnVOCCTm2HGeo3C5HvGE5Yrh6UBoZ0vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "shell-quote": "^1.8.1" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/loader-runner": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", + "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.11.5" + } + }, + "node_modules/loader-utils": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + }, + "engines": { + "node": ">=8.9.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "license": "MIT" + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/material-colors": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/material-colors/-/material-colors-1.2.6.tgz", + "integrity": "sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==", + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/memfs": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.3.tgz", + "integrity": "sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==", + "dev": true, + "license": "Unlicense", + "dependencies": { + "fs-monkey": "^1.0.4" + }, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mini-css-extract-plugin": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.9.2.tgz", + "integrity": "sha512-GJuACcS//jtq4kCtd5ii/M0SZf7OZRH+BxdqXZHaJfb8TJiVl+NgQRPwiYt2EuqeSkNydn/7vP+bcE27C5mb9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "schema-utils": "^4.0.0", + "tapable": "^2.2.1" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "dev": true, + "license": "ISC" + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/multicast-dns": { + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", + "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", + "dev": true, + "license": "MIT", + "dependencies": { + "dns-packet": "^5.2.2", + "thunky": "^1.0.2" + }, + "bin": { + "multicast-dns": "cli.js" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-forge": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", + "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", + "dev": true, + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalizr": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/normalizr/-/normalizr-3.6.2.tgz", + "integrity": "sha512-30qCybsBaCBciotorvuOZTCGEg2AXrJfADMT2Kk/lvpIAcipHdK0zc33nNtwKzyfQAqIJXAcqET6YgflYUgsoQ==", + "license": "MIT" + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/numeral": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/numeral/-/numeral-2.0.6.tgz", + "integrity": "sha512-qaKRmtYPZ5qdw4jWJD6bxEf1FJEqllJrwxCLIm0sQU/A7v2/czigzOb+C2uSiFsa9lBUzeH7M1oK+Q+OLxL3kA==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/obuf": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", + "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", + "dev": true, + "license": "MIT" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/open": { + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/open/-/open-10.1.2.tgz", + "integrity": "sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/opener": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", + "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==", + "dev": true, + "license": "(WTFPL OR MIT)", + "bin": { + "opener": "bin/opener-bin.js" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-retry": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-6.2.1.tgz", + "integrity": "sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.2", + "is-network-error": "^1.0.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/param-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", + "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/pascal-case": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", + "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz", + "integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz", + "integrity": "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==", + "dev": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-modules-extract-imports": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", + "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-local-by-default": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.2.0.tgz", + "integrity": "sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^7.0.0", + "postcss-value-parser": "^4.1.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-scope": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz", + "integrity": "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==", + "dev": true, + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "icss-utils": "^5.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", + "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pretty-error": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-4.0.0.tgz", + "integrity": "sha512-AoJ5YMAcXKYxKhuJGdcvse+Voc6v1RgnsR3nWcYU7q4t6z0Q6T86sv5Zq8VIRbOWWFpvdGE83LtdSMNd+6Y0xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash": "^4.17.20", + "renderkid": "^3.0.0" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-addr/node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/raf-schd": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz", + "integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==", + "license": "MIT" + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-color": { + "version": "2.19.3", + "resolved": "https://registry.npmjs.org/react-color/-/react-color-2.19.3.tgz", + "integrity": "sha512-LEeGE/ZzNLIsFWa1TMe8y5VYqr7bibneWmvJwm1pCn/eNmrabWDh659JSPn9BuaMpEfU83WTOJfnCcjDZwNQTA==", + "license": "MIT", + "dependencies": { + "@icons/material": "^0.2.4", + "lodash": "^4.17.15", + "lodash-es": "^4.17.15", + "material-colors": "^1.2.1", + "prop-types": "^15.5.10", + "reactcss": "^1.2.0", + "tinycolor2": "^1.4.1" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/react-datepicker": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/react-datepicker/-/react-datepicker-7.5.0.tgz", + "integrity": "sha512-6MzeamV8cWSOcduwePHfGqY40acuGlS1cG//ePHT6bVbLxWyqngaStenfH03n1wbzOibFggF66kWaBTb1SbTtQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/react": "^0.26.23", + "clsx": "^2.1.1", + "date-fns": "^3.6.0", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17 || ^18", + "react-dom": "^16.9.0 || ^17 || ^18" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "license": "MIT" + }, + "node_modules/react-lifecycles-compat": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", + "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==", + "license": "MIT" + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz", + "integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz", + "integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.0", + "react-router": "6.30.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-select": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/react-select/-/react-select-5.10.1.tgz", + "integrity": "sha512-roPEZUL4aRZDx6DcsD+ZNreVl+fM8VsKn0Wtex1v4IazH60ILp5xhdlp464IsEAlJdXeD+BhDAFsBVMfvLQueA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.0", + "@emotion/cache": "^11.4.0", + "@emotion/react": "^11.8.1", + "@floating-ui/dom": "^1.0.1", + "@types/react-transition-group": "^4.4.0", + "memoize-one": "^6.0.0", + "prop-types": "^15.6.0", + "react-transition-group": "^4.3.0", + "use-isomorphic-layout-effect": "^1.2.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/react-treebeard": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/react-treebeard/-/react-treebeard-3.2.4.tgz", + "integrity": "sha512-TsvdUq2kbLavRXa8k4mmqfPse8HmSA9G9s1SZUtIpiYSccSwa0Tm6miMgx7DZ5gpKofQ+j/3Ua0rjsahM3/FQg==", + "license": "MIT", + "dependencies": { + "@emotion/core": "^10.0.10", + "@emotion/styled": "^10.0.10", + "deep-equal": "^1.0.1", + "shallowequal": "^1.1.0", + "velocity-react": "^1.4.1" + }, + "peerDependencies": { + "@babel/runtime": ">=7.0.0", + "@emotion/styled": "^10.0.10", + "prop-types": ">=15.7.2", + "react": ">=16.7.0", + "react-dom": ">=16.7.0" + } + }, + "node_modules/reactcss": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz", + "integrity": "sha512-KiwVUcFu1RErkI97ywr8nvx8dNOpT03rbnma0SSalTYjkrPYaEajR4a/MRt6DZ46K6arDRbWMNHF+xH7G7n/8A==", + "license": "MIT", + "dependencies": { + "lodash": "^4.0.1" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rechoir": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", + "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve": "^1.20.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.0.tgz", + "integrity": "sha512-DqHn3DwbmmPVzeKj9woBadqmXxLvQoQIwu7nopMc72ztvxVmVk2SBhSnx67zuye5TP+lJsb/TBQsjLKhnDf3MA==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regex-parser": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/regex-parser/-/regex-parser-2.3.1.tgz", + "integrity": "sha512-yXLRqatcCuKtVHsWrNg0JL3l1zGfdXeEvDa0bdu4tCDQw0RpMDZsqbkyRTUnKMR0tXF627V2oEWjBEaEdqTwtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexpu-core": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.2.0.tgz", + "integrity": "sha512-H66BPQMrv+V16t8xtmq+UC0CBpiTBA60V8ibS1QVReIp8T1z8hwFxqcGzm9K6lgsN7sB5edVH8a+ze6Fqm4weA==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.2.0", + "regjsgen": "^0.8.0", + "regjsparser": "^0.12.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/regjsparser": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.12.0.tgz", + "integrity": "sha512-cnE+y8bz4NhMjISKbgeVJtqNbtf5QpjZP+Bslo+UqkIt9QPnX9q095eiRRASJG1/tz6dlNr6Z5NsBiWYokp6EQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~3.0.2" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/regjsparser/node_modules/jsesc": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", + "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/relateurl": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", + "integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/renderkid": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz", + "integrity": "sha512-q/7VIQA8lmM1hF+jn+sFSPWGlMkSAeNYcPLmDQx2zzuiDfaLrOmumR8iaUKlenFgh0XRPIUeSPlH3A+AW3Z5pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-select": "^4.1.3", + "dom-converter": "^0.2.0", + "htmlparser2": "^6.1.0", + "lodash": "^4.17.21", + "strip-ansi": "^6.0.1" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-cwd/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-url-loader": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-url-loader/-/resolve-url-loader-5.0.0.tgz", + "integrity": "sha512-uZtduh8/8srhBoMx//5bwqjQ+rfYOUq8zC9NrMUGtjBiGTtFJM42s58/36+hTqeqINcnYe08Nj3LkK9lW4N8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "adjust-sourcemap-loader": "^4.0.0", + "convert-source-map": "^1.7.0", + "loader-utils": "^2.0.0", + "postcss": "^8.2.14", + "source-map": "0.6.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/resolve-url-loader/node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/resolve-url-loader/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/rimraf": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.0.1.tgz", + "integrity": "sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A==", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^11.0.0", + "package-json-from-dist": "^1.0.0" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-applescript": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.0.0.tgz", + "integrity": "sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sass": { + "version": "1.79.6", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.79.6.tgz", + "integrity": "sha512-PVVjeeiUGx6Nj4PtEE/ecwu8ltwfPKzHxbbVmmLj4l1FYHhOyfA0scuVF8sVaa+b+VY4z7BVKjKq0cPUQdUU3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@parcel/watcher": "^2.4.1", + "chokidar": "^4.0.0", + "immutable": "^4.0.0", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-loader": { + "version": "16.0.5", + "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-16.0.5.tgz", + "integrity": "sha512-oL+CMBXrj6BZ/zOq4os+UECPL+bWqt6OAC6DWS8Ln8GZRcMDjlJ4JC3FBDuHJdYaFWIdKNIBYmtZtK2MaMkNIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "neo-async": "^2.6.2" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || 1.x", + "node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0", + "sass": "^1.3.0", + "sass-embedded": "*", + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "node-sass": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/sass/node_modules/immutable": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz", + "integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/schema-utils": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz", + "integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/select-hose": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", + "integrity": "sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==", + "dev": true, + "license": "MIT" + }, + "node_modules/selfsigned": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.4.1.tgz", + "integrity": "sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node-forge": "^1.3.0", + "node-forge": "^1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/serve-index": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.1.tgz", + "integrity": "sha512-pXHfKNP4qujrtteMrSBb0rc8HJ9Ms/GrXwcUtUtD5s4ewDJI8bT3Cz2zTVRMKtri49pLx2e0Ya8ziP5Ya2pZZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "batch": "0.6.1", + "debug": "2.6.9", + "escape-html": "~1.0.3", + "http-errors": "~1.6.2", + "mime-types": "~2.1.17", + "parseurl": "~1.3.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/serve-index/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/serve-index/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/http-errors": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", + "integrity": "sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.0", + "statuses": ">= 1.4.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", + "dev": true, + "license": "ISC" + }, + "node_modules/serve-index/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/serve-index/node_modules/setprototypeof": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", + "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/serve-index/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, + "node_modules/shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "dev": true, + "license": "MIT", + "dependencies": { + "kind-of": "^6.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shallowequal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", + "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sirv": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", + "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/sockjs": { + "version": "0.3.24", + "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", + "integrity": "sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "faye-websocket": "^0.11.3", + "uuid": "^8.3.2", + "websocket-driver": "^0.7.4" + } + }, + "node_modules/source-map": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-loader": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/source-map-loader/-/source-map-loader-5.0.0.tgz", + "integrity": "sha512-k2Dur7CbSLcAH73sBcIkV5xjPV4SzqO1NJ7+XaQl8if3VODDUj3FNchNGpqgJSKbvUfJuhVdv8K2Eu8/TNl2eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.72.1" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/spdy": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/spdy/-/spdy-4.0.2.tgz", + "integrity": "sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.0", + "handle-thing": "^2.0.0", + "http-deceiver": "^1.2.7", + "select-hose": "^2.0.0", + "spdy-transport": "^3.0.0" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/spdy-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spdy-transport/-/spdy-transport-3.0.0.tgz", + "integrity": "sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.0", + "detect-node": "^2.0.4", + "hpack.js": "^2.1.6", + "obuf": "^1.1.2", + "readable-stream": "^3.0.6", + "wbuf": "^1.7.3" + } + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/stackframe": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", + "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/style-loader": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-4.0.0.tgz", + "integrity": "sha512-1V4WqhhZZgjVAVJyt7TdDPZoPBPNHbekX4fWnCJL1yQukhCeZhJySUL+gL9y6sNdN95uEOS83Y55SqHcP7MzLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.27.0" + } + }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tabbable": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", + "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz", + "integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/terser": { + "version": "5.43.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.43.1.tgz", + "integrity": "sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.14.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.3.14", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz", + "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "serialize-javascript": "^6.0.2", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/thingies": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/thingies/-/thingies-1.21.0.tgz", + "integrity": "sha512-hsqsJsFMsV+aD4s3CWKk85ep/3I9XzYV/IXaSouJMYIoDlgyi11cBhsqYe9/geRfB0YIikBQg6raRaM+nIMP9g==", + "dev": true, + "license": "Unlicense", + "engines": { + "node": ">=10.18" + }, + "peerDependencies": { + "tslib": "^2" + } + }, + "node_modules/thunky": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", + "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tree-dump": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.0.3.tgz", + "integrity": "sha512-il+Cv80yVHFBwokQSfd4bldvr1Md951DpgAGfmhydt04L+YzHgubm2tQ7zueWDcGENKHq0ZvGFR/hjvNXilHEg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", + "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.0.tgz", + "integrity": "sha512-4IehN3V/+kkr5YeSSDDQG8QLqO26XpL2XP3GQtqwlT/QYSECAwFztxVHjlbh0+gjJ3XmNLS0zDsbgs9jWKExLg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", + "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-isomorphic-layout-effect": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz", + "integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", + "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/utila": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/utila/-/utila-0.4.0.tgz", + "integrity": "sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/velocity-animate": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/velocity-animate/-/velocity-animate-1.5.2.tgz", + "integrity": "sha512-m6EXlCAMetKztO1ppBhGU1/1MR3IiEevO6ESq6rcrSQ3Q77xYSW13jkfXW88o4xMrkXJhy/U7j4wFR/twMB0Eg==", + "license": "MIT" + }, + "node_modules/velocity-react": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/velocity-react/-/velocity-react-1.4.3.tgz", + "integrity": "sha512-zvefGm85A88S3KdF9/dz5vqyFLAiwKYlXGYkHH2EbXl+CZUD1OT0a0aS1tkX/WXWTa/FUYqjBaAzAEFYuSobBQ==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.5", + "prop-types": "^15.5.8", + "react-transition-group": "^2.0.0", + "velocity-animate": "^1.4.0" + }, + "peerDependencies": { + "react": "^15.3.0 || ^16.0.0", + "react-dom": "^15.3.0 || ^16.0.0" + } + }, + "node_modules/velocity-react/node_modules/dom-helpers": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-3.4.0.tgz", + "integrity": "sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.1.2" + } + }, + "node_modules/velocity-react/node_modules/react-transition-group": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.9.0.tgz", + "integrity": "sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg==", + "license": "BSD-3-Clause", + "dependencies": { + "dom-helpers": "^3.4.0", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2", + "react-lifecycles-compat": "^3.0.4" + }, + "peerDependencies": { + "react": ">=15.0.0", + "react-dom": ">=15.0.0" + } + }, + "node_modules/vis-data": { + "version": "7.1.10", + "resolved": "https://registry.npmjs.org/vis-data/-/vis-data-7.1.10.tgz", + "integrity": "sha512-23juM9tdCaHTX5vyIQ7XBzsfZU0Hny+gSTwniLrfFcmw9DOm7pi3+h9iEBsoZMp5rX6KNqWwc1MF0fkAmWVuoQ==", + "license": "(Apache-2.0 OR MIT)", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/visjs" + }, + "peerDependencies": { + "uuid": "^3.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "vis-util": "^5.0.1" + } + }, + "node_modules/vis-network": { + "version": "9.1.13", + "resolved": "https://registry.npmjs.org/vis-network/-/vis-network-9.1.13.tgz", + "integrity": "sha512-HLeHd5KZS92qzO1kC59qMh1/FWAZxMUEwUWBwDMoj6RKj/Ajkrgy/heEYo0Zc8SZNQ2J+u6omvK2+a28GX1QuQ==", + "license": "(Apache-2.0 OR MIT)", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/visjs" + }, + "peerDependencies": { + "@egjs/hammerjs": "^2.0.0", + "component-emitter": "^1.3.0 || ^2.0.0", + "keycharm": "^0.2.0 || ^0.3.0 || ^0.4.0", + "uuid": "^3.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "vis-data": "^6.3.0 || ^7.0.0", + "vis-util": "^5.0.1" + } + }, + "node_modules/watchpack": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz", + "integrity": "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/wbuf": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/wbuf/-/wbuf-1.7.3.tgz", + "integrity": "sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimalistic-assert": "^1.0.0" + } + }, + "node_modules/webpack": { + "version": "5.100.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.100.0.tgz", + "integrity": "sha512-H8yBSBTk+BqxrINJnnRzaxU94SVP2bjd7WmA+PfCphoIdDpeQMJ77pq9/4I7xjLq38cB1bNKfzYPZu8pB3zKtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.15.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.24.0", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.17.2", + "es-module-lexer": "^1.2.1", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.2.0", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.2", + "tapable": "^2.1.1", + "terser-webpack-plugin": "^5.3.11", + "watchpack": "^2.4.1", + "webpack-sources": "^3.3.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-bundle-analyzer": { + "version": "4.10.2", + "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.2.tgz", + "integrity": "sha512-vJptkMm9pk5si4Bv922ZbKLV8UTT4zib4FPgXMhgzUny0bfDDkLXAVQs3ly3fS4/TN9ROFtb0NFrm04UXFE/Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@discoveryjs/json-ext": "0.5.7", + "acorn": "^8.0.4", + "acorn-walk": "^8.0.0", + "commander": "^7.2.0", + "debounce": "^1.2.1", + "escape-string-regexp": "^4.0.0", + "gzip-size": "^6.0.0", + "html-escaper": "^2.0.2", + "opener": "^1.5.2", + "picocolors": "^1.0.0", + "sirv": "^2.0.3", + "ws": "^7.3.1" + }, + "bin": { + "webpack-bundle-analyzer": "lib/bin/analyzer.js" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/webpack-bundle-analyzer/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/webpack-cli": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-6.0.1.tgz", + "integrity": "sha512-MfwFQ6SfwinsUVi0rNJm7rHZ31GyTcpVE5pgVA3hwFRb7COD4TzjUUwhGWKfO50+xdc2MQPuEBBJoqIMGt3JDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@discoveryjs/json-ext": "^0.6.1", + "@webpack-cli/configtest": "^3.0.1", + "@webpack-cli/info": "^3.0.1", + "@webpack-cli/serve": "^3.0.1", + "colorette": "^2.0.14", + "commander": "^12.1.0", + "cross-spawn": "^7.0.3", + "envinfo": "^7.14.0", + "fastest-levenshtein": "^1.0.12", + "import-local": "^3.0.2", + "interpret": "^3.1.1", + "rechoir": "^0.8.0", + "webpack-merge": "^6.0.1" + }, + "bin": { + "webpack-cli": "bin/cli.js" + }, + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.82.0" + }, + "peerDependenciesMeta": { + "webpack-bundle-analyzer": { + "optional": true + }, + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/webpack-cli/node_modules/@discoveryjs/json-ext": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.6.3.tgz", + "integrity": "sha512-4B4OijXeVNOPZlYA2oEwWOTkzyltLao+xbotHQeqN++Rv27Y6s818+n2Qkp8q+Fxhn0t/5lA5X1Mxktud8eayQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.17.0" + } + }, + "node_modules/webpack-cli/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/webpack-dev-middleware": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-7.4.2.tgz", + "integrity": "sha512-xOO8n6eggxnwYpy1NlzUKpvrjfJTvae5/D6WOK0S2LSo7vjmo5gCM1DbLUmFqrMTJP+W/0YZNctm7jasWvLuBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "colorette": "^2.0.10", + "memfs": "^4.6.0", + "mime-types": "^2.1.31", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "schema-utils": "^4.0.0" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "webpack": { + "optional": true + } + } + }, + "node_modules/webpack-dev-middleware/node_modules/memfs": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.17.2.tgz", + "integrity": "sha512-NgYhCOWgovOXSzvYgUW0LQ7Qy72rWQMGGFJDoWg4G30RHd3z77VbYdtJ4fembJXBy8pMIUA31XNAupobOQlwdg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/json-pack": "^1.0.3", + "@jsonjoy.com/util": "^1.3.0", + "tree-dump": "^1.0.1", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">= 4.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + } + }, + "node_modules/webpack-dev-server": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.2.tgz", + "integrity": "sha512-QcQ72gh8a+7JO63TAx/6XZf/CWhgMzu5m0QirvPfGvptOusAxG12w2+aua1Jkjr7hzaWDnJ2n6JFeexMHI+Zjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/bonjour": "^3.5.13", + "@types/connect-history-api-fallback": "^1.5.4", + "@types/express": "^4.17.21", + "@types/express-serve-static-core": "^4.17.21", + "@types/serve-index": "^1.9.4", + "@types/serve-static": "^1.15.5", + "@types/sockjs": "^0.3.36", + "@types/ws": "^8.5.10", + "ansi-html-community": "^0.0.8", + "bonjour-service": "^1.2.1", + "chokidar": "^3.6.0", + "colorette": "^2.0.10", + "compression": "^1.7.4", + "connect-history-api-fallback": "^2.0.0", + "express": "^4.21.2", + "graceful-fs": "^4.2.6", + "http-proxy-middleware": "^2.0.9", + "ipaddr.js": "^2.1.0", + "launch-editor": "^2.6.1", + "open": "^10.0.3", + "p-retry": "^6.2.0", + "schema-utils": "^4.2.0", + "selfsigned": "^2.4.1", + "serve-index": "^1.9.1", + "sockjs": "^0.3.24", + "spdy": "^4.0.2", + "webpack-dev-middleware": "^7.4.2", + "ws": "^8.18.0" + }, + "bin": { + "webpack-dev-server": "bin/webpack-dev-server.js" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "webpack": { + "optional": true + }, + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-dev-server/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/webpack-dev-server/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/webpack-dev-server/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/webpack-dev-server/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/webpack-dev-server/node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/webpack-merge": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-6.0.1.tgz", + "integrity": "sha512-hXXvrjtx2PLYx4qruKl+kyRSLc52V+cCvMxRjmKwoA+CBbbF5GfIBtR6kCvl0fYGqTUPKB+1ktVmTHqMOzgCBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-deep": "^4.0.1", + "flat": "^5.0.2", + "wildcard": "^2.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/webpack-sources": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.3.tgz", + "integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wildcard": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", + "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/query/src/client/Hello/BrowserApp.tsx b/query/src/client/Hello/BrowserApp.tsx new file mode 100644 index 00000000000..6f3caa4d5a7 --- /dev/null +++ b/query/src/client/Hello/BrowserApp.tsx @@ -0,0 +1,601 @@ +import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; + +// Rely on the global LABKEY object to avoid introducing new dependencies here. +// Types are intentionally loose to minimize coupling to specific API shapes. +declare const LABKEY: any; + +interface SchemaItem { + name: string; + displayName?: string; +} + +interface QueryItem { + name: string; + schemaName: string; + isUserDefined?: boolean; +} + +const buildExecuteUrl = (schemaName: string, queryName: string) => { + try { + return LABKEY.ActionURL.buildURL('query', 'executeQuery', undefined, { schemaName, queryName }); + } catch (e) { + return '#'; + } +}; + +const buildNewQueryUrl = (schemaName: string, baseTableName?: string) => { + try { + const params: any = { schemaName }; + if (baseTableName) params.ff_baseTableName = baseTableName; + return LABKEY.ActionURL.buildURL('query', 'newQuery', undefined, params); + } catch (e) { + return '#'; + } +}; + +const buildEditQueryUrl = (schemaName: string, queryName: string) => { + try { + return LABKEY.ActionURL.buildURL('query', 'sourceQuery', undefined, { schemaName, queryName }); + } catch (e) { + return '#'; + } +}; + +const getContainerPath = () => { + try { + return LABKEY.ActionURL.getContainer(); + } catch { + return undefined; + } +}; + +const useHashRoute = () => { + const [hash, setHash] = useState(() => window.location.hash || ''); + + useEffect(() => { + const onHashChange = () => setHash(window.location.hash || ''); + window.addEventListener('hashchange', onHashChange); + return () => window.removeEventListener('hashchange', onHashChange); + }, []); + + const route = useMemo(() => { + // Supported formats: + // #/schema/SchemaName + // #/schema/SchemaName/query/QueryName + const trimmed = hash.startsWith('#') ? hash.substring(1) : hash; + const parts = trimmed.split('/').filter(Boolean); + if (parts.length >= 2 && parts[0] === 'schema') { + const schemaName = decodeURIComponent(parts[1]); + if (parts.length >= 4 && parts[2] === 'query') { + const queryName = decodeURIComponent(parts[3]); + return { schemaName, queryName }; + } + return { schemaName }; + } + return {} as { schemaName?: string; queryName?: string }; + }, [hash]); + + const setRoute = useCallback((schemaName?: string, queryName?: string) => { + if (!schemaName) { + window.location.hash = ''; + } else if (!queryName) { + window.location.hash = `#/schema/${encodeURIComponent(schemaName)}`; + } else { + window.location.hash = `#/schema/${encodeURIComponent(schemaName)}/query/${encodeURIComponent(queryName)}`; + } + }, []); + + return { route, setRoute } as const; +}; + +const Toolbar: FC<{ + schemaName?: string; + queryName?: string; +}> = ({ schemaName, queryName }) => { + const mc = LABKEY?.moduleContext || {}; + const currentUser = LABKEY?.Security?.currentUser || {}; + + const canCreate = mc?.query?.hasEditQueriesPermission && currentUser?.canUpdate && !!schemaName; + + return ( +
+ Schema Browser + + {currentUser?.isSystemAdmin && mc?.query?.hasQueryAnalysisService && ( + + )} + {currentUser?.isAdmin && ( + + )} + {canCreate && ( + + )} + {currentUser?.isAdmin && mc?.dataintegration && ( + + )} +
+ ); +}; + +interface LookupInfo { + schemaName?: string; + queryName?: string; + containerPath?: string; +} + +interface ColumnInfo { + name: string; + caption?: string; + type?: string; + nullable?: boolean; + lookup?: LookupInfo; +} + +const displayType = (col: ColumnInfo | any): string => { + const t = col?.type || col?.jsonType || col?.jdbcType || ''; + if (t) return String(t); + const rangeURI: string | undefined = col?.rangeURI || col?.rangeUri; + if (rangeURI) { + const hash = rangeURI.lastIndexOf('#'); + if (hash > -1) return rangeURI.substring(hash + 1); + const slash = rangeURI.lastIndexOf('/'); + if (slash > -1) return rangeURI.substring(slash + 1); + return rangeURI; + } + return ''; +}; + +const toBool = (v: any): boolean | undefined => (v === undefined ? undefined : !!v); + +const normalizeLookup = (c: any): LookupInfo | undefined => { + const lk = c?.lookup || c?.lookupJSON || c?.fk || c?.foreignKey || c?.displayFieldFK; + if (!lk) return undefined; + const schema = lk.schemaName || lk.schema || lk.schemaNameFull || lk.schemaPath || lk.schemaDisplay || lk.schemaQueryName; + const query = lk.queryName || lk.table || lk.query || lk.tableName; + const containerPath = lk.containerPath || lk.container || lk.publicContainer; + if (!schema || !query) return undefined; + return { schemaName: schema, queryName: query, containerPath }; +}; + +const normalizeColumns = (input: any[]): ColumnInfo[] => { + const cols: ColumnInfo[] = []; + input?.forEach((c: any) => { + const name = c?.name || c?.fieldKey || c?.columnName; + if (!name) return; + const caption = c?.caption || c?.label || c?.displayName || name; + const type = displayType(c); + const required = toBool(c?.required); + const allowNull = c?.allowNull ?? c?.nullable ?? c?.allowMissingValue; + cols.push({ + name, + caption, + type, + nullable: allowNull !== undefined ? !!allowNull : required !== undefined ? !required : undefined, + lookup: normalizeLookup(c), + }); + }); + return cols.sort((a, b) => a.name.localeCompare(b.name)); +}; + +const extractColumnsFromGetQueryDetails = (result: any): ColumnInfo[] => { + const cols = result?.columns || result?.queryDetail?.columns || result?.metaData?.columns || []; + return normalizeColumns(cols); +}; + +const extractColumnsFromSelectRows = (result: any): ColumnInfo[] => { + const cols = result?.metaData?.fields || result?.metaData?.columns || result?.columnModel || result?.columns || []; + return normalizeColumns(cols); +}; + +const QueryDetails: FC<{ + schemaName: string; + queryName: string; + isUserDefined?: boolean; + onLookupClick: (schemaName: string, queryName: string, containerPath?: string) => void; +}> = ({ schemaName, queryName, isUserDefined, onLookupClick }) => { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(); + const [columns, setColumns] = useState(); + + // Fetch details when schema/query changes + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(undefined); + setColumns(undefined); + + const done = (cols: ColumnInfo[]) => { + if (cancelled) return; + setColumns(cols); + setLoading(false); + }; + const fail = (msg?: string) => { + if (cancelled) return; + setError(msg || 'Unable to load query details.'); + setColumns([]); + setLoading(false); + }; + + const trySelectRowsFallback = () => { + if (!LABKEY?.Query?.selectRows) { + fail('Query APIs not available.'); + return; + } + LABKEY.Query.selectRows({ + schemaName, + queryName, + maxRows: 0, + containerPath: getContainerPath(), + success: (res: any) => done(extractColumnsFromSelectRows(res)), + failure: (err: any) => fail(err?.exception || err?.message), + }); + }; + + if (LABKEY?.Query?.getQueryDetails) { + LABKEY.Query.getQueryDetails({ + schemaName, + queryName, + containerPath: getContainerPath(), + success: (res: any) => { + const cols = extractColumnsFromGetQueryDetails(res); + if (cols && cols.length) done(cols); + else trySelectRowsFallback(); + }, + failure: () => trySelectRowsFallback(), + }); + } else { + trySelectRowsFallback(); + } + + return () => { + cancelled = true; + }; + }, [schemaName, queryName]); + + return ( +
+

+ {schemaName}.{queryName} +

+
+ + View Data Grid + + + Derive New Query + + {(() => { + const mc = LABKEY?.moduleContext || {}; + const currentUser = LABKEY?.Security?.currentUser || {}; + const hasEditPerm = mc?.query?.hasEditQueriesPermission && currentUser?.canUpdate; + // Show edit if we have permission and either know it's user-defined or we don't know + const showEdit = !!hasEditPerm && isUserDefined !== false; + return ( + showEdit && ( + + Edit Query + + ) + ); + })()} +
+ {loading &&
Loading query details…
} + {error &&
{error}
} + {!loading && columns && ( +
+
Columns
+ {columns.length === 0 ? ( +
No columns available.
+ ) : ( +
+ + + + + + + + + + + + {columns.map(col => ( + + + + + + + + ))} + +
ColumnCaptionTypeNullableLookup
{col.name}{col.caption || ''}{col.type || ''} + {col.nullable === undefined ? '' : col.nullable ? 'Yes' : 'No'} + + {col.lookup?.schemaName && col.lookup?.queryName ? ( + + ) : ( + — + )} +
+
+ )} + + {/* Dependencies Section */} +
Dependencies
+ {(() => { + const depMap: { [key: string]: { schemaName: string; queryName: string; containerPath?: string } } = {}; + columns.forEach(c => { + const lk = c.lookup; + if (lk?.schemaName && lk?.queryName) { + const key = `${lk.containerPath || ''}|${lk.schemaName}|${lk.queryName}`; + if (!depMap[key]) depMap[key] = { schemaName: lk.schemaName, queryName: lk.queryName, containerPath: lk.containerPath }; + } + }); + const deps = Object.values(depMap); + if (deps.length === 0) { + return
No dependencies found.
; + } + return ( +
    + {deps.map(d => ( +
  • + + {d.containerPath && d.containerPath !== getContainerPath() && ( + ({d.containerPath}) + )} +
  • + ))} +
+ ); + })()} +
+ )} +
+ ); +}; + +export const BrowserApp: FC = () => { + const { route, setRoute } = useHashRoute(); + const [schemas, setSchemas] = useState([]); + const [schemasLoading, setSchemasLoading] = useState(false); + const [queries, setQueries] = useState([]); + const [queriesLoading, setQueriesLoading] = useState(false); + + const selectedSchema = route.schemaName; + const selectedQuery = route.queryName; + + // Load schemas on mount + useEffect(() => { + let cancelled = false; + setSchemasLoading(true); + const onSuccess = (result: any) => { + if (cancelled) return; + // result.schemas may be an object keyed by name + const items: SchemaItem[] = []; + if (result?.schemas) { + if (Array.isArray(result.schemas)) { + result.schemas.forEach((s: any) => items.push({ name: s.name || s, displayName: s.displayName })); + } else { + Object.keys(result.schemas).forEach(name => items.push({ name, displayName: result.schemas[name]?.name || name })); + } + } + items.sort((a, b) => a.name.localeCompare(b.name)); + setSchemas(items); + setSchemasLoading(false); + }; + const onFailure = () => { + if (cancelled) return; + setSchemas([]); + setSchemasLoading(false); + }; + if (LABKEY?.Query?.getSchemas) { + LABKEY.Query.getSchemas({ + containerPath: getContainerPath(), + success: onSuccess, + failure: onFailure, + }); + } else { + onFailure(); + } + return () => { + cancelled = true; + }; + }, []); + + // Load queries when schema changes + useEffect(() => { + if (!selectedSchema) { + setQueries([]); + return; + } + let cancelled = false; + setQueriesLoading(true); + const onSuccess = (result: any) => { + if (cancelled) return; + const list: QueryItem[] = []; + const queries = result?.queries || result?.QuerySet || result?.querySet || []; + const toIsUserDefined = (q: any): boolean | undefined => { + return ( + q?.isUserDefined ?? + q?.userDefined ?? + q?.isUserDefinedQuery ?? + q?.isUserQuery ?? + undefined + ); + }; + if (Array.isArray(queries)) { + queries.forEach((q: any) => { + const name = q?.name || q?.queryName || q; + if (name) list.push({ name, schemaName: selectedSchema, isUserDefined: toIsUserDefined(q) }); + }); + } else if (queries?.queries) { + // sometimes nested + queries.queries.forEach((q: any) => list.push({ name: q.name, schemaName: selectedSchema, isUserDefined: toIsUserDefined(q) })); + } + list.sort((a, b) => a.name.localeCompare(b.name)); + setQueries(list); + setQueriesLoading(false); + }; + const onFailure = () => { + if (cancelled) return; + setQueries([]); + setQueriesLoading(false); + }; + if (LABKEY?.Query?.getQueries) { + LABKEY.Query.getQueries({ + containerPath: getContainerPath(), + schemaName: selectedSchema, + includeUserQueries: true, + includeSystemQueries: true, + success: onSuccess, + failure: onFailure, + }); + } else { + onFailure(); + } + return () => { + cancelled = true; + }; + }, [selectedSchema]); + + const onSchemaClick = useCallback((schemaName: string) => setRoute(schemaName), [setRoute]); + const onQueryClick = useCallback((schemaName: string, queryName: string) => setRoute(schemaName, queryName), [setRoute]); + + return ( +
+ +
+ {/* Left: Schema/Query tree */} +
+
Schemas
+ {schemasLoading ? ( +
Loading schemas…
+ ) : ( +
    + {schemas.map(s => ( +
  • + + {selectedSchema === s.name && ( +
    + {queriesLoading ? ( +
    Loading queries…
    + ) : ( +
      + {queries.map(q => ( +
    • + +
    • + ))} + {queries.length === 0 && !queriesLoading && ( +
    • No queries
    • + )} +
    + )} +
    + )} +
  • + ))} +
+ )} +
+ + {/* Right: Details */} +
+ {!selectedSchema && ( +
+

Welcome

+

Select a schema on the left to view its queries.

+
+ )} + {selectedSchema && !selectedQuery && ( +
+

{selectedSchema}

+

Select a query to view details.

+
+ )} + {selectedSchema && selectedQuery && ( + q.schemaName === selectedSchema && q.name === selectedQuery)?.isUserDefined} + onLookupClick={(schema: string, query: string, containerPath?: string) => { + if (containerPath && containerPath !== getContainerPath()) { + const url = LABKEY.ActionURL.buildURL('query', 'begin', containerPath, { + schemaName: schema, + queryName: query, + }); + window.open(url); + } else { + onQueryClick(schema, query); + } + }} + /> + )} +
+
+
+ ); +}; diff --git a/query/src/client/Hello/app.tsx b/query/src/client/Hello/app.tsx new file mode 100644 index 00000000000..41cd34b0256 --- /dev/null +++ b/query/src/client/Hello/app.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { Hello } from './hello'; + +window.addEventListener('DOMContentLoaded', () => { + const el = document.getElementById('app'); + if (el) { + createRoot(el).render(); + } +}); \ No newline at end of file diff --git a/query/src/client/Hello/hello.tsx b/query/src/client/Hello/hello.tsx new file mode 100644 index 00000000000..ec80314424f --- /dev/null +++ b/query/src/client/Hello/hello.tsx @@ -0,0 +1,6 @@ +import React, { FC } from 'react'; +import { BrowserApp } from './BrowserApp'; + +export const Hello: FC = () => { + return ; +}; \ No newline at end of file diff --git a/query/src/client/entryPoints.js b/query/src/client/entryPoints.js new file mode 100644 index 00000000000..1ea8b3c7c6f --- /dev/null +++ b/query/src/client/entryPoints.js @@ -0,0 +1,8 @@ +module.exports = { + apps: [{ + name: 'hello', + title: 'React Query Schema Browser', + permissionClasses: ['org.labkey.api.security.permissions.ReadPermission'], + path: './src/client/Hello' + }] +}; \ No newline at end of file diff --git a/query/tsconfig.json b/query/tsconfig.json new file mode 100644 index 00000000000..51a3abbe122 --- /dev/null +++ b/query/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "./node_modules/@labkey/build/webpack/tsconfig.json", + "include": ["src/client/**/*"], + "exclude": ["node_modules"] +} diff --git a/specimen/src/org/labkey/specimen/actions/SpecimenController.java b/specimen/src/org/labkey/specimen/actions/SpecimenController.java index d3d180977fb..2d05110bb03 100644 --- a/specimen/src/org/labkey/specimen/actions/SpecimenController.java +++ b/specimen/src/org/labkey/specimen/actions/SpecimenController.java @@ -186,6 +186,7 @@ import org.labkey.specimen.view.SpecimenRequestNotificationEmailTemplate; import org.labkey.specimen.view.SpecimenSearchWebPart; import org.labkey.specimen.view.SpecimenWebPart; +import org.labkey.vfs.FileLike; import org.springframework.validation.BindException; import org.springframework.validation.Errors; import org.springframework.validation.ObjectError; @@ -919,7 +920,7 @@ public boolean isMerge() } } - public static void submitSpecimenBatch(Container c, User user, ActionURL url, File f, PipeRoot root, boolean merge) throws IOException + public static void submitSpecimenBatch(Container c, User user, ActionURL url, FileLike f, PipeRoot root, boolean merge) throws IOException { if (null == f || !f.exists() || !f.isFile()) throw new NotFoundException(); @@ -942,7 +943,7 @@ public boolean handlePost(PipelineForm form, BindException errors) throws Except Container c = getContainer(); PipeRoot root = PipelineService.get().findPipelineRoot(c); boolean first = true; - for (File f : form.getValidatedFiles(c)) + for (FileLike f : form.getValidatedFiles(c)) { // Only possibly overwrite when the first archive is loaded: boolean merge = !first || form.isMerge(); @@ -977,13 +978,13 @@ public boolean handlePost(PipelineForm form, BindException errors) throws Except { Container c = getContainer(); String path = form.getPath(); - File f = null; + FileLike f = null; PipeRoot root = PipelineService.get().findPipelineRoot(c); if (path != null) { if (root != null) - f = root.resolvePath(path); + f = root.resolvePathToFileLike(path); } submitSpecimenBatch(c, getUser(), getViewContext().getActionURL(), f, root, form.isMerge()); @@ -1083,18 +1084,18 @@ public class ImportSpecimenDataAction extends SimpleViewAction @Override public ModelAndView getView(PipelineForm form, BindException bindErrors) { - List dataFiles = form.getValidatedFiles(getContainer()); + List dataFiles = form.getValidatedFiles(getContainer()); List archives = new ArrayList<>(); List errors = new ArrayList<>(); _filePaths = form.getFile(); - for (File dataFile : dataFiles) + for (FileLike dataFile : dataFiles) { if (null == dataFile || !dataFile.exists() || !dataFile.isFile()) { throw new NotFoundException(); } - if (!dataFile.canRead()) + if (!dataFile.toNioPathForRead().toFile().canRead()) errors.add("Can't read data file: " + dataFile); SpecimenArchive archive = new SpecimenArchive(dataFile); diff --git a/specimen/src/org/labkey/specimen/pipeline/SpecimenArchive.java b/specimen/src/org/labkey/specimen/pipeline/SpecimenArchive.java index dc43d69d266..781b8d8681a 100644 --- a/specimen/src/org/labkey/specimen/pipeline/SpecimenArchive.java +++ b/specimen/src/org/labkey/specimen/pipeline/SpecimenArchive.java @@ -19,8 +19,8 @@ import org.labkey.api.data.Container; import org.labkey.api.study.SpecimenService; import org.labkey.api.study.SpecimenTransform; +import org.labkey.vfs.FileLike; -import java.io.File; import java.io.IOException; import java.util.ArrayList; import java.util.Date; @@ -36,14 +36,14 @@ */ public class SpecimenArchive { - private final File _definitionFile; + private final FileLike _definitionFile; - public SpecimenArchive(File definitionFile) + public SpecimenArchive(FileLike definitionFile) { _definitionFile = definitionFile; } - public File getDefinitionFile() + public FileLike getDefinitionFile() { return _definitionFile; } @@ -56,13 +56,13 @@ public List getEntryDescriptions(Container container) throws I { if (transform.getFileType().isType(_definitionFile)) { - entryList.add(new EntryDescription(_definitionFile.getName(), _definitionFile.length(), new Date(_definitionFile.lastModified()))); + entryList.add(new EntryDescription(_definitionFile.getName(), _definitionFile.getSize(), new Date(_definitionFile.getLastModified()))); return entryList; } } // standard non-transformed specimen archive - try (ZipFile zip = new ZipFile(_definitionFile)) + try (ZipFile zip = new ZipFile(_definitionFile.toNioPathForRead().toFile())) { Enumeration entries = zip.entries(); while (entries.hasMoreElements()) diff --git a/specimen/src/org/labkey/specimen/pipeline/SpecimenBatch.java b/specimen/src/org/labkey/specimen/pipeline/SpecimenBatch.java index e072c092d94..b9c6a2acbcd 100644 --- a/specimen/src/org/labkey/specimen/pipeline/SpecimenBatch.java +++ b/specimen/src/org/labkey/specimen/pipeline/SpecimenBatch.java @@ -29,6 +29,7 @@ import org.labkey.api.util.PageFlowUtil; import org.labkey.api.view.ActionURL; import org.labkey.api.view.ViewBackgroundInfo; +import org.labkey.vfs.FileLike; import java.io.File; import java.io.Serializable; @@ -48,7 +49,7 @@ public class SpecimenBatch extends StudyBatch implements Serializable, SpecimenJ // For serialization protected SpecimenBatch() {} - public SpecimenBatch(ViewBackgroundInfo info, File definitionFile, PipeRoot root, boolean merge) + public SpecimenBatch(ViewBackgroundInfo info, FileLike definitionFile, PipeRoot root, boolean merge) { super(info, definitionFile, root); _isMerge = merge; @@ -78,7 +79,7 @@ public ActionURL getStatusHref() @Override public Path getSpecimenArchivePath() { - return _definitionFile.toPath(); + return _definitionFile.toNioPathForRead(); } @Override diff --git a/specimen/src/org/labkey/specimen/pipeline/SpecimenReloadJob.java b/specimen/src/org/labkey/specimen/pipeline/SpecimenReloadJob.java index c635f80c900..ad69c897659 100644 --- a/specimen/src/org/labkey/specimen/pipeline/SpecimenReloadJob.java +++ b/specimen/src/org/labkey/specimen/pipeline/SpecimenReloadJob.java @@ -22,6 +22,7 @@ import org.labkey.api.study.SpecimenTransform; import org.labkey.api.util.FileUtil; import org.labkey.api.view.ViewBackgroundInfo; +import org.labkey.vfs.FileLike; import java.io.File; import java.io.Serializable; @@ -47,7 +48,7 @@ public SpecimenReloadJob(ViewBackgroundInfo info, PipeRoot root, String transfor } @Override - public void setSpecimenArchive(File archiveFile) + public void setSpecimenArchive(FileLike archiveFile) { _definitionFile = archiveFile; } diff --git a/specimen/src/org/labkey/specimen/pipeline/SpecimenReloadJobSupport.java b/specimen/src/org/labkey/specimen/pipeline/SpecimenReloadJobSupport.java index 42f6a61a49c..2ecc7cd0306 100644 --- a/specimen/src/org/labkey/specimen/pipeline/SpecimenReloadJobSupport.java +++ b/specimen/src/org/labkey/specimen/pipeline/SpecimenReloadJobSupport.java @@ -16,6 +16,7 @@ package org.labkey.specimen.pipeline; import org.labkey.api.study.SpecimenTransform; +import org.labkey.vfs.FileLike; import java.io.File; @@ -24,7 +25,7 @@ */ public interface SpecimenReloadJobSupport extends SpecimenJobSupport { - void setSpecimenArchive(File archiveFile); + void setSpecimenArchive(FileLike archiveFile); String getSpecimenTransform(); diff --git a/specimen/src/org/labkey/specimen/pipeline/SpecimenReloadTask.java b/specimen/src/org/labkey/specimen/pipeline/SpecimenReloadTask.java index 9cd2bf6de14..6a88f0e86a9 100644 --- a/specimen/src/org/labkey/specimen/pipeline/SpecimenReloadTask.java +++ b/specimen/src/org/labkey/specimen/pipeline/SpecimenReloadTask.java @@ -58,8 +58,7 @@ public RecordedActionSet run() throws PipelineJobException PipeRoot root = PipelineService.get().findPipelineRoot(job.getContainer()); if (root != null) { - FileLike archiveFileLike = root.getRootFileLike().resolveChild(FileUtil.makeFileNameWithTimestamp("specimen_reload", transform.getFileType().getDefaultSuffix())); - File archive = FileSystemLike.toFile(archiveFileLike); + FileLike archive = root.getRootFileLike().resolveChild(FileUtil.makeFileNameWithTimestamp("specimen_reload", transform.getFileType().getDefaultSuffix())); transform.importFromExternalSource(job, support.getExternalImportConfig(), archive); support.setSpecimenArchive(archive); diff --git a/study/api-src/org/labkey/api/study/pipeline/StudyBatch.java b/study/api-src/org/labkey/api/study/pipeline/StudyBatch.java index 6e2564b018c..fe8382aee8c 100644 --- a/study/api-src/org/labkey/api/study/pipeline/StudyBatch.java +++ b/study/api-src/org/labkey/api/study/pipeline/StudyBatch.java @@ -24,6 +24,7 @@ import org.labkey.api.util.PageFlowUtil; import org.labkey.api.view.ActionURL; import org.labkey.api.view.ViewBackgroundInfo; +import org.labkey.vfs.FileLike; import java.io.File; import java.io.IOException; @@ -36,12 +37,12 @@ */ public abstract class StudyBatch extends PipelineJob implements Serializable { - protected File _definitionFile; + protected FileLike _definitionFile; // For serialization protected StudyBatch() {} - public StudyBatch(ViewBackgroundInfo info, File definitionFile, PipeRoot root) + public StudyBatch(ViewBackgroundInfo info, FileLike definitionFile, PipeRoot root) { super("Study", info, root); _definitionFile = definitionFile; @@ -74,9 +75,4 @@ public void submit() throws IOException throw new IOException(e); } } - - public File getDefinitionFile() - { - return _definitionFile; - } } diff --git a/study/src/org/labkey/study/controllers/StudyController.java b/study/src/org/labkey/study/controllers/StudyController.java index 4cd599b94ee..607c686b86f 100644 --- a/study/src/org/labkey/study/controllers/StudyController.java +++ b/study/src/org/labkey/study/controllers/StudyController.java @@ -1,7828 +1,7829 @@ -/* - * Copyright (c) 2008-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.labkey.study.controllers; - -import com.google.common.cache.Cache; -import com.google.common.cache.CacheBuilder; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpSession; -import org.apache.commons.beanutils.ConversionException; -import org.apache.commons.collections4.FactoryUtils; -import org.apache.commons.collections4.MapUtils; -import org.apache.commons.collections4.MultiValuedMap; -import org.apache.commons.collections4.multimap.ArrayListValuedHashMap; -import org.apache.commons.lang3.BooleanUtils; -import org.apache.commons.lang3.StringUtils; -import org.apache.commons.lang3.math.NumberUtils; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.apache.xmlbeans.XmlException; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.json.JSONObject; -import org.labkey.api.action.ApiJsonForm; -import org.labkey.api.action.ApiResponse; -import org.labkey.api.action.ApiSimpleResponse; -import org.labkey.api.action.ConfirmAction; -import org.labkey.api.action.FormApiAction; -import org.labkey.api.action.FormHandlerAction; -import org.labkey.api.action.FormViewAction; -import org.labkey.api.action.HasAllowBindParameter; -import org.labkey.api.action.HasViewContext; -import org.labkey.api.action.Marshal; -import org.labkey.api.action.Marshaller; -import org.labkey.api.action.MutatingApiAction; -import org.labkey.api.action.QueryViewAction; -import org.labkey.api.action.ReadOnlyApiAction; -import org.labkey.api.action.ReturnUrlForm; -import org.labkey.api.action.SimpleErrorView; -import org.labkey.api.action.SimpleRedirectAction; -import org.labkey.api.action.SimpleViewAction; -import org.labkey.api.action.SpringActionController; -import org.labkey.api.admin.AdminUrls; -import org.labkey.api.admin.ImportException; -import org.labkey.api.admin.notification.NotificationService; -import org.labkey.api.assay.AssayUrls; -import org.labkey.api.attachments.AttachmentFile; -import org.labkey.api.attachments.AttachmentForm; -import org.labkey.api.attachments.AttachmentParent; -import org.labkey.api.attachments.AttachmentService; -import org.labkey.api.attachments.BaseDownloadAction; -import org.labkey.api.audit.AuditLogService; -import org.labkey.api.audit.TransactionAuditProvider; -import org.labkey.api.collections.CaseInsensitiveHashMap; -import org.labkey.api.collections.IntHashMap; -import org.labkey.api.collections.IntHashSet; -import org.labkey.api.collections.LabKeyCollectors; -import org.labkey.api.compliance.ComplianceService; -import org.labkey.api.data.ActionButton; -import org.labkey.api.data.ButtonBar; -import org.labkey.api.data.ColumnHeaderType; -import org.labkey.api.data.ColumnInfo; -import org.labkey.api.data.CompareType; -import org.labkey.api.data.Container; -import org.labkey.api.data.ContainerFilter; -import org.labkey.api.data.DataRegion; -import org.labkey.api.data.DataRegionSelection; -import org.labkey.api.data.DbSchema; -import org.labkey.api.data.DbScope; -import org.labkey.api.data.DisplayColumn; -import org.labkey.api.data.PropertyManager; -import org.labkey.api.data.PropertyManager.WritablePropertyMap; -import org.labkey.api.data.RenderContext; -import org.labkey.api.data.Results; -import org.labkey.api.data.ResultsFactory; -import org.labkey.api.data.RuntimeSQLException; -import org.labkey.api.data.SQLFragment; -import org.labkey.api.data.ShowRows; -import org.labkey.api.data.SimpleDisplayColumn; -import org.labkey.api.data.SimpleFilter; -import org.labkey.api.data.Sort; -import org.labkey.api.data.SqlExecutor; -import org.labkey.api.data.SqlSelector; -import org.labkey.api.data.TSVGridWriter; -import org.labkey.api.data.Table; -import org.labkey.api.data.TableInfo; -import org.labkey.api.data.TableSelector; -import org.labkey.api.data.TableViewForm; -import org.labkey.api.data.views.DataViewService; -import org.labkey.api.exp.LsidManager; -import org.labkey.api.exp.OntologyManager; -import org.labkey.api.exp.PropertyDescriptor; -import org.labkey.api.exp.api.ExpProtocol; -import org.labkey.api.exp.api.ExpSampleType; -import org.labkey.api.exp.property.Domain; -import org.labkey.api.gwt.client.AuditBehaviorType; -import org.labkey.api.module.ModuleHtmlView; -import org.labkey.api.module.ModuleLoader; -import org.labkey.api.pipeline.PipeRoot; -import org.labkey.api.pipeline.PipelineJob; -import org.labkey.api.pipeline.PipelineService; -import org.labkey.api.pipeline.PipelineStatusUrls; -import org.labkey.api.pipeline.PipelineUrls; -import org.labkey.api.pipeline.PipelineValidationException; -import org.labkey.api.pipeline.browse.PipelinePathForm; -import org.labkey.api.qc.AbstractDeleteDataStateAction; -import org.labkey.api.qc.AbstractManageDataStatesForm; -import org.labkey.api.qc.AbstractManageQCStatesAction; -import org.labkey.api.qc.AbstractManageQCStatesBean; -import org.labkey.api.qc.DataState; -import org.labkey.api.qc.DataStateHandler; -import org.labkey.api.qc.DeleteDataStateForm; -import org.labkey.api.qc.QCStateManager; -import org.labkey.api.query.AbstractQueryImportAction; -import org.labkey.api.query.BatchValidationException; -import org.labkey.api.query.CustomView; -import org.labkey.api.query.DetailsURL; -import org.labkey.api.query.FieldKey; -import org.labkey.api.query.InvalidKeyException; -import org.labkey.api.query.QueryAction; -import org.labkey.api.query.QueryDefinition; -import org.labkey.api.query.QueryForm; -import org.labkey.api.query.QueryParseException; -import org.labkey.api.query.QueryService; -import org.labkey.api.query.QuerySettings; -import org.labkey.api.query.QueryUpdateService; -import org.labkey.api.query.QueryUpdateServiceException; -import org.labkey.api.query.QueryView; -import org.labkey.api.query.SchemaKey; -import org.labkey.api.query.UserSchema; -import org.labkey.api.query.ValidationException; -import org.labkey.api.query.snapshot.QuerySnapshotDefinition; -import org.labkey.api.query.snapshot.QuerySnapshotForm; -import org.labkey.api.query.snapshot.QuerySnapshotService; -import org.labkey.api.reader.DataLoader; -import org.labkey.api.reader.TabLoader; -import org.labkey.api.reports.Report; -import org.labkey.api.reports.ReportService; -import org.labkey.api.reports.model.ReportPropsManager; -import org.labkey.api.reports.model.ViewCategory; -import org.labkey.api.reports.model.ViewCategoryManager; -import org.labkey.api.reports.report.AbstractReportIdentifier; -import org.labkey.api.reports.report.QueryReport; -import org.labkey.api.reports.report.ReportIdentifier; -import org.labkey.api.reports.report.ReportUrls; -import org.labkey.api.search.SearchService; -import org.labkey.api.search.SearchUrls; -import org.labkey.api.security.RequiresAllOf; -import org.labkey.api.security.RequiresLogin; -import org.labkey.api.security.RequiresNoPermission; -import org.labkey.api.security.RequiresPermission; -import org.labkey.api.security.SecurityManager; -import org.labkey.api.security.User; -import org.labkey.api.security.permissions.AdminPermission; -import org.labkey.api.security.permissions.BrowserDeveloperPermission; -import org.labkey.api.security.permissions.DeletePermission; -import org.labkey.api.security.permissions.InsertPermission; -import org.labkey.api.security.permissions.Permission; -import org.labkey.api.security.permissions.PlatformDeveloperPermission; -import org.labkey.api.security.permissions.QCAnalystPermission; -import org.labkey.api.security.permissions.ReadPermission; -import org.labkey.api.security.permissions.UpdatePermission; -import org.labkey.api.settings.OptionalFeatureService; -import org.labkey.api.specimen.SpecimenManager; -import org.labkey.api.specimen.SpecimenMigrationService; -import org.labkey.api.specimen.location.LocationImpl; -import org.labkey.api.specimen.location.LocationManager; -import org.labkey.api.study.CohortFilter; -import org.labkey.api.study.CompletionType; -import org.labkey.api.study.Dataset; -import org.labkey.api.study.Dataset.KeyManagementType; -import org.labkey.api.study.DatasetTable; -import org.labkey.api.study.MasterPatientIndexService; -import org.labkey.api.study.ParticipantCategory; -import org.labkey.api.study.Study; -import org.labkey.api.study.StudyService; -import org.labkey.api.study.StudyUrls; -import org.labkey.api.study.TimepointType; -import org.labkey.api.study.Visit; -import org.labkey.api.study.model.ParticipantGroup; -import org.labkey.api.study.publish.StudyPublishService; -import org.labkey.api.study.security.permissions.ManageStudyPermission; -import org.labkey.api.studydesign.StudyDesignManager; -import org.labkey.api.util.ContainerContext; -import org.labkey.api.util.CsrfInput; -import org.labkey.api.util.DateUtil; -import org.labkey.api.util.DemoMode; -import org.labkey.api.util.FileStream; -import org.labkey.api.util.HtmlString; -import org.labkey.api.util.HtmlStringBuilder; -import org.labkey.api.util.LinkBuilder; -import org.labkey.api.util.PageFlowUtil; -import org.labkey.api.util.Pair; -import org.labkey.api.util.StringExpression; -import org.labkey.api.util.URLHelper; -import org.labkey.api.util.XmlBeansUtil; -import org.labkey.api.view.ActionURL; -import org.labkey.api.view.DataView; -import org.labkey.api.view.GridView; -import org.labkey.api.view.HtmlView; -import org.labkey.api.view.HttpView; -import org.labkey.api.view.JspView; -import org.labkey.api.view.NavTree; -import org.labkey.api.view.NotFoundException; -import org.labkey.api.view.Portal; -import org.labkey.api.view.RedirectException; -import org.labkey.api.view.UnauthorizedException; -import org.labkey.api.view.VBox; -import org.labkey.api.view.ViewBackgroundInfo; -import org.labkey.api.view.ViewContext; -import org.labkey.api.view.ViewForm; -import org.labkey.api.view.WebPartView; -import org.labkey.api.view.template.EmptyView; -import org.labkey.api.view.template.PageConfig; -import org.labkey.api.writer.FileSystemFile; -import org.labkey.api.writer.HtmlWriter; -import org.labkey.api.writer.VirtualFile; -import org.labkey.data.xml.TablesDocument; -import org.labkey.study.CohortFilterFactory; -import org.labkey.study.MasterPatientIndexMaintenanceTask; -import org.labkey.study.StudyModule; -import org.labkey.study.StudySchema; -import org.labkey.study.assay.AssayPublishConfirmAction; -import org.labkey.study.assay.AssayPublishStartAction; -import org.labkey.study.assay.StudyPublishManager; -import org.labkey.study.audit.ParticipantGroupAuditProvider; -import org.labkey.study.controllers.publish.SampleTypePublishConfirmAction; -import org.labkey.study.controllers.publish.SampleTypePublishStartAction; -import org.labkey.study.controllers.security.SecurityController; -import org.labkey.study.dataset.DatasetSnapshotProvider; -import org.labkey.study.dataset.DatasetViewProvider; -import org.labkey.study.designer.StudySchedule; -import org.labkey.study.importer.DatasetImportUtils; -import org.labkey.study.importer.SchemaReader; -import org.labkey.study.importer.SchemaXmlReader; -import org.labkey.study.importer.VisitMapImporter; -import org.labkey.study.model.CohortImpl; -import org.labkey.study.model.CohortManager; -import org.labkey.study.model.CustomParticipantView; -import org.labkey.study.model.DatasetDefinition; -import org.labkey.study.model.DatasetDomainKind; -import org.labkey.study.model.DatasetDomainKindProperties; -import org.labkey.study.model.DatasetManager; -import org.labkey.study.model.DatasetReorderer; -import org.labkey.study.model.DateDatasetDomainKind; -import org.labkey.study.model.Participant; -import org.labkey.study.model.ParticipantCategoryImpl; -import org.labkey.study.model.ParticipantGroupManager; -import org.labkey.study.model.QCStateSet; -import org.labkey.study.model.SecurityType; -import org.labkey.study.model.StudyImpl; -import org.labkey.study.model.StudyManager; -import org.labkey.study.model.StudySnapshot; -import org.labkey.study.model.UploadLog; -import org.labkey.study.model.VisitDataset; -import org.labkey.study.model.VisitDatasetType; -import org.labkey.study.model.VisitImpl; -import org.labkey.study.model.VisitMapKey; -import org.labkey.study.pipeline.DatasetFileReader; -import org.labkey.study.pipeline.MasterPatientIndexUpdateTask; -import org.labkey.study.pipeline.StudyPipeline; -import org.labkey.study.qc.StudyQCStateHandler; -import org.labkey.study.query.DatasetQuerySettings; -import org.labkey.study.query.DatasetQueryView; -import org.labkey.study.query.LocationTable; -import org.labkey.study.query.PublishedRecordQueryView; -import org.labkey.study.query.QueryDatasetTable; -import org.labkey.study.query.StudyQuerySchema; -import org.labkey.study.query.StudyQueryView; -import org.labkey.study.reports.ReportManager; -import org.labkey.study.view.SubjectsWebPart; -import org.labkey.study.visitmanager.SequenceVisitManager; -import org.labkey.study.visitmanager.VisitManager; -import org.labkey.study.visitmanager.VisitManager.VisitStatistic; -import org.labkey.study.xml.DatasetsDocument; -import org.springframework.validation.BindException; -import org.springframework.validation.Errors; -import org.springframework.web.servlet.ModelAndView; -import org.springframework.web.servlet.mvc.Controller; - -import java.io.File; -import java.io.IOException; -import java.io.PrintWriter; -import java.math.BigDecimal; -import java.net.URISyntaxException; -import java.sql.SQLException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.Date; -import java.util.EnumSet; -import java.util.HashMap; -import java.util.HashSet; -import java.util.LinkedHashSet; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.TimeUnit; -import java.util.function.Predicate; -import java.util.regex.Pattern; - -import static org.labkey.api.util.IntegerUtils.asInteger; -import static org.labkey.study.model.QCStateSet.PUBLIC_STATES_LABEL; -import static org.labkey.study.model.QCStateSet.getQCStateFilteredURL; -import static org.labkey.study.model.QCStateSet.getQCUrlFilterKey; -import static org.labkey.study.model.QCStateSet.getQCUrlFilterValue; -import static org.labkey.study.model.QCStateSet.selectedQCStateLabelFromUrl; -import static org.labkey.study.query.DatasetQueryView.EXPERIMENTAL_ALLOW_MERGE_WITH_MANAGED_KEYS; - -public class StudyController extends BaseStudyController -{ - private static final Logger _log = LogManager.getLogger(StudyController.class); - private static final String PARTICIPANT_CACHE_PREFIX = "Study_participants/participantCache"; - private static final String EXPAND_CONTAINERS_KEY = StudyController.class.getName() + "/expandedContainers"; - private static final String DATASET_DATAREGION_NAME = "Dataset"; - - private static final ActionResolver ACTION_RESOLVER = new DefaultActionResolver( - StudyController.class, - CreateChildStudyAction.class, - AutoCompleteAction.class - ); - - public static final String DATASET_REPORT_ID_PARAMETER_NAME = "Dataset.reportId"; - public static final String DATASET_VIEW_NAME_PARAMETER_NAME = "Dataset.viewName"; - - public static class StudyUrlsImpl implements StudyUrls - { - @Override - public ActionURL getBeginURL(Container container) - { - return new ActionURL(BeginAction.class, container); - } - - @Override - public ActionURL getCompletionURL(Container studyContainer, CompletionType type) - { - if (studyContainer == null) - return null; - - ActionURL url = new ActionURL(AutoCompleteAction.class, studyContainer); - url.addParameter("type", type.name()); - url.addParameter("prefix", ""); - return url; - } - - @Override - public ActionURL getCreateStudyURL(Container container) - { - return new ActionURL(CreateStudyAction.class, container); - } - - @Override - public ActionURL getManageStudyURL(Container container) - { - return new ActionURL(ManageStudyAction.class, container); - } - - @Override - public Class getManageStudyClass() - { - return ManageStudyAction.class; - } - - @Override - public ActionURL getStudyOverviewURL(Container container) - { - return new ActionURL(OverviewAction.class, container); - } - - @Override - public ActionURL getDatasetURL(Container container, int datasetId) - { - return new ActionURL(DatasetAction.class, container).addParameter(Dataset.DATASET_KEY, datasetId); - } - - @Override - public ActionURL getDatasetsURL(Container container) - { - return new ActionURL(DatasetsAction.class, container); - } - - @Override - public ActionURL getManageDatasetsURL(Container container) - { - return new ActionURL(ManageTypesAction.class, container); - } - - @Override - public ActionURL getManageReportPermissions(Container container) - { - return new ActionURL(SecurityController.ReportPermissionsAction.class, container); - } - - @Override - public ActionURL getManageFileWatchersURL(Container container) - { - return new ActionURL(StudyController.ManageFilewatchersAction.class, container); - } - - @Override - public ActionURL getLinkToStudyURL(Container container, ExpSampleType sampleType) - { - ActionURL url = new ActionURL(SampleTypePublishStartAction.class, container); - if (sampleType != null) - url.addParameter("rowId", sampleType.getRowId()); - return url; - } - - @Override - public ActionURL getLinkToStudyURL(Container container, ExpProtocol protocol) - { - return urlProvider(AssayUrls.class).getProtocolURL(container, protocol, AssayPublishStartAction.class); - } - - @Override - public ActionURL getLinkToStudyConfirmURL(Container container, ExpProtocol protocol) - { - return urlProvider(AssayUrls.class).getProtocolURL(container, protocol, AssayPublishConfirmAction.class); - } - - @Override - public ActionURL getLinkToStudyConfirmURL(Container container, ExpSampleType sampleType) - { - ActionURL url = new ActionURL(SampleTypePublishConfirmAction.class, container); - if (sampleType != null) - url.addParameter("rowId", sampleType.getRowId()); - return url; - } - - @Override - public void addManageStudyNavTrail(NavTree root, Container container, User user) - { - _addManageStudy(root, container, user); - } - - @Override - public ActionURL getTypeNotFoundURL(Container container, int datasetId) - { - return new ActionURL(TypeNotFoundAction.class, container).addParameter("id", datasetId); - } - - @Override - public ActionURL getManageLocationsURL(Container container) - { - return new ActionURL(ManageLocationsAction.class, container); - } - - @Override - public ActionURL getManageVisitsURL(Container container) - { - return new ActionURL(ManageVisitsAction.class, container); - } - - @Override - public ActionURL getManageCohortsURL(Container container) - { - return new ActionURL(CohortController.ManageCohortsAction.class, container); - } - - @Override - public ActionURL getVisitOrderURL(Container container) - { - return new ActionURL(VisitOrderAction.class, container); - } - } - - public StudyController() - { - setActionResolver(ACTION_RESOLVER); - } - - protected void _addNavTrailVisitAdmin(NavTree root) - { - _addManageStudy(root); - - StringBuilder sb = new StringBuilder("Manage "); - - Study visitStudy = StudyManager.getInstance().getStudyForVisits(getStudy()); - if (visitStudy.getShareVisitDefinitions() == Boolean.TRUE) - sb.append("Shared "); - - sb.append(getVisitLabelPlural()); - - root.addChild(sb.toString(), new ActionURL(ManageVisitsAction.class, getContainer())); - } - - @RequiresPermission(ReadPermission.class) - public class BeginAction extends SimpleViewAction - { - private Study _study; - - @Override - public ModelAndView getView(Object o, BindException errors) throws Exception - { - _study = getStudy(); - - WebPartView overview = StudyModule.manageStudyPartFactory.getWebPartView(getViewContext(), StudyModule.manageStudyPartFactory.createWebPart()); - WebPartView views = StudyModule.reportsPartFactory.getWebPartView(getViewContext(), StudyModule.reportsPartFactory.createWebPart()); - return new VBox(overview, views); - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild(_study == null ? "No Study In Folder" : _study.getLabel()); - } - } - - @RequiresPermission(AdminPermission.class) - public class DefineDatasetTypeAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object o, BindException errors) throws Exception - { - getStudyRedirectIfNull(); - return ModuleHtmlView.get(ModuleLoader.getInstance().getModule("core"), ModuleHtmlView.getGeneratedViewPath("datasetDesigner")); - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("createDataset"); - _addNavTrailDatasetAdmin(root); - root.addChild("Create Dataset Definition"); - } - } - - @Marshal(Marshaller.Jackson) - @RequiresPermission(ReadPermission.class) - public static class GetDatasetAction extends ReadOnlyApiAction - { - @Override - public Object execute(DatasetForm form, BindException errors) throws Exception - { - DatasetDomainKindProperties properties = DatasetManager.get().getDatasetDomainKindProperties(getContainer(), form.getDatasetId()); - if (properties != null) - return properties; - else - throw new NotFoundException("Dataset does not exist in this container for datasetId " + form.getDatasetIdStr() + "."); - } - } - - @RequiresPermission(AdminPermission.class) - @SuppressWarnings("unchecked") - public class EditTypeAction extends SimpleViewAction - { - private Dataset _def; - - @Override - public ModelAndView getView(DatasetForm form, BindException errors) - { - StudyImpl study = getStudyRedirectIfNull(); - if (null == form.getDatasetId()) - throw new NotFoundException("No datasetId parameter provided."); - - DatasetDefinition def = study.getDataset(form.getDatasetId()); - _def = def; - if (null == def) - throw new NotFoundException("No dataset found for datasetId " + form.getDatasetId() + "."); - - if (def.isQueryDataset()) - throw new UnsupportedOperationException("Query dataset definition cannot be edited. Update the source query to change definition."); - - if (!def.canUpdateDefinition(getUser())) - { - ActionURL details = new ActionURL(DatasetDetailsAction.class,getContainer()).addParameter("id",def.getDatasetId()); - throw new RedirectException(details); - } - - if (null == def.getTypeURI()) - { - def = def.createMutable(); - String domainURI = StudyManager.getInstance().getDomainURI(study.getContainer(), getUser(), def); - OntologyManager.ensureDomainDescriptor(domainURI, def.getName(), study.getContainer()); - def.setTypeURI(domainURI); - } - - return ModuleHtmlView.get(ModuleLoader.getInstance().getModule("core"), ModuleHtmlView.getGeneratedViewPath("datasetDesigner")); - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("datasetProperties"); - _addNavTrailDatasetAdmin(root); - root.addChild(_def.getName(), new ActionURL(DatasetDetailsAction.class, getContainer()).addParameter("id", _def.getDatasetId())); - root.addChild("Edit Dataset Definition"); - } - } - - @RequiresPermission(ReadPermission.class) - public class DatasetDetailsAction extends SimpleViewAction - { - private DatasetDefinition _def; - - @Override - public ModelAndView getView(IdForm form, BindException errors) - { - _def = StudyManager.getInstance().getDatasetDefinition(getStudyRedirectIfNull(), form.getId()); - if (_def == null) - { - throw new NotFoundException("Invalid Dataset ID"); - } - return new StudyJspView<>(StudyManager.getInstance().getStudy(getContainer()), - "/org/labkey/study/view/datasetDetails.jsp", _def, errors); - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("datasetProperties"); - root.addChild(_def.getLabel(), urlProvider(StudyUrls.class).getDatasetURL(getContainer(), _def.getDatasetId())); - root.addChild("Dataset Properties"); - } - } - - public static class DatasetFilterForm extends QueryViewAction.QueryExportForm implements HasViewContext - { - private ViewContext _viewContext; - - @Override - public void setViewContext(ViewContext context) - { - _viewContext = context; - } - - @Override - public ViewContext getViewContext() - { - return _viewContext; - } - } - - - public static class OverviewForm extends DatasetFilterForm - { - private String _qcState; - private String[] _visitStatistic = new String[0]; - - public String getQCState() - { - return _qcState; - } - - public void setQCState(String qcState) - { - _qcState = qcState; - } - - public String[] getVisitStatistic() - { - return _visitStatistic; - } - - @SuppressWarnings({"UnusedDeclaration"}) - public void setVisitStatistic(String[] visitStatistic) - { - _visitStatistic = visitStatistic; - } - - private Set getVisitStatistics() - { - Set set = EnumSet.noneOf(VisitStatistic.class); - - for (String statName : _visitStatistic) - set.add(VisitStatistic.valueOf(statName)); - - if (set.isEmpty()) - set.add(VisitStatistic.values()[0]); - - return set; - } - } - - - @RequiresPermission(ReadPermission.class) - public class OverviewAction extends SimpleViewAction - { - private StudyImpl _study; - - @Override - public ModelAndView getView(OverviewForm form, BindException errors) throws Exception - { - _study = getStudyRedirectIfNull(); - OverviewBean bean = new OverviewBean(); - bean.study = _study; - bean.showAll = "1".equals(getViewContext().get("showAll")); - bean.canManage = getContainer().hasPermission(getUser(), ManageStudyPermission.class); - bean.showCohorts = StudyManager.getInstance().showCohorts(getContainer(), getUser()); - bean.stats = form.getVisitStatistics(); - bean.showSpecimens = SpecimenManager.get().isSpecimenModuleActive(getContainer()); - - if (QCStateManager.getInstance().showStates(getContainer())) - bean.qcStates = QCStateSet.getSelectedStates(getContainer(), form.getQCState()); - - if (!bean.showCohorts) - bean.cohortFilter = null; - else - bean.cohortFilter = CohortFilterFactory.getFromURL(getContainer(), getUser(), getViewContext().getActionURL(), DatasetQueryView.DATAREGION); - - VisitManager visitManager = StudyManager.getInstance().getVisitManager(bean.study); - bean.visitMapSummary = visitManager.getVisitSummary(getUser(), bean.cohortFilter, bean.qcStates, bean.stats, bean.showAll); - - return new StudyJspView<>(_study, "/org/labkey/study/view/overview.jsp", bean, errors); - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("studyDashboard#navigator"); - root.addChild("Overview: " + _study.getLabel()); - } - } - - public static class QueryReportForm extends QueryViewAction.QueryExportForm - { - ReportIdentifier _reportId; - - public ReportIdentifier getReportId() - { - return _reportId; - } - - public void setReportId(ReportIdentifier reportId) - { - _reportId = reportId; - } - } - - @RequiresPermission(ReadPermission.class) - public static class QueryReportAction extends QueryViewAction - { - protected Report _report; - - public QueryReportAction() - { - super(QueryReportForm.class); - } - - @Override - protected ModelAndView getHtmlView(QueryReportForm form, BindException errors) throws Exception - { - Report report = getReport(form); - - if (report != null) - return report.getRunReportView(getViewContext()); - else - throw new NotFoundException("Unable to locate the requested report: " + form.getReportId()); - } - - @Override - protected QueryView createQueryView(QueryReportForm form, BindException errors, boolean forExport, String dataRegion) throws Exception - { - Report report = getReport(form); - if (report instanceof QueryReport) - return ((QueryReport)report).getQueryViewGenerator().generateQueryView(getViewContext(), report.getDescriptor()); - - return null; - } - - @Override - public void addNavTrail(NavTree root) - { - if (_report != null) - root.addChild(_report.getDescriptor().getReportName()); - else - root.addChild("Study Query Report"); - } - - protected Report getReport(QueryReportForm form) - { - if (_report == null) - { - ReportIdentifier identifier = form.getReportId(); - if (identifier != null) - _report = identifier.getReport(getViewContext()); - } - return _report; - } - } - - @RequiresPermission(ReadPermission.class) - public class DatasetReportAction extends QueryReportAction - { - @Override - protected Report getReport(QueryReportForm form) - { - if (_report == null) - { - String reportId = (String)getViewContext().get(DATASET_REPORT_ID_PARAMETER_NAME); - - ReportIdentifier identifier = ReportService.get().getReportIdentifier(reportId, getViewContext().getUser(), getViewContext().getContainer()); - if (identifier != null) - _report = identifier.getReport(getViewContext()); - } - return _report; - } - - @Override - protected ModelAndView getHtmlView(QueryReportForm form, BindException errors) throws Exception - { - ViewContext context = getViewContext(); - Report report = getReport(form); - - // is not a report (either the default grid view or a custom view)... - if (report == null) - { - return HttpView.redirect(createRedirectURLfrom(DatasetAction.class, context)); - } - - int datasetId = NumberUtils.toInt((String)context.get(Dataset.DATASET_KEY), -1); - Dataset def = StudyManager.getInstance().getDatasetDefinition(getStudyRedirectIfNull(), datasetId); - - if (def != null) - { - ActionURL url = getViewContext().cloneActionURL().setAction(StudyController.DatasetAction.class). - replaceParameter(DATASET_REPORT_ID_PARAMETER_NAME, report.getDescriptor().getReportId().toString()). - replaceParameter(Dataset.DATASET_KEY, def.getDatasetId()); - - return HttpView.redirect(url); - } - else if (ReportManager.get().canReadReport(getUser(), getContainer(), report)) - return report.getRunReportView(getViewContext()); - else - return HtmlView.of("User does not have read permission on this report."); - } - } - - private ActionURL createRedirectURLfrom(Class action, ViewContext context) - { - ActionURL newUrl = new ActionURL(action, context.getContainer()); - return newUrl.addParameters(context.getActionURL().getParameters()); - } - - @RequiresPermission(ReadPermission.class) - public class DatasetAction extends QueryViewAction - { - private DatasetDefinition _def; - - public DatasetAction() - { - super(DatasetFilterForm.class); - } - - private DatasetDefinition getDatasetDefinition() - { - if (null == _def) - { - Object datasetKeyObject = getViewContext().get(Dataset.DATASET_KEY); - if (datasetKeyObject instanceof List list) - { - // bug 7365: It's been specified twice -- once in the POST, once in the GET. Just need one of them. - datasetKeyObject = list.get(0); - } - if (null != datasetKeyObject) - { - try - { - int id = NumberUtils.toInt(String.valueOf(datasetKeyObject), 0); - _def = StudyManager.getInstance().getDatasetDefinition(getStudyRedirectIfNull(), id); - } - catch (ConversionException x) - { - throw new NotFoundException(); - } - } - else - { - String entityId = (String)getViewContext().get("entityId"); - if (null != entityId) - _def = StudyManager.getInstance().getDatasetDefinitionByEntityId(getStudyRedirectIfNull(), entityId); - } - } - if (null == _def) - throw new NotFoundException(); - return _def; - } - - @Override - public ModelAndView getView(DatasetFilterForm form, BindException errors) throws Exception - { - ActionURL url = getViewContext().getActionURL(); - String viewName = url.getParameter(DATASET_VIEW_NAME_PARAMETER_NAME); - - // if the view name refers to a report id (legacy style), redirect to use the newer report id parameter - if (NumberUtils.isDigits(viewName)) - { - // one last check to see if there is a view with that name before trying to redirect to the report - DatasetDefinition def = getDatasetDefinition(); - - if (def != null && - QueryService.get().getCustomView(getUser(), getContainer(), getUser(), StudySchema.getInstance().getSchemaName(), def.getName(), viewName) == null) - { - ReportIdentifier reportId = AbstractReportIdentifier.fromString(viewName, getViewContext().getUser(), getViewContext().getContainer()); - if (reportId != null && reportId.getReport(getViewContext()) != null) - { - ActionURL newURL = url.clone().deleteParameter(DATASET_VIEW_NAME_PARAMETER_NAME). - addParameter(DATASET_REPORT_ID_PARAMETER_NAME, reportId.toString()); - return HttpView.redirect(newURL); - } - } - } - return super.getView(form, errors); - } - - @Override - protected ModelAndView getHtmlView(DatasetFilterForm form, BindException errors) throws Exception - { - // the full resultset is a join of all datasets for each participant - // each dataset is determined by a visitid/datasetid - - // Ensure a study is present - getStudyRedirectIfNull(); - ViewContext context = getViewContext(); - - String export = StringUtils.trimToNull(context.getActionURL().getParameter("export")); - - DatasetDefinition def = getDatasetDefinition(); - if (null == def) - return new TypeNotFoundAction().getView(form, errors); - String typeURI = def.getTypeURI(); - if (null == typeURI) - return new TypeNotFoundAction().getView(form, errors); - - boolean showEditLinks = !QueryService.get().isQuerySnapshot(getContainer(), StudySchema.getInstance().getSchemaName(), def.getName()) && - !def.isPublishedData(); - - UserSchema schema = QueryService.get().getUserSchema(getUser(), getContainer(), StudyQuerySchema.SCHEMA_NAME); - DatasetQuerySettings settings = (DatasetQuerySettings)schema.getSettings(getViewContext(), DatasetQueryView.DATAREGION, def.getName()); - - settings.setShowEditLinks(showEditLinks); - settings.setShowSourceLinks(true); - - final ActionURL url = context.getActionURL(); - - // clear the property map cache and the sort map cache - getParticipantPropsMap(context).clear(); - getDatasetSortColumnMap(context).clear(); - - QueryView queryView = schema.createView(getViewContext(), settings, errors); - final TableInfo table = queryView.getTable(); - if (table != null) - { - setColumnURL(url, queryView, schema, def); - - // Clear any cached participant lists... not really necessary, since the cache key is now the entire - // query string (including all filters & sorts), but it doesn't really hurt. List is regenerated only if - // user navigates to an individual participant. - removeParticipantListFromSession(context); - getExpandedState(context, def.getDatasetId()).clear(); - } - - if (null != export) - { - if ("tsv".equals(export)) - queryView.exportToTsv(context.getResponse()); - else if ("xls".equals(export)) - queryView.exportToExcel(context.getResponse()); - return null; - } - - HtmlStringBuilder sb = HtmlStringBuilder.of(); - if (def.getDescription() != null && !def.getDescription().isEmpty()) - sb.unsafeAppend(PageFlowUtil.filter(def.getDescription(), true, true)).unsafeAppend("
"); - CohortFilter cohortFilter = queryView instanceof StudyQueryView studyQueryView ? studyQueryView.getCohortFilter() : null; - if (cohortFilter != null) - sb.unsafeAppend("
Cohort: ").append(cohortFilter.getDescription(getContainer(), getUser())).unsafeAppend(""); - - if (QCStateManager.getInstance().showStates(getContainer())) - { - String publicQCUrlFilterValue = getQCUrlFilterValue(QCStateSet.getPublicStates(getContainer())); - String privateQCUrlFilterValue = getQCUrlFilterValue(QCStateSet.getPrivateStates(getContainer())); - - for (QCStateSet set : QCStateSet.getSelectableSets(getContainer())) - { - String selectedQCLabel = selectedQCStateLabelFromUrl(getViewContext().getActionURL(), settings.getDataRegionName(), set.getLabel(), publicQCUrlFilterValue, privateQCUrlFilterValue); - if (selectedQCLabel != null && selectedQCLabel.equals(set.getLabel())) - { - sb.unsafeAppend("
QC States: ").append(set.getLabel()).unsafeAppend(""); - break; - } - } - } - if (ReportPropsManager.get().getPropertyValue(def.getEntityId(), getContainer(), "refreshDate") != null) - { - sb.unsafeAppend("
Data Cut Date: "); - Object refreshDate = (ReportPropsManager.get().getPropertyValue(def.getEntityId(), getContainer(), "refreshDate")); - if (refreshDate instanceof Date) - { - sb.append(DateUtil.formatDate(getContainer(), (Date)refreshDate)); - } - else - { - sb.append(ReportPropsManager.get().getPropertyValue(def.getEntityId(), getContainer(), "refreshDate").toString()); - } - } - HtmlView header = new HtmlView(sb); - VBox view = new VBox(header, queryView); - - String status = (String)ReportPropsManager.get().getPropertyValue(def.getEntityId(), getContainer(), "status"); - if (status != null) - { - // inject the dataset status marker class, but it is up to the client to style the page accordingly - HtmlView scriptLock = new HtmlView(HtmlString.unsafe("")); - view.addView(scriptLock); - } - - Report report = queryView.getSettings().getReportView(context); - if (report != null && !ReportManager.get().canReadReport(getUser(), getContainer(), report)) - { - return HtmlView.of("User does not have read permission on this report."); - } - else if (report == null && (null==table || !table.hasPermission(getUser(),ReadPermission.class))) - { - return HtmlView.of("User does not have read permission on this dataset."); - } - return view; - } - - @Override - protected QueryView createQueryView(DatasetFilterForm datasetFilterForm, BindException errors, boolean forExport, String dataRegion) throws Exception - { - QuerySettings qs = new QuerySettings(getViewContext(), DATASET_DATAREGION_NAME); - Report report = qs.getReportView(getViewContext()); - if (report instanceof QueryReport) - { - return ((QueryReport)report).getQueryViewGenerator().generateQueryView(getViewContext(), report.getDescriptor()); - } - return null; - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("gridBasics"); - _addNavTrail(root, getDatasetDefinition().getDatasetId(), getViewContext().getActionURL()); - } - } - - @RequiresNoPermission - public static class ExpandStateNotifyAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object o, BindException errors) - { - final ActionURL url = getViewContext().getActionURL(); - final String collapse = url.getParameter("collapse"); - final int datasetId = NumberUtils.toInt(url.getParameter(Dataset.DATASET_KEY), -1); - final int id = NumberUtils.toInt(url.getParameter("id"), -1); - - if (datasetId != -1 && id != -1) - { - Map expandedMap = getExpandedState(getViewContext(), id); - // collapse param is only set on a collapse action - if (collapse != null) - expandedMap.put(datasetId, "collapse"); - else - expandedMap.put(datasetId, "expand"); - } - return null; - } - - @Override - public void addNavTrail(NavTree root) - { - } - } - - - Participant findParticipant(Study study, String particpantId) throws StudyManager.ParticipantNotUniqueException - { - Participant participant = StudyManager.getInstance().getParticipant(study, particpantId); - if (participant == null) - { - if (study.isDataspaceStudy()) - { - Container c = StudyManager.getInstance().findParticipant(study, particpantId); - Study s = null == c ? null : StudyManager.getInstance().getStudy(c); - if (null != s && c.hasPermission(getUser(), ReadPermission.class)) - { - participant = StudyManager.getInstance().getParticipant(s, particpantId); - } - } - } - return participant; - } - - @RequiresPermission(ReadPermission.class) - public class ParticipantAction extends SimpleViewAction - { - private ParticipantForm _bean; - - @Override - public ModelAndView getView(ParticipantForm form, BindException errors) - { - Study study = getStudyRedirectIfNull(); - _bean = form; - ActionURL previousParticipantURL = null; - ActionURL nextParticipantURL = null; - Participant participant; - StringBuilder errorMsg = new StringBuilder(); - - if (form.getParticipantId() == null) - { - errorMsg.append("No ").append(study.getSubjectNounSingular()).append(" specified"); - } - else - { - try - { - participant = findParticipant(study, form.getParticipantId()); - if (null == participant) - errorMsg.append("Could not find ").append(study.getSubjectNounSingular()).append(" ").append(form.getParticipantId()); - } - catch (StudyManager.ParticipantNotUniqueException x) - { - errorMsg.append(x.getMessage()); - } - } - - if (!errorMsg.isEmpty()) - return HtmlView.err(errorMsg.toString()); - - String viewName = (String) getViewContext().get(DATASET_VIEW_NAME_PARAMETER_NAME); - - CohortFilter cohortFilter = CohortFilterFactory.getFromURL(getContainer(), getUser(), getViewContext().getActionURL(), DatasetQueryView.DATAREGION); - // display the next and previous buttons only if we have a cached participant index - if (cohortFilter != null && !StudyManager.getInstance().showCohorts(getContainer(), getUser())) - throw new UnauthorizedException("User does not have permission to view cohort information"); - - List participants = getParticipantListFromSession(getViewContext(), form.getDatasetId(), viewName); - - if (isDebug()) - { - _log.info("Cached participants: {}", participants); - } - int idx = participants.indexOf(form.getParticipantId()); - if (idx != -1) - { - if (idx > 0) - { - final String ptid = participants.get(idx-1); - previousParticipantURL = getViewContext().cloneActionURL(); - previousParticipantURL.replaceParameter("participantId", ptid); - } - - if (idx < participants.size()-1) - { - final String ptid = participants.get(idx+1); - nextParticipantURL = getViewContext().cloneActionURL(); - nextParticipantURL.replaceParameter("participantId", ptid); - } - } - - VBox vbox = new VBox(); - ParticipantNavView navView = new ParticipantNavView(previousParticipantURL, nextParticipantURL, form.getParticipantId(), null); - vbox.addView(navView); - - CustomParticipantView customParticipantView = StudyManager.getInstance().getCustomParticipantView(study); - if (customParticipantView != null && customParticipantView.isActive()) - { - vbox.addView(customParticipantView.getView()); - } - else - { - ModelAndView characteristicsView = StudyManager.getInstance().getParticipantDemographicsView(getContainer(), form, errors); - ModelAndView dataView = StudyManager.getInstance().getParticipantView(getContainer(), form, errors); - vbox.addView(characteristicsView); - vbox.addView(dataView); - } - - return vbox; - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("participantViews"); - _addNavTrail(root, _bean.getDatasetId(), _bean.getReturnActionURL()); - root.addChild(StudyService.get().getSubjectNounSingular(getContainer()) + " - " + id(_bean.getParticipantId())); - } - } - - @RequiresPermission(ReadPermission.class) - public class Participant2Action extends SimpleViewAction - { - // TODO participant list support? cohortfilter support? - // TODO define participant context -// { -// particpantId:"", -// participantGroup:"" -// demoMode:false -// } - - - @Override - public ModelAndView getView(ParticipantForm form, BindException errors) throws Exception - { - ViewContext context = getViewContext(); - Study study = getStudyRedirectIfNull(); - - if (form.getParticipantId() == null) - { - throw new NotFoundException("No " + study.getSubjectNounSingular() + " specified"); - } - - Participant participant; - try - { - participant = findParticipant(study, form.getParticipantId()); - if (null == participant) - throw new NotFoundException("Could not find " + study.getSubjectNounSingular() + " " + form.getParticipantId()); - } - catch (StudyManager.ParticipantNotUniqueException x) - { - return HtmlView.of(x.getMessage()); - } - - PageConfig page = getPageConfig(); - - // add participant to view context for java/jsp based web parts - context.put(Participant.class.getName(), participant); - // add to javascript context for file based web parts - page.getPortalContext().put("participantId", participant.getParticipantId()); - - String pageId = Participant.class.getName(); - boolean canCustomize = context.getContainer().hasPermission("populatePortalView",context.getUser(), AdminPermission.class); - - HttpView template = PageConfig.Template.Home.getTemplate(getViewContext(), new VBox(), page); - int parts = Portal.populatePortalView(getViewContext(), pageId, template, isPrint(), canCustomize, false, true, Portal.STUDY_PARTICIPANT_PORTAL_PAGE); - - if (parts == 0 && canCustomize) - { - // TODO: make webparts out of default views and actually save portal config -// ParticipantAction pa = new ParticipantAction(); -// pa.setViewContext(context); -// ModelAndView v = pa.getView(form, errors); -// Portal.addViewToRegion(template, WebPartFactory.LOCATION_BODY, (HttpView)v); - - // force page admin mode - template = PageConfig.Template.Home.getTemplate(getViewContext(), new VBox(), page); - Portal.populatePortalView(getViewContext(), pageId, template, isPrint(), canCustomize, true, true, Participant.class.getName()); - - } - - getPageConfig().setTemplate(PageConfig.Template.None); - return template; - } - - @Override - public void addNavTrail(NavTree root) - { - } - } - - - - // Obfuscate the passed in test if this user is in "demo" mode in this container - private String id(String id) - { - return id(id, getContainer(), getUser()); - } - - // Obfuscate the passed in test if this user is in "demo" mode in this container - private static String id(String id, Container c, User user) - { - return DemoMode.id(id, c, user); - } - - - @RequiresPermission(AdminPermission.class) - public class ImportVisitMapAction extends FormViewAction - { - @Override - public ModelAndView getView(ImportVisitMapForm form, boolean reshow, BindException errors) - { - StudyImpl study = getStudyThrowIfNull(); - redirectToSharedVisitStudy(study, getViewContext().getActionURL()); - return new StudyJspView<>(getStudyRedirectIfNull(), "/org/labkey/study/view/importVisitMap.jsp", null, errors); - } - - @Override - public void validateCommand(ImportVisitMapForm form, Errors errors) - { - } - - @Override - public boolean handlePost(ImportVisitMapForm form, BindException errors) throws Exception - { - VisitMapImporter importer = new VisitMapImporter(); - List errorMsg = new LinkedList<>(); - if (!importer.process(getUser(), getStudyThrowIfNull(), form.getContent(), VisitMapImporter.Format.Xml, errorMsg, _log)) - { - for (String error : errorMsg) - errors.reject("uploadVisitMap", error); - return false; - } - return true; - } - - @Override - public ActionURL getSuccessURL(ImportVisitMapForm form) - { - return new ActionURL(BeginAction.class, getContainer()); - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("importVisitMap"); - _addNavTrailVisitAdmin(root); - root.addChild("Import Visit Map"); - } - } - - @RequiresPermission(AdminPermission.class) - public class CreateStudyAction extends FormViewAction - { - @Override - public ModelAndView getView(StudyPropertiesForm form, boolean reshow, BindException errors) throws Exception - { - if (null != getStudy()) - { - BeginAction action = (BeginAction)initAction(this, new BeginAction()); - return action.getView(form, errors); - } - // Set default values for the form - if (form.getLabel() == null) - { - form.setLabel(HttpView.currentContext().getContainer().getName() + " Study"); - } - if (form.getStartDate() == null) - { - form.setStartDate(new Date()); - } - if (form.getDefaultTimepointDuration() == 0) - { - form.setDefaultTimepointDuration(1); - } - // NOTE: should be a better way to do this (e.g. get the correct value in the form/backend to begin with) - Study sharedStudy = getStudy(getContainer().getProject()); - if (sharedStudy != null && sharedStudy.getShareVisitDefinitions() == Boolean.TRUE) - { - form.setShareVisits(sharedStudy.getShareVisitDefinitions()); - form.setTimepointType(sharedStudy.getTimepointType()); - form.setStartDate(sharedStudy.getStartDate()); - form.setDefaultTimepointDuration(sharedStudy.getDefaultTimepointDuration()); - } - return new StudyJspView<>(null, "/org/labkey/study/view/createStudy.jsp", form, errors); - } - - @Override - public void validateCommand(StudyPropertiesForm target, Errors errors) - { - if (target.getTimepointType() == TimepointType.DATE && null == target.getStartDate()) - errors.reject(ERROR_MSG, "Start date must be supplied for a date-based study."); - - target.setLabel(StringUtils.trimToNull(target.getLabel())); - if (null == target.getLabel()) - errors.reject(ERROR_MSG, "Please supply a label"); - - String message; - - if (null != (message = StudyService.get().getSubjectColumnNameValidationErrorMessage(getContainer(), target.getSubjectColumnName()))) - errors.reject(ERROR_MSG, message); - - if (null != (message = StudyService.get().getSubjectNounSingularValidationErrorMessage(getContainer(), target.getSubjectNounSingular()))) - errors.reject(ERROR_MSG, message); - - if (null != (message = StudyService.get().getSubjectNounPluralValidationErrorMessage(getContainer(), target.getSubjectNounPlural()))) - errors.reject(ERROR_MSG, message); - } - - @Override - public boolean handlePost(StudyPropertiesForm form, BindException errors) - { - createStudy(getStudy(), getContainer(), getUser(), form); - return true; - } - - @Override - public ActionURL getSuccessURL(StudyPropertiesForm form) - { - return form.getSuccessActionURL(new ActionURL(ManageStudyAction.class, getContainer())); - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild("Create Study"); - } - } - - public static StudyImpl createStudy(@Nullable StudyImpl study, Container c, User user, StudyPropertiesForm form) - { - if (null == study) - { - study = new StudyImpl(c, form.getLabel()); - study.setTimepointType(form.getTimepointType()); - study.setStartDate(form.getStartDate()); - study.setEndDate(form.getEndDate()); - study.setSecurityType(form.getSecurityType()); - study.setSubjectNounSingular(form.getSubjectNounSingular()); - study.setSubjectNounPlural(form.getSubjectNounPlural()); - study.setSubjectColumnName(form.getSubjectColumnName()); - study.setAssayPlan(form.getAssayPlan()); - study.setDescription(form.getDescription()); - study.setDefaultTimepointDuration(Math.max(form.getDefaultTimepointDuration(), 1)); - if (form.getDescriptionRendererType() != null) - study.setDescriptionRendererType(form.getDescriptionRendererType()); - study.setGrant(form.getGrant()); - study.setInvestigator(form.getInvestigator()); - study.setSpecies(form.getSpecies()); - study.setAlternateIdPrefix(form.getAlternateIdPrefix()); - study.setAlternateIdDigits(form.getAlternateIdDigits()); - study.setAllowReqLocRepository(form.isAllowReqLocRepository()); - study.setAllowReqLocClinic(form.isAllowReqLocClinic()); - study.setAllowReqLocSal(form.isAllowReqLocSal()); - study.setAllowReqLocEndpoint(form.isAllowReqLocEndpoint()); - if (c.isProject()) - { - study.setShareDatasetDefinitions(form.isShareDatasets()); - study.setShareVisitDefinitions(form.isShareVisits()); - } - - study = StudyManager.getInstance().createStudy(user, study); - SpecimenMigrationService sms = SpecimenMigrationService.get(); - if (null != sms) - sms.setDefaultRequestabilityRules(c, user); - } - return study; - } - - @RequiresPermission(ManageStudyPermission.class) - public class ManageStudyAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object o, BindException errors) - { - return new StudyJspView<>(getStudy(), "/org/labkey/study/view/manageStudy.jsp", null, errors); - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("manageStudy"); - _addManageStudy(root); - } - } - - @RequiresPermission(DeletePermission.class) - public static class DeleteParticipantAction extends MutatingApiAction - { - @Override - public Object execute(DeleteParticipantForm deleteParticipantForm, BindException errors) throws Exception - { - //Note: In the EHR system, 'Participant' tables are prefixed with "Animal". For example, the equivalent of the - //Participant table is named Animal, and ParticipantGroupMap is AnimalGroupMap, etc. - //Additionally, the participantId column is labeled as "Id" in the Animal table and other "Animal" tables. - - DbSchema schema = StudySchema.getInstance().getSchema(); - - Study study = StudyManager.getInstance().getStudy(getContainer()); - if (study == null) - { - errors.reject(ERROR_MSG, "Study not found in this folder."); - return new ApiSimpleResponse("success", false); - } - String participantId = deleteParticipantForm.getParticipantId(); - String participantIdColumnName = study.getSubjectColumnName(); - String participantTableNamePrefix = study.getSubjectNounSingular(); - - try (DbScope.Transaction transaction = schema.getScope().ensureTransaction()) - { - _log.info("Starting participant deletion for ID: " + participantId); - List datasets = study.getDatasets(); - - //delete participant rows from datasets - for (Dataset dataset : datasets) - { - if (dataset.isDemographicData()) - deleteParticipantFromDemographics(dataset.getTableInfo(getUser()), participantIdColumnName, participantId, errors); - else - deleteParticipantFromDatasets(dataset.getTableInfo(getUser()), participantIdColumnName, participantId, errors); - } - - //delete from study.participantGroupMap - TableInfo participantGroupMapTable = QueryService.get().getUserSchema(getUser(), getContainer(), "study").getTable(participantTableNamePrefix + "GroupMap"); - if (null != participantGroupMapTable) - { - TableSelector ts = new TableSelector(participantGroupMapTable, Set.of(participantIdColumnName, "GroupId"), new SimpleFilter(FieldKey.fromString(participantIdColumnName), participantId), null); - ParticipantGroupManager.ParticipantGroupMap[] pgm = ts.getArray(ParticipantGroupManager.ParticipantGroupMap.class); - deleteFromParticipantGroupMapTable(participantGroupMapTable, participantId, participantIdColumnName, pgm, errors); - } - transaction.commit(); - } - ApiSimpleResponse response = new ApiSimpleResponse(); - response.put("success", !errors.hasErrors()); - if (errors.hasErrors()) - { - _log.error("Failed to delete participant: {}", participantId); - response.put("message", errors.getMessage()); - } - else - { - _log.info("Successfully deleted participant: {}", participantId); - response.put("message", "Successfully deleted participant " + participantId); - } - return response; - } - - private void deleteParticipantFromDemographics(TableInfo ti, String participantIdColumnName, String participantId, BindException errors) - { - ColumnInfo idCol = ti.getColumn(FieldKey.fromParts(participantIdColumnName)); - deleteParticipantRows(ti, Collections.singletonList(Collections.singletonMap(idCol.getName(), participantId)), errors); - } - - private void deleteParticipantFromDatasets(TableInfo ti, String participantIdColumnName, String participantId, BindException errors) - { - TableSelector ts = new TableSelector(ti, Collections.singleton(DatasetDomainKind.LSID), new SimpleFilter(FieldKey.fromString(participantIdColumnName), participantId), null); - deleteParticipantRows(ti, ts.getMapCollection().stream().toList(), errors); - } - - private void deleteParticipantRows(TableInfo ti, List> keys, BindException errors) - { - try - { - ti.getUpdateService().deleteRows(getUser(), getContainer(), keys, null, null); - } - catch (InvalidKeyException | BatchValidationException | QueryUpdateServiceException | SQLException e) - { - String msg = "Failed to delete participant rows from " + ti.getName(); - _log.error(msg, e); - errors.reject(ERROR_MSG, msg + ": " + e.getMessage()); - } - } - - private void deleteFromParticipantGroupMapTable(TableInfo ti, String participantId, String participantColName, ParticipantGroupManager.ParticipantGroupMap[] groups, BindException errors) - { - try - { - SQLFragment sql = new SQLFragment("DELETE FROM study.participantgroupmap WHERE participantid = ?", participantId); - new SqlExecutor(ti.getSchema()).execute(sql); - } - catch (Exception e) - { - String msg = "Failed to delete row from " + ti.getSchema().getName() + "." + ti.getName() + " for " + participantColName + " '" + participantId + "'"; - _log.error(msg, e); - errors.reject(ERROR_MSG, msg + " :" + e.getMessage()); - } - - for (ParticipantGroupManager.ParticipantGroupMap group : groups) - { - ParticipantGroupAuditProvider.ParticipantGroupAuditEvent event = ParticipantGroupAuditProvider.EventFactory.participantDeleted(participantId, getContainer(), group.getLabel(), group.getGroupId()); - AuditLogService.get().addEvent(getUser(), event); - } - } - } - - public static class DeleteParticipantForm - { - private String _participantId; - - public String getParticipantId() - { - return _participantId; - } - - public void setParticipantId(String participantId) - { - this._participantId = participantId; - } - } - - @RequiresPermission(AdminPermission.class) - public class DeleteStudyAction extends FormViewAction - { - @Override - public void validateCommand(DeleteStudyForm form, Errors errors) - { - if (!form.isConfirm()) - errors.reject("deleteStudy", "Need to confirm Study deletion"); - } - - @Override - public ModelAndView getView(DeleteStudyForm form, boolean reshow, BindException errors) - { - return new StudyJspView<>(getStudyRedirectIfNull(), "/org/labkey/study/view/confirmDeleteStudy.jsp", null, errors); - } - - @Override - public boolean handlePost(DeleteStudyForm form, BindException errors) - { - StudyManager.getInstance().deleteAllStudyData(getContainer(), getUser()); - return true; - } - - @Override - public ActionURL getSuccessURL(DeleteStudyForm deleteStudyForm) - { - return getContainer().getFolderType().getStartURL(getContainer(), getUser()); - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild("Confirm Delete Study"); - } - } - - public static class DeleteStudyForm - { - private boolean confirm; - - public boolean isConfirm() - { - return confirm; - } - - public void setConfirm(boolean confirm) - { - this.confirm = confirm; - } - } - - public static class RemoveProtocolDocumentForm - { - private String _name; - - public String getName() - { - return _name; - } - - public void setName(String name) - { - _name = name; - } - } - - @RequiresPermission(AdminPermission.class) - public class RemoveProtocolDocumentAction extends FormHandlerAction - { - @Override - public void validateCommand(RemoveProtocolDocumentForm target, Errors errors) - { - } - - @Override - public boolean handlePost(RemoveProtocolDocumentForm removeProtocolDocumentForm, BindException errors) throws Exception - { - Study study = getStudyThrowIfNull(); - study.removeProtocolDocument(removeProtocolDocumentForm.getName(), getUser()); - return true; - } - - @Override - public URLHelper getSuccessURL(RemoveProtocolDocumentForm removeProtocolDocumentForm) - { - return new ActionURL(ManageStudyPropertiesAction.class, getContainer()); - } - } - - @RequiresPermission(ReadPermission.class) - public class ManageStudyPropertiesAction extends FormApiAction - { - @Override - protected @NotNull TableViewForm getCommand(HttpServletRequest request) - { - User user = getUser(); - UserSchema schema = QueryService.get().getUserSchema(user, getContainer(), SchemaKey.fromParts(StudyQuerySchema.SCHEMA_NAME)); - TableViewForm form = new TableViewForm(schema.getTable("StudyProperties")); - form.setViewContext(getViewContext()); - return form; - } - - @Override - public ModelAndView getView(TableViewForm form, BindException errors) - { - Study study = getStudy(); - if (null == study) - throw new RedirectException(new ActionURL(CreateStudyAction.class, getContainer())); - return new StudyJspView<>(getStudy(), "/org/labkey/study/view/manageStudyPropertiesExt.jsp", study, null); - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("manageStudy"); - _addManageStudy(root); - root.addChild("Study Properties"); - } - - @Override - public void validateForm(TableViewForm form, Errors errors) - { - // Skip validation if Spring binding already has an error for subject noun singular - if (errors.getFieldError("SubjectNounSingular") == null) - { - // Issue 47444 and Issue 47881: Validate that subject noun singular doesn't match the name of an existing - // study table or dataset - String subjectNounSingular = form.get("SubjectNounSingular"); - if (null != subjectNounSingular) - { - String message = StudyService.get().getSubjectNounSingularValidationErrorMessage(getContainer(), subjectNounSingular); - if (message != null) - errors.reject(ERROR_MSG, message); - } - } - - // Skip validation if Spring binding already has an error for subject noun plural - if (errors.getFieldError("SubjectNounPlural") == null) - { - String subjectNounPlural = form.get("SubjectNounPlural"); - if (null != subjectNounPlural) - { - String message = StudyService.get().getSubjectNounPluralValidationErrorMessage(getContainer(), subjectNounPlural); - if (message != null) - errors.reject(ERROR_MSG, message); - } - } - - // Skip validation if Spring binding already has an error for subject column name - if (errors.getFieldError("SubjectColumnName") == null) - { - // Issue 43898: Validate that the subject column name is not a user-defined field in one of the datasets - String subjectColName = form.get("SubjectColumnName"); - if (null != subjectColName) - { - String message = StudyService.get().getSubjectColumnNameValidationErrorMessage(getContainer(), subjectColName); - if (message != null) - errors.reject(ERROR_MSG, message); - } - } - } - - @Override - public ApiResponse execute(TableViewForm form, BindException errors) throws Exception - { - if (!getContainer().hasPermission(getUser(),AdminPermission.class)) - throw new UnauthorizedException(); - - Map values = form.getTypedValues(); - values.put("container", getContainer().getId()); - - TableInfo studyProperties = form.getTable(); - QueryUpdateService qus = studyProperties.getUpdateService(); - if (null == qus) - throw new UnauthorizedException(); - try (DbScope.Transaction transaction = StudySchema.getInstance().getSchema().getScope().ensureTransaction()) - { - BatchValidationException batchErrors = new BatchValidationException(); - qus.updateRows(getUser(), getContainer(), Collections.singletonList(values), Collections.singletonList(values), batchErrors, null, null); - if (batchErrors.hasErrors()) - throw batchErrors; - List files = getAttachmentFileList(); - getStudyThrowIfNull().attachProtocolDocument(files, getUser()); - transaction.commit(); - } - catch (BatchValidationException x) - { - x.addToErrors(errors); - return null; - } - catch (AttachmentService.DuplicateFilenameException x) - { - JSONObject json = new JSONObject(); - json.put("failure", true); - json.put("msg", x.getMessage()); - return new ApiSimpleResponse(json); - } - - JSONObject json = new JSONObject(); - json.put("success", true); - return new ApiSimpleResponse(json); - } - } - - - @RequiresPermission(AdminPermission.class) - public class ManageVisitsAction extends FormViewAction - { - @Override - public void validateCommand(StudyPropertiesForm target, Errors errors) - { - StudyImpl study = getStudy(); - if (study.getTimepointType() == TimepointType.DATE) - { - if (target.getTimepointType() == TimepointType.DATE && null == target.getStartDate()) - errors.reject(ERROR_MSG, "Start date must be supplied for a date-based study."); - if (target.getDefaultTimepointDuration() < 1) - errors.reject(ERROR_MSG, "Default timepoint duration must be a positive number."); - } - } - - @Override - public ModelAndView getView(StudyPropertiesForm form, boolean reshow, BindException errors) throws Exception - { - StudyImpl study = getStudy(); - if (null == study) - { - CreateStudyAction action = (CreateStudyAction)initAction(this, new CreateStudyAction()); - return action.getView(form, false, errors); - } - - if (study.getTimepointType() == TimepointType.CONTINUOUS) - return HtmlView.err("Unsupported operation for continuous study"); - - redirectToSharedVisitStudy(study, getViewContext().getActionURL()); - - return new StudyJspView<>(study, _jspName(study), form, errors); - } - - @Override - public boolean handlePost(StudyPropertiesForm form, BindException errors) - { - StudyImpl study = getStudyThrowIfNull().createMutable(); - redirectToSharedVisitStudy(study, getViewContext().getActionURL()); - - if (study.getTimepointType() == TimepointType.DATE) - { - study.setStartDate(form.getStartDate()); - study.setDefaultTimepointDuration(form.getDefaultTimepointDuration()); - } - study.setFailForUndefinedTimepoints(form.isFailForUndefinedTimepoints()); - - StudyManager.getInstance().updateStudy(getUser(), study); - - return true; - } - - @Override - public ActionURL getSuccessURL(StudyPropertiesForm studyPropertiesForm) - { - return new ActionURL(ManageStudyAction.class, getContainer()); - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("manageVisits"); - _addNavTrailVisitAdmin(root); - } - - private String _jspName(Study study) - { - assert study.getTimepointType() != TimepointType.CONTINUOUS; - return study.getTimepointType() == TimepointType.DATE ? "/org/labkey/study/view/manageTimepoints.jsp" : "/org/labkey/study/view/manageVisits.jsp"; - } - } - - @RequiresPermission(AdminPermission.class) - public class ManageTypesAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object o, BindException errors) - { - return new StudyJspView<>(getStudyRedirectIfNull(), "/org/labkey/study/view/manageTypes.jsp", this, errors); - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("manageDatasets"); - _addManageStudy(root); - root.addChild("Manage Datasets"); - } - } - - @RequiresPermission(AdminPermission.class) - public class ManageFilewatchersAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object o, BindException errors) - { - return new StudyJspView<>(getStudyRedirectIfNull(), "/org/labkey/study/view/manageFilewatchers.jsp", this, errors); - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("fileWatcher"); - _addManageStudy(root); - root.addChild("Manage File Watchers"); - } - } - - @RequiresPermission(AdminPermission.class) - public class ManageLocationsAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object o, BindException errors) throws Exception - { - UserSchema schema = QueryService.get().getUserSchema(getUser(), getContainer(), StudyQuerySchema.SCHEMA_NAME); - QuerySettings settings = schema.getSettings(getViewContext(), QueryView.DATAREGIONNAME_DEFAULT, StudyQuerySchema.LOCATION_TABLE_NAME); - - return schema.createView(getViewContext(), settings, errors); - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("manageLocations"); - _addManageStudy(root); - root.addChild("Manage Locations"); - } - } - - @RequiresPermission(AdminPermission.class) - public static class DeleteAllUnusedLocationsAction extends ConfirmAction - { - @Override - public ModelAndView getConfirmView(LocationForm form, BindException errors) - { - List temp = new ArrayList<>(); - for (Container c : getContainers(form)) - { - if (c.hasPermission(getUser(), AdminPermission.class)) - { - LocationManager mgr = LocationManager.get(); - for (LocationImpl loc : mgr.getLocations(c)) - { - if (!mgr.isLocationInUse(loc)) - { - temp.add(c.getName() + "/" + loc.getLabel()); - } - } - } - } - String[] labels = new String[temp.size()]; - for(int i = 0; i("/org/labkey/study/view/confirmDeleteLocation.jsp", form, errors); - } - - @Override - public boolean handlePost(LocationForm form, BindException errors) throws Exception - { - for (Container c : getContainers(form)) - { - if (c.hasPermission(getUser(), AdminPermission.class)) - { - LocationManager mgr = LocationManager.get(); - for (LocationImpl loc : mgr.getLocations(c)) - { - if (!mgr.isLocationInUse(loc)) - { - mgr.deleteLocation(loc); - } - } - } - } - return true; - } - - @Override - public void validateCommand(LocationForm locationEditForm, Errors errors) - { - } - - @NotNull - @Override - public URLHelper getSuccessURL(LocationForm form) - { - return form.getReturnUrlHelper(); - } - - private Collection getContainers(LocationForm form) - { - String containerFilterName = form.getContainerFilter(); - - if (null != containerFilterName) - return LocationTable.getStudyContainers(getContainer(), ContainerFilter.getContainerFilterByName(form.getContainerFilter(), getContainer(), getUser())); - else - return Collections.singleton(getContainer()); - } - } - - public static class LocationForm extends ViewForm - { - private int[] _ids; - private String[] _labels; - private String _containerFilter; - public String[] getLabels() - { - return _labels; - } - - public void setLabels(String[] labels) - { - _labels = labels; - } - - public int[] getIds() - { - return _ids; - } - - public void setIds(int[] ids) - { - _ids = ids; - } - - public String getContainerFilter() - { - return _containerFilter; - } - - public void setContainerFilter(String containerFilter) - { - _containerFilter = containerFilter; - } - } - - - @RequiresPermission(AdminPermission.class) - public class VisitSummaryAction extends FormViewAction - { - private VisitImpl _v; - - @Override - public void validateCommand(VisitForm target, Errors errors) - { - StudyImpl study = getStudyRedirectIfNull(); - if (study.getTimepointType() == TimepointType.CONTINUOUS) - errors.reject(null, "Unsupported operation for continuous date study"); - - Study sharedStudy = StudyManager.getInstance().getSharedStudy(study); - if (sharedStudy != null && sharedStudy.getShareVisitDefinitions()) - errors.reject(null, "Can't edit visits in a study with shared visits"); - - target.validate(errors, study); - if (errors.getErrorCount() > 0) - return; - - VisitImpl visitBean = target.getBean(); - - //check for overlapping visits that the target num is within the range - VisitManager visitMgr = StudyManager.getInstance().getVisitManager(study); - if (visitMgr.isVisitOverlapping(visitBean)) - errors.reject(null, "Visit range overlaps with an existing visit in this study. Please enter a different range."); - } - - @Override - public ModelAndView getView(VisitForm form, boolean reshow, BindException errors) - { - StudyImpl study = getStudyRedirectIfNull(); - if (study.getTimepointType() == TimepointType.CONTINUOUS) - return HtmlView.err("Unsupported operation for continuous date study"); - - redirectToSharedVisitStudy(study, getViewContext().getActionURL()); - - int id = NumberUtils.toInt((String)getViewContext().get("id")); - _v = StudyManager.getInstance().getVisitForRowId(study, id); - if (_v == null) - { - return HttpView.redirect(new ActionURL(BeginAction.class, getContainer())); - } - VisitSummaryBean visitSummary = new VisitSummaryBean(); - visitSummary.setVisit(_v); - - return new StudyJspView<>(study, "/org/labkey/study/view/editVisit.jsp", visitSummary, errors); - } - - @Override - public boolean handlePost(VisitForm form, BindException errors) - { - VisitImpl postedVisit = form.getBean(); - if (!getContainer().getId().equals(postedVisit.getContainer().getId())) - throw new UnauthorizedException(); - - StudyImpl study = getStudyThrowIfNull(); - redirectToSharedVisitStudy(study, getViewContext().getActionURL()); - - // UNDONE: how do I get struts to handle this checkbox? - postedVisit.setShowByDefault(null != StringUtils.trimToNull((String)getViewContext().get("showByDefault"))); - - // UNDONE: reshow is broken for this form, but we have to validate - Collection visits = StudyManager.getInstance().getVisitManager(study).getVisits(); - boolean validRange = true; - // make sure there is no overlapping visit - for (VisitImpl v : visits) - { - if (v.getRowId() == postedVisit.getRowId()) - continue; - BigDecimal maxL = v.getSequenceNumMin().max(postedVisit.getSequenceNumMin()); - BigDecimal minR = v.getSequenceNumMax().min(postedVisit.getSequenceNumMax()); - if (maxL.compareTo(minR) <= 0) - { - errors.reject("visitSummary", getVisitLabel() + " range overlaps with '" + v.getDisplayString() + "'"); - validRange = false; - } - } - - if (!validRange) - { - return false; - } - - StudyManager.getInstance().updateVisit(getUser(), postedVisit); - - HashMap visitTypeMap = new IntHashMap<>(); - for (VisitDataset vds : postedVisit.getVisitDatasets()) - visitTypeMap.put(vds.getDatasetId(), vds.isRequired() ? VisitDatasetType.REQUIRED : VisitDatasetType.OPTIONAL); - - if (form.getDatasetIds() != null) - { - for (int i = 0; i < form.getDatasetIds().length; i++) - { - int datasetId = form.getDatasetIds()[i]; - VisitDatasetType type = VisitDatasetType.valueOf(form.getDatasetStatus()[i]); - VisitDatasetType oldType = visitTypeMap.get(datasetId); - if (oldType == null) - oldType = VisitDatasetType.NOT_ASSOCIATED; - if (type != oldType) - { - StudyManager.getInstance().updateVisitDatasetMapping(getUser(), getContainer(), - postedVisit.getRowId(), datasetId, type); - } - } - } - return true; - } - - @Override - public ActionURL getSuccessURL(VisitForm form) - { - return new ActionURL(ManageVisitsAction.class, getContainer()); - } - - @Override - public void addNavTrail(NavTree root) - { - _addNavTrailVisitAdmin(root); - root.addChild(_v.getDisplayString()); - } - } - - public static class VisitSummaryBean - { - private VisitImpl visit; - - public VisitImpl getVisit() - { - return visit; - } - - public void setVisit(VisitImpl visit) - { - this.visit = visit; - } - } - - @RequiresPermission(ManageStudyPermission.class) - public class StudyScheduleAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object o, BindException errors) throws Exception - { - return StudyModule.studyScheduleWebPartFactory.getWebPartView(getViewContext(), StudyModule.studyScheduleWebPartFactory.createWebPart()); - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("studySchedule"); - _addManageStudy(root); - root.addChild("Study Schedule"); - } - } - - @RequiresPermission(AdminPermission.class) - public class DeleteVisitAction extends FormHandlerAction - { - @Override - public void validateCommand(IdForm target, Errors errors) - { - StudyImpl study = getStudyThrowIfNull(); - if (study.getTimepointType() == TimepointType.CONTINUOUS) - errors.reject(null, "Unsupported operation for continuous date study"); - - - Study sharedStudy = StudyManager.getInstance().getSharedStudy(study); - if (sharedStudy != null && sharedStudy.getShareVisitDefinitions()) - errors.reject(null, "Can't edit visits in a study with shared visits"); - } - - @Override - public boolean handlePost(IdForm form, BindException errors) - { - int visitId = form.getId(); - StudyImpl study = getStudyThrowIfNull(); - - redirectToSharedVisitStudy(study, getViewContext().getActionURL()); - - VisitImpl visit = StudyManager.getInstance().getVisitForRowId(study, visitId); - if (visit != null) - { - StudyManager.getInstance().deleteVisit(study, visit, getUser()); - return true; - } - throw new NotFoundException(); - } - - @Override - public ActionURL getSuccessURL(IdForm idForm) - { - return new ActionURL(ManageVisitsAction.class, getContainer()); - } - } - - - @RequiresPermission(AdminPermission.class) - public class DeleteUnusedVisitsAction extends ConfirmAction - { - @Override - public void validateCommand(IdForm target, Errors errors) - { - StudyImpl study = getStudyThrowIfNull(); - if (study.getTimepointType() == TimepointType.CONTINUOUS) - errors.reject(null, "Unsupported operation for continuous date study"); - - Study sharedStudy = StudyManager.getInstance().getSharedStudy(study); - if (sharedStudy != null && sharedStudy.getShareVisitDefinitions()) - errors.reject(null, "Can't delete visits from a study with shared visits"); - } - - @Override - public ModelAndView getConfirmView(IdForm idForm, BindException errors) - { - if (getPageConfig().getTitle() == null) - setTitle("Delete Unused Visits"); - - StudyImpl study = getStudyThrowIfNull(); - - redirectToSharedVisitStudy(study, getViewContext().getActionURL()); - - Collection visits = getUnusedVisits(); - HtmlStringBuilder sb = HtmlStringBuilder.of(); - - if (visits.isEmpty()) - { - sb.unsafeAppend("No unused visits found.
"); - } - else - { - // Put them in a table to help with StudyTest verification - sb.unsafeAppend("\n"); - sb.unsafeAppend("\n\n"); - - for (VisitImpl visit : visits) - { - sb.unsafeAppend("\n"); - } - - sb.unsafeAppend("
Are you sure you want to delete the unused visits listed below?
 
") - .append(visit.getLabel()) - .append(" (") - .append(visit.getSequenceString()) - .append(")") - .unsafeAppend("
\n"); - } - - return new HtmlView(sb); - } - - @Override - public boolean handlePost(IdForm form, BindException errors) - { - long start = System.currentTimeMillis(); - StudyImpl study = getStudyThrowIfNull(); - - StudyManager.getInstance().deleteVisits(study, getUnusedVisits(), getUser(), true); - - _log.info("Delete unused visits took: " + DateUtil.formatDuration(System.currentTimeMillis() - start)); - - return true; - } - - private @NotNull Collection getUnusedVisits() - { - return new SqlSelector(StudySchema.getInstance().getSchema(), new SQLFragment( - "SELECT * FROM study.Visit v WHERE Container = ? AND rowid NOT IN (SELECT DISTINCT VisitRowId FROM study.ParticipantVisit pv WHERE pv.Container = ?)", - getContainer(), getContainer() - )).getArrayList(VisitImpl.class); - } - - @Override - @NotNull - public ActionURL getSuccessURL(IdForm idForm) - { - return new ActionURL(ManageVisitsAction.class, getContainer()); - } - } - - @RequiresPermission(AdminPermission.class) - public class BulkDeleteVisitsAction extends FormViewAction - { - private TimepointType _timepointType; - private List _visitsToDelete; - - @Override - public ModelAndView getView(DeleteVisitsForm form, boolean reshow, BindException errors) - { - StudyImpl study = getStudyRedirectIfNull(); - _timepointType = study.getTimepointType(); - - Study sharedStudy = StudyManager.getInstance().getSharedStudy(study); - if (sharedStudy != null && sharedStudy.getShareVisitDefinitions()) - return HtmlView.err("Can't delete visits from a study with shared visits."); - - if (_timepointType == TimepointType.CONTINUOUS) - return HtmlView.err("Unsupported operation for continuous study."); - - return new StudyJspView<>(getStudyRedirectIfNull(), "/org/labkey/study/view/bulkVisitDelete.jsp", form, errors); - } - - @Override - public void addNavTrail(NavTree root) - { - _addNavTrailVisitAdmin(root); - root.addChild("Delete " + (_timepointType == TimepointType.DATE ? "Timepoints" : "Visits")); - } - - @Override - public void validateCommand(DeleteVisitsForm form, Errors errors) - { - StudyImpl study = getStudyThrowIfNull(); - - Study sharedStudy = StudyManager.getInstance().getSharedStudy(study); - if (sharedStudy != null && sharedStudy.getShareVisitDefinitions()) - { - errors.reject(null, "Can't delete visits from a study with shared visits."); - return; - } - - int[] visitIds = form.getVisitIds(); - if (visitIds == null || visitIds.length == 0) - { - errors.reject(ERROR_MSG, "No " + (_timepointType == TimepointType.DATE ? "timepoints" : "visits") + " selected."); - return; - } - - _visitsToDelete = new ArrayList<>(); - for (int id : visitIds) - { - VisitImpl visit = StudyManager.getInstance().getVisitForRowId(study, id); - if (visit == null) - errors.reject(ERROR_MSG, "Unable to find visit for id " + id); - else - _visitsToDelete.add(visit); - } - } - - @Override - public boolean handlePost(DeleteVisitsForm form, BindException errors) - { - long start = System.currentTimeMillis(); - StudyImpl study = getStudyThrowIfNull(); - StudyManager.getInstance().deleteVisits(study, _visitsToDelete, getUser(), false); - _log.info("Bulk delete visits took: " + DateUtil.formatDuration(System.currentTimeMillis() - start)); - return true; - } - - @Override - public ActionURL getSuccessURL(DeleteVisitsForm form) - { - return new ActionURL(ManageVisitsAction.class, getContainer()); - } - } - - public static class DeleteVisitsForm extends ReturnUrlForm - { - private int[] _visitIds; - - public int[] getVisitIds() - { - return _visitIds; - } - - public void setVisitIds(int[] visitIds) - { - _visitIds = visitIds; - } - } - - @RequiresPermission(AdminPermission.class) - public class ConfirmDeleteVisitAction extends SimpleViewAction - { - private VisitImpl _visit; - private TimepointType _timepointType; - - @Override - public ModelAndView getView(IdForm form, BindException errors) - { - int visitId = form.getId(); - StudyImpl study = getStudyRedirectIfNull(); - _timepointType = study.getTimepointType(); - - if (_timepointType == TimepointType.CONTINUOUS) - return HtmlView.err("Unsupported operation for continuous study"); - - redirectToSharedVisitStudy(study, getViewContext().getActionURL()); - - _visit = StudyManager.getInstance().getVisitForRowId(study, visitId); - if (null == _visit) - throw new NotFoundException(); - - return new StudyJspView<>(study, "/org/labkey/study/view/confirmDeleteVisit.jsp", _visit, errors); - } - - @Override - public void addNavTrail(NavTree root) - { - String noun = _timepointType == TimepointType.DATE ? "Timepoint" : "Visit"; - root.addChild("Delete " + noun + " -- " + _visit.getDisplayString()); - } - } - - @RequiresPermission(AdminPermission.class) - public class CreateVisitAction extends FormViewAction - { - @Override - public void validateCommand(VisitForm target, Errors errors) - { - StudyImpl study = getStudyThrowIfNull(); - if (study.getTimepointType() == TimepointType.CONTINUOUS) - errors.reject(null, "Unsupported operation for continuous date study"); - - Study sharedStudy = StudyManager.getInstance().getSharedStudy(study); - if (sharedStudy != null && sharedStudy.getShareVisitDefinitions()) - errors.reject(null, "Can't create visits in a study with shared visits"); - - target.validate(errors, study); - if (errors.getErrorCount() > 0) - return; - - //check for overlapping visits - VisitManager visitMgr = StudyManager.getInstance().getVisitManager(study); - if (visitMgr.isVisitOverlapping(target.getBean())) - errors.reject(null, "Visit range overlaps with an existing visit in this study. Please enter a different range."); - } - - @Override - public ModelAndView getView(VisitForm form, boolean reshow, BindException errors) - { - StudyImpl study = getStudyRedirectIfNull(); - - if (study.getTimepointType() == TimepointType.CONTINUOUS) - errors.reject(null, "Unsupported operation for continuous date study"); - - redirectToSharedVisitStudy(study, getViewContext().getActionURL()); - - form.setReshow(reshow); - return new StudyJspView<>(study, "/org/labkey/study/view/createVisit.jsp", form, errors); - } - - @Override - public boolean handlePost(VisitForm form, BindException errors) - { - VisitImpl visit = form.getBean(); - if (visit != null) - StudyManager.getInstance().createVisit(getStudyThrowIfNull(), getUser(), visit); - return true; - } - - @Override - public ActionURL getSuccessURL(VisitForm visitForm) - { - return visitForm.getReturnActionURL(); - } - - @Override - public void addNavTrail(NavTree root) - { - _addNavTrailVisitAdmin(root); - root.addChild("Create New " + getVisitLabel()); - } - } - - /** - * Called from the vaccine design webpart for the study design module - */ - @RequiresPermission(UpdatePermission.class) - public class CreateVisitForVaccineDesign extends MutatingApiAction - { - @Override - public void validateForm(VisitForm form, Errors errors) - { - if (!StudyDesignManager.get().isModuleActive(getContainer())) - { - errors.reject(ERROR_MSG, "This action can only be called if the study design module is active"); - return; - } - - Study study = getStudy(getContainer()); - boolean isDateBased = study.getTimepointType() == TimepointType.DATE; - - form.validate(errors, study); - if (errors.getErrorCount() > 0) - return; - - //check for overlapping visits - VisitManager visitMgr = StudyManager.getInstance().getVisitManager(study); - String range = isDateBased ? "day range" : "sequence range"; - if (visitMgr.isVisitOverlapping(form.getBean())) - errors.reject(null, "The visit " + range + " provided overlaps with an existing visit in this study. Please enter a different " + range + "."); - } - - @Override - public ApiResponse execute(VisitForm form, BindException errors) - { - ApiSimpleResponse response = new ApiSimpleResponse(); - - VisitImpl visit = form.getBean(); - visit = StudyManager.getInstance().createVisit(getStudyThrowIfNull(), getUser(), visit); - - response.put("RowId", visit.getRowId()); - response.put("Label", visit.getDisplayString()); - response.put("SequenceNumMin", visit.getSequenceNumMin()); - response.put("DisplayOrder", visit.getDisplayOrder()); - response.put("Included", true); - response.put("success", true); - - return response; - } - } - - @RequiresPermission(AdminPermission.class) - public class UpdateDatasetVisitMappingAction extends FormViewAction - { - private DatasetDefinition _def; - - @Override - public void validateCommand(DatasetForm form, Errors errors) - { - if (null == form.getDatasetId() || form.getDatasetId() < 1) - { - errors.reject(SpringActionController.ERROR_MSG, "DatasetId must be a positive integer."); - } - else - { - _def = StudyManager.getInstance().getDatasetDefinition(getStudyRedirectIfNull(), form.getDatasetId()); - if (null == _def) - errors.reject(SpringActionController.ERROR_MSG, "Dataset not found."); - } - } - - @Override - public ModelAndView getView(DatasetForm form, boolean reshow, BindException errors) throws Exception - { - validateCommand(form, errors); - - if (errors.hasErrors()) - { - getPageConfig().setTemplate(PageConfig.Template.Dialog); - return new SimpleErrorView(errors); - } - - return new JspView<>("/org/labkey/study/view/updateDatasetVisitMapping.jsp", _def, errors); - } - - @Override - public boolean handlePost(DatasetForm form, BindException errors) - { - DatasetDefinition modified = _def.createMutable(); - if (null != form.getVisitRowIds()) - { - for (int i = 0; i < form.getVisitRowIds().length; i++) - { - int visitRowId = form.getVisitRowIds()[i]; - VisitDatasetType type = VisitDatasetType.valueOf(form.getVisitStatus()[i]); - if (modified.getVisitType(visitRowId) != type) - { - StudyManager.getInstance().updateVisitDatasetMapping(getUser(), getContainer(), - visitRowId, form.getDatasetId(), type); - } - } - } - return true; - } - - @Override - public ActionURL getSuccessURL(DatasetForm datasetForm) - { - return new ActionURL(DatasetDetailsAction.class, getContainer()).addParameter("id", datasetForm.getDatasetId()); - } - - @Override - public void addNavTrail(NavTree root) - { - _addNavTrailDatasetAdmin(root); - if (_def != null) - { - VisitManager visitManager = StudyManager.getInstance().getVisitManager(getStudyThrowIfNull()); - root.addChild("Edit " + _def.getLabel() + " " + visitManager.getPluralLabel()); - } - } - } - - - @RequiresPermission(InsertPermission.class) - public class ImportAction extends AbstractQueryImportAction - { - private ImportDatasetForm _form = null; - private StudyImpl _study = null; - private DatasetDefinition _def = null; - private TableInfo _table = null; - - @Override - protected void initRequest(ImportDatasetForm form) throws ServletException - { - _form = form; - _study = getStudyRedirectIfNull(); - - if ((_study.getParticipantAliasDatasetId() != null) && (_study.getParticipantAliasDatasetId() == form.getDatasetId())) - { - super.setImportMessage("This is the Alias Dataset. You do not need to include information for the date column."); - } - - _def = StudyManager.getInstance().getDatasetDefinition(_study, form.getDatasetId()); - if (null == _def && null != form.getName()) - _def = StudyManager.getInstance().getDatasetDefinitionByName(_study, form.getName()); - if (null == _def) - throw new NotFoundException("Dataset not found"); - if (null == _def.getTypeURI()) - return; - - - User user = getUser(); - // Go through normal getTable() codepath to be sure all metadata is applied - _table = StudyQuerySchema.createSchema(_study, user).getDatasetTable(_def, null); - if (_table == null) - throw new NotFoundException("Dataset not found"); - setTarget(_table); - - if (!_table.hasPermission(user, InsertPermission.class) && getUser().isGuest()) - throw new UnauthorizedException(); - } - - @Override - protected boolean canInsert(User user) - { - return _table.hasPermission(user, InsertPermission.class); - } - - @Override - protected boolean canUpdate(User user) - { - return _table.hasPermission(user, UpdatePermission.class); - } - - @Override - public ModelAndView getView(ImportDatasetForm form, BindException errors) throws Exception - { - initRequest(form); - - // TODO need a shorthand for this check - if (_def.isShared() && _def.getContainer().equals(_def.getDefinitionContainer())) - return new HtmlView("Error", HtmlString.of("Cannot insert dataset data in this folder. Use a sub-study to import data.")); - - if (_def.getTypeURI() == null) - throw new NotFoundException("Dataset is not yet defined."); - - if (null == PipelineService.get().findPipelineRoot(getContainer())) - return new RequirePipelineView(_study, true, errors); - - boolean showImportOptions = OptionalFeatureService.get().isFeatureEnabled(EXPERIMENTAL_ALLOW_MERGE_WITH_MANAGED_KEYS) || _def.getKeyManagementType() == Dataset.KeyManagementType.None; - setShowMergeOption(showImportOptions); - setShowUpdateOption(showImportOptions); - setSuccessMessageSuffix("imported"); //Works for when the merge option is selected (may include updates) vs default "inserted" - return getDefaultImportView(form, errors); - } - - @Override - protected int importData(DataLoader dl, FileStream file, String originalName, BatchValidationException errors, @Nullable AuditBehaviorType auditBehaviorType, @Nullable TransactionAuditProvider.TransactionAuditEvent auditEvent, @Nullable String auditUserComment) - { - if (null == PipelineService.get().findPipelineRoot(getContainer())) - { - errors.addRowError(new ValidationException("Pipeline file system is not setup.")); - return -1; - } - - // Allow for mapping of the ParticipantId and Sequence Num (i.e. timepoint column), - // these are passed in for the "create dataset from a file and import data" case - Map columnMap = new CaseInsensitiveHashMap<>(); - if (null != _form.getParticipantId()) - columnMap.put(_form.getParticipantId(),"ParticipantId"); - if (null != _form.getSequenceNum()) - { - String column = _def.getDomainKind().getKindName().equalsIgnoreCase(DateDatasetDomainKind.KIND_NAME) ? "Date" : "SequenceNum"; - columnMap.put(_form.getSequenceNum(), column); - } - - Pair, UploadLog> result = StudyPublishManager.getInstance().importDatasetTSV(getUser(), _study, _def, dl, getLookupResolutionType(), file, originalName, columnMap, errors, _form.getInsertOption(), auditBehaviorType); - - if (!result.getKey().isEmpty()) - { - // Log the import when SUMMARY is configured, if DETAILED is configured the DetailedAuditLogDataIterator will handle each row change. - // It would be nice in the future to replace the DetailedAuditLogDataIterator with a general purpose AuditLogDataIterator - // that can delegate the audit behavior type to the AuditDataHandler, so this code can go away - // - String comment = "Dataset data imported. " + result.getKey().size() + " rows imported"; - new DatasetDefinition.DatasetAuditHandler(_def).addAuditEvent(getUser(), getContainer(), AuditBehaviorType.SUMMARY, comment, result.getValue()); - } - - return result.getKey().size(); - } - - @Override - public ActionURL getSuccessURL(ImportDatasetForm form) - { - return new ActionURL(DatasetAction.class, getContainer()).addParameter(Dataset.DATASET_KEY, form.getDatasetId()); - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild(_study.getLabel(), new ActionURL(BeginAction.class, getContainer())); - ActionURL datasetURL = new ActionURL(DatasetAction.class, getContainer()). - addParameter(Dataset.DATASET_KEY, _form.getDatasetId()); - root.addChild(_def.getName(), datasetURL); - root.addChild("Import Data"); - } - } - - @RequiresPermission(AdminPermission.class) - public class ImportDatasetSchemaAction extends FormViewAction - { - @Override - public void validateCommand(ImportDatasetSchemaForm target, Errors errors) - { - } - - @Override - public ModelAndView getView(ImportDatasetSchemaForm form, boolean reshow, BindException errors) - { - return new StudyJspView<>(getStudyRedirectIfNull(), "/org/labkey/study/view/importDatasetSchema.jsp", form, errors); - } - - @Override - public boolean handlePost(ImportDatasetSchemaForm form, BindException errors) throws ImportException - { - if (form.getManifest() == null) - errors.reject(null, "Manifest is required."); - - if (form.getMetadata() == null) - errors.reject(null, "Metadata is required."); - - if (errors.hasErrors()) - return false; - - DatasetsDocument.Datasets manifestDatasetsDoc; - - try - { - manifestDatasetsDoc = DatasetsDocument.Factory.parse(form.getManifest(), XmlBeansUtil.getDefaultParseOptions()).getDatasets(); - } - catch (XmlException e) - { - errors.reject(null, "Invalid manifest XML: " + e.getMessage()); - return false; - } - - TablesDocument tablesDoc; - - try - { - tablesDoc = TablesDocument.Factory.parse(form.getMetadata(), XmlBeansUtil.getDefaultParseOptions()); - } - catch (XmlException e) - { - errors.reject(null, "Invalid metadata XML: " + e.getMessage()); - return false; - } - - SchemaReader reader = new SchemaXmlReader(getStudyThrowIfNull(), "metadata XML", tablesDoc, manifestDatasetsDoc); - - ComplianceService complianceService = ComplianceService.get(); - return StudyManager.getInstance().importDatasetSchemas(getStudyThrowIfNull(), getUser(), reader, errors, false, true, complianceService.getCurrentActivity(getViewContext())); - } - - @Override - public ActionURL getSuccessURL(ImportDatasetSchemaForm bulkImportTypesForm) - { - return new ActionURL(ManageTypesAction.class, getContainer()); - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("DatasetBulkDefinition"); - _addNavTrailDatasetAdmin(root); - root.addChild("Import Dataset Schema"); - } - } - - public static class ImportDatasetSchemaForm - { - private String _metadata; - private String _manifest; - - public String getMetadata() - { - return _metadata; - } - - @SuppressWarnings("unused") - public void setMetadata(String metadata) - { - _metadata = metadata; - } - - public String getManifest() - { - return _manifest; - } - - @SuppressWarnings("unused") - public void setManifest(String manifest) - { - _manifest = manifest; - } - } - - @RequiresPermission(UpdatePermission.class) - public class ShowUploadHistoryAction extends SimpleViewAction - { - String _datasetLabel; - - @Override - public ModelAndView getView(IdForm form, BindException errors) - { - TableInfo tInfo = StudySchema.getInstance().getTableInfoUploadLog(); - DataRegion dr = new DataRegion(); - dr.addColumns(tInfo, "RowId,Created,CreatedBy,Status,Description"); - GridView gv = new GridView(dr, errors); - DisplayColumn dc = new SimpleDisplayColumn(null) { - @Override - public void renderGridCellContents(RenderContext ctx, HtmlWriter out) - { - ActionURL url = new ActionURL(DownloadTsvAction.class, ctx.getContainer()).addParameter("id", String.valueOf(ctx.get("RowId"))); - out.write(LinkBuilder.labkeyLink("Download Data File", url)); - } - }; - dr.addDisplayColumn(dc); - - SimpleFilter filter = SimpleFilter.createContainerFilter(getContainer()); - if (form.getId() != 0) - { - filter.addCondition(Dataset.DATASET_KEY, form.getId()); - DatasetDefinition dsd = StudyManager.getInstance().getDatasetDefinition(getStudyRedirectIfNull(), form.getId()); - if (dsd != null) - _datasetLabel = dsd.getLabel(); - } - - gv.setFilter(filter); - return gv; - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild("Upload History" + (null != _datasetLabel ? " for " + _datasetLabel : "")); - } - } - - @RequiresPermission(UpdatePermission.class) - public static class DownloadTsvAction extends SimpleViewAction - { - @Override - public ModelAndView getView(IdForm form, BindException errors) throws Exception - { - UploadLog ul = StudyPublishManager.getInstance().getUploadLog(getContainer(), form.getId()); - PageFlowUtil.streamFile(getViewContext().getResponse(), new File(ul.getFilePath()).toPath(), true); - - return null; - } - - @Override - public void addNavTrail(NavTree root) - { - } - } - - @RequiresPermission(ReadPermission.class) - public static class DatasetItemDetailsAction extends SimpleViewAction - { - @Override - public ModelAndView getView(SourceLsidForm form, BindException errors) - { - ActionURL url = LsidManager.get().getDisplayURL(form.getSourceLsid()); - if (url == null) - { - return HtmlView.of("The assay run that produced the data has been deleted."); - } - return HttpView.redirect(url); - } - - @Override - public void addNavTrail(NavTree root) - { - } - } - - public static class PublishHistoryDetailsForm - { - private @Nullable Integer _protocolId; - private @Nullable Integer _sampleTypeId; - private int _datasetId; - private String _sourceLsid; - private int _recordCount; - - public Integer getProtocolId() - { - return _protocolId; - } - - public void setProtocolId(Integer protocolId) - { - _protocolId = protocolId; - } - - public Integer getSampleTypeId() - { - return _sampleTypeId; - } - - public void setSampleTypeId(Integer sampleTypeId) - { - _sampleTypeId = sampleTypeId; - } - - public int getDatasetId() - { - return _datasetId; - } - - public void setDatasetId(int datasetId) - { - _datasetId = datasetId; - } - - public String getSourceLsid() - { - return _sourceLsid; - } - - public void setSourceLsid(String sourceLsid) - { - _sourceLsid = sourceLsid; - } - - public int getRecordCount() - { - return _recordCount; - } - - public void setRecordCount(int recordCount) - { - _recordCount = recordCount; - } - } - - @RequiresPermission(ReadPermission.class) - public class PublishHistoryDetailsAction extends SimpleViewAction - { - @Override - public ModelAndView getView(PublishHistoryDetailsForm form, BindException errors) - { - final StudyImpl study = getStudyRedirectIfNull(); - - VBox view = new VBox(); - - int datasetId = form.getDatasetId(); - final DatasetDefinition def = StudyManager.getInstance().getDatasetDefinition(study, datasetId); - - if (def != null) - { - final StudyQuerySchema querySchema = StudyQuerySchema.createSchema(study, getUser()); - DatasetQuerySettings qs = (DatasetQuerySettings)querySchema.getSettings(getViewContext(), DatasetQueryView.DATAREGION, def.getName()); - - if (!def.canRead(getUser())) - { - //requiresLogin(); - view.addView(new HtmlView(HtmlString.of("User does not have read permission on this dataset."))); - } - else - { - Integer protocolId = form.getProtocolId(); - Integer sampleTypeId = form.getSampleTypeId(); - - if (protocolId == null && sampleTypeId == null) - throw new IllegalArgumentException("Expected either a protocolId or sampleId parameter"); - - String sourceLsid = form.getSourceLsid(); // the assay protocol or sample type LSID - int recordCount = form.getRecordCount(); - - ActionURL deleteURL = new ActionURL(DeletePublishedRowsAction.class, getContainer()); - deleteURL.addParameter("publishSourceId", protocolId != null ? protocolId : sampleTypeId); - deleteURL.addParameter("sourceLsid", sourceLsid); - final ActionButton deleteRows = new ActionButton(deleteURL, "Recall Rows"); - - deleteRows.setRequiresSelection(true, "Recall selected row of this dataset?", "Recall selected rows of this dataset?"); - deleteRows.setActionType(ActionButton.Action.POST); - deleteRows.setDisplayPermission(DeletePermission.class); - - PublishedRecordQueryView qv = new PublishedRecordQueryView(querySchema, qs, sourceLsid, def.getPublishSource(), - protocolId != null ? protocolId : sampleTypeId, recordCount) { - - @Override - protected void populateButtonBar(DataView view, ButtonBar bar) - { - bar.add(deleteRows); - } - }; - - view.addView(qv); - } - } - else - view.addView(new HtmlView(HtmlString.of("The Dataset does not exist."))); - return view; - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild("Link to Study History Details"); - } - } - - @RequiresPermission(DeletePermission.class) - public class DeletePublishedRowsAction extends FormHandlerAction - { - private DatasetDefinition _def; - private Collection _allLsids; - private MultiValuedMap> _sourceLsidToLsidPair; - private Long _sourceRowId = null; - - @Override - public void validateCommand(DeleteDatasetRowsForm target, Errors errors) - { - _def = StudyManager.getInstance().getDatasetDefinition(getStudyThrowIfNull(), target.getDatasetId()); - if (_def == null) - throw new IllegalArgumentException("Could not find a dataset definition for id: " + target.getDatasetId()); - if (!target.isDeleteAllData()) - { - _allLsids = DataRegionSelection.getSelected(getViewContext(), true); - - if (_allLsids.isEmpty()) - { - errors.reject("deletePublishedRows", "No rows were selected"); - } - } - else - { - _allLsids = StudyManager.getInstance().getDatasetLSIDs(getUser(), _def); - } - - // Need to handle this by groups of source lsids -- each assay or SampleType container needs logging - _sourceLsidToLsidPair = new ArrayListValuedHashMap<>(); - List rowIds = new ArrayList<>(); - List> data = _def.getDatasetRows(getUser(), _allLsids); - - for (Map row : data) - { - String sourceLSID = (String)row.get(StudyPublishService.SOURCE_LSID_PROPERTY_NAME); - String datasetRowLsid = (String)row.get(StudyPublishService.LSID_PROPERTY_NAME); - Long rowId = MapUtils.getLong(row,StudyPublishService.ROWID_PROPERTY_NAME); - rowIds.add(rowId); - if (sourceLSID != null && datasetRowLsid != null) - _sourceLsidToLsidPair.put(sourceLSID, Pair.of(datasetRowLsid, rowId)); - - if (_sourceRowId == null && rowId != null) - _sourceRowId = rowId; - } - - String errorMsg = StudyPublishService.get().checkForLockedLinks(_def, rowIds); - if (!StringUtils.isEmpty(errorMsg)) - errors.reject(ERROR_MSG, errorMsg); - } - - @Override - public boolean handlePost(DeleteDatasetRowsForm form, BindException errors) - { - String originalSourceLsid = (String)getViewContext().get("sourceLsid"); - - Dataset.PublishSource publishSource = _def.getPublishSource(); - if (form.getPublishSourceId() != null && publishSource != null) - { - for (Map.Entry>> entry : _sourceLsidToLsidPair.asMap().entrySet()) - { - String sourceLsid = entry.getKey(); - Collection> pairs = entry.getValue(); - Container sourceContainer = publishSource.resolveSourceLsidContainer(sourceLsid, _sourceRowId); - if (sourceContainer != null) - StudyPublishService.get().addRecallAuditEvent(sourceContainer, getUser(), _def, pairs.size(), pairs); - } - } - - _def.deleteDatasetRows(getUser(), _allLsids); - - // if the recall was initiated from link to study details view of the publish source, redirect back to the same view - if (publishSource != null && originalSourceLsid != null && form.getPublishSourceId() != null) - { - Container container = publishSource.resolveSourceLsidContainer(originalSourceLsid, _sourceRowId); - if (container != null) - throw new RedirectException(StudyPublishService.get().getPublishHistory(container, publishSource, form.getPublishSourceId())); - } - return true; - } - - @Override - public ActionURL getSuccessURL(DeleteDatasetRowsForm form) - { - return new ActionURL(DatasetAction.class, getContainer()). - addParameter(Dataset.DATASET_KEY, form.getDatasetId()); - } - } - - public static class DeleteDatasetRowsForm - { - private int datasetId; - private boolean deleteAllData; - private Long _publishSourceId; - - public int getDatasetId() - { - return datasetId; - } - - public void setDatasetId(int datasetId) - { - this.datasetId = datasetId; - } - - public boolean isDeleteAllData() - { - return deleteAllData; - } - - public void setDeleteAllData(boolean deleteAllData) - { - this.deleteAllData = deleteAllData; - } - - public Long getPublishSourceId() - { - return _publishSourceId; - } - - public void setPublishSourceId(Long publishSourceId) - { - _publishSourceId = publishSourceId; - } - } - - // Dataset.canDelete() permissions check is below. This accommodates dataset security, where user might not have delete permission in the folder. - @RequiresPermission(ReadPermission.class) - public class DeleteDatasetRowsAction extends FormHandlerAction - { - @Override - public void validateCommand(DeleteDatasetRowsForm target, Errors errors) - { - } - - @Override - public boolean handlePost(DeleteDatasetRowsForm form, BindException errors) throws Exception - { - int datasetId = form.getDatasetId(); - StudyImpl study = getStudyThrowIfNull(); - StudyQuerySchema schema = StudyQuerySchema.createSchema(study, getUser()); - DatasetDefinition dataset = StudyManager.getInstance().getDatasetDefinition(study, datasetId); - TableInfo datasetTable = null==dataset ? null : schema.getDatasetTable(dataset, null); - - if (null == dataset || null == datasetTable) - throw new NotFoundException(); - - if (!datasetTable.hasPermission(getUser(), DeletePermission.class)) - throw new UnauthorizedException("User does not have permission to delete rows from this dataset"); - - // Operate on each individually for audit logging purposes, but transact the whole thing - DbScope scope = StudySchema.getInstance().getSchema().getScope(); - - try (DbScope.Transaction transaction = scope.ensureTransaction()) - { - Set lsids = DataRegionSelection.getSelected(getViewContext(), null, false); - List> keys = new ArrayList<>(lsids.size()); - for (String lsid : lsids) - keys.add(Collections.singletonMap("lsid", lsid)); - - QueryUpdateService qus = datasetTable.getUpdateService(); - assert qus != null; - - qus.deleteRows(getUser(), getContainer(), keys, null, null); - - transaction.commit(); - return true; - } - finally - { - DataRegionSelection.clearAll(getViewContext(), null); - } - } - - @Override - public ActionURL getSuccessURL(DeleteDatasetRowsForm form) - { - return new ActionURL(DatasetAction.class, getContainer()). - addParameter(Dataset.DATASET_KEY, form.getDatasetId()); - } - } - - public static class OverviewBean - { - public StudyImpl study; - public Map visitMapSummary; - public boolean showAll; - public boolean canManage; - public CohortFilter cohortFilter; - public boolean showCohorts; - public QCStateSet qcStates; - public Set stats; - public boolean showSpecimens; - } - - /** - * Tweak the link url for participant view so that it contains enough information to regenerate - * the cached list of participants. - */ - private void setColumnURL(final ActionURL url, final QueryView queryView, - final UserSchema querySchema, final Dataset def) - { - List columns; - try - { - columns = queryView.getDisplayColumns(); - } - catch (QueryParseException qpe) - { - return; - } - - // push any filter, sort params, and viewname - ActionURL base = new ActionURL(ParticipantAction.class, querySchema.getContainer()); - base.addParameter(Dataset.DATASET_KEY, Integer.toString(def.getDatasetId())); - for (Pair param : url.getParameters()) - { - if ((param.getKey().contains(".sort")) || - (param.getKey().contains("~")) || - (DATASET_VIEW_NAME_PARAMETER_NAME.equals(param.getKey()))) - { - base.addParameter(param.getKey(), param.getValue()); - } - } - base.addReturnUrl(url); // Set current URL so participant page can navigate back (nav trail) - - for (DisplayColumn col : columns) - { - String subjectColName = StudyService.get().getSubjectColumnName(def.getContainer()); - if (subjectColName.equalsIgnoreCase(col.getName())) - { - StringExpression old = col.getURLExpression(); - ContainerContext cc = old instanceof DetailsURL ? ((DetailsURL)old).getContainerContext() : null; - DetailsURL dets = new DetailsURL(base, "participantId", col.getColumnInfo().getFieldKey()); - dets.setContainerContext(null != cc ? cc : getContainer()); - col.setURLExpression(dets); - } - } - } - - public static ActionURL getProtocolDocumentDownloadURL(Container c, String name) - { - ActionURL url = new ActionURL(ProtocolDocumentDownloadAction.class, c); - url.addParameter("name", name); - - return url; - } - - @RequiresPermission(ReadPermission.class) - public class ProtocolDocumentDownloadAction extends BaseDownloadAction - { - @Override - public @Nullable Pair getAttachment(AttachmentForm form) - { - StudyImpl study = getStudyRedirectIfNull(); - return new Pair<>(study.getProtocolDocumentAttachmentParent(), form.getName()); - } - } - - private static final String PARTICIPANT_PROPS_CACHE = "Study_participants/propertyCache"; - private static final String DATASET_SORT_COLUMN_CACHE = "Study_participants/datasetSortColumnCache"; - @SuppressWarnings("unchecked") - private static Map> getParticipantPropsMap(ViewContext context) - { - HttpSession session = context.getRequest().getSession(true); - Map> map = (Map>) session.getAttribute(PARTICIPANT_PROPS_CACHE); - if (map == null) - { - map = new HashMap<>(); - session.setAttribute(PARTICIPANT_PROPS_CACHE, map); - } - return map; - } - - public static List getParticipantPropsFromCache(ViewContext context, String typeURI) - { - Map> map = getParticipantPropsMap(context); - List props = map.get(typeURI); - if (props == null) - { - props = OntologyManager.getPropertiesForType(typeURI, context.getContainer()); - map.put(typeURI, props); - } - return props; - } - - @SuppressWarnings("unchecked") - private static Map> getDatasetSortColumnMap(ViewContext context) - { - HttpSession session = context.getRequest().getSession(true); - Map> map = (Map>) session.getAttribute(DATASET_SORT_COLUMN_CACHE); - if (map == null) - { - map = new HashMap<>(); - session.setAttribute(DATASET_SORT_COLUMN_CACHE, map); - } - return map; - } - - public static @NotNull Map getSortedColumnList(ViewContext context, Dataset dsd) - { - Map> map = getDatasetSortColumnMap(context); - Map sortMap = map.get(dsd.getLabel()); - - if (sortMap == null) - { - QueryDefinition qd = QueryService.get().getQueryDef(context.getUser(), dsd.getContainer(), "study", dsd.getName()); - if (qd == null) - { - UserSchema schema = QueryService.get().getUserSchema(context.getUser(), context.getContainer(), "study"); - qd = schema.getQueryDefForTable(dsd.getName()); - } - CustomView cview = qd.getCustomView(context.getUser(), context.getRequest(), null); - if (cview != null) - { - sortMap = new HashMap<>(); - int i = 0; - for (FieldKey key : cview.getColumns()) - { - final String name = key.toString(); - if (!sortMap.containsKey(name)) - sortMap.put(name, i++); - } - map.put(dsd.getLabel(), sortMap); - } - else - { - // there is no custom view for this dataset - sortMap = Collections.emptyMap(); - map.put(dsd.getLabel(), Collections.emptyMap()); - } - } - return new CaseInsensitiveHashMap<>(sortMap); - } - - private static String getParticipantListCacheKey(ViewContext context) - { - // The query string includes all parameters that affect the participant list: dataset id, filters, sorts, etc. - // But need to strip off the participant ID parameter. - return context.cloneActionURL().deleteParameter("participantId").getQueryString(); - } - - public static void removeParticipantListFromSession(ViewContext context) - { - Cache> cache = getParticipantMapFromSession(context); - String key = getParticipantListCacheKey(context); - // Guava Cache doesn't tolerate null keys - if (key != null) - { - _log.debug("Invalidate participant list with key: {}", key); - cache.invalidate(key); - } - } - - @SuppressWarnings("unchecked") - private static Cache> getParticipantMapFromSession(ViewContext context) - { - HttpSession session = context.getRequest().getSession(true); - Cache> map = (Cache>) session.getAttribute(PARTICIPANT_CACHE_PREFIX); - if (map == null) - { - // Use a cache to limit the size (10) and to keep entries for no more than 10 minutes after last access - map = CacheBuilder.newBuilder().maximumSize(10).expireAfterAccess(10, TimeUnit.MINUTES).build(); - session.setAttribute(PARTICIPANT_CACHE_PREFIX, map); - } - return map; - } - - @SuppressWarnings("unchecked") - public static Map getExpandedState(ViewContext viewContext, int datasetId) - { - HttpSession session = viewContext.getRequest().getSession(true); - Map> map = (Map>) session.getAttribute(EXPAND_CONTAINERS_KEY); - if (map == null) - { - map = new HashMap<>(); - session.setAttribute(EXPAND_CONTAINERS_KEY, map); - } - - return map.computeIfAbsent(datasetId, k -> new HashMap<>()); - } - - public static @NotNull List getParticipantListFromSession(ViewContext context, int dataset, String viewName) - { - Cache> cache = getParticipantMapFromSession(context); - String key = getParticipantListCacheKey(context); - List ret = Collections.emptyList(); - - // Short-circuit for navigation from somewhere other than a dataset... esp. since Guava Cache doesn't tolerate null keys - if (null != key && dataset > 0) - { - try - { - ret = cache.get(key, () -> generateParticipantListFromURL(context, dataset, viewName)); - } - catch (ExecutionException ignored) - { - // Shouldn't ever happen since our loader doesn't throw exceptions - } - _log.debug("Get participant list of size {} with key: {}", ret.size(), key); - } - - return ret; - } - - private static List generateParticipantListFromURL(ViewContext context, int dataset, String viewName) - { - List ret; - try - { - final StudyManager studyMgr = StudyManager.getInstance(); - final StudyImpl study = studyMgr.getStudy(context.getContainer()); - - DatasetDefinition def = studyMgr.getDatasetDefinition(study, dataset); - if (null == def) - return Collections.emptyList(); - String typeURI = def.getTypeURI(); - if (null == typeURI) - return Collections.emptyList(); - - StudyQuerySchema querySchema = StudyQuerySchema.createSchema(study, context.getUser()); - QuerySettings qs = querySchema.getSettings(context, DatasetQueryView.DATAREGION, def.getName()); - qs.setViewName(viewName); - - QueryView queryView = querySchema.createView(context, qs, null); - - ret = generateParticipantList(queryView); - } - catch (Exception ignored) - { - ret = Collections.emptyList(); - } - - _log.debug("Generate participant list of size {}", ret.size()); - - return ret; - } - - public static List generateParticipantList(QueryView queryView) - { - final TableInfo table = queryView.getTable(); - - if (table != null) - { - try - { - // Do a single-column query to get the list of participants that match the filter criteria for this - // dataset - FieldKey ptidKey = FieldKey.fromParts(StudyService.get().getSubjectColumnName(queryView.getContainer())); - Map columns = QueryService.get().getColumns(table, Collections.singleton(ptidKey)); - ColumnInfo ptidColumnInfo = columns.get(ptidKey); - // Don't bother unless we actually found the participant column (we always should) - if (ptidColumnInfo != null) - { - // Go through the RenderContext directly to get the ResultSet so that we don't also end up calculating - // row counts or other aggregates we don't care about - DataView dataView = queryView.createDataView(); - RenderContext ctx = dataView.getRenderContext(); - DataRegion dataRegion = dataView.getDataRegion(); - queryView.getSettings().setShowRows(ShowRows.ALL); - try (Results results = ctx.getResults(columns, dataRegion.getDisplayColumns(), table, queryView.getSettings(), dataRegion.getQueryParameters(), Table.ALL_ROWS, dataRegion.getOffset(), dataRegion.getName(), false)) - { - int ptidIndex = ptidColumnInfo.findColumn(results); - - Set participantSet = new LinkedHashSet<>(); - while (results.next() && ptidIndex > 0) - { - String ptid = results.getString(ptidIndex); - participantSet.add(ptid); - } - - return new ArrayList<>(participantSet); - } - } - } - catch (Exception x) - { - throw new RuntimeException(x); - } - } - return Collections.emptyList(); - } - - public class ManageQCStatesBean extends AbstractManageQCStatesBean - { - ManageQCStatesBean(ActionURL returnUrl) - { - super(returnUrl); - _qcStateHandler = new StudyQCStateHandler(); - _manageAction = new ManageQCStatesAction(); - _deleteAction = DeleteQCStateAction.class; - _noun = "dataset"; - _dataNoun = "study"; - } - } - - public static class ManageQCStatesForm extends AbstractManageDataStatesForm - { - private Long _defaultPipelineQCState; - private Long _defaultPublishDataQCState; - private Long _defaultDirectEntryQCState; - private boolean _showPrivateDataByDefault; - - public Long getDefaultPipelineQCState() - { - return _defaultPipelineQCState; - } - - public void setDefaultPipelineQCState(Long defaultPipelineQCState) - { - _defaultPipelineQCState = defaultPipelineQCState; - } - - public Long getDefaultPublishDataQCState() - { - return _defaultPublishDataQCState; - } - - public void setDefaultPublishDataQCState(Long defaultPublishDataQCState) - { - _defaultPublishDataQCState = defaultPublishDataQCState; - } - - public Long getDefaultDirectEntryQCState() - { - return _defaultDirectEntryQCState; - } - - public void setDefaultDirectEntryQCState(Long defaultDirectEntryQCState) - { - _defaultDirectEntryQCState = defaultDirectEntryQCState; - } - - public boolean isShowPrivateDataByDefault() - { - return _showPrivateDataByDefault; - } - - public void setShowPrivateDataByDefault(boolean showPrivateDataByDefault) - { - _showPrivateDataByDefault = showPrivateDataByDefault; - } - } - - public static ActionURL getManageQCStatesURL(Container c, @NotNull ActionURL returnUrl) - { - return new ActionURL(ManageQCStatesAction.class, c).addReturnUrl(returnUrl); - } - - @RequiresPermission(AdminPermission.class) - public class ManageQCStatesAction extends AbstractManageQCStatesAction - { - public ManageQCStatesAction() - { - super(new StudyQCStateHandler(), ManageQCStatesForm.class); - } - - @Override - public ModelAndView getView(ManageQCStatesForm manageQCStatesForm, boolean reshow, BindException errors) - { - return new JspView<>("/org/labkey/api/qc/view/manageQCStates.jsp", - new ManageQCStatesBean(manageQCStatesForm.getReturnActionURL()), errors); - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("manageQC"); - _addManageStudy(root); - root.addChild("Manage Dataset QC States"); - } - - @Override - public URLHelper getSuccessURL(ManageQCStatesForm manageQCStatesForm) - { - ActionURL successUrl = getSuccessURL(manageQCStatesForm, ManageQCStatesAction.class, ManageStudyAction.class); - if (!manageQCStatesForm.isReshowPage() && !manageQCStatesForm.isShowPrivateDataByDefault()) - return getQCStateFilteredURL(successUrl, PUBLIC_STATES_LABEL, DATASET_DATAREGION_NAME, getContainer()); - - return successUrl; - } - - @Override - public boolean hasQcStateDefaultsPanel() - { - return true; - } - - @Override - public HtmlString getQcStateDefaultsPanel(Container container, DataStateHandler qcStateHandler) - { - _study = StudyController.getStudyThrowIfNull(container); - - HtmlStringBuilder panelHtml = HtmlStringBuilder.of(); - panelHtml.unsafeAppend(" "); - panelHtml.unsafeAppend(" "); - panelHtml.unsafeAppend(" "); - panelHtml.unsafeAppend(" "); - panelHtml.unsafeAppend(" "); - panelHtml.unsafeAppend(" "); - panelHtml.append(getQcStateHtml(container, qcStateHandler, "defaultPipelineQCState", _study.getDefaultPipelineQCState())); - panelHtml.unsafeAppend(" "); - panelHtml.unsafeAppend(" "); - panelHtml.unsafeAppend(" "); - panelHtml.append(getQcStateHtml(container, qcStateHandler, "defaultPublishDataQCState", _study.getDefaultPublishDataQCState())); - panelHtml.unsafeAppend(" "); - panelHtml.unsafeAppend(" "); - panelHtml.unsafeAppend(" "); - panelHtml.append(getQcStateHtml(container, qcStateHandler, "defaultDirectEntryQCState", _study.getDefaultDirectEntryQCState())); - panelHtml.unsafeAppend(" "); - panelHtml.unsafeAppend("
These settings allow different default QC states depending on data source."); - panelHtml.unsafeAppend(" If set, all imported data without an explicit QC state will have the selected state automatically assigned.
Pipeline imported datasets:
Data linked to this study:
Directly inserted/updated dataset data:
"); - - return panelHtml.getHtmlString(); - } - - @Override - public boolean hasDataVisibilityPanel() - { - return true; - } - - @Override - public HtmlString getDataVisibilityPanel(Container container, DataStateHandler qcStateHandler) - { - HtmlStringBuilder panelHtml = HtmlStringBuilder.of(); - panelHtml.unsafeAppend(" "); - panelHtml.unsafeAppend(" "); - panelHtml.unsafeAppend(" "); - panelHtml.unsafeAppend(" "); - panelHtml.unsafeAppend(" "); - panelHtml.unsafeAppend(" "); - panelHtml.unsafeAppend(" "); - panelHtml.unsafeAppend(" "); - panelHtml.unsafeAppend("
This setting determines whether users see non-public data by default."); - panelHtml.unsafeAppend(" Users can always explicitly choose to see data in any QC state.
Default visibility:"); - panelHtml.unsafeAppend(" "); - panelHtml.unsafeAppend("
"); - - return panelHtml.getHtmlString(); - } - - @Override - public boolean hasRequiresCommentPanel() - { - return false; - } - - @Override - public HtmlString getRequiresCommentPanel(Container container, DataStateHandler qcStateHandler) - { - throw new IllegalStateException("This action does not support a requires comment panel."); - } - } - - @RequiresPermission(AdminPermission.class) - public class DeleteQCStateAction extends AbstractDeleteDataStateAction - { - public DeleteQCStateAction() - { - super(); - _dataStateHandler = new StudyQCStateHandler(); - } - - @Override - public ActionURL getSuccessURL(DeleteDataStateForm form) - { - ActionURL returnUrl = new ActionURL(ManageQCStatesAction.class, getContainer()); - if (form.getManageReturnUrl() != null) - returnUrl.addParameter(ActionURL.Param.returnUrl, form.getManageReturnUrl()); - return returnUrl; - } - } - - public static class UpdateQCStateForm extends ReturnUrlForm - { - private String _comments; - private boolean _update; - private int _datasetId; - private String _dataRegionSelectionKey; - private Long _newState; - private DatasetQueryView _queryView; - private String _dataRegionName; - - public String getComments() - { - return _comments; - } - - public void setComments(String comments) - { - _comments = comments; - } - - public boolean isUpdate() - { - return _update; - } - - public void setUpdate(boolean update) - { - _update = update; - } - - public int getDatasetId() - { - return _datasetId; - } - - public void setDatasetId(int datasetId) - { - _datasetId = datasetId; - } - - public String getDataRegionSelectionKey() - { - return _dataRegionSelectionKey; - } - - public void setDataRegionSelectionKey(String dataRegionSelectionKey) - { - _dataRegionSelectionKey = dataRegionSelectionKey; - } - - public Long getNewState() - { - return _newState; - } - - public void setNewState(Long newState) - { - _newState = newState; - } - - public void setQueryView(DatasetQueryView queryView) - { - _queryView = queryView; - } - - public DatasetQueryView getQueryView() - { - return _queryView; - } - - public String getDataRegionName() - { - return _dataRegionName; - } - - public void setDataRegionName(String dataRegionName) - { - _dataRegionName = dataRegionName; - } - } - - @RequiresPermission(QCAnalystPermission.class) - public class UpdateQCStateAction extends FormViewAction - { - private UpdateQCStateForm _form; - - @Override - public void validateCommand(UpdateQCStateForm updateQCForm, Errors errors) - { - if (updateQCForm.isUpdate()) - { - if (updateQCForm.getComments() == null || updateQCForm.getComments().isEmpty()) - errors.reject(null, "Comments are required."); - } - } - - @Override - public ModelAndView getView(UpdateQCStateForm updateQCForm, boolean reshow, BindException errors) - { - StudyImpl study = getStudyRedirectIfNull(); - _form = updateQCForm; - int datasetId = updateQCForm.getDatasetId(); - DatasetDefinition def = StudyManager.getInstance().getDatasetDefinition(study, datasetId); - if (def == null) - { - throw new NotFoundException("No dataset found for id: " + datasetId); - } - Set lsids = null; - if (isPost()) - lsids = DataRegionSelection.getSelected(getViewContext(), updateQCForm.getDataRegionSelectionKey(), false); - if (lsids == null || lsids.isEmpty()) - return HtmlView.unsafe("No data rows selected. " + LinkBuilder.labkeyLink("back").onClick("back()")); - - StudyQuerySchema querySchema = StudyQuerySchema.createSchema(study, getUser()); - DatasetQuerySettings qs = new DatasetQuerySettings(getViewContext().getBindPropertyValues(), DatasetQueryView.DATAREGION); - - qs.setSchemaName(querySchema.getSchemaName()); - qs.setQueryName(def.getName()); - qs.setMaxRows(Table.ALL_ROWS); - qs.setShowSourceLinks(false); - qs.setShowEditLinks(false); - - final Set finalLsids = lsids; - - DatasetQueryView queryView = new DatasetQueryView(querySchema, qs, errors) - { - @Override - public DataView createDataView() - { - DataView view = super.createDataView(); - view.getDataRegion().setSortable(false); - view.getDataRegion().setShowFilters(false); - view.getDataRegion().setShowRecordSelectors(false); - view.getDataRegion().setShowPagination(false); - SimpleFilter filter = (SimpleFilter) view.getRenderContext().getBaseFilter(); - if (null == filter) - { - filter = new SimpleFilter(); - view.getRenderContext().setBaseFilter(filter); - } - filter.addInClause(FieldKey.fromParts("lsid"), new ArrayList<>(finalLsids)); - return view; - } - }; - queryView.setShowDetailsColumn(false); - updateQCForm.setQueryView(queryView); - updateQCForm.setDataRegionSelectionKey(DataRegionSelection.getSelectionKeyFromRequest(getViewContext())); - updateQCForm.setDataRegionName(queryView.getSettings().getDataRegionName()); - return new JspView<>("/org/labkey/study/view/updateQCState.jsp", updateQCForm, errors); - } - - @Override - public boolean handlePost(UpdateQCStateForm updateQCForm, BindException errors) - { - if (!updateQCForm.isUpdate()) - return false; - Set lsids = DataRegionSelection.getSelected(getViewContext(), updateQCForm.getDataRegionSelectionKey(), false); - - DataState newState = null; - if (updateQCForm.getNewState() != null) - { - newState = QCStateManager.getInstance().getStateForRowId(getContainer(), updateQCForm.getNewState()); - if (newState == null) - { - errors.reject(null, "The selected state could not be found. It may have been deleted from the database."); - return false; - } - } - StudyManager.getInstance().updateDataQCState(getContainer(), getUser(), - updateQCForm.getDatasetId(), lsids, newState, updateQCForm.getComments()); - - // if everything has succeeded, we can clear our saved checkbox state now: - DataRegionSelection.clearAll(getViewContext(), updateQCForm.getDataRegionSelectionKey()); - return true; - } - - @Override - public ActionURL getSuccessURL(UpdateQCStateForm updateQCForm) - { - ActionURL url = updateQCForm.getReturnActionURL(); - if (null == url) - { - // We've lost the returnUrl... at least redirect back to the dataset - url = new ActionURL(DatasetAction.class, getContainer()); - url.addParameter(Dataset.DATASET_KEY, updateQCForm.getDatasetId()); - } - if (updateQCForm.getNewState() != null) - url.replaceParameter(getQCUrlFilterKey(CompareType.EQUAL, updateQCForm.getDataRegionName()), QCStateManager.getInstance().getStateForRowId(getContainer(), updateQCForm.getNewState().longValue()).getLabel()); - return url; - } - - @Override - public void addNavTrail(NavTree root) - { - root = _addNavTrail(root, _form.getDatasetId(), _form.getReturnActionURL()); - root.addChild("Change QC State"); - } - } - - public static class ResetPipelinePathForm extends PipelinePathForm - { - private String _redirect; - - public String getRedirect() - { - return _redirect; - } - - public void setRedirect(String redirect) - { - _redirect = redirect; - } - } - - @RequiresPermission(AdminPermission.class) - public static class ResetPipelineAction extends FormHandlerAction - { - @Override - public void validateCommand(ResetPipelinePathForm form, Errors errors) - { - } - - @Override - public boolean handlePost(ResetPipelinePathForm form, BindException errors) throws Exception - { - for (File f : form.getValidatedFiles(getContainer())) - { - if (f.isFile() && f.getName().endsWith(".lock")) - { - f.delete(); - } - } - return true; - } - - @Override - public URLHelper getSuccessURL(ResetPipelinePathForm form) - { - String redirect = form.getRedirect(); - if (null != redirect) - { - try - { - return new URLHelper(redirect); - } - catch (URISyntaxException e) - { - _log.warn("ResetPipelineAction redirect string invalid: " + redirect); - } - } - return urlProvider(PipelineStatusUrls.class).urlBegin(getContainer()); - } - } - - @RequiresPermission(ReadPermission.class) - public class DefaultDatasetReportAction extends SimpleRedirectAction - { - @Override - public ActionURL getRedirectURL(Object o) - { - ViewContext context = getViewContext(); //_study.isShowPrivateDataByDefault() - Object unparsedDatasetId = context.get(Dataset.DATASET_KEY); - - try - { - int datasetId = null == unparsedDatasetId ? 0 : Integer.parseInt(unparsedDatasetId.toString()); - - ActionURL url = context.cloneActionURL(); - url.setAction(DatasetReportAction.class); - - String defaultView = getDefaultView(context, datasetId); - if (!StringUtils.isEmpty(defaultView)) - { - ReportIdentifier reportId = ReportService.get().getReportIdentifier(defaultView, getViewContext().getUser(), getViewContext().getContainer()); - if (reportId != null) - url.addParameter(DATASET_REPORT_ID_PARAMETER_NAME, defaultView); - else - url.addParameter(DATASET_VIEW_NAME_PARAMETER_NAME, defaultView); - } - - if (!"1".equals(url.getParameter("skipDataVisibility"))) - { - StudyImpl studyImpl = StudyManager.getInstance().getStudy(getContainer()); - if (studyImpl != null && !studyImpl.isShowPrivateDataByDefault()) - url = getQCStateFilteredURL(url, PUBLIC_STATES_LABEL, DATASET_DATAREGION_NAME, getContainer()); - } - return url; - } - catch (NumberFormatException e) - { - throw new NotFoundException("No such dataset with ID: " + unparsedDatasetId); - } - } - } - - public static ActionURL getViewPreferencesURL(Container c, int id, String viewName) - { - // Issue 26030: we don't distinguish null vs empty string for url parameters. - // Empty string will be converted to null for beans so "" shouldn't be used as the url param for Default Grid View. - return new ActionURL(ViewPreferencesAction.class, c).addParameter(Dataset.DATASET_KEY, id).addParameter("defaultView", viewName != null ? (viewName.isEmpty() ? "defaultGrid": viewName) : null); - } - - public static class ViewPreferencesForm extends DatasetController.DatasetIdForm - { - private String _defaultView; - - public String getDefaultView() - { - return _defaultView; - } - - @SuppressWarnings("unused") - public void setDefaultView(String defaultView) - { - _defaultView = "defaultGrid".equals(defaultView) ? "" : defaultView; - } - } - - @RequiresPermission(ReadPermission.class) - @RequiresLogin // Don't set a default view for guests, Issue 52863 - public class ViewPreferencesAction extends FormViewAction - { - private StudyImpl _study; - private Dataset _def; - - private int init(ViewPreferencesForm form) - { - int dsid = form.getDatasetId(); - _study = getStudyRedirectIfNull(); - _def = StudyManager.getInstance().getDatasetDefinition(_study, dsid); - return dsid; - } - - @Override - public ModelAndView getView(ViewPreferencesForm form, boolean reshow, BindException errors) throws Exception - { - init(form); - if (_def != null) - { - List> views = ReportManager.get().getReportLabelsForDataset(getViewContext(), _def); - ViewPrefsBean bean = new ViewPrefsBean(views, _def); - return new StudyJspView<>(_study, "/org/labkey/study/view/viewPreferences.jsp", bean, errors); - } - throw new NotFoundException("Invalid dataset ID"); - } - - @Override - public boolean handlePost(ViewPreferencesForm form, BindException errors) throws Exception - { - int dsid = init(form); - String defaultView = form.getDefaultView(); - if ((_def != null) && (defaultView != null)) - { - setDefaultView(dsid, defaultView); - return true; - } - throw new NotFoundException("Invalid dataset ID"); - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("customViews"); - - root.addChild(_study.getLabel(), new ActionURL(BeginAction.class, getContainer())); - - ActionURL datasetURL = getViewContext().getActionURL().clone(); - datasetURL.setAction(DatasetAction.class); - - String label = _def.getLabel() != null ? _def.getLabel() : "" + _def.getDatasetId(); - root.addChild(new NavTree(label, datasetURL)); - - root.addChild(new NavTree("View Preferences")); - } - - @Override - public URLHelper getSuccessURL(ViewPreferencesForm viewPreferencesForm) { return null; } - - @Override - public void validateCommand(ViewPreferencesForm target, Errors errors) { } - } - - @RequiresPermission(AdminPermission.class) - public class ImportStudyBatchAction extends SimpleViewAction - { - private String path; - - @Override - public ModelAndView getView(PipelinePathForm form, BindException errors) throws Exception - { - Container c = getContainer(); - - File definitionFile = form.getValidatedSingleFile(c); - path = form.getPath(); - if (!path.endsWith("/")) - { - path += "/"; - } - path += definitionFile.getName(); - - if (!definitionFile.isFile()) - { - throw new NotFoundException(); - } - - File lockFile = StudyPipeline.lockForDataset(getStudyRedirectIfNull(), definitionFile); - - if (!definitionFile.canRead()) - errors.reject("importStudyBatch", "Can't read dataset file: " + path); - if (lockFile.exists()) - errors.reject("importStudyBatch", "Lock file exists. Delete file before running import. " + lockFile.getName()); - - VirtualFile datasetsDir = new FileSystemFile(definitionFile.getParentFile()); - DatasetFileReader reader = new DatasetFileReader(datasetsDir, definitionFile.getName(), getStudyRedirectIfNull()); - - if (!errors.hasErrors()) - { - List parseErrors = new ArrayList<>(); - reader.validate(parseErrors); - for (String error : parseErrors) - errors.reject("importStudyBatch", error); - } - - return new StudyJspView<>( - getStudyRedirectIfNull(), "/org/labkey/study/view/importStudyBatch.jsp", new ImportStudyBatchBean(reader, path), errors); - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild(getStudyRedirectIfNull().getLabel(), new ActionURL(StudyController.BeginAction.class, getContainer())); - root.addChild("Import Study Batch - " + path); - } - } - - @RequiresPermission(AdminPermission.class) - public class SubmitStudyBatchAction extends FormHandlerAction - { - private ActionURL _successUrl = null; - - @Override - public void validateCommand(PipelinePathForm target, Errors errors) - { - } - - @Override - public boolean handlePost(PipelinePathForm form, BindException errors) throws Exception - { - Study study = getStudyRedirectIfNull(); - Container c = getContainer(); - String path = form.getPath(); - File f = null; - - PipeRoot root = PipelineService.get().findPipelineRoot(c); - if (path != null) - { - if (root != null) - f = root.resolvePath(path); - } - - try - { - if (f != null) - { - VirtualFile datasetsDir = new FileSystemFile(f.getParentFile()); - DatasetImportUtils.submitStudyBatch(study, datasetsDir, f.getName(), c, getUser(), getViewContext().getActionURL(), root); - } - _successUrl = urlProvider(PipelineStatusUrls.class).urlBegin(getContainer()); - } - catch (DatasetImportUtils.DatasetLockExistsException e) - { - ActionURL importURL = new ActionURL(ImportStudyBatchAction.class, getContainer()); - importURL.addParameter("path", form.getPath()); - _successUrl = importURL; - } - - return true; - } - - @Override - public URLHelper getSuccessURL(PipelinePathForm pipelinePathForm) - { - return _successUrl; - } - } - - @RequiresPermission(ReadPermission.class) - public class TypeNotFoundAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object o, BindException errors) - { - return new StudyJspView(getStudyRedirectIfNull(), "/org/labkey/study/view/typeNotFound.jsp", null, errors); - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild("Type Not Found"); - } - } - - @RequiresPermission(AdminPermission.class) - public class UpdateParticipantVisitsAction extends FormViewAction - { - private int _count; - - @Override - public void validateCommand(Object target, Errors errors) - { - } - - @Override - public ModelAndView getView(Object o, boolean reshow, BindException errors) throws Exception - { - if (reshow) - { - return HtmlView.unsafe( - "
" + _count + " rows were updated.

" + - PageFlowUtil.button("Done").href(new ActionURL(ManageVisitsAction.class, getContainer())) + - "

"); - } - else - { - return HtmlView.unsafe( - "
Click the button below to recalculate visit dates for all participants in this study.

" + - PageFlowUtil.button("Recalculate Visit Dates").href(new ActionURL(UpdateParticipantVisitsAction.class, getContainer())).submit(true) + - new CsrfInput(getViewContext()) + - "

"); - } - } - - @Override - public boolean handlePost(Object o, BindException errors) throws Exception - { - var vm = StudyManager.getInstance().getVisitManager(getStudyRedirectIfNull()); - if (vm instanceof SequenceVisitManager svm) - { - // This could be optimized by combining with updateParticipantVisits(). - // However, updateParticipantVisits() handles incremental updates and would need to be refactored a bit - // and this isn't a common code path. - svm.purgeParticipantVisit(getUser()); - } - vm.updateParticipantVisits(getUser(), getStudyRedirectIfNull().getDatasets()); - - TableInfo tinfoParticipantVisit = StudySchema.getInstance().getTableInfoParticipantVisit(); - _count = new SqlSelector(StudySchema.getInstance().getSchema(), - "SELECT COUNT(VisitDate) FROM " + tinfoParticipantVisit + "\nWHERE Container = ?", - getContainer()).getObject(Integer.class); - - return true; - } - - @Override - public URLHelper getSuccessURL(Object o) - { - return null; - } - - @Override - public void addNavTrail(NavTree root) - { - _addNavTrailVisitAdmin(root); - root.addChild("Recalculate Visit Dates"); - } - } - - @RequiresPermission(AdminPermission.class) - public class VisitOrderAction extends FormViewAction - { - @Override - public ModelAndView getView(VisitReorderForm reorderForm, boolean reshow, BindException errors) - { - StudyImpl study = getStudyRedirectIfNull(); - redirectToSharedVisitStudy(study, getViewContext().getActionURL()); - - return new StudyJspView(study, "/org/labkey/study/view/visitOrder.jsp", reorderForm, errors); - } - - @Override - public void addNavTrail(NavTree root) - { - _addNavTrailVisitAdmin(root); - root.addChild("Visit Order"); - } - - @Override - public void validateCommand(VisitReorderForm target, Errors errors) {} - - private Map getVisitIdToOrderIndex(String orderedIds) - { - Map order = null; - if (orderedIds != null && !orderedIds.isEmpty()) - { - order = new HashMap<>(); - String[] idArray = orderedIds.split(","); - for (int i = 0; i < idArray.length; i++) - { - int id = Integer.parseInt(idArray[i]); - // 1-index display orders, since 0 is the database default, and we'd like to know - // that these were set explicitly for all visits: - order.put(id, i + 1); - } - } - return order; - } - - private Map getVisitIdToZeroMap(Collection visits) - { - Map order = new IntHashMap<>(); - for (VisitImpl visit : visits) - order.put(visit.getRowId(), 0); - return order; - } - - @Override - public boolean handlePost(VisitReorderForm form, BindException errors) - { - StudyImpl study = getStudyRedirectIfNull(); - redirectToSharedVisitStudy(study, getViewContext().getActionURL()); - - Map displayOrder = null; - Map chronologicalOrder = null; - Collection visits = StudyManager.getInstance().getVisits(study, Visit.Order.SEQUENCE_NUM); - - if (form.isExplicitDisplayOrder()) - displayOrder = getVisitIdToOrderIndex(form.getDisplayOrder()); - if (displayOrder == null) - displayOrder = getVisitIdToZeroMap(visits); - - if (form.isExplicitChronologicalOrder()) - chronologicalOrder = getVisitIdToOrderIndex(form.getChronologicalOrder()); - if (chronologicalOrder == null) - chronologicalOrder = getVisitIdToZeroMap(visits); - - for (VisitImpl visit : visits) - { - // it's possible that a new visit has been created between when the update page was rendered - // and posted. This will result in a visit that isn't in our ID maps. There's no great way - // to handle this, so we'll just skip setting display/chronological order on these visits for now. - if (displayOrder.containsKey(visit.getRowId()) && chronologicalOrder.containsKey(visit.getRowId())) - { - int displayIndex = displayOrder.get(visit.getRowId()).intValue(); - int chronologicalIndex = chronologicalOrder.get(visit.getRowId()).intValue(); - - if (visit.getDisplayOrder() != displayIndex || visit.getChronologicalOrder() != chronologicalIndex) - { - visit = visit.createMutable(); - visit.setDisplayOrder(displayIndex); - visit.setChronologicalOrder(chronologicalIndex); - StudyManager.getInstance().updateVisit(getUser(), visit); - } - } - } - - // Changing visit order can cause cohort assignments to change when advanced cohort tracking is enabled: - if (study.isAdvancedCohorts()) - CohortManager.getInstance().updateParticipantCohorts(getUser(), study); - return true; - } - - @Override - public ActionURL getSuccessURL(VisitReorderForm reorderForm) - { - return reorderForm.getReturnActionURL(); - } - } - - @RequiresPermission(AdminPermission.class) - public class VisitVisibilityAction extends FormViewAction - { - @Override - public ModelAndView getView(VisitPropertyForm visitPropertyForm, boolean reshow, BindException errors) - { - StudyImpl study = getStudyRedirectIfNull(); - redirectToSharedVisitStudy(study, getViewContext().getActionURL()); - - return new StudyJspView(study, "/org/labkey/study/view/visitVisibility.jsp", visitPropertyForm, errors); - } - - @Override - public void addNavTrail(NavTree root) - { - _addNavTrailVisitAdmin(root); - root.addChild("Properties"); - } - - @Override - public void validateCommand(VisitPropertyForm target, Errors errors) {} - - @Override - public boolean handlePost(VisitPropertyForm form, BindException errors) - { - StudyImpl study = getStudyRedirectIfNull(); - redirectToSharedVisitStudy(study, getViewContext().getActionURL()); - - int[] allIds = form.getIds() == null ? new int[0] : form.getIds(); - int[] visibleIds = form.getVisible() == null ? new int[0] : form.getVisible(); - String[] labels = form.getLabel() == null ? new String[0] : form.getLabel(); - String[] typeStrs = form.getExtraData()== null ? new String[0] : form.getExtraData(); - - Set visible = new IntHashSet(visibleIds.length); - for (int id : visibleIds) - visible.add(id); - if (allIds.length != form.getLabel().length) - throw new IllegalStateException("Arrays must be the same length."); - for (int i = 0; i < allIds.length; i++) - { - VisitImpl def = StudyManager.getInstance().getVisitForRowId(study, allIds[i]); - boolean show = visible.contains(allIds[i]); - String label = (i < labels.length) ? labels[i] : null; - String typeStr = (i < typeStrs.length) ? typeStrs[i] : null; - - Integer cohortId = null; - if (form.getCohort() != null && form.getCohort()[i] != -1) - cohortId = form.getCohort()[i]; - Character type = typeStr != null && !typeStr.isEmpty() ? typeStr.charAt(0) : null; - if (def.isShowByDefault() != show || !nullSafeEqual(label, def.getLabel()) || type != def.getTypeCode() || !nullSafeEqual(cohortId, def.getCohortId())) - { - def = def.createMutable(); - def.setShowByDefault(show); - def.setLabel(label); - def.setCohortId(cohortId); - def.setTypeCode(type); - StudyManager.getInstance().updateVisit(getUser(), def); - } - } - return true; - } - - @Override - public ActionURL getSuccessURL(VisitPropertyForm visitPropertyForm) - { - return new ActionURL(ManageVisitsAction.class, getContainer()); - } - } - - @RequiresPermission(AdminPermission.class) - public class DatasetVisibilityAction extends FormViewAction - { - @Override - public ModelAndView getView(DatasetPropertyForm form, boolean reshow, BindException errors) - { - _study = getStudyRedirectIfNull(); - var sqs = StudyQuerySchema.createSchema(_study, getUser()); - Map bean = new IntHashMap<>(); - for (DatasetDefinition def : _study.getDatasets()) - { - DatasetVisibilityData data = new DatasetVisibilityData(); - data.label = def.getLabel(); - data.categoryId = def.getViewCategory() != null ? def.getViewCategory().getRowId() : null; - data.cohort = def.getCohortId(); - data.visible = def.isShowByDefault(); - data.shared = def.isShared(); - data.inherited = def.isInherited(); - data.status = (String)ReportPropsManager.get().getPropertyValue(def.getEntityId(), getContainer(), "status"); - if ("None".equals(data.status)) - data.status = null; - DatasetTable t = sqs.getDatasetTable(def, null); - if (null != t) - { - long rowCount = new TableSelector(t).getRowCount(); - data.rowCount = rowCount; - data.empty = 0 == rowCount; - } - bean.put(def.getDatasetId(), data); - } - - // Merge with form data - Map formDataset = form.getDataset(); - if (formDataset != null) - { - for (Map.Entry entry : formDataset.entrySet()) - { - DatasetVisibilityData formData = entry.getValue(); - DatasetVisibilityData beanData = bean.get(entry.getKey()); - if (formData == null || beanData == null) - continue; - - beanData.label = formData.label; - beanData.categoryId = formData.categoryId; - beanData.cohort = formData.cohort; - beanData.visible = formData.visible; - } - } - - return new StudyJspView<>( - getStudyRedirectIfNull(), "/org/labkey/study/view/datasetVisibility.jsp", bean, errors); - } - - @Override - public void addNavTrail(NavTree root) - { - _addManageStudy(root); - root.addChild("Manage Datasets", new ActionURL(ManageTypesAction.class, getContainer())); - root.addChild("Properties"); - } - - @Override - public void validateCommand(DatasetPropertyForm form, Errors errors) - { - // Check for bad labels - Set labels = new HashSet<>(); - for (DatasetVisibilityData data : form.getDataset().values()) - { - String label = data.getLabel(); - if (StringUtils.isBlank(label)) - { - errors.reject("datasetVisibility", "Label cannot be blank"); - } - if (labels.contains(label)) - { - errors.reject("datasetVisibility", "Labels must be unique. Found two or more labels called '" + label + "'."); - } - labels.add(label); - } - } - - @Override - public boolean handlePost(DatasetPropertyForm form, BindException errors) throws Exception - { - for (Map.Entry entry : form.getDataset().entrySet()) - { - Integer id = entry.getKey(); - DatasetVisibilityData data = entry.getValue(); - - if (id == null) - throw new IllegalArgumentException("id required"); - - DatasetDefinition def = StudyManager.getInstance().getDatasetDefinition(getStudyThrowIfNull(), id); - if (def == null) - throw new NotFoundException("dataset"); - - String label = data.getLabel(); - boolean show = data.isVisible(); - Integer categoryId = data.getCategoryId(); - Integer cohortId = data.getCohort(); - if (cohortId != null && cohortId.intValue() == -1) - cohortId = null; - - if (def.isShowByDefault() != show || !nullSafeEqual(categoryId, def.getCategoryId()) || !nullSafeEqual(label, def.getLabel()) || !BaseStudyController.nullSafeEqual(cohortId, def.getCohortId())) - { - def = def.createMutable(); - def.setShowByDefault(show); - def.setCategoryId(categoryId); - def.setCohortId(cohortId); - def.setLabel(label); - List saveErrors = new ArrayList<>(); - StudyManager.getInstance().updateDatasetDefinition(getUser(), def, saveErrors); - for (String error : saveErrors) - { - errors.reject(ERROR_MSG, error); - return false; - } - } - ReportPropsManager.get().setPropertyValue(def.getEntityId(), getContainer(), "status", data.getStatus()); - } - - return true; - } - - @Override - public ActionURL getSuccessURL(DatasetPropertyForm form) - { - return new ActionURL(ManageTypesAction.class, getContainer()); - } - } - - // Bean will be an map of these - public static class DatasetVisibilityData - { - // form POSTed values - public String label; - public Integer cohort; // null for none - public String status; - public Integer categoryId; - public boolean visible; - - // not form POSTed -- used to render view - public long rowCount; - public boolean empty; - public boolean shared; - public boolean inherited; - - public String getLabel() - { - return label; - } - - public void setLabel(String label) - { - this.label = label; - } - - public Integer getCohort() - { - return cohort; - } - - public void setCohort(Integer cohort) - { - this.cohort = cohort; - } - - public String getStatus() - { - return status; - } - - public void setStatus(String status) - { - this.status = status; - } - - public boolean isVisible() - { - return visible; - } - - public void setVisible(boolean visible) - { - this.visible = visible; - } - - public Integer getCategoryId() - { - return categoryId; - } - - public void setCategoryId(Integer categoryId) - { - this.categoryId = categoryId; - } - } - - @Marshal(Marshaller.Jackson) - @RequiresPermission(AdminPermission.class) - public static class DeleteDatasetPropertyOverrideAction extends MutatingApiAction - { - @Override - public Object execute(Object o, BindException errors) - { - StudyManager.getInstance().deleteDatasetPropertyOverrides(getUser(), getContainer(), errors); - return errors.hasErrors() ? null : success(); - } - } - - @RequiresPermission(AdminPermission.class) - public class DatasetDisplayOrderAction extends FormViewAction - { - @Override - public ModelAndView getView(DatasetReorderForm form, boolean reshow, BindException errors) - { - return new StudyJspView(getStudyRedirectIfNull(), "/org/labkey/study/view/datasetDisplayOrder.jsp", form, errors); - } - - @Override - public void addNavTrail(NavTree root) - { - _addManageStudy(root); - root.addChild("Manage Datasets", new ActionURL(ManageTypesAction.class, getContainer())); - root.addChild("Display Order"); - } - - @Override - public void validateCommand(DatasetReorderForm target, Errors errors) {} - - @Override - public boolean handlePost(DatasetReorderForm form, BindException errors) - { - String order = form.getOrder(); - - if (order != null && !order.isEmpty() && !form.isResetOrder()) - { - String[] ids = order.split(","); - List orderedIds = new ArrayList<>(ids.length); - - for (String id : ids) - orderedIds.add(Integer.parseInt(id)); - - DatasetReorderer reorderer = new DatasetReorderer(getStudyThrowIfNull(), getUser()); - reorderer.reorderDatasets(orderedIds); - } - else if (form.isResetOrder()) - { - DatasetReorderer reorderer = new DatasetReorderer(getStudyThrowIfNull(), getUser()); - reorderer.resetOrder(); - } - - return true; - } - - @Override - public ActionURL getSuccessURL(DatasetReorderForm visitPropertyForm) - { - return new ActionURL(ManageTypesAction.class, getContainer()); - } - } - - - @RequiresPermission(AdminPermission.class) - public class DeleteDatasetAction extends FormHandlerAction - { - @Override - public void validateCommand(IdForm target, Errors errors) - { - - } - - @Override - public boolean handlePost(IdForm form, BindException errors) throws Exception - { - Study study = getStudyRedirectIfNull(getContainer()); - - DatasetDefinition ds = StudyManager.getInstance().getDatasetDefinition(study, form.getId()); - if (null == ds) - redirectTypeNotFound(form.getId()); - if (!ds.canDeleteDefinition(getUser())) - errors.reject(ERROR_MSG, "Can't delete this dataset: " + ds.getName()); - - if (errors.hasErrors()) - return false; - - DbScope scope = StudySchema.getInstance().getSchema().getScope(); - try (DbScope.Transaction transaction = scope.ensureTransaction()) - { - // performStudyResync==false so we can do this out of the transaction - StudyManager.getInstance().deleteDataset(getStudyRedirectIfNull(), getUser(), ds, false, null); - transaction.commit(); - } - - StudyManager.getInstance().getVisitManager(study).updateParticipantVisits(getUser(), Collections.emptySet()); - return true; - } - - @Override - public URLHelper getSuccessURL(IdForm idForm) - { - throw new RedirectException(new ActionURL(ManageTypesAction.class, getContainer())); - } - } - - - private static final String DEFAULT_PARTICIPANT_VIEW_SOURCE = - """ -
Loading...
- - - /* Adjust width of first column: */ - """; - - public static class CustomizeParticipantViewForm extends ReturnUrlForm - { - private String _customScript; - private String _participantId; - private boolean _useCustomView; - private boolean _reshow; - private boolean _editable = true; - - public boolean isEditable() - { - return _editable; - } - - public void setEditable(boolean editable) - { - _editable = editable; - } - - public String getCustomScript() - { - return _customScript; - } - - public String getDefaultScript() - { - return DEFAULT_PARTICIPANT_VIEW_SOURCE; - } - - public void setCustomScript(String customScript) - { - _customScript = customScript; - } - - public String getParticipantId() - { - return _participantId; - } - - public void setParticipantId(String participantId) - { - _participantId = participantId; - } - - public boolean isReshow() - { - return _reshow; - } - - public void setReshow(boolean reshow) - { - _reshow = reshow; - } - - public boolean isUseCustomView() - { - return _useCustomView; - } - - public void setUseCustomView(boolean useCustomView) - { - _useCustomView = useCustomView; - } - } - - @RequiresAllOf({AdminPermission.class, BrowserDeveloperPermission.class}) - public class CustomizeParticipantViewAction extends FormViewAction - { - @Override - public void validateCommand(CustomizeParticipantViewForm target, Errors errors) - { - } - - @Override - public ModelAndView getView(CustomizeParticipantViewForm form, boolean reshow, BindException errors) - { - Study study = getStudyRedirectIfNull(); - CustomParticipantView view = StudyManager.getInstance().getCustomParticipantView(study); - if (view != null) - { - form.setCustomScript(view.getBody()); - form.setUseCustomView(view.isActive()); - form.setEditable(!view.isModuleParticipantView()); - } - - return new JspView<>("/org/labkey/study/view/customizeParticipantView.jsp", form); - } - - @Override - public boolean handlePost(CustomizeParticipantViewForm form, BindException errors) - { - Study study = getStudyThrowIfNull(); - CustomParticipantView view = StudyManager.getInstance().getCustomParticipantView(study); - if (view == null) - view = new CustomParticipantView(); - view.setBody(form.getCustomScript()); - view.setActive(form.isUseCustomView()); - view = StudyManager.getInstance().saveCustomParticipantView(study, getUser(), view); - return view != null; - } - - @Override - public ActionURL getSuccessURL(CustomizeParticipantViewForm form) - { - if (form.isReshow()) - { - ActionURL reshowURL = new ActionURL(CustomizeParticipantViewAction.class, getContainer()); - if (form.getParticipantId() != null && !form.getParticipantId().isEmpty()) - reshowURL.addParameter("participantId", form.getParticipantId()); - if (form.getReturnUrl() != null && !form.getReturnUrl().isEmpty()) - reshowURL.addParameter(ActionURL.Param.returnUrl, form.getReturnUrl()); - return reshowURL; - } - else if (form.getReturnUrl() != null && !form.getReturnUrl().isEmpty()) - return new ActionURL(form.getReturnUrl()); - else - return urlProvider(ReportUrls.class).urlManageViews(getContainer()); - } - - @Override - public void addNavTrail(NavTree root) - { - _addManageStudy(root); - root.addChild("Manage Views", urlProvider(ReportUrls.class).urlManageViews(getContainer())); - root.addChild("Customize " + StudyService.get().getSubjectNounSingular(getContainer()) + " View"); - } - } - - public static class StudySnapshotForm extends QuerySnapshotForm - { - private int _snapshotDatasetId = -1; - private String _action; - private Boolean _queryDataset; - - public static final String EDIT_DATASET = "editDataset"; - public static final String CREATE_SNAPSHOT = "createSnapshot"; - public static final String CANCEL = "cancel"; - - public int getSnapshotDatasetId() - { - return _snapshotDatasetId; - } - - public void setSnapshotDatasetId(int snapshotDatasetId) - { - _snapshotDatasetId = snapshotDatasetId; - } - - public Boolean getQueryDataset() - { - return _queryDataset; - } - - public void setQueryDataset(Boolean queryDataset) - { - _queryDataset = queryDataset; - } - - public String getAction() - { - return _action; - } - - public void setAction(String action) - { - _action = action; - } - } - - @RequiresPermission(AdminPermission.class) - public static class CreateSnapshotAction extends FormViewAction - { - ActionURL _successURL; - - @Override - public void validateCommand(StudySnapshotForm form, Errors errors) - { - if (StudySnapshotForm.CANCEL.equals(form.getAction())) - return; - - Study study = StudyManager.getInstance().getStudy(getContainer()); - if (null == study) - throw new NotFoundException("No study in this folder"); - - if (form.getQueryDataset() != null) - { - if (study.getTimepointType() != TimepointType.CONTINUOUS) - { - errors.reject("snapshotQuery.error", "Query based snapshot is only available for continuous studies"); - } - else - { - TableInfo ti = QueryService.get().getUserSchema(getUser(), getContainer(), form.getSchemaName()).getTable(form.getQueryName()); - Set colNames = ti.getColumns().stream().map(ColumnInfo::getName).collect(LabKeyCollectors.toCaseInsensitiveHashSet()); - - List notFound = Arrays.stream(QueryDatasetTable.REQUIRED_COLUMNS) - .filter(value -> !colNames.contains(value)) - .toList(); - - if (!notFound.isEmpty()) - errors.reject("snapshotQuery.error", "The source query is missing the following required columns for a query backed dataset: " + String.join(", ", notFound)); - } - } - - String name = StringUtils.trimToNull(form.getSnapshotName()); - - if (name != null) - { - QuerySnapshotDefinition def = QueryService.get().getSnapshotDef(getContainer(), form.getSchemaName(), name); - if (def != null) - { - errors.reject("snapshotQuery.error", "A Snapshot with the same name already exists"); - return; - } - - // check for a dataset with the same label/name unless it's one that we created - Dataset dataset = StudyManager.getInstance().getDatasetDefinitionByQueryName(study, name); - if (dataset != null) - { - if (dataset.getDatasetId() != form.getSnapshotDatasetId()) - errors.reject("snapshotQuery.error", "A Dataset with the same name/label already exists"); - } - } - else - errors.reject("snapshotQuery.error", "The Query Snapshot name cannot be blank"); - } - - @Override - public ModelAndView getView(StudySnapshotForm form, boolean reshow, BindException errors) - { - if (!reshow || errors.hasErrors()) - { - ActionURL url = getViewContext().getActionURL(); - - if (StringUtils.isEmpty(form.getSnapshotName())) - form.setSnapshotName(url.getParameter("ff_snapshotName")); - form.setUpdateDelay(NumberUtils.toInt(url.getParameter("ff_updateDelay"))); - form.setSnapshotDatasetId(NumberUtils.toInt(url.getParameter("ff_snapshotDatasetId"), -1)); - - return new JspView("/org/labkey/study/view/createDatasetSnapshot.jsp", form, errors); - } - else if (StudySnapshotForm.EDIT_DATASET.equals(form.getAction())) - { - throw new NotFoundException("Unable to edit the created dataset definition."); - } - return null; - } - - private void deletePreviousDatasetDefinition(StudySnapshotForm form) - { - if (form.getSnapshotDatasetId() != -1) - { - StudyImpl study = StudyManager.getInstance().getStudy(getContainer()); - - // a dataset definition was edited previously, but under a different name, need to delete the old one - DatasetDefinition dsDef = StudyManager.getInstance().getDatasetDefinition(study, form.getSnapshotDatasetId()); - if (dsDef != null) - { - StudyManager.getInstance().deleteDataset(study, getUser(), dsDef, true, null); - form.setSnapshotDatasetId(-1); - } - } - } - - private Dataset createDataset(StudySnapshotForm form, BindException errors) throws Exception - { - StudyImpl study = StudyManager.getInstance().getStudy(getContainer()); - Dataset dsDef = StudyManager.getInstance().getDatasetDefinitionByName(study, form.getSnapshotName()); - - if (dsDef == null) - { - deletePreviousDatasetDefinition(form); - - // if this snapshot is being created from an existing dataset, copy key field settings - int datasetId = NumberUtils.toInt(getViewContext().getActionURL().getParameter(Dataset.DATASET_KEY), -1); - String additionalKey = null; - DatasetDefinition.KeyManagementType keyManagementType = KeyManagementType.None; - boolean isDemographicData = false; - boolean useTimeKeyField = false; - List columnsToProvision = new ArrayList<>(); - - if (datasetId != -1) - { - DatasetDefinition sourceDef = study.getDataset(datasetId); - if (sourceDef != null) - { - additionalKey = sourceDef.getKeyPropertyName(); - keyManagementType = sourceDef.getKeyManagementType(); - isDemographicData = sourceDef.isDemographicData(); - useTimeKeyField = sourceDef.getUseTimeKeyField(); - - // make sure we provision any managed key fields - if ((additionalKey != null) && (keyManagementType != KeyManagementType.None)) - { - TableInfo sourceTable = sourceDef.getTableInfo(getUser()); - ColumnInfo col = sourceTable.getColumn(FieldKey.fromParts(additionalKey)); - if (col != null) - columnsToProvision.add(col); - } - } - } - - DatasetDefinition.Builder builder = new DatasetDefinition.Builder(form.getSnapshotName()) - .setStudy(study) - .setKeyPropertyName(additionalKey) - .setDemographicData(isDemographicData) - .setUseTimeKeyField(useTimeKeyField); - - - if (Boolean.TRUE.equals(form.getQueryDataset())) - { - builder.setSourceQueryName(form.getQueryName()) - .setSourceQuerySchema(form.getSchemaName()) - .setSourceQueryContainer(getContainer()) - .setKeyPropertyName("Key"); - } - - DatasetDefinition def = StudyPublishManager.getInstance().createDataset(getUser(), builder); - - form.setSnapshotDatasetId(def.getDatasetId()); - if (keyManagementType != KeyManagementType.None) - { - def = def.createMutable(); - def.setKeyManagementType(keyManagementType); - - StudyManager.getInstance().updateDatasetDefinition(getUser(), def); - } - - // NOTE getDisplayColumns() indirectly causes a query of the datasets, - // Do this before provisionTable() so we don't query the dataset we are about to create - // causes a problem on postgres (bug 11153) - for (DisplayColumn dc : QuerySnapshotService.get(form.getSchemaName()).getDisplayColumns(form, errors)) - { - ColumnInfo col = dc.getColumnInfo(); - if (col != null && !DatasetDefinition.isDefaultFieldName(col.getName(), study)) - columnsToProvision.add(col); - } - - // def may not be provisioned yet, create before we start adding properties - if (def.isQueryDataset()) - { - def.provisionQueryDataset(true); - } - else - { - def.provisionTable(true); - } - - Domain d = def.getDomain(true); - - for (ColumnInfo col : columnsToProvision) - { - DatasetSnapshotProvider.addAsDomainProperty(d, col); - } - d.save(getUser()); - - return def; - } - - return dsDef; - } - - @Override - public boolean handlePost(StudySnapshotForm form, BindException errors) throws Exception - { - DbSchema schema = StudySchema.getInstance().getSchema(); - - try (DbScope.Transaction transaction = schema.getScope().ensureTransaction()) - { - if (StudySnapshotForm.EDIT_DATASET.equals(form.getAction())) - { - Dataset def = createDataset(form, errors); - if (!errors.hasErrors() && def != null) - { - ActionURL returnUrl = getViewContext().cloneActionURL() - .replaceParameter("ff_snapshotName", form.getSnapshotName()) - .replaceParameter("ff_updateDelay", form.getUpdateDelay()) - .replaceParameter("ff_snapshotDatasetId", form.getSnapshotDatasetId()); - - _successURL = new ActionURL(StudyController.EditTypeAction.class, getContainer()) - .addParameter("datasetId", def.getDatasetId()) - .addReturnUrl(returnUrl); - } - } - else if (StudySnapshotForm.CREATE_SNAPSHOT.equals(form.getAction())) - { - Dataset def = createDataset(form, errors); - if (!errors.hasErrors()) - if (Boolean.TRUE.equals(form.getQueryDataset())) - { - _successURL = new ActionURL(StudyController.DatasetAction.class, getContainer()). - addParameter(Dataset.DATASET_KEY, def.getDatasetId()); - } - else - { - _successURL = QuerySnapshotService.get(form.getSchemaName()).createSnapshot(form, errors); - } - } - else if (StudySnapshotForm.CANCEL.equals(form.getAction())) - { - deletePreviousDatasetDefinition(form); - String redirect = getViewContext().getActionURL().getParameter(ActionURL.Param.redirectUrl); - if (redirect != null) - _successURL = new ActionURL(PageFlowUtil.decode(redirect)); - } - - if (!errors.hasErrors()) - transaction.commit(); - } - - return !errors.hasErrors(); - } - - @Override - public ActionURL getSuccessURL(StudySnapshotForm queryForm) - { - return _successURL; - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("querySnapshot"); - root.addChild("Create Query Snapshot"); - } - } - - /** - * Provides a view to update study query snapshots. Since query snapshots are implemented as datasets, the - * dataset properties editor can be shown in this view. - */ - @RequiresPermission(AdminPermission.class) - public static class EditSnapshotAction extends FormViewAction - { - ActionURL _successURL; - - @Override - public void validateCommand(StudySnapshotForm form, Errors errors) - { - } - - @Override - public ModelAndView getView(StudySnapshotForm form, boolean reshow, BindException errors) throws Exception - { - form.setEdit(true); - if (!reshow) - form.init(QueryService.get().getSnapshotDef(getContainer(), form.getSchemaName(), form.getSnapshotName()), getUser()); - - VBox box = new VBox(); - - QuerySnapshotService.Provider provider = QuerySnapshotService.get(form.getSchemaName()); - if (provider != null) - { - box.addView(new JspView("/org/labkey/study/view/editSnapshot.jsp", form)); - box.addView(new JspView("/org/labkey/study/view/createDatasetSnapshot.jsp", form, errors)); - - boolean showHistory = BooleanUtils.toBoolean(getViewContext().getActionURL().getParameter("showHistory")); - if (showHistory) - { - HttpView historyView = provider.createAuditView(form, errors); - if (historyView != null) - box.addView(historyView); - } - } - return box; - } - - @Override - public boolean handlePost(StudySnapshotForm form, BindException errors) throws Exception - { - if (StudySnapshotForm.CANCEL.equals(form.getAction())) - { - String redirect = getViewContext().getActionURL().getParameter(ActionURL.Param.redirectUrl); - if (redirect != null) - _successURL = new ActionURL(PageFlowUtil.decode(redirect)); - } - else if (form.isUpdateSnapshot()) - { - _successURL = QuerySnapshotService.get(form.getSchemaName()).updateSnapshot(form, errors); - - return !errors.hasErrors(); - } - else - { - QuerySnapshotDefinition def = QueryService.get().getSnapshotDef(getContainer(), form.getSchemaName(), form.getSnapshotName()); - if (def != null) - { - def.setUpdateDelay(form.getUpdateDelay()); - _successURL = QuerySnapshotService.get(form.getSchemaName()).updateSnapshotDefinition(getViewContext(), def, errors); - return !errors.hasErrors(); - } - else - { - errors.reject("snapshotQuery.error", "Unable to create QuerySnapshotDefinition"); - return false; - } - } - return true; - } - - @Override - public ActionURL getSuccessURL(StudySnapshotForm form) - { - return _successURL; - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild("Edit Query Snapshot"); - } - } - - public static class DatasetPropertyForm implements HasAllowBindParameter - { - private Map _map = MapUtils.lazyMap(new IntHashMap<>(), FactoryUtils.instantiateFactory(DatasetVisibilityData.class)); - - public Map getDataset() - { - return _map; - } - - public void setDataset(Map map) - { - _map = map; - } - - private static final Pattern pat = Pattern.compile("dataset\\[(\\d*)]\\.(\\w*)"); - - @Override - public Predicate allowBindParameter() - { - return (name) -> - { - if (name.startsWith(SpringActionController.FIELD_MARKER)) - name = name.substring(SpringActionController.FIELD_MARKER.length()); - if (HasAllowBindParameter.getDefaultPredicate().test(name)) - return true; - return pat.matcher(name).matches(); - }; - } - } - - public static class RequirePipelineView extends StudyJspView - { - public RequirePipelineView(StudyImpl study, boolean showGoBack, BindException errors) - { - super(study, "/org/labkey/study/view/requirePipeline.jsp", showGoBack, errors); - } - } - - public static class VisitPropertyForm extends PropertyForm - { - private int[] _ids; - private int[] _visible; - - public int[] getIds() - { - return _ids; - } - - public void setIds(int[] ids) - { - _ids = ids; - } - - public int[] getVisible() - { - return _visible; - } - - public void setVisible(int[] visible) - { - _visible = visible; - } - } - - public abstract static class PropertyForm - { - private String[] _label; - private String[] _extraData; - private int[] _cohort; - - public String[] getExtraData() - { - return _extraData; - } - - public void setExtraData(String[] extraData) - { - _extraData = extraData; - } - - public String[] getLabel() - { - return _label; - } - - public void setLabel(String[] label) - { - _label = label; - } - - public int[] getCohort() - { - return _cohort; - } - - public void setCohort(int[] cohort) - { - _cohort = cohort; - } - } - - - public static class DatasetReorderForm - { - private String order; - private boolean resetOrder = false; - - public String getOrder() {return order;} - - public void setOrder(String order) {this.order = order;} - - public boolean isResetOrder() - { - return resetOrder; - } - - public void setResetOrder(boolean resetOrder) - { - this.resetOrder = resetOrder; - } - } - - public static class VisitReorderForm extends ReturnUrlForm - { - private boolean _explicitDisplayOrder; - private boolean _explicitChronologicalOrder; - private String _displayOrder; - private String _chronologicalOrder; - - public String getDisplayOrder() - { - return _displayOrder; - } - - public void setDisplayOrder(String displayOrder) - { - _displayOrder = displayOrder; - } - - public String getChronologicalOrder() - { - return _chronologicalOrder; - } - - public void setChronologicalOrder(String chronologicalOrder) - { - _chronologicalOrder = chronologicalOrder; - } - - public boolean isExplicitDisplayOrder() - { - return _explicitDisplayOrder; - } - - public void setExplicitDisplayOrder(boolean explicitDisplayOrder) - { - _explicitDisplayOrder = explicitDisplayOrder; - } - - public boolean isExplicitChronologicalOrder() - { - return _explicitChronologicalOrder; - } - - public void setExplicitChronologicalOrder(boolean explicitChronologicalOrder) - { - _explicitChronologicalOrder = explicitChronologicalOrder; - } - } - - public static class ImportStudyBatchBean - { - private final DatasetFileReader reader; - private final String path; - - public ImportStudyBatchBean(DatasetFileReader reader, String path) - { - this.reader = reader; - this.path = path; - } - - public DatasetFileReader getReader() - { - return reader; - } - - public String getPath() - { - return path; - } - } - - public static class ViewPrefsBean - { - private final List> _views; - private final Dataset _def; - - public ViewPrefsBean(List> views, Dataset def) - { - _views = views; - _def = def; - } - - public List> getViews(){return _views;} - public Dataset getDatasetDefinition(){return _def;} - } - - - private static final String DEFAULT_DATASET_VIEW = "Study.defaultDatasetView"; - - public static String getDefaultView(ViewContext context, int datasetId) - { - User user = context.getUser(); - // Don't return a default view for guests, Issue 52863 - if (!user.isGuest()) - { - Map viewMap = PropertyManager.getProperties(user, context.getContainer(), DEFAULT_DATASET_VIEW); - - final String key = Integer.toString(datasetId); - if (viewMap.containsKey(key)) - { - return viewMap.get(key); - } - } - return ""; - } - - private void setDefaultView(int datasetId, String view) - { - User user = getUser(); - if (user.isGuest()) - throw new IllegalStateException("Can't set a default view for guests"); - WritablePropertyMap viewMap = PropertyManager.getWritableProperties(user, getContainer(), DEFAULT_DATASET_VIEW, true); - - viewMap.put(Integer.toString(datasetId), view); - viewMap.save(); - } - - private String getVisitLabel() - { - StudyImpl study = getStudy(); - if (study != null) - { - return StudyManager.getInstance().getVisitManager(getStudyRedirectIfNull()).getLabel(); - } - return "Visit"; - } - - - private String getVisitLabelPlural() - { - StudyImpl study = getStudy(); - if (study != null) - { - return StudyManager.getInstance().getVisitManager(getStudyRedirectIfNull()).getPluralLabel(); - } - return "Visits"; - } - - public static class ParticipantForm extends ViewForm implements StudyManager.ParticipantViewConfig - { - private String participantId; - private int datasetId; - private double sequenceNum; - private String action; - private Map aliases; - - @Override - public String getParticipantId(){return participantId;} - - public void setParticipantId(String participantId) - { - this.participantId = participantId; - aliases = StudyManager.getInstance().getAliasMap(StudyManager.getInstance().getStudy(getContainer()), getUser(), participantId); - } - - @Override - public Map getAliases() - { - return null == aliases ? Map.of() : aliases; - } - - @Override - public int getDatasetId(){return datasetId;} - public void setDatasetId(int datasetId){this.datasetId = datasetId;} - - public double getSequenceNum(){return sequenceNum;} - public void setSequenceNum(double sequenceNum){this.sequenceNum = sequenceNum;} - - public String getAction(){return action;} - public void setAction(String action){this.action = action;} - } - - public static class StudyPropertiesForm extends ReturnUrlForm - { - private String _label; - private TimepointType _timepointType; - private Date _startDate; - private Date _endDate; - private SecurityType _securityType; - private String _subjectNounSingular = "Participant"; - private String _subjectNounPlural = "Participants"; - private String _subjectColumnName = "ParticipantId"; - private String _assayPlan; - private String _description; - private String _descriptionRendererType; - private String _grant; - private String _investigator; - private String _species; - private int _defaultTimepointDuration = 0; - private String _alternateIdPrefix; - private int _alternateIdDigits; - private boolean _allowReqLocRepository = true; - private boolean _allowReqLocClinic = true; - private boolean _allowReqLocSal = true; - private boolean _allowReqLocEndpoint = true; - private boolean _shareDatasets = false; - private boolean _shareVisits = false; - private boolean _failForUndefinedTimepoints; - - public String getLabel() - { - return _label; - } - - public void setLabel(String label) - { - _label = label; - } - - public TimepointType getTimepointType() - { - return _timepointType; - } - - public void setTimepointType(TimepointType timepointType) - { - _timepointType = timepointType; - } - - public Date getStartDate() - { - return _startDate; - } - - public void setStartDate(Date startDate) - { - _startDate = startDate; - } - - public void setSecurityString(String security) - { - _securityType = SecurityType.valueOf(security); - } - - public String getSecurityString() - { - return _securityType == null ? null : _securityType.name(); - } - - public void setSecurityType(SecurityType securityType) - { - _securityType = securityType; - } - - public SecurityType getSecurityType() - { - return _securityType; - } - - public String getSubjectNounSingular() - { - return _subjectNounSingular; - } - - public void setSubjectNounSingular(String subjectNounSingular) - { - _subjectNounSingular = subjectNounSingular; - } - - public String getSubjectNounPlural() - { - return _subjectNounPlural; - } - - public void setSubjectNounPlural(String subjectNounPlural) - { - _subjectNounPlural = subjectNounPlural; - } - - public String getSubjectColumnName() - { - return _subjectColumnName; - } - - public void setSubjectColumnName(String subjectColumnName) - { - _subjectColumnName = subjectColumnName; - } - - public String getDescription() - { - return _description; - } - - public void setDescription(String description) - { - _description = description; - } - - public String getDescriptionRendererType() - { - return _descriptionRendererType; - } - - public void setDescriptionRendererType(String descriptionRendererType) - { - _descriptionRendererType = descriptionRendererType; - } - - public String getInvestigator() - { - return _investigator; - } - - public void setInvestigator(String investigator) - { - _investigator = investigator; - } - - public String getGrant() - { - return _grant; - } - - public void setGrant(String grant) - { - _grant = grant; - } - - public int getDefaultTimepointDuration() - { - return _defaultTimepointDuration; - } - - public void setDefaultTimepointDuration(int defaultTimepointDuration) - { - _defaultTimepointDuration = defaultTimepointDuration; - } - - public String getAlternateIdPrefix() - { - return _alternateIdPrefix; - } - - public void setAlternateIdPrefix(String alternateIdPrefix) - { - _alternateIdPrefix = alternateIdPrefix; - } - - public int getAlternateIdDigits() - { - return _alternateIdDigits; - } - - public void setAlternateIdDigits(int alternateIdDigits) - { - _alternateIdDigits = alternateIdDigits; - } - - public boolean isAllowReqLocRepository() - { - return _allowReqLocRepository; - } - - public void setAllowReqLocRepository(boolean allowReqLocRepository) - { - _allowReqLocRepository = allowReqLocRepository; - } - - public boolean isAllowReqLocClinic() - { - return _allowReqLocClinic; - } - - public void setAllowReqLocClinic(boolean allowReqLocClinic) - { - _allowReqLocClinic = allowReqLocClinic; - } - - public boolean isAllowReqLocSal() - { - return _allowReqLocSal; - } - - public void setAllowReqLocSal(boolean allowReqLocSal) - { - _allowReqLocSal = allowReqLocSal; - } - - public boolean isAllowReqLocEndpoint() - { - return _allowReqLocEndpoint; - } - - public void setAllowReqLocEndpoint(boolean allowReqLocEndpoint) - { - _allowReqLocEndpoint = allowReqLocEndpoint; - } - - public Date getEndDate() - { - return _endDate; - } - - public void setEndDate(Date endDate) - { - _endDate = endDate; - } - - public String getAssayPlan() - { - return _assayPlan; - } - - public void setAssayPlan(String assayPlan) - { - _assayPlan = assayPlan; - } - - public String getSpecies() - { - return _species; - } - - public void setSpecies(String species) - { - _species = species; - } - - public boolean isShareDatasets() - { - return _shareDatasets; - } - - public void setShareDatasets(boolean shareDatasets) - { - _shareDatasets = shareDatasets; - } - - public boolean isShareVisits() - { - return _shareVisits; - } - - public void setShareVisits(boolean shareDatasets) - { - _shareVisits = shareDatasets; - } - - public boolean isFailForUndefinedTimepoints() - { - return _failForUndefinedTimepoints; - } - - public void setFailForUndefinedTimepoints(boolean failForUndefinedTimepoints) - { - _failForUndefinedTimepoints = failForUndefinedTimepoints; - } - } - - public static class IdForm - { - private int _id; - - public int getId() {return _id;} - - public void setId(int id) {_id = id;} - } - - public static class SourceLsidForm - { - private String _sourceLsid; - - public String getSourceLsid() {return _sourceLsid;} - - public void setSourceLsid(String sourceLsid) {_sourceLsid = sourceLsid;} - } - - /** - * Adds next and prev buttons to the participant view - */ - public static class ParticipantNavView extends HttpView - { - private final ActionURL _prevURL; - private final ActionURL _nextURL; - private final String _display; - private final String _currentParticipantId; - private boolean _showCustomizeLink = true; - - public ParticipantNavView(ActionURL prevURL, ActionURL nextURL, String currentParticipantId, String display) - { - _prevURL = prevURL; - _nextURL = nextURL; - _display = display; - _currentParticipantId = currentParticipantId; - } - - @Override - protected void renderInternal(Object model, PrintWriter out) - { - Container c = getViewContext().getContainer(); - User user = getViewContext().getUser(); - - String subjectNoun = PageFlowUtil.filter(StudyService.get().getSubjectNounSingular(getViewContext().getContainer())); - out.print("
"); - if (_prevURL != null) - { - LinkBuilder.labkeyLink("Previous " + subjectNoun, _prevURL).appendTo(out); - out.print(" "); - } - - if (_nextURL != null) - { - LinkBuilder.labkeyLink("Next " + subjectNoun, _nextURL).appendTo(out); - out.print(" "); - } - - SearchService ss = SearchService.get(); - - if (null != _currentParticipantId) - { - ActionURL search = urlProvider(SearchUrls.class).getSearchURL(c, "+" + ss.escapeTerm(_currentParticipantId)); - LinkBuilder.labkeyLink("Search for '" + id(_currentParticipantId, c, user) + "'", search).appendTo(out); - out.print(" "); - } - - // Show customize link to site admins (who are always developers) and folder admins who are developers: - Set> permissions = new HashSet<>(); - permissions.add(AdminPermission.class); - permissions.add(PlatformDeveloperPermission.class); - if (_showCustomizeLink && c.hasPermissions(getViewContext().getUser(), permissions)) - { - ActionURL customizeURL = new ActionURL(CustomizeParticipantViewAction.class, c); - customizeURL.addReturnUrl(getViewContext().getActionURL()); - customizeURL.addParameter("participantId", _currentParticipantId); - out.print(""); - LinkBuilder.labkeyLink("Customize View", customizeURL).appendTo(out); - } - - if (_display != null) - { - out.print(""); - out.print(PageFlowUtil.filter(_display)); - } - out.print("
"); - } - - public void setShowCustomizeLink(boolean showCustomizeLink) - { - _showCustomizeLink = showCustomizeLink; - } - } - - public static class ImportDatasetForm - { - private int datasetId = 0; - private String typeURI; - private String tsv; - private String keys; - private String _participantId; - private String _sequenceNum; - private String _name; - private QueryUpdateService.InsertOption _insertOption = QueryUpdateService.InsertOption.IMPORT; - - public int getDatasetId() - { - return datasetId; - } - - public void setDatasetId(int datasetId) - { - this.datasetId = datasetId; - } - - public String getTsv() - { - return tsv; - } - - public void setTsv(String tsv) - { - this.tsv = tsv; - } - - public String getKeys() - { - return keys; - } - - public void setKeys(String keys) - { - this.keys = keys; - } - - public String getTypeURI() - { - return typeURI; - } - - public void setTypeURI(String typeURI) - { - this.typeURI = typeURI; - } - - public String getParticipantId() - { - return _participantId; - } - - public void setParticipantId(String participantId) - { - _participantId = participantId; - } - - public String getSequenceNum() - { - return _sequenceNum; - } - - public void setSequenceNum(String sequenceNum) - { - _sequenceNum = sequenceNum; - } - - public String getName() - { - return _name; - } - - public void setName(String name) - { - _name = name; - } - - public QueryUpdateService.InsertOption getInsertOption() - { - return _insertOption; - } - - public void setInsertOption(QueryUpdateService.InsertOption insertOption) - { - _insertOption = insertOption; - } - } - - public static class DatasetForm - { - private String _name; - private String _label; - private Integer _datasetId; - private String _category; - private boolean _showByDefault; - private String _visitDatePropertyName; - private String[] _visitStatus; - private int[] _visitRowIds; - private String _description; - private Integer _cohortId; - private boolean _demographicData; - private boolean _create; - - public boolean isShowByDefault() - { - return _showByDefault; - } - - public void setShowByDefault(boolean showByDefault) - { - _showByDefault = showByDefault; - } - - public String getCategory() - { - return _category; - } - - public void setCategory(String category) - { - _category = category; - } - - public String getDatasetIdStr() - { - return _datasetId > 0 ? String.valueOf(_datasetId) : ""; - } - - /** - * Don't blow up when posting bad value - */ - public void setDatasetIdStr(String datasetIdStr) - { - try - { - if (null == StringUtils.trimToNull(datasetIdStr)) - _datasetId = 0; - else - _datasetId = Integer.parseInt(datasetIdStr); - } - catch (Exception x) - { - _datasetId = 0; - } - } - - public Integer getDatasetId() - { - return _datasetId; - } - - public void setDatasetId(Integer datasetId) - { - _datasetId = datasetId; - } - - public String getLabel() - { - return _label; - } - - public void setLabel(String label) - { - _label = label; - } - - public String getName() - { - return _name; - } - - public void setName(String name) - { - _name = name; - } - - public String[] getVisitStatus() - { - return _visitStatus; - } - - public void setVisitStatus(String[] visitStatus) - { - _visitStatus = visitStatus; - } - - public int[] getVisitRowIds() - { - return _visitRowIds; - } - - public void setVisitRowIds(int[] visitIds) - { - _visitRowIds = visitIds; - } - - public String getVisitDatePropertyName() - { - return _visitDatePropertyName; - } - - public void setVisitDatePropertyName(String visitDatePropertyName) - { - _visitDatePropertyName = visitDatePropertyName; - } - - public String getDescription() - { - return _description; - } - - public void setDescription(String description) - { - _description = description; - } - - public boolean isDemographicData() - { - return _demographicData; - } - - public void setDemographicData(boolean demographicData) - { - _demographicData = demographicData; - } - - public boolean isCreate() - { - return _create; - } - - public void setCreate(boolean create) - { - _create = create; - } - - public Integer getCohortId() - { - return _cohortId; - } - - public void setCohortId(Integer cohortId) - { - _cohortId = cohortId; - } - } - - @RequiresPermission(ReadPermission.class) - public class DatasetsAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object o, BindException errors) throws Exception - { - return StudyModule.datasetsPartFactory.getWebPartView(getViewContext(), StudyModule.datasetsPartFactory.createWebPart()); - } - - @Override - public void addNavTrail(NavTree root) - { - _addNavTrail(root); - root.addChild("Datasets"); - } - } - - @RequiresPermission(ReadPermission.class) - public static class ViewDataAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object o, BindException errors) throws Exception - { - return new VBox( - StudyModule.reportsPartFactory.getWebPartView(getViewContext(), StudyModule.reportsPartFactory.createWebPart()), - StudyModule.datasetsPartFactory.getWebPartView(getViewContext(), StudyModule.datasetsPartFactory.createWebPart()) - ); - } - - @Override - public void addNavTrail(NavTree root) - { - } - } - - private static class DatasetDetailRedirectForm extends ReturnUrlForm - { - private String _datasetId; - private String _lsid; - - public String getDatasetId() - { - return _datasetId; - } - - public void setDatasetId(String datasetId) - { - _datasetId = datasetId; - } - - public String getLsid() - { - return _lsid; - } - - public void setLsid(String lsid) - { - _lsid = lsid; - } - } - - @RequiresPermission(AdminPermission.class) - public class ManageExternalReloadAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object form, BindException errors) - { - return new StudyJspView<>(getStudyRedirectIfNull(), "/org/labkey/study/view/manageExternalReload.jsp", form, errors); - } - - @Override - public void addNavTrail(NavTree root) - { - _addManageStudy(root); - root.addChild("Manage External Reloading"); - } - } - - @RequiresPermission(ReadPermission.class) - public static class DatasetDetailRedirectAction extends SimpleRedirectAction - { - @Override - public URLHelper getRedirectURL(DatasetDetailRedirectForm form) - { - StudyImpl study = StudyManager.getInstance().getStudy(getContainer()); - if (study == null) - { - throw new NotFoundException("No study found"); - } - // First try the dataset id as an entityid - DatasetDefinition dataset = StudyManager.getInstance().getDatasetDefinitionByEntityId(study, form.getDatasetId()); - if (dataset == null) - { - try - { - // Then try the dataset id as an integer - int id = Integer.parseInt(form.getDatasetId()); - dataset = StudyManager.getInstance().getDatasetDefinition(study, id); - } - catch (NumberFormatException ignored) {} - - if (dataset == null) - { - throw new NotFoundException("Could not find dataset " + form.getDatasetId()); - } - } - - if (form.getLsid() == null) - { - throw new NotFoundException("No LSID specified"); - } - - StudyQuerySchema schema = StudyQuerySchema.createSchema(study, getUser()); - - QueryDefinition queryDef = QueryService.get().createQueryDefForTable(schema, dataset.getName()); - assert queryDef != null : "Dataset was found but couldn't get a corresponding TableInfo"; - - ActionURL url = queryDef.urlFor(QueryAction.detailsQueryRow, getContainer(), Collections.singletonMap("lsid", form.getLsid())); - String referrer = getViewContext().getRequest().getHeader("Referer"); - if (referrer != null) - { - url.addParameter(ActionURL.Param.returnUrl, referrer); - } - - return url; - } - } - - public static class ImportVisitMapForm - { - private String _content; - - public String getContent() - { - return _content; - } - - public void setContent(String content) - { - _content = content; - } - } - - @RequiresPermission(AdminPermission.class) - public class DemoModeAction extends FormViewAction - { - @Override - public URLHelper getSuccessURL(DemoModeForm form) - { - return null; - } - - @Override - public void validateCommand(DemoModeForm form, Errors errors) - { - } - - @Override - public ModelAndView getView(DemoModeForm form, boolean reshow, BindException errors) - { - return new JspView<>("/org/labkey/study/view/demoMode.jsp"); - } - - @Override - public boolean handlePost(DemoModeForm form, BindException errors) - { - DemoMode.setDemoMode(getContainer(), getUser(), form.getMode()); - return false; // Reshow page - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("demoMode"); - _addManageStudy(root); - root.addChild("Demo Mode"); - } - } - - - public static class DemoModeForm - { - private boolean mode; - - public boolean getMode() - { - return mode; - } - - public void setMode(boolean mode) - { - this.mode = mode; - } - } - - - @RequiresPermission(AdminPermission.class) - public class ShowVisitImportMappingAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object o, BindException errors) - { - return new JspView<>("/org/labkey/study/view/visitImportMapping.jsp", new ImportMappingBean(getStudyRedirectIfNull())); - } - - @Override - public void addNavTrail(NavTree root) - { - _addNavTrailVisitAdmin(root); - root.addChild("Visit Import Mapping"); - } - } - - - public static class ImportMappingBean - { - private final Collection _customMapping; - private final Collection _standardMapping; - - public ImportMappingBean(Study study) - { - _customMapping = StudyManager.getInstance().getCustomVisitImportMapping(study); - _standardMapping = StudyManager.getInstance().getStandardVisitImportMapping(study); - } - - public Collection getCustomMapping() - { - return _customMapping; - } - - public Collection getStandardMapping() - { - return _standardMapping; - } - } - - - @RequiresPermission(AdminPermission.class) - public class ImportVisitAliasesAction extends FormViewAction - { - @Override - public URLHelper getSuccessURL(VisitAliasesForm form) - { - return new ActionURL(ShowVisitImportMappingAction.class, getContainer()); - } - - @Override - public void validateCommand(VisitAliasesForm form, Errors errors) - { - } - - @Override - public ModelAndView getView(VisitAliasesForm form, boolean reshow, BindException errors) - { - getPageConfig().setFocusId("tsv"); - return new JspView<>("/org/labkey/study/view/importVisitAliases.jsp", null, errors); - } - - @Override - public boolean handlePost(VisitAliasesForm form, BindException errors) - { - boolean hadCustomMapping = !StudyManager.getInstance().getCustomVisitImportMapping(getStudyThrowIfNull()).isEmpty(); - - try - { - String tsv = form.getTsv(); - - if (null == tsv) - { - errors.reject(ERROR_MSG, "Please insert tab-separated data with two columns, Name and SequenceNum"); - return false; - } - - StudyManager.getInstance().importVisitAliases(getStudyThrowIfNull(), getUser(), new TabLoader(form.getTsv(), true)); - } - catch (RuntimeSQLException e) - { - if (e.isConstraintException()) - { - errors.reject(ERROR_MSG, "The visit import mapping includes duplicate visit names: " + e.getMessage()); - return false; - } - else - { - throw e; - } - } - catch (ValidationException e) - { - errors.reject(ERROR_MSG, e.getMessage()); - return false; - } - - // TODO: Change to audit log - _log.info("The visit import custom mapping was " + (hadCustomMapping ? "replaced" : "imported")); - - return true; - } - - @Override - public void addNavTrail(NavTree root) - { - _addNavTrailVisitAdmin(root); - root.addChild("Import Visit Aliases"); - } - } - - - public static class VisitAliasesForm - { - private String _tsv; - - public String getTsv() - { - return _tsv; - } - - @SuppressWarnings({"UnusedDeclaration"}) - public void setTsv(String tsv) - { - _tsv = tsv; - } - } - - - @RequiresPermission(AdminPermission.class) - public class ClearVisitAliasesAction extends ConfirmAction - { - @Override - public ModelAndView getConfirmView(Object o, BindException errors) - { - if (getPageConfig().getTitle() == null) - setTitle("Clear Custom Mapping"); - - return HtmlView.of("Are you sure you want to delete the visit import custom mapping for this study?"); - } - - @Override - public boolean handlePost(Object o, BindException errors) - { - StudyManager.getInstance().clearVisitAliases(getStudyThrowIfNull()); - // TODO: Change to audit log - _log.info("The visit import custom mapping was cleared"); - - return true; - } - - @Override - public void validateCommand(Object o, Errors errors) - { - } - - @Override - public @NotNull URLHelper getSuccessURL(Object o) - { - return new ActionURL(ShowVisitImportMappingAction.class, getContainer()); - } - } - - @RequiresPermission(ReadPermission.class) @RequiresLogin - public class ManageParticipantCategoriesAction extends SimpleViewAction - { - @Override - public ModelAndView getView(SentGroupForm form, BindException errors) - { - // if the user is viewing a sent participant group, remove any notifications related to it - if (form.getGroupId() != null) - { - NotificationService.get().removeNotifications(getContainer(), form.getGroupId().toString(), - Collections.singletonList(ParticipantCategory.SEND_PARTICIPANT_GROUP_TYPE), getUser().getUserId()); - } - - return new JspView<>("/org/labkey/study/view/manageParticipantCategories.jsp"); - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("participantGroups"); - _addManageStudy(root); - root.addChild("Manage " + getStudyRedirectIfNull().getSubjectNounSingular() + " Groups"); - } - } - - public static class SentGroupForm - { - private Integer _groupId; - - public Integer getGroupId() - { - return _groupId; - } - - public void setGroupId(Integer groupId) - { - _groupId = groupId; - } - } - - @RequiresLogin @RequiresPermission(ReadPermission.class) - public class SendParticipantGroupAction extends FormViewAction - { - List _validRecipients = new ArrayList<>(); - - @Override - public URLHelper getSuccessURL(SendParticipantGroupForm form) - { - return form.getReturnActionURL(form.getDefaultUrl(getContainer())); - } - - @Override - public ModelAndView getView(SendParticipantGroupForm form, boolean reshow, BindException errors) - { - if (form.getRowId() == null) - { - return HtmlView.err("No participant group RowId provided."); - } - else - { - ParticipantGroup group = ParticipantGroupManager.getInstance().getParticipantGroup(getContainer(), getUser(), form.getRowId()); - if (group != null) - { - ParticipantCategoryImpl category = ParticipantGroupManager.getInstance().getParticipantCategory(getContainer(), getUser(), group.getCategoryId()); - if (category != null && category.canRead(getContainer(), getUser())) - { - form.setLabel(group.getLabel()); - return new JspView<>("/org/labkey/study/view/sendParticipantGroup.jsp", form, errors); - } - } - - return HtmlView.err("Could not find participant group for RowId " + form.getRowId() + " or you do not have permission to read it."); - } - } - - @Override - public void validateCommand(SendParticipantGroupForm form, Errors errors) - { - _validRecipients = SecurityManager.parseRecipientListForContainer(getContainer(), form.getRecipientList(), errors); - } - - @Override - public boolean handlePost(SendParticipantGroupForm form, BindException errors) throws Exception - { - if (!errors.hasErrors() && !_validRecipients.isEmpty()) - { - for (User recipient : _validRecipients) - { - NotificationService.get().sendMessageForRecipient( - getContainer(), getUser(), recipient, - form.getMessageSubject(), form.getMessageBody(), form.getSendGroupUrl(getContainer()), - form.getRowId().toString(), ParticipantCategory.SEND_PARTICIPANT_GROUP_TYPE - ); - - String auditMsg = "The following participant group was shared: recipient: " + recipient.getName() + " (" + recipient.getUserId() + ")" - + ", groupId: " + form.getRowId() + ", name: " + form.getLabel(); - StudyService.get().addStudyAuditEvent(getContainer(), getUser(), auditMsg); - } - } - - return !errors.hasErrors(); - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("participantGroups"); - String manageGroupsTitle = "Manage " + getStudyRedirectIfNull().getSubjectNounSingular() + " Groups"; - root.addChild(manageGroupsTitle, new ActionURL(ManageParticipantCategoriesAction.class, getContainer())); - root.addChild("Send Participant Group"); - } - } - - public static class SendParticipantGroupForm extends ReturnUrlForm - { - private Integer _rowId; - private String _label; - private String _recipientList; - private String _messageSubject; - private String _messageBody; - - public Integer getRowId() - { - return _rowId; - } - - public void setRowId(Integer rowId) - { - _rowId = rowId; - } - - public String getLabel() - { - return _label; - } - - public void setLabel(String label) - { - _label = label; - } - - public String getRecipientList() - { - return _recipientList; - } - - public void setRecipientList(String recipientList) - { - _recipientList = recipientList; - } - - public String getMessageSubject() - { - return _messageSubject; - } - - public void setMessageSubject(String messageSubject) - { - _messageSubject = messageSubject; - } - - public String getMessageBody() - { - return _messageBody; - } - - public void setMessageBody(String messageBody) - { - _messageBody = messageBody; - } - - public ActionURL getDefaultUrl(Container container) - { - return new ActionURL(ManageParticipantCategoriesAction.class, container); - } - - public ActionURL getSendGroupUrl(Container container) - { - ActionURL sendGroupUrl = getReturnActionURL(getDefaultUrl(container)); - sendGroupUrl.addParameter("groupId", getRowId()); - return sendGroupUrl; - } - } - - @RequiresPermission(AdminPermission.class) - public class ManageParticipantsAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object form, BindException errors) - { - ChangeAlternateIdsForm changeAlternateIdsForm = getChangeAlternateIdForm(getStudyRedirectIfNull()); - return new JspView<>("/org/labkey/study/view/manageParticipants.jsp", changeAlternateIdsForm); - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("alternateIDs"); - _addManageStudy(root); - String pluralNoun = getStudyRedirectIfNull().getSubjectNounPlural(); - root.addChild("Manage " + pluralNoun, new ActionURL(ManageParticipantsAction.class, getContainer())); - } - } - - @RequiresPermission(AdminPermission.class) - public class MergeParticipantsAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object form, BindException errors) - { - return new JspView<>("/org/labkey/study/view/mergeParticipants.jsp"); - } - - @Override - public void addNavTrail(NavTree root) - { - // Add Manage Participants nav trail - ManageParticipantsAction manageParticipantsAction = new ManageParticipantsAction(); - manageParticipantsAction.setViewContext(getViewContext()); - manageParticipantsAction.setPageConfig(new PageConfig(getViewContext().getRequest())); - manageParticipantsAction.addNavTrail(root); - - String subjectColumnName = getStudyRedirectIfNull().getSubjectColumnName(); - root.addChild("Change or Merge " + subjectColumnName + "s"); - } - } - - @RequiresPermission(ReadPermission.class) - public static class SubjectListAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object o, BindException errors) - { - return new SubjectsWebPart(getViewContext(), true, 0); - } - - @Override - public void addNavTrail(NavTree root) - { - } - } - - @RequiresPermission(ReadPermission.class) - public static class BrowseStudyScheduleAction extends MutatingApiAction - { - @Override - public ApiResponse execute(BrowseStudyForm browseDataForm, BindException errors) throws Exception - { - ApiSimpleResponse response = new ApiSimpleResponse(); - StudyManager manager = StudyManager.getInstance(); - Study study = manager.getStudy(getContainer()); - StudySchedule schedule = new StudySchedule(); - CohortImpl cohort = null; - - if (browseDataForm.getCohortId() != null) - { - cohort = manager.getCohortForRowId(getContainer(), getUser(), browseDataForm.getCohortId()); - } - - if (cohort == null && browseDataForm.getCohortLabel() != null) - { - cohort = manager.getCohortByLabel(getContainer(), getUser(), browseDataForm.getCohortLabel()); - } - - if (study != null) - { - schedule.setVisits(manager.getVisits(study, cohort, getUser(), Visit.Order.DISPLAY)); - schedule.setDatasets( - manager.getDatasetDefinitions(study, cohort, Dataset.TYPE_STANDARD, Dataset.TYPE_PLACEHOLDER), - DataViewService.get().getViews(getViewContext(), Collections.singletonList(DatasetViewProvider.TYPE))); - - response.put("schedule", schedule.toJSON(getUser())); - response.put("success", true); - - return response; - } - else - throw new IllegalStateException("A study does not exist in this folder"); - } - } - - @RequiresPermission(ReadPermission.class) - public static class GetStudyTimepointsAction extends MutatingApiAction - { - @Override - public ApiResponse execute(BrowseStudyForm browseDataForm, BindException errors) - { - ApiSimpleResponse response = new ApiSimpleResponse(); - StudyManager manager = StudyManager.getInstance(); - Study study = manager.getStudy(getContainer()); - StudySchedule schedule = new StudySchedule(); - CohortImpl cohort = null; - - if (browseDataForm.getCohortId() != null) - { - cohort = manager.getCohortForRowId(getContainer(), getUser(), browseDataForm.getCohortId()); - } - - if (cohort == null && browseDataForm.getCohortLabel() != null) - { - cohort = manager.getCohortByLabel(getContainer(), getUser(), browseDataForm.getCohortLabel()); - } - - if (study != null) - { - schedule.setVisits(manager.getVisits(study, cohort, getUser(), Visit.Order.DISPLAY)); - - response.put("schedule", schedule.toJSON(getUser())); - response.put("success", true); - - return response; - } - else - throw new IllegalStateException("A study does not exist in this folder"); - } - } - - @RequiresPermission(AdminPermission.class) - public static class UpdateStudyScheduleAction extends MutatingApiAction - { - @Override - public void validateForm(StudySchedule form, Errors errors) - { - if (form.getSchedule().size() <= 0) - errors.reject(ERROR_MSG, "No study schedule records have been specified"); - } - - @Override - public ApiResponse execute(StudySchedule form, BindException errors) - { - ApiSimpleResponse response = new ApiSimpleResponse(); - Study study = StudyManager.getInstance().getStudy(getContainer()); - - if (study != null) - { - for (Map.Entry> entry : form.getSchedule().entrySet()) - { - Dataset ds = StudyService.get().getDataset(getContainer(), entry.getKey()); - if (ds != null) - { - for (VisitDataset visit : entry.getValue()) - { - VisitDatasetType type = visit.isRequired() ? VisitDatasetType.REQUIRED : VisitDatasetType.NOT_ASSOCIATED; - - StudyManager.getInstance().updateVisitDatasetMapping(getUser(), getContainer(), - visit.getVisitRowId(), ds.getDatasetId(), type); - } - } - } - response.put("success", true); - - return response; - } - else - throw new IllegalStateException("A study does not exist in this folder"); - } - } - - public static class BrowseStudyForm - { - private Integer _cohortId; - private String _cohortLabel; - - public Integer getCohortId() - { - return _cohortId; - } - - public void setCohortId(Integer cohortId) - { - _cohortId = cohortId; - } - - public String getCohortLabel() - { - return _cohortLabel; - } - - public void setCohortLabel(String cohortLabel) - { - _cohortLabel = cohortLabel; - } - } - - @RequiresPermission(AdminPermission.class) - public class DefineDatasetAction extends MutatingApiAction - { - private StudyImpl _study; - - @Override - public void validateForm(DefineDatasetForm form, Errors errors) - { - _study = StudyManager.getInstance().getStudy(getContainer()); - - if (_study != null) - { - switch (form.getType()) - { - case defineManually: - case placeHolder: - if (StringUtils.isEmpty(form.getName())) - errors.reject(ERROR_MSG, "A Dataset name must be specified."); - else if (StudyManager.getInstance().getDatasetDefinitionByName(_study, form.getName()) != null) - errors.reject(ERROR_MSG, "A Dataset named: " + form.getName() + " already exists in this folder."); - break; - - case linkToTarget: - if (form.getExpectationDataset() == null || form.getTargetDataset() == null) - errors.reject(ERROR_MSG, "An expectation Dataset and target Dataset must be specified."); - break; - - case linkManually: - if (form.getExpectationDataset() == null) - errors.reject(ERROR_MSG, "An expectation Dataset must be specified."); - break; - } - } - else - errors.reject(ERROR_MSG, "A study does not exist in this folder"); - } - - @Override - public ApiResponse execute(DefineDatasetForm form, BindException errors) - { - ApiSimpleResponse response = new ApiSimpleResponse(); - DatasetDefinition def; - - DbScope scope = StudySchema.getInstance().getSchema().getScope(); - - try (DbScope.Transaction transaction = scope.ensureTransaction()) - { - Integer categoryId = null; - - if (form.getCategory() != null) - { - ViewCategory category = ViewCategoryManager.getInstance().ensureViewCategory(getContainer(), getUser(), form.getCategory().getLabel()); - categoryId = category.getRowId(); - } - - switch (form.getType()) - { - case defineManually: - { - def = StudyPublishManager.getInstance().createDataset(getUser(), new DatasetDefinition.Builder(form.getName()) - .setStudy(_study) - .setDemographicData(false) - .setCategoryId(categoryId)); - def.provisionTable(false); - - ActionURL redirect = new ActionURL(EditTypeAction.class, getContainer()).addParameter(Dataset.DATASET_KEY, def.getDatasetId()); - response.put("redirectUrl", redirect.getLocalURIString()); - break; - } - case placeHolder: - def = StudyPublishManager.getInstance().createDataset(getUser(), new DatasetDefinition.Builder(form.getName()) - .setStudy(_study) - .setDemographicData(false) - .setType(Dataset.TYPE_PLACEHOLDER) - .setCategoryId(categoryId)); - def.provisionTable(false); - response.put("datasetId", def.getDatasetId()); - break; - - case linkManually: - def = StudyManager.getInstance().getDatasetDefinition(_study, form.getExpectationDataset()); - if (def != null) - { - def = def.createMutable(); - - def.setType(Dataset.TYPE_STANDARD); - def.save(getUser()); - - // add a cancel url to rollback either the manual link or import from file link - ActionURL cancelURL = new ActionURL(CancelDefineDatasetAction.class, getContainer()).addParameter("expectationDataset", form.getExpectationDataset()); - - ActionURL redirect = new ActionURL(EditTypeAction.class, getContainer()).addParameter(Dataset.DATASET_KEY, form.getExpectationDataset()); - redirect.addCancelURL(cancelURL); - response.put("redirectUrl", redirect.getLocalURIString()); - } - else - throw new IllegalArgumentException("The expectation Dataset did not exist"); - break; - - case linkToTarget: - DatasetDefinition expectationDataset = StudyManager.getInstance().getDatasetDefinition(_study, form.getExpectationDataset()); - DatasetDefinition targetDataset = StudyManager.getInstance().getDatasetDefinition(_study, form.getTargetDataset()); - - StudyManager.getInstance().linkPlaceHolderDataset(_study, getUser(), expectationDataset, targetDataset); - break; - } - response.put("success", true); - transaction.commit(); - } - - return response; - } - } - - @RequiresPermission(AdminPermission.class) - public class CancelDefineDatasetAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object form, BindException errors) - { - // switch the dataset back to a placeholder type - Study study = getStudy(getContainer()); - if (study != null) - { - String expectationDataset = getViewContext().getActionURL().getParameter("expectationDataset"); - if (NumberUtils.isDigits(expectationDataset)) - { - DatasetDefinition def = StudyManager.getInstance().getDatasetDefinition(study, NumberUtils.toInt(expectationDataset)); - if (def != null) - { - def = def.createMutable(); - - def.setType(Dataset.TYPE_PLACEHOLDER); - def.save(getUser()); - } - } - } - throw new RedirectException(new ActionURL(StudyScheduleAction.class, getContainer())); - } - - @Override - public void addNavTrail(NavTree root) - { - } - } - - public static class DefineDatasetForm implements ApiJsonForm, HasViewContext - { - enum Type - { - defineManually, - placeHolder, - linkToTarget, - linkManually, - } - - private ViewContext _context; - private DefineDatasetForm.Type _type; - private String _name; - private ViewCategory _category; - private Integer _expectationDataset; - private Integer _targetDataset; - - public Type getType() - { - return _type; - } - - public String getName() - { - return _name; - } - - public ViewCategory getCategory() - { - return _category; - } - - public Integer getExpectationDataset() - { - return _expectationDataset; - } - - public Integer getTargetDataset() - { - return _targetDataset; - } - - @Override - public void bindJson(JSONObject json) - { - JSONObject categoryProp = json.optJSONObject("category"); - if (null != categoryProp) - { - _category = ViewCategory.fromJSON(_context.getContainer(), categoryProp); - } - - _name = json.optString("name", null); - - String type = json.optString("type", null); - if (null != type) - _type = Type.valueOf(type); - - _expectationDataset = asInteger(json.opt("expectationDataset")); - _targetDataset = asInteger(json.opt("targetDataset")); - } - - @Override - public void setViewContext(ViewContext context) - { - _context = context; - } - - @Override - public ViewContext getViewContext() - { - return _context; - } - } - - public static class ChangeAlternateIdsForm - { - private String _prefix = ""; - private int _numDigits = StudyManager.ALTERNATEID_DEFAULT_NUM_DIGITS; - private int _aliasDatasetId = -1; - private String _aliasColumn = ""; - private String _sourceColumn = ""; - - public String getAliasColumn() - { - return _aliasColumn; - } - - public void setAliasColumn(String aliasColumn) - { - _aliasColumn = aliasColumn; - } - - public String getSourceColumn() - { - return _sourceColumn; - } - - public void setSourceColumn(String sourceColumn) - { - _sourceColumn = sourceColumn; - } - - public String getPrefix() - { - return _prefix; - } - - public void setPrefix(String prefix) - { - _prefix = prefix; - } - - public int getNumDigits() - { - return _numDigits; - } - - public void setNumDigits(int numDigits) - { - _numDigits = numDigits; - } - - public int getAliasDatasetId() - { - return _aliasDatasetId; - } - public void setAliasDatasetId(int aliasDatasetId) - { - _aliasDatasetId = aliasDatasetId; - } - } - - @RequiresPermission(AdminPermission.class) - public class ChangeAlternateIdsAction extends MutatingApiAction - { - @Override - public ApiResponse execute(ChangeAlternateIdsForm form, BindException errors) - { - ApiSimpleResponse response = new ApiSimpleResponse(); - StudyImpl study = StudyManager.getInstance().getStudy(getContainer()); - if (study != null) - { - setAlternateIdProperties(study, form.getPrefix(), form.getNumDigits()); - StudyManager.getInstance().clearAlternateParticipantIds(study); - response.put("success", true); - return response; - } - else - throw new IllegalStateException("A study does not exist in this folder"); - } - } - - public static class MapAliasIdsForm - { - private int _datasetId; - private String _aliasColumn = ""; - private String _sourceColumn = ""; - - public int getDatasetId() - { - return _datasetId; - } - - public void setDatasetId(int datasetId) - { - _datasetId = datasetId; - } - - public String getAliasColumn() - { - return _aliasColumn; - } - - public void setAliasColumn(String aliasColumn) - { - _aliasColumn = aliasColumn; - } - - public String getSourceColumn() - { - return _sourceColumn; - } - - public void setSourceColumn(String sourceColumn) - { - _sourceColumn = sourceColumn; - } - } - - @RequiresPermission(AdminPermission.class) - public class MapAliasIdsAction extends MutatingApiAction - { - @Override - public ApiResponse execute(MapAliasIdsForm form, BindException errors) - { - ApiSimpleResponse response = new ApiSimpleResponse(); - StudyImpl study = StudyManager.getInstance().getStudy(getContainer()); - if (study != null) - { - setAliasMappingProperties(study, form.getDatasetId(), form.getAliasColumn(), form.getSourceColumn()); - StudyManager.getInstance().clearAlternateParticipantIds(study); - response.put("success", true); - return response; - } - else - throw new IllegalStateException("A study does not exist in this folder"); - } - } - - - @RequiresPermission(AdminPermission.class) - public static class ExportParticipantTransformsAction extends FormHandlerAction - { - @Override - public void validateCommand(Object target, Errors errors) - { - } - - @Override - public boolean handlePost(Object o, BindException errors) throws Exception - { - Study study = StudyManager.getInstance().getStudy(getContainer()); - if (study != null) - { - // Ensure alternateIds are generated for all participants - StudyManager.getInstance().generateNeededAlternateParticipantIds(study, getUser()); - - TableInfo ti = StudySchema.getInstance().getTableInfoParticipant(); - List cols = new ArrayList<>(); - cols.add(ti.getColumn("participantid")); - cols.add(ti.getColumn("alternateid")); - cols.add(ti.getColumn("dateoffset")); - SimpleFilter filter = new SimpleFilter(); - filter.addCondition(ti.getColumn("container"), getContainer()); - ResultsFactory factory = ()->QueryService.get().select(ti, cols, filter, new Sort("participantid")); - - // NOTE: TSVGridWriter closes PrintWriter and ResultSet - try (TSVGridWriter writer = new TSVGridWriter(factory)) - { - writer.setApplyFormats(false); - writer.setFilenamePrefix("ParticipantTransforms"); - writer.setColumnHeaderType(ColumnHeaderType.DisplayFieldKey); // CONSIDER: Use FieldKey instead - writer.write(getViewContext().getResponse()); - } - - return true; - } - else - throw new IllegalStateException("A study does not exist in this folder"); - } - - @Override - public URLHelper getSuccessURL(Object o) - { - return null; - } - } - - public static ChangeAlternateIdsForm getChangeAlternateIdForm(StudyImpl study) - { - ChangeAlternateIdsForm changeAlternateIdsForm = new ChangeAlternateIdsForm(); - changeAlternateIdsForm.setPrefix(study.getAlternateIdPrefix()); - changeAlternateIdsForm.setNumDigits(study.getAlternateIdDigits()); - if (study.getParticipantAliasDatasetId() != null) - { - changeAlternateIdsForm.setAliasDatasetId(study.getParticipantAliasDatasetId()); - changeAlternateIdsForm.setAliasColumn(study.getParticipantAliasProperty()); - changeAlternateIdsForm.setSourceColumn(study.getParticipantAliasSourceProperty()); - } - - return changeAlternateIdsForm; - } - - private void setAlternateIdProperties(StudyImpl study, String prefix, int numDigits) - { - study = study.createMutable(); - study.setAlternateIdPrefix(prefix); - study.setAlternateIdDigits(numDigits); - StudyManager.getInstance().updateStudy(getUser(), study); - } - - private void setAliasMappingProperties(StudyImpl study, int datasetId, String aliasColumn, String sourceColumn) - { - study = study.createMutable(); - study.setParticipantAliasDatasetId(datasetId); - study.setParticipantAliasProperty(aliasColumn); - study.setParticipantAliasSourceProperty(sourceColumn); - StudyManager.getInstance().updateStudy(getUser(), study); - } - - @RequiresPermission(ManageStudyPermission.class) - public class ImportAlternateIdMappingAction extends AbstractQueryImportAction - { - private Study _study; - private int _requestId = -1; - - @Override - protected void initRequest(IdForm form) throws ServletException - { - _requestId = form.getId(); - setHasColumnHeaders(true); - if (null != getStudy()) - { - _study = getStudy(); - setImportMessage("Upload a mapping of " + _study.getSubjectNounPlural() + " to Alternate IDs and date offsets from a TXT, CSV or Excel file or paste the mapping directly into the text box below. " + - "There must be a header row, which must contain ParticipantId and either AlternateId, DateOffset or both. Click the button below to export the current mapping."); - } - setTarget(StudySchema.getInstance().getTableInfoParticipant()); - setHideTsvCsvCombo(true); - setSuccessMessageSuffix("uploaded"); - } - - @Override - public ModelAndView getView(IdForm form, BindException errors) throws Exception - { - _study = getStudyThrowIfNull(); - initRequest(form); - return getDefaultImportView(form, errors); - } - - @Override - protected boolean skipInsertOptionValidation() - { - return true; // allow QueryUpdateService.InsertOption.INSERT for study.participant - } - - @Override - protected void validatePermission(User user, BindException errors) - { - checkPermissions(); - } - - @Override - protected boolean canInsert(User user) - { - return getContainer().hasPermission(user, ManageStudyPermission.class); - } - - @Override - protected int importData(DataLoader dl, FileStream file, String originalName, BatchValidationException errors, @Nullable AuditBehaviorType auditBehaviorType, TransactionAuditProvider.@Nullable TransactionAuditEvent auditEvent, @Nullable String auditUserComment) throws IOException - { - if (null == _study) - return 0; - int rows = StudyManager.getInstance().setImportedAlternateParticipantIds(_study, dl, errors); - - // Insert a clear warning at the top that the mappings have not been imported, #36517 - if (errors.hasErrors()) - { - List rowErrors = errors.getRowErrors(); - int count = rowErrors.size(); - rowErrors.add(0, new ValidationException("Warning: NONE of participant mappings have been imported because this mapping file contains " + (1 == count ? "an error" : "errors") + "! Please correct the following:")); - } - - return rows; - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild("Upload " + _study.getSubjectNounSingular() + " Mapping"); - } - - @Override - protected ActionURL getSuccessURL(IdForm form) - { - return new ActionURL(ManageParticipantsAction.class, getContainer()); - } - } - - @RequiresPermission(AdminPermission.class) - public class SnapshotSettingsAction extends FormViewAction - { - private StudyImpl _study; - - @Override - public ModelAndView getView(SnapshotSettingsForm form, boolean reshow, BindException errors) - { - _study = getStudyRedirectIfNull(); - StudySnapshot snapshot = StudyManager.getInstance().getStudySnapshot(_study.getStudySnapshot()); - - if (null == snapshot) - { - errors.reject(null, "This is not a published study"); - return new SimpleErrorView(errors); - } - else - { - return new JspView<>("/org/labkey/study/view/snapshotSettings.jsp", snapshot); - } - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("studyPubRefresh"); - _addManageStudy(root); - root.addChild((_study.getStudySnapshotType() != null ? _study.getStudySnapshotType().getTitle() : "") + " Study Settings"); - } - - @Override - public void validateCommand(SnapshotSettingsForm form, Errors errors) - { - } - - @Override - public boolean handlePost(SnapshotSettingsForm form, BindException errors) - { - StudyImpl study = getStudyRedirectIfNull(); - StudySnapshot snapshot = StudyManager.getInstance().getStudySnapshot(study.getStudySnapshot()); - assert null != snapshot; - snapshot.setRefresh(form.isRefresh()); - StudyManager.getInstance().updateStudySnapshot(snapshot, getUser()); - return false; - } - - @Override - public URLHelper getSuccessURL(SnapshotSettingsForm form) - { - return new ActionURL(getClass(), getContainer()); - } - } - - public static class SnapshotSettingsForm - { - private boolean _refresh = false; - - public boolean isRefresh() - { - return _refresh; - } - - public void setRefresh(boolean refresh) - { - _refresh = refresh; - } - } - - /** - * Set up the site wide settings for a master patient provider - */ - @RequiresPermission(AdminPermission.class) - public static class MasterPatientProviderAction extends FormViewAction - { - @Override - public void validateCommand(MasterPatientProviderSettings form, Errors errors) - { - if (!form.isValid()) - errors.reject(ERROR_MSG, "All required fields are not specified"); - } - - @Override - public ModelAndView getView(MasterPatientProviderSettings form, boolean reshow, BindException errors) throws Exception - { - return new JspView<>("/org/labkey/study/view/masterPatientProvider.jsp", form, errors); - } - - @Override - public boolean handlePost(MasterPatientProviderSettings form, BindException errors) throws Exception - { - if (form.getType() != null) - { - try (DbScope.Transaction transaction = StudySchema.getInstance().getScope().ensureTransaction()) - { - MasterPatientIndexService svc = MasterPatientIndexService.getProvider(form.getType()); - if (svc != null) - { - WritablePropertyMap map = PropertyManager.getNormalStore().getWritableProperties(MasterPatientProviderSettings.CATEGORY, true); - - map.put(MasterPatientProviderSettings.TYPE, form.getType()); - map.save(); - - svc.setServerSettings(form); - transaction.commit(); - } - } - } - return true; - } - - @Override - public URLHelper getSuccessURL(MasterPatientProviderSettings form) - { - return urlProvider(AdminUrls.class).getAdminConsoleURL(); - } - - @Override - public void addNavTrail(NavTree root) - { - urlProvider(AdminUrls.class).addAdminNavTrail(root, "Configure Master Patient Index", getClass(), getContainer()); - } - } - - @RequiresPermission(AdminPermission.class) - public static class TestMasterPatientProviderAction extends MutatingApiAction - { - @Override - public void validateForm(MasterPatientProviderSettings form, Errors errors) - { - if (!form.isValid()) - errors.reject(ERROR_MSG, "All required fields are not specified"); - } - - @Override - public Object execute(MasterPatientProviderSettings form, BindException errors) throws Exception - { - ApiSimpleResponse response = new ApiSimpleResponse(); - - if (form.getType() != null) - { - MasterPatientIndexService svc = MasterPatientIndexService.getProvider(form.getType()); - if (svc != null) - { - if (svc.checkServerSettings(form)) - { - response.put("success", true); - response.put("message", "The specified settings are valid."); - } - else - { - response.put("success", false); - response.put("message", "The specified settings are not valid."); - } - } - } - return response; - } - } - - public static class MasterPatientProviderSettings extends MasterPatientIndexService.ServerSettings - { - public static final String CATEGORY = "MASTER_PATIENT_PROVIDER"; - public static final String TYPE = "TYPE"; - - private String _type; - - public String getType() - { - return _type; - } - - public void setType(String type) - { - _type = type; - } - } - - @RequiresPermission(AdminPermission.class) - public class ConfigureMasterPatientSettingsAction extends FormViewAction - { - private MasterPatientIndexService _svc; - - @Override - public void validateCommand(MasterPatientIndexService.FolderSettings form, Errors errors) - { - if (!form.isValid()) - errors.reject(ERROR_MSG, "All required fields are not specified"); - } - - @Override - public ModelAndView getView(MasterPatientIndexService.FolderSettings form, boolean reshow, BindException errors) throws Exception - { - return new JspView<>("/org/labkey/study/view/manageMasterPatientConfig.jsp", getService(), errors); - } - - @Override - public boolean handlePost(MasterPatientIndexService.FolderSettings form, BindException errors) throws Exception - { - MasterPatientIndexService svc = getService(); - if (svc != null) - { - form.setReloadUser(getUser().getUserId()); - svc.setFolderSettings(getContainer(), form); - } - return true; - } - - @Override - public URLHelper getSuccessURL(MasterPatientIndexService.FolderSettings form) - { - return new ActionURL(ManageStudyAction.class, getContainer()); - } - - @Override - public void addNavTrail(NavTree root) - { - MasterPatientIndexService svc = getService(); - if (svc != null) - root.addChild("Manage " + svc.getName() + " Configuration"); - else - root.addChild("Manage Master Patient Index Configuration"); - } - - private MasterPatientIndexService getService() - { - if (_svc == null) - { - _svc = MasterPatientIndexMaintenanceTask.getConfiguredService(); - } - return _svc; - } - } - - @RequiresPermission(AdminPermission.class) - public static class RefreshMasterPatientIndexAction extends MutatingApiAction - { - @Override - public ApiResponse execute(Object o, BindException errors) throws Exception - { - ApiSimpleResponse response = new ApiSimpleResponse(); - try - { - ViewBackgroundInfo info = new ViewBackgroundInfo(getContainer(), getUser(), getViewContext().getActionURL()); - MasterPatientIndexService svc = MasterPatientIndexMaintenanceTask.getConfiguredService(); - - MasterPatientIndexService.FolderSettings settings = svc.getFolderSettings(getContainer()); - if (settings.isEnabled()) - { - PipelineJob job = new MasterPatientIndexUpdateTask(info, PipelineService.get().findPipelineRoot(getContainer()), svc); - - PipelineService.get().queueJob(job); - - response.put("success", true); - response.put(ActionURL.Param.returnUrl.name(), urlProvider(PipelineUrls.class).urlBegin(getContainer())); - } - else - { - response.put("success", false); - response.put("message", "The specified configuration is not enabled."); - } - } - catch (PipelineValidationException e) - { - throw new IOException(e); - } - return response; - } - } - - @RequiresPermission(AdminPermission.class) - public static class DeleteMasterPatientRecordsAction extends MutatingApiAction - { - @Override - public ApiResponse execute(DeleteMPIForm form, BindException errors) throws Exception - { - ApiSimpleResponse response = new ApiSimpleResponse(); - - List> params = form.getParams(); - MasterPatientIndexService svc = MasterPatientIndexMaintenanceTask.getConfiguredService(); - if (svc != null && !params.isEmpty()) - { - int count = svc.deleteMatchingRecords(params); - - response.put("success", true); - response.put("count", count); - } - return response; - } - } - - public static class DeleteMPIForm implements ApiJsonForm - { - private final List> _params = new ArrayList<>(); - - public List> getParams() - { - return _params; - } - - @Override - public void bindJson(JSONObject json) - { - for (String key : json.keySet()) - { - _params.add(new Pair<>(key, String.valueOf(json.get(key)))); - } - } - } - - // Render the HTML description if a study exists in this folder. Used by the client-side CSP validator. - @RequiresPermission(ReadPermission.class) - public static class DescriptionAction extends SimpleViewAction - { - private StudyImpl _study; - - @Override - public ModelAndView getView(Object o, BindException errors) throws Exception - { - _study = getStudy(getContainer()); - return null != _study ? new HtmlView(_study.getDescriptionHtml()) : new EmptyView(); - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild(_study != null ? "Overview: " + _study.getLabel() : "No Study"); - } - } -} +/* + * Copyright (c) 2008-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.labkey.study.controllers; + +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import org.apache.commons.beanutils.ConversionException; +import org.apache.commons.collections4.FactoryUtils; +import org.apache.commons.collections4.MapUtils; +import org.apache.commons.collections4.MultiValuedMap; +import org.apache.commons.collections4.multimap.ArrayListValuedHashMap; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.math.NumberUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.xmlbeans.XmlException; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.json.JSONObject; +import org.labkey.api.action.ApiJsonForm; +import org.labkey.api.action.ApiResponse; +import org.labkey.api.action.ApiSimpleResponse; +import org.labkey.api.action.ConfirmAction; +import org.labkey.api.action.FormApiAction; +import org.labkey.api.action.FormHandlerAction; +import org.labkey.api.action.FormViewAction; +import org.labkey.api.action.HasAllowBindParameter; +import org.labkey.api.action.HasViewContext; +import org.labkey.api.action.Marshal; +import org.labkey.api.action.Marshaller; +import org.labkey.api.action.MutatingApiAction; +import org.labkey.api.action.QueryViewAction; +import org.labkey.api.action.ReadOnlyApiAction; +import org.labkey.api.action.ReturnUrlForm; +import org.labkey.api.action.SimpleErrorView; +import org.labkey.api.action.SimpleRedirectAction; +import org.labkey.api.action.SimpleViewAction; +import org.labkey.api.action.SpringActionController; +import org.labkey.api.admin.AdminUrls; +import org.labkey.api.admin.ImportException; +import org.labkey.api.admin.notification.NotificationService; +import org.labkey.api.assay.AssayUrls; +import org.labkey.api.attachments.AttachmentFile; +import org.labkey.api.attachments.AttachmentForm; +import org.labkey.api.attachments.AttachmentParent; +import org.labkey.api.attachments.AttachmentService; +import org.labkey.api.attachments.BaseDownloadAction; +import org.labkey.api.audit.AuditLogService; +import org.labkey.api.audit.TransactionAuditProvider; +import org.labkey.api.collections.CaseInsensitiveHashMap; +import org.labkey.api.collections.IntHashMap; +import org.labkey.api.collections.IntHashSet; +import org.labkey.api.collections.LabKeyCollectors; +import org.labkey.api.compliance.ComplianceService; +import org.labkey.api.data.ActionButton; +import org.labkey.api.data.ButtonBar; +import org.labkey.api.data.ColumnHeaderType; +import org.labkey.api.data.ColumnInfo; +import org.labkey.api.data.CompareType; +import org.labkey.api.data.Container; +import org.labkey.api.data.ContainerFilter; +import org.labkey.api.data.DataRegion; +import org.labkey.api.data.DataRegionSelection; +import org.labkey.api.data.DbSchema; +import org.labkey.api.data.DbScope; +import org.labkey.api.data.DisplayColumn; +import org.labkey.api.data.PropertyManager; +import org.labkey.api.data.PropertyManager.WritablePropertyMap; +import org.labkey.api.data.RenderContext; +import org.labkey.api.data.Results; +import org.labkey.api.data.ResultsFactory; +import org.labkey.api.data.RuntimeSQLException; +import org.labkey.api.data.SQLFragment; +import org.labkey.api.data.ShowRows; +import org.labkey.api.data.SimpleDisplayColumn; +import org.labkey.api.data.SimpleFilter; +import org.labkey.api.data.Sort; +import org.labkey.api.data.SqlExecutor; +import org.labkey.api.data.SqlSelector; +import org.labkey.api.data.TSVGridWriter; +import org.labkey.api.data.Table; +import org.labkey.api.data.TableInfo; +import org.labkey.api.data.TableSelector; +import org.labkey.api.data.TableViewForm; +import org.labkey.api.data.views.DataViewService; +import org.labkey.api.exp.LsidManager; +import org.labkey.api.exp.OntologyManager; +import org.labkey.api.exp.PropertyDescriptor; +import org.labkey.api.exp.api.ExpProtocol; +import org.labkey.api.exp.api.ExpSampleType; +import org.labkey.api.exp.property.Domain; +import org.labkey.api.gwt.client.AuditBehaviorType; +import org.labkey.api.module.ModuleHtmlView; +import org.labkey.api.module.ModuleLoader; +import org.labkey.api.pipeline.PipeRoot; +import org.labkey.api.pipeline.PipelineJob; +import org.labkey.api.pipeline.PipelineService; +import org.labkey.api.pipeline.PipelineStatusUrls; +import org.labkey.api.pipeline.PipelineUrls; +import org.labkey.api.pipeline.PipelineValidationException; +import org.labkey.api.pipeline.browse.PipelinePathForm; +import org.labkey.api.qc.AbstractDeleteDataStateAction; +import org.labkey.api.qc.AbstractManageDataStatesForm; +import org.labkey.api.qc.AbstractManageQCStatesAction; +import org.labkey.api.qc.AbstractManageQCStatesBean; +import org.labkey.api.qc.DataState; +import org.labkey.api.qc.DataStateHandler; +import org.labkey.api.qc.DeleteDataStateForm; +import org.labkey.api.qc.QCStateManager; +import org.labkey.api.query.AbstractQueryImportAction; +import org.labkey.api.query.BatchValidationException; +import org.labkey.api.query.CustomView; +import org.labkey.api.query.DetailsURL; +import org.labkey.api.query.FieldKey; +import org.labkey.api.query.InvalidKeyException; +import org.labkey.api.query.QueryAction; +import org.labkey.api.query.QueryDefinition; +import org.labkey.api.query.QueryForm; +import org.labkey.api.query.QueryParseException; +import org.labkey.api.query.QueryService; +import org.labkey.api.query.QuerySettings; +import org.labkey.api.query.QueryUpdateService; +import org.labkey.api.query.QueryUpdateServiceException; +import org.labkey.api.query.QueryView; +import org.labkey.api.query.SchemaKey; +import org.labkey.api.query.UserSchema; +import org.labkey.api.query.ValidationException; +import org.labkey.api.query.snapshot.QuerySnapshotDefinition; +import org.labkey.api.query.snapshot.QuerySnapshotForm; +import org.labkey.api.query.snapshot.QuerySnapshotService; +import org.labkey.api.reader.DataLoader; +import org.labkey.api.reader.TabLoader; +import org.labkey.api.reports.Report; +import org.labkey.api.reports.ReportService; +import org.labkey.api.reports.model.ReportPropsManager; +import org.labkey.api.reports.model.ViewCategory; +import org.labkey.api.reports.model.ViewCategoryManager; +import org.labkey.api.reports.report.AbstractReportIdentifier; +import org.labkey.api.reports.report.QueryReport; +import org.labkey.api.reports.report.ReportIdentifier; +import org.labkey.api.reports.report.ReportUrls; +import org.labkey.api.search.SearchService; +import org.labkey.api.search.SearchUrls; +import org.labkey.api.security.RequiresAllOf; +import org.labkey.api.security.RequiresLogin; +import org.labkey.api.security.RequiresNoPermission; +import org.labkey.api.security.RequiresPermission; +import org.labkey.api.security.SecurityManager; +import org.labkey.api.security.User; +import org.labkey.api.security.permissions.AdminPermission; +import org.labkey.api.security.permissions.BrowserDeveloperPermission; +import org.labkey.api.security.permissions.DeletePermission; +import org.labkey.api.security.permissions.InsertPermission; +import org.labkey.api.security.permissions.Permission; +import org.labkey.api.security.permissions.PlatformDeveloperPermission; +import org.labkey.api.security.permissions.QCAnalystPermission; +import org.labkey.api.security.permissions.ReadPermission; +import org.labkey.api.security.permissions.UpdatePermission; +import org.labkey.api.settings.OptionalFeatureService; +import org.labkey.api.specimen.SpecimenManager; +import org.labkey.api.specimen.SpecimenMigrationService; +import org.labkey.api.specimen.location.LocationImpl; +import org.labkey.api.specimen.location.LocationManager; +import org.labkey.api.study.CohortFilter; +import org.labkey.api.study.CompletionType; +import org.labkey.api.study.Dataset; +import org.labkey.api.study.Dataset.KeyManagementType; +import org.labkey.api.study.DatasetTable; +import org.labkey.api.study.MasterPatientIndexService; +import org.labkey.api.study.ParticipantCategory; +import org.labkey.api.study.Study; +import org.labkey.api.study.StudyService; +import org.labkey.api.study.StudyUrls; +import org.labkey.api.study.TimepointType; +import org.labkey.api.study.Visit; +import org.labkey.api.study.model.ParticipantGroup; +import org.labkey.api.study.publish.StudyPublishService; +import org.labkey.api.study.security.permissions.ManageStudyPermission; +import org.labkey.api.studydesign.StudyDesignManager; +import org.labkey.api.util.ContainerContext; +import org.labkey.api.util.CsrfInput; +import org.labkey.api.util.DateUtil; +import org.labkey.api.util.DemoMode; +import org.labkey.api.util.FileStream; +import org.labkey.api.util.HtmlString; +import org.labkey.api.util.HtmlStringBuilder; +import org.labkey.api.util.LinkBuilder; +import org.labkey.api.util.PageFlowUtil; +import org.labkey.api.util.Pair; +import org.labkey.api.util.StringExpression; +import org.labkey.api.util.URLHelper; +import org.labkey.api.util.XmlBeansUtil; +import org.labkey.api.view.ActionURL; +import org.labkey.api.view.DataView; +import org.labkey.api.view.GridView; +import org.labkey.api.view.HtmlView; +import org.labkey.api.view.HttpView; +import org.labkey.api.view.JspView; +import org.labkey.api.view.NavTree; +import org.labkey.api.view.NotFoundException; +import org.labkey.api.view.Portal; +import org.labkey.api.view.RedirectException; +import org.labkey.api.view.UnauthorizedException; +import org.labkey.api.view.VBox; +import org.labkey.api.view.ViewBackgroundInfo; +import org.labkey.api.view.ViewContext; +import org.labkey.api.view.ViewForm; +import org.labkey.api.view.WebPartView; +import org.labkey.api.view.template.EmptyView; +import org.labkey.api.view.template.PageConfig; +import org.labkey.api.writer.FileSystemFile; +import org.labkey.api.writer.HtmlWriter; +import org.labkey.api.writer.VirtualFile; +import org.labkey.data.xml.TablesDocument; +import org.labkey.study.CohortFilterFactory; +import org.labkey.study.MasterPatientIndexMaintenanceTask; +import org.labkey.study.StudyModule; +import org.labkey.study.StudySchema; +import org.labkey.study.assay.AssayPublishConfirmAction; +import org.labkey.study.assay.AssayPublishStartAction; +import org.labkey.study.assay.StudyPublishManager; +import org.labkey.study.audit.ParticipantGroupAuditProvider; +import org.labkey.study.controllers.publish.SampleTypePublishConfirmAction; +import org.labkey.study.controllers.publish.SampleTypePublishStartAction; +import org.labkey.study.controllers.security.SecurityController; +import org.labkey.study.dataset.DatasetSnapshotProvider; +import org.labkey.study.dataset.DatasetViewProvider; +import org.labkey.study.designer.StudySchedule; +import org.labkey.study.importer.DatasetImportUtils; +import org.labkey.study.importer.SchemaReader; +import org.labkey.study.importer.SchemaXmlReader; +import org.labkey.study.importer.VisitMapImporter; +import org.labkey.study.model.CohortImpl; +import org.labkey.study.model.CohortManager; +import org.labkey.study.model.CustomParticipantView; +import org.labkey.study.model.DatasetDefinition; +import org.labkey.study.model.DatasetDomainKind; +import org.labkey.study.model.DatasetDomainKindProperties; +import org.labkey.study.model.DatasetManager; +import org.labkey.study.model.DatasetReorderer; +import org.labkey.study.model.DateDatasetDomainKind; +import org.labkey.study.model.Participant; +import org.labkey.study.model.ParticipantCategoryImpl; +import org.labkey.study.model.ParticipantGroupManager; +import org.labkey.study.model.QCStateSet; +import org.labkey.study.model.SecurityType; +import org.labkey.study.model.StudyImpl; +import org.labkey.study.model.StudyManager; +import org.labkey.study.model.StudySnapshot; +import org.labkey.study.model.UploadLog; +import org.labkey.study.model.VisitDataset; +import org.labkey.study.model.VisitDatasetType; +import org.labkey.study.model.VisitImpl; +import org.labkey.study.model.VisitMapKey; +import org.labkey.study.pipeline.DatasetFileReader; +import org.labkey.study.pipeline.MasterPatientIndexUpdateTask; +import org.labkey.study.pipeline.StudyPipeline; +import org.labkey.study.qc.StudyQCStateHandler; +import org.labkey.study.query.DatasetQuerySettings; +import org.labkey.study.query.DatasetQueryView; +import org.labkey.study.query.LocationTable; +import org.labkey.study.query.PublishedRecordQueryView; +import org.labkey.study.query.QueryDatasetTable; +import org.labkey.study.query.StudyQuerySchema; +import org.labkey.study.query.StudyQueryView; +import org.labkey.study.reports.ReportManager; +import org.labkey.study.view.SubjectsWebPart; +import org.labkey.study.visitmanager.SequenceVisitManager; +import org.labkey.study.visitmanager.VisitManager; +import org.labkey.study.visitmanager.VisitManager.VisitStatistic; +import org.labkey.study.xml.DatasetsDocument; +import org.labkey.vfs.FileLike; +import org.springframework.validation.BindException; +import org.springframework.validation.Errors; +import org.springframework.web.servlet.ModelAndView; +import org.springframework.web.servlet.mvc.Controller; + +import java.io.File; +import java.io.IOException; +import java.io.PrintWriter; +import java.math.BigDecimal; +import java.net.URISyntaxException; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Date; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.function.Predicate; +import java.util.regex.Pattern; + +import static org.labkey.api.util.IntegerUtils.asInteger; +import static org.labkey.study.model.QCStateSet.PUBLIC_STATES_LABEL; +import static org.labkey.study.model.QCStateSet.getQCStateFilteredURL; +import static org.labkey.study.model.QCStateSet.getQCUrlFilterKey; +import static org.labkey.study.model.QCStateSet.getQCUrlFilterValue; +import static org.labkey.study.model.QCStateSet.selectedQCStateLabelFromUrl; +import static org.labkey.study.query.DatasetQueryView.EXPERIMENTAL_ALLOW_MERGE_WITH_MANAGED_KEYS; + +public class StudyController extends BaseStudyController +{ + private static final Logger _log = LogManager.getLogger(StudyController.class); + private static final String PARTICIPANT_CACHE_PREFIX = "Study_participants/participantCache"; + private static final String EXPAND_CONTAINERS_KEY = StudyController.class.getName() + "/expandedContainers"; + private static final String DATASET_DATAREGION_NAME = "Dataset"; + + private static final ActionResolver ACTION_RESOLVER = new DefaultActionResolver( + StudyController.class, + CreateChildStudyAction.class, + AutoCompleteAction.class + ); + + public static final String DATASET_REPORT_ID_PARAMETER_NAME = "Dataset.reportId"; + public static final String DATASET_VIEW_NAME_PARAMETER_NAME = "Dataset.viewName"; + + public static class StudyUrlsImpl implements StudyUrls + { + @Override + public ActionURL getBeginURL(Container container) + { + return new ActionURL(BeginAction.class, container); + } + + @Override + public ActionURL getCompletionURL(Container studyContainer, CompletionType type) + { + if (studyContainer == null) + return null; + + ActionURL url = new ActionURL(AutoCompleteAction.class, studyContainer); + url.addParameter("type", type.name()); + url.addParameter("prefix", ""); + return url; + } + + @Override + public ActionURL getCreateStudyURL(Container container) + { + return new ActionURL(CreateStudyAction.class, container); + } + + @Override + public ActionURL getManageStudyURL(Container container) + { + return new ActionURL(ManageStudyAction.class, container); + } + + @Override + public Class getManageStudyClass() + { + return ManageStudyAction.class; + } + + @Override + public ActionURL getStudyOverviewURL(Container container) + { + return new ActionURL(OverviewAction.class, container); + } + + @Override + public ActionURL getDatasetURL(Container container, int datasetId) + { + return new ActionURL(DatasetAction.class, container).addParameter(Dataset.DATASET_KEY, datasetId); + } + + @Override + public ActionURL getDatasetsURL(Container container) + { + return new ActionURL(DatasetsAction.class, container); + } + + @Override + public ActionURL getManageDatasetsURL(Container container) + { + return new ActionURL(ManageTypesAction.class, container); + } + + @Override + public ActionURL getManageReportPermissions(Container container) + { + return new ActionURL(SecurityController.ReportPermissionsAction.class, container); + } + + @Override + public ActionURL getManageFileWatchersURL(Container container) + { + return new ActionURL(StudyController.ManageFilewatchersAction.class, container); + } + + @Override + public ActionURL getLinkToStudyURL(Container container, ExpSampleType sampleType) + { + ActionURL url = new ActionURL(SampleTypePublishStartAction.class, container); + if (sampleType != null) + url.addParameter("rowId", sampleType.getRowId()); + return url; + } + + @Override + public ActionURL getLinkToStudyURL(Container container, ExpProtocol protocol) + { + return urlProvider(AssayUrls.class).getProtocolURL(container, protocol, AssayPublishStartAction.class); + } + + @Override + public ActionURL getLinkToStudyConfirmURL(Container container, ExpProtocol protocol) + { + return urlProvider(AssayUrls.class).getProtocolURL(container, protocol, AssayPublishConfirmAction.class); + } + + @Override + public ActionURL getLinkToStudyConfirmURL(Container container, ExpSampleType sampleType) + { + ActionURL url = new ActionURL(SampleTypePublishConfirmAction.class, container); + if (sampleType != null) + url.addParameter("rowId", sampleType.getRowId()); + return url; + } + + @Override + public void addManageStudyNavTrail(NavTree root, Container container, User user) + { + _addManageStudy(root, container, user); + } + + @Override + public ActionURL getTypeNotFoundURL(Container container, int datasetId) + { + return new ActionURL(TypeNotFoundAction.class, container).addParameter("id", datasetId); + } + + @Override + public ActionURL getManageLocationsURL(Container container) + { + return new ActionURL(ManageLocationsAction.class, container); + } + + @Override + public ActionURL getManageVisitsURL(Container container) + { + return new ActionURL(ManageVisitsAction.class, container); + } + + @Override + public ActionURL getManageCohortsURL(Container container) + { + return new ActionURL(CohortController.ManageCohortsAction.class, container); + } + + @Override + public ActionURL getVisitOrderURL(Container container) + { + return new ActionURL(VisitOrderAction.class, container); + } + } + + public StudyController() + { + setActionResolver(ACTION_RESOLVER); + } + + protected void _addNavTrailVisitAdmin(NavTree root) + { + _addManageStudy(root); + + StringBuilder sb = new StringBuilder("Manage "); + + Study visitStudy = StudyManager.getInstance().getStudyForVisits(getStudy()); + if (visitStudy.getShareVisitDefinitions() == Boolean.TRUE) + sb.append("Shared "); + + sb.append(getVisitLabelPlural()); + + root.addChild(sb.toString(), new ActionURL(ManageVisitsAction.class, getContainer())); + } + + @RequiresPermission(ReadPermission.class) + public class BeginAction extends SimpleViewAction + { + private Study _study; + + @Override + public ModelAndView getView(Object o, BindException errors) throws Exception + { + _study = getStudy(); + + WebPartView overview = StudyModule.manageStudyPartFactory.getWebPartView(getViewContext(), StudyModule.manageStudyPartFactory.createWebPart()); + WebPartView views = StudyModule.reportsPartFactory.getWebPartView(getViewContext(), StudyModule.reportsPartFactory.createWebPart()); + return new VBox(overview, views); + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild(_study == null ? "No Study In Folder" : _study.getLabel()); + } + } + + @RequiresPermission(AdminPermission.class) + public class DefineDatasetTypeAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object o, BindException errors) throws Exception + { + getStudyRedirectIfNull(); + return ModuleHtmlView.get(ModuleLoader.getInstance().getModule("core"), ModuleHtmlView.getGeneratedViewPath("datasetDesigner")); + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("createDataset"); + _addNavTrailDatasetAdmin(root); + root.addChild("Create Dataset Definition"); + } + } + + @Marshal(Marshaller.Jackson) + @RequiresPermission(ReadPermission.class) + public static class GetDatasetAction extends ReadOnlyApiAction + { + @Override + public Object execute(DatasetForm form, BindException errors) throws Exception + { + DatasetDomainKindProperties properties = DatasetManager.get().getDatasetDomainKindProperties(getContainer(), form.getDatasetId()); + if (properties != null) + return properties; + else + throw new NotFoundException("Dataset does not exist in this container for datasetId " + form.getDatasetIdStr() + "."); + } + } + + @RequiresPermission(AdminPermission.class) + @SuppressWarnings("unchecked") + public class EditTypeAction extends SimpleViewAction + { + private Dataset _def; + + @Override + public ModelAndView getView(DatasetForm form, BindException errors) + { + StudyImpl study = getStudyRedirectIfNull(); + if (null == form.getDatasetId()) + throw new NotFoundException("No datasetId parameter provided."); + + DatasetDefinition def = study.getDataset(form.getDatasetId()); + _def = def; + if (null == def) + throw new NotFoundException("No dataset found for datasetId " + form.getDatasetId() + "."); + + if (def.isQueryDataset()) + throw new UnsupportedOperationException("Query dataset definition cannot be edited. Update the source query to change definition."); + + if (!def.canUpdateDefinition(getUser())) + { + ActionURL details = new ActionURL(DatasetDetailsAction.class,getContainer()).addParameter("id",def.getDatasetId()); + throw new RedirectException(details); + } + + if (null == def.getTypeURI()) + { + def = def.createMutable(); + String domainURI = StudyManager.getInstance().getDomainURI(study.getContainer(), getUser(), def); + OntologyManager.ensureDomainDescriptor(domainURI, def.getName(), study.getContainer()); + def.setTypeURI(domainURI); + } + + return ModuleHtmlView.get(ModuleLoader.getInstance().getModule("core"), ModuleHtmlView.getGeneratedViewPath("datasetDesigner")); + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("datasetProperties"); + _addNavTrailDatasetAdmin(root); + root.addChild(_def.getName(), new ActionURL(DatasetDetailsAction.class, getContainer()).addParameter("id", _def.getDatasetId())); + root.addChild("Edit Dataset Definition"); + } + } + + @RequiresPermission(ReadPermission.class) + public class DatasetDetailsAction extends SimpleViewAction + { + private DatasetDefinition _def; + + @Override + public ModelAndView getView(IdForm form, BindException errors) + { + _def = StudyManager.getInstance().getDatasetDefinition(getStudyRedirectIfNull(), form.getId()); + if (_def == null) + { + throw new NotFoundException("Invalid Dataset ID"); + } + return new StudyJspView<>(StudyManager.getInstance().getStudy(getContainer()), + "/org/labkey/study/view/datasetDetails.jsp", _def, errors); + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("datasetProperties"); + root.addChild(_def.getLabel(), urlProvider(StudyUrls.class).getDatasetURL(getContainer(), _def.getDatasetId())); + root.addChild("Dataset Properties"); + } + } + + public static class DatasetFilterForm extends QueryViewAction.QueryExportForm implements HasViewContext + { + private ViewContext _viewContext; + + @Override + public void setViewContext(ViewContext context) + { + _viewContext = context; + } + + @Override + public ViewContext getViewContext() + { + return _viewContext; + } + } + + + public static class OverviewForm extends DatasetFilterForm + { + private String _qcState; + private String[] _visitStatistic = new String[0]; + + public String getQCState() + { + return _qcState; + } + + public void setQCState(String qcState) + { + _qcState = qcState; + } + + public String[] getVisitStatistic() + { + return _visitStatistic; + } + + @SuppressWarnings({"UnusedDeclaration"}) + public void setVisitStatistic(String[] visitStatistic) + { + _visitStatistic = visitStatistic; + } + + private Set getVisitStatistics() + { + Set set = EnumSet.noneOf(VisitStatistic.class); + + for (String statName : _visitStatistic) + set.add(VisitStatistic.valueOf(statName)); + + if (set.isEmpty()) + set.add(VisitStatistic.values()[0]); + + return set; + } + } + + + @RequiresPermission(ReadPermission.class) + public class OverviewAction extends SimpleViewAction + { + private StudyImpl _study; + + @Override + public ModelAndView getView(OverviewForm form, BindException errors) throws Exception + { + _study = getStudyRedirectIfNull(); + OverviewBean bean = new OverviewBean(); + bean.study = _study; + bean.showAll = "1".equals(getViewContext().get("showAll")); + bean.canManage = getContainer().hasPermission(getUser(), ManageStudyPermission.class); + bean.showCohorts = StudyManager.getInstance().showCohorts(getContainer(), getUser()); + bean.stats = form.getVisitStatistics(); + bean.showSpecimens = SpecimenManager.get().isSpecimenModuleActive(getContainer()); + + if (QCStateManager.getInstance().showStates(getContainer())) + bean.qcStates = QCStateSet.getSelectedStates(getContainer(), form.getQCState()); + + if (!bean.showCohorts) + bean.cohortFilter = null; + else + bean.cohortFilter = CohortFilterFactory.getFromURL(getContainer(), getUser(), getViewContext().getActionURL(), DatasetQueryView.DATAREGION); + + VisitManager visitManager = StudyManager.getInstance().getVisitManager(bean.study); + bean.visitMapSummary = visitManager.getVisitSummary(getUser(), bean.cohortFilter, bean.qcStates, bean.stats, bean.showAll); + + return new StudyJspView<>(_study, "/org/labkey/study/view/overview.jsp", bean, errors); + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("studyDashboard#navigator"); + root.addChild("Overview: " + _study.getLabel()); + } + } + + public static class QueryReportForm extends QueryViewAction.QueryExportForm + { + ReportIdentifier _reportId; + + public ReportIdentifier getReportId() + { + return _reportId; + } + + public void setReportId(ReportIdentifier reportId) + { + _reportId = reportId; + } + } + + @RequiresPermission(ReadPermission.class) + public static class QueryReportAction extends QueryViewAction + { + protected Report _report; + + public QueryReportAction() + { + super(QueryReportForm.class); + } + + @Override + protected ModelAndView getHtmlView(QueryReportForm form, BindException errors) throws Exception + { + Report report = getReport(form); + + if (report != null) + return report.getRunReportView(getViewContext()); + else + throw new NotFoundException("Unable to locate the requested report: " + form.getReportId()); + } + + @Override + protected QueryView createQueryView(QueryReportForm form, BindException errors, boolean forExport, String dataRegion) throws Exception + { + Report report = getReport(form); + if (report instanceof QueryReport) + return ((QueryReport)report).getQueryViewGenerator().generateQueryView(getViewContext(), report.getDescriptor()); + + return null; + } + + @Override + public void addNavTrail(NavTree root) + { + if (_report != null) + root.addChild(_report.getDescriptor().getReportName()); + else + root.addChild("Study Query Report"); + } + + protected Report getReport(QueryReportForm form) + { + if (_report == null) + { + ReportIdentifier identifier = form.getReportId(); + if (identifier != null) + _report = identifier.getReport(getViewContext()); + } + return _report; + } + } + + @RequiresPermission(ReadPermission.class) + public class DatasetReportAction extends QueryReportAction + { + @Override + protected Report getReport(QueryReportForm form) + { + if (_report == null) + { + String reportId = (String)getViewContext().get(DATASET_REPORT_ID_PARAMETER_NAME); + + ReportIdentifier identifier = ReportService.get().getReportIdentifier(reportId, getViewContext().getUser(), getViewContext().getContainer()); + if (identifier != null) + _report = identifier.getReport(getViewContext()); + } + return _report; + } + + @Override + protected ModelAndView getHtmlView(QueryReportForm form, BindException errors) throws Exception + { + ViewContext context = getViewContext(); + Report report = getReport(form); + + // is not a report (either the default grid view or a custom view)... + if (report == null) + { + return HttpView.redirect(createRedirectURLfrom(DatasetAction.class, context)); + } + + int datasetId = NumberUtils.toInt((String)context.get(Dataset.DATASET_KEY), -1); + Dataset def = StudyManager.getInstance().getDatasetDefinition(getStudyRedirectIfNull(), datasetId); + + if (def != null) + { + ActionURL url = getViewContext().cloneActionURL().setAction(StudyController.DatasetAction.class). + replaceParameter(DATASET_REPORT_ID_PARAMETER_NAME, report.getDescriptor().getReportId().toString()). + replaceParameter(Dataset.DATASET_KEY, def.getDatasetId()); + + return HttpView.redirect(url); + } + else if (ReportManager.get().canReadReport(getUser(), getContainer(), report)) + return report.getRunReportView(getViewContext()); + else + return HtmlView.of("User does not have read permission on this report."); + } + } + + private ActionURL createRedirectURLfrom(Class action, ViewContext context) + { + ActionURL newUrl = new ActionURL(action, context.getContainer()); + return newUrl.addParameters(context.getActionURL().getParameters()); + } + + @RequiresPermission(ReadPermission.class) + public class DatasetAction extends QueryViewAction + { + private DatasetDefinition _def; + + public DatasetAction() + { + super(DatasetFilterForm.class); + } + + private DatasetDefinition getDatasetDefinition() + { + if (null == _def) + { + Object datasetKeyObject = getViewContext().get(Dataset.DATASET_KEY); + if (datasetKeyObject instanceof List list) + { + // bug 7365: It's been specified twice -- once in the POST, once in the GET. Just need one of them. + datasetKeyObject = list.get(0); + } + if (null != datasetKeyObject) + { + try + { + int id = NumberUtils.toInt(String.valueOf(datasetKeyObject), 0); + _def = StudyManager.getInstance().getDatasetDefinition(getStudyRedirectIfNull(), id); + } + catch (ConversionException x) + { + throw new NotFoundException(); + } + } + else + { + String entityId = (String)getViewContext().get("entityId"); + if (null != entityId) + _def = StudyManager.getInstance().getDatasetDefinitionByEntityId(getStudyRedirectIfNull(), entityId); + } + } + if (null == _def) + throw new NotFoundException(); + return _def; + } + + @Override + public ModelAndView getView(DatasetFilterForm form, BindException errors) throws Exception + { + ActionURL url = getViewContext().getActionURL(); + String viewName = url.getParameter(DATASET_VIEW_NAME_PARAMETER_NAME); + + // if the view name refers to a report id (legacy style), redirect to use the newer report id parameter + if (NumberUtils.isDigits(viewName)) + { + // one last check to see if there is a view with that name before trying to redirect to the report + DatasetDefinition def = getDatasetDefinition(); + + if (def != null && + QueryService.get().getCustomView(getUser(), getContainer(), getUser(), StudySchema.getInstance().getSchemaName(), def.getName(), viewName) == null) + { + ReportIdentifier reportId = AbstractReportIdentifier.fromString(viewName, getViewContext().getUser(), getViewContext().getContainer()); + if (reportId != null && reportId.getReport(getViewContext()) != null) + { + ActionURL newURL = url.clone().deleteParameter(DATASET_VIEW_NAME_PARAMETER_NAME). + addParameter(DATASET_REPORT_ID_PARAMETER_NAME, reportId.toString()); + return HttpView.redirect(newURL); + } + } + } + return super.getView(form, errors); + } + + @Override + protected ModelAndView getHtmlView(DatasetFilterForm form, BindException errors) throws Exception + { + // the full resultset is a join of all datasets for each participant + // each dataset is determined by a visitid/datasetid + + // Ensure a study is present + getStudyRedirectIfNull(); + ViewContext context = getViewContext(); + + String export = StringUtils.trimToNull(context.getActionURL().getParameter("export")); + + DatasetDefinition def = getDatasetDefinition(); + if (null == def) + return new TypeNotFoundAction().getView(form, errors); + String typeURI = def.getTypeURI(); + if (null == typeURI) + return new TypeNotFoundAction().getView(form, errors); + + boolean showEditLinks = !QueryService.get().isQuerySnapshot(getContainer(), StudySchema.getInstance().getSchemaName(), def.getName()) && + !def.isPublishedData(); + + UserSchema schema = QueryService.get().getUserSchema(getUser(), getContainer(), StudyQuerySchema.SCHEMA_NAME); + DatasetQuerySettings settings = (DatasetQuerySettings)schema.getSettings(getViewContext(), DatasetQueryView.DATAREGION, def.getName()); + + settings.setShowEditLinks(showEditLinks); + settings.setShowSourceLinks(true); + + final ActionURL url = context.getActionURL(); + + // clear the property map cache and the sort map cache + getParticipantPropsMap(context).clear(); + getDatasetSortColumnMap(context).clear(); + + QueryView queryView = schema.createView(getViewContext(), settings, errors); + final TableInfo table = queryView.getTable(); + if (table != null) + { + setColumnURL(url, queryView, schema, def); + + // Clear any cached participant lists... not really necessary, since the cache key is now the entire + // query string (including all filters & sorts), but it doesn't really hurt. List is regenerated only if + // user navigates to an individual participant. + removeParticipantListFromSession(context); + getExpandedState(context, def.getDatasetId()).clear(); + } + + if (null != export) + { + if ("tsv".equals(export)) + queryView.exportToTsv(context.getResponse()); + else if ("xls".equals(export)) + queryView.exportToExcel(context.getResponse()); + return null; + } + + HtmlStringBuilder sb = HtmlStringBuilder.of(); + if (def.getDescription() != null && !def.getDescription().isEmpty()) + sb.unsafeAppend(PageFlowUtil.filter(def.getDescription(), true, true)).unsafeAppend("
"); + CohortFilter cohortFilter = queryView instanceof StudyQueryView studyQueryView ? studyQueryView.getCohortFilter() : null; + if (cohortFilter != null) + sb.unsafeAppend("
Cohort: ").append(cohortFilter.getDescription(getContainer(), getUser())).unsafeAppend(""); + + if (QCStateManager.getInstance().showStates(getContainer())) + { + String publicQCUrlFilterValue = getQCUrlFilterValue(QCStateSet.getPublicStates(getContainer())); + String privateQCUrlFilterValue = getQCUrlFilterValue(QCStateSet.getPrivateStates(getContainer())); + + for (QCStateSet set : QCStateSet.getSelectableSets(getContainer())) + { + String selectedQCLabel = selectedQCStateLabelFromUrl(getViewContext().getActionURL(), settings.getDataRegionName(), set.getLabel(), publicQCUrlFilterValue, privateQCUrlFilterValue); + if (selectedQCLabel != null && selectedQCLabel.equals(set.getLabel())) + { + sb.unsafeAppend("
QC States: ").append(set.getLabel()).unsafeAppend(""); + break; + } + } + } + if (ReportPropsManager.get().getPropertyValue(def.getEntityId(), getContainer(), "refreshDate") != null) + { + sb.unsafeAppend("
Data Cut Date: "); + Object refreshDate = (ReportPropsManager.get().getPropertyValue(def.getEntityId(), getContainer(), "refreshDate")); + if (refreshDate instanceof Date) + { + sb.append(DateUtil.formatDate(getContainer(), (Date)refreshDate)); + } + else + { + sb.append(ReportPropsManager.get().getPropertyValue(def.getEntityId(), getContainer(), "refreshDate").toString()); + } + } + HtmlView header = new HtmlView(sb); + VBox view = new VBox(header, queryView); + + String status = (String)ReportPropsManager.get().getPropertyValue(def.getEntityId(), getContainer(), "status"); + if (status != null) + { + // inject the dataset status marker class, but it is up to the client to style the page accordingly + HtmlView scriptLock = new HtmlView(HtmlString.unsafe("")); + view.addView(scriptLock); + } + + Report report = queryView.getSettings().getReportView(context); + if (report != null && !ReportManager.get().canReadReport(getUser(), getContainer(), report)) + { + return HtmlView.of("User does not have read permission on this report."); + } + else if (report == null && (null==table || !table.hasPermission(getUser(),ReadPermission.class))) + { + return HtmlView.of("User does not have read permission on this dataset."); + } + return view; + } + + @Override + protected QueryView createQueryView(DatasetFilterForm datasetFilterForm, BindException errors, boolean forExport, String dataRegion) throws Exception + { + QuerySettings qs = new QuerySettings(getViewContext(), DATASET_DATAREGION_NAME); + Report report = qs.getReportView(getViewContext()); + if (report instanceof QueryReport) + { + return ((QueryReport)report).getQueryViewGenerator().generateQueryView(getViewContext(), report.getDescriptor()); + } + return null; + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("gridBasics"); + _addNavTrail(root, getDatasetDefinition().getDatasetId(), getViewContext().getActionURL()); + } + } + + @RequiresNoPermission + public static class ExpandStateNotifyAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object o, BindException errors) + { + final ActionURL url = getViewContext().getActionURL(); + final String collapse = url.getParameter("collapse"); + final int datasetId = NumberUtils.toInt(url.getParameter(Dataset.DATASET_KEY), -1); + final int id = NumberUtils.toInt(url.getParameter("id"), -1); + + if (datasetId != -1 && id != -1) + { + Map expandedMap = getExpandedState(getViewContext(), id); + // collapse param is only set on a collapse action + if (collapse != null) + expandedMap.put(datasetId, "collapse"); + else + expandedMap.put(datasetId, "expand"); + } + return null; + } + + @Override + public void addNavTrail(NavTree root) + { + } + } + + + Participant findParticipant(Study study, String particpantId) throws StudyManager.ParticipantNotUniqueException + { + Participant participant = StudyManager.getInstance().getParticipant(study, particpantId); + if (participant == null) + { + if (study.isDataspaceStudy()) + { + Container c = StudyManager.getInstance().findParticipant(study, particpantId); + Study s = null == c ? null : StudyManager.getInstance().getStudy(c); + if (null != s && c.hasPermission(getUser(), ReadPermission.class)) + { + participant = StudyManager.getInstance().getParticipant(s, particpantId); + } + } + } + return participant; + } + + @RequiresPermission(ReadPermission.class) + public class ParticipantAction extends SimpleViewAction + { + private ParticipantForm _bean; + + @Override + public ModelAndView getView(ParticipantForm form, BindException errors) + { + Study study = getStudyRedirectIfNull(); + _bean = form; + ActionURL previousParticipantURL = null; + ActionURL nextParticipantURL = null; + Participant participant; + StringBuilder errorMsg = new StringBuilder(); + + if (form.getParticipantId() == null) + { + errorMsg.append("No ").append(study.getSubjectNounSingular()).append(" specified"); + } + else + { + try + { + participant = findParticipant(study, form.getParticipantId()); + if (null == participant) + errorMsg.append("Could not find ").append(study.getSubjectNounSingular()).append(" ").append(form.getParticipantId()); + } + catch (StudyManager.ParticipantNotUniqueException x) + { + errorMsg.append(x.getMessage()); + } + } + + if (!errorMsg.isEmpty()) + return HtmlView.err(errorMsg.toString()); + + String viewName = (String) getViewContext().get(DATASET_VIEW_NAME_PARAMETER_NAME); + + CohortFilter cohortFilter = CohortFilterFactory.getFromURL(getContainer(), getUser(), getViewContext().getActionURL(), DatasetQueryView.DATAREGION); + // display the next and previous buttons only if we have a cached participant index + if (cohortFilter != null && !StudyManager.getInstance().showCohorts(getContainer(), getUser())) + throw new UnauthorizedException("User does not have permission to view cohort information"); + + List participants = getParticipantListFromSession(getViewContext(), form.getDatasetId(), viewName); + + if (isDebug()) + { + _log.info("Cached participants: {}", participants); + } + int idx = participants.indexOf(form.getParticipantId()); + if (idx != -1) + { + if (idx > 0) + { + final String ptid = participants.get(idx-1); + previousParticipantURL = getViewContext().cloneActionURL(); + previousParticipantURL.replaceParameter("participantId", ptid); + } + + if (idx < participants.size()-1) + { + final String ptid = participants.get(idx+1); + nextParticipantURL = getViewContext().cloneActionURL(); + nextParticipantURL.replaceParameter("participantId", ptid); + } + } + + VBox vbox = new VBox(); + ParticipantNavView navView = new ParticipantNavView(previousParticipantURL, nextParticipantURL, form.getParticipantId(), null); + vbox.addView(navView); + + CustomParticipantView customParticipantView = StudyManager.getInstance().getCustomParticipantView(study); + if (customParticipantView != null && customParticipantView.isActive()) + { + vbox.addView(customParticipantView.getView()); + } + else + { + ModelAndView characteristicsView = StudyManager.getInstance().getParticipantDemographicsView(getContainer(), form, errors); + ModelAndView dataView = StudyManager.getInstance().getParticipantView(getContainer(), form, errors); + vbox.addView(characteristicsView); + vbox.addView(dataView); + } + + return vbox; + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("participantViews"); + _addNavTrail(root, _bean.getDatasetId(), _bean.getReturnActionURL()); + root.addChild(StudyService.get().getSubjectNounSingular(getContainer()) + " - " + id(_bean.getParticipantId())); + } + } + + @RequiresPermission(ReadPermission.class) + public class Participant2Action extends SimpleViewAction + { + // TODO participant list support? cohortfilter support? + // TODO define participant context +// { +// particpantId:"", +// participantGroup:"" +// demoMode:false +// } + + + @Override + public ModelAndView getView(ParticipantForm form, BindException errors) throws Exception + { + ViewContext context = getViewContext(); + Study study = getStudyRedirectIfNull(); + + if (form.getParticipantId() == null) + { + throw new NotFoundException("No " + study.getSubjectNounSingular() + " specified"); + } + + Participant participant; + try + { + participant = findParticipant(study, form.getParticipantId()); + if (null == participant) + throw new NotFoundException("Could not find " + study.getSubjectNounSingular() + " " + form.getParticipantId()); + } + catch (StudyManager.ParticipantNotUniqueException x) + { + return HtmlView.of(x.getMessage()); + } + + PageConfig page = getPageConfig(); + + // add participant to view context for java/jsp based web parts + context.put(Participant.class.getName(), participant); + // add to javascript context for file based web parts + page.getPortalContext().put("participantId", participant.getParticipantId()); + + String pageId = Participant.class.getName(); + boolean canCustomize = context.getContainer().hasPermission("populatePortalView",context.getUser(), AdminPermission.class); + + HttpView template = PageConfig.Template.Home.getTemplate(getViewContext(), new VBox(), page); + int parts = Portal.populatePortalView(getViewContext(), pageId, template, isPrint(), canCustomize, false, true, Portal.STUDY_PARTICIPANT_PORTAL_PAGE); + + if (parts == 0 && canCustomize) + { + // TODO: make webparts out of default views and actually save portal config +// ParticipantAction pa = new ParticipantAction(); +// pa.setViewContext(context); +// ModelAndView v = pa.getView(form, errors); +// Portal.addViewToRegion(template, WebPartFactory.LOCATION_BODY, (HttpView)v); + + // force page admin mode + template = PageConfig.Template.Home.getTemplate(getViewContext(), new VBox(), page); + Portal.populatePortalView(getViewContext(), pageId, template, isPrint(), canCustomize, true, true, Participant.class.getName()); + + } + + getPageConfig().setTemplate(PageConfig.Template.None); + return template; + } + + @Override + public void addNavTrail(NavTree root) + { + } + } + + + + // Obfuscate the passed in test if this user is in "demo" mode in this container + private String id(String id) + { + return id(id, getContainer(), getUser()); + } + + // Obfuscate the passed in test if this user is in "demo" mode in this container + private static String id(String id, Container c, User user) + { + return DemoMode.id(id, c, user); + } + + + @RequiresPermission(AdminPermission.class) + public class ImportVisitMapAction extends FormViewAction + { + @Override + public ModelAndView getView(ImportVisitMapForm form, boolean reshow, BindException errors) + { + StudyImpl study = getStudyThrowIfNull(); + redirectToSharedVisitStudy(study, getViewContext().getActionURL()); + return new StudyJspView<>(getStudyRedirectIfNull(), "/org/labkey/study/view/importVisitMap.jsp", null, errors); + } + + @Override + public void validateCommand(ImportVisitMapForm form, Errors errors) + { + } + + @Override + public boolean handlePost(ImportVisitMapForm form, BindException errors) throws Exception + { + VisitMapImporter importer = new VisitMapImporter(); + List errorMsg = new LinkedList<>(); + if (!importer.process(getUser(), getStudyThrowIfNull(), form.getContent(), VisitMapImporter.Format.Xml, errorMsg, _log)) + { + for (String error : errorMsg) + errors.reject("uploadVisitMap", error); + return false; + } + return true; + } + + @Override + public ActionURL getSuccessURL(ImportVisitMapForm form) + { + return new ActionURL(BeginAction.class, getContainer()); + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("importVisitMap"); + _addNavTrailVisitAdmin(root); + root.addChild("Import Visit Map"); + } + } + + @RequiresPermission(AdminPermission.class) + public class CreateStudyAction extends FormViewAction + { + @Override + public ModelAndView getView(StudyPropertiesForm form, boolean reshow, BindException errors) throws Exception + { + if (null != getStudy()) + { + BeginAction action = (BeginAction)initAction(this, new BeginAction()); + return action.getView(form, errors); + } + // Set default values for the form + if (form.getLabel() == null) + { + form.setLabel(HttpView.currentContext().getContainer().getName() + " Study"); + } + if (form.getStartDate() == null) + { + form.setStartDate(new Date()); + } + if (form.getDefaultTimepointDuration() == 0) + { + form.setDefaultTimepointDuration(1); + } + // NOTE: should be a better way to do this (e.g. get the correct value in the form/backend to begin with) + Study sharedStudy = getStudy(getContainer().getProject()); + if (sharedStudy != null && sharedStudy.getShareVisitDefinitions() == Boolean.TRUE) + { + form.setShareVisits(sharedStudy.getShareVisitDefinitions()); + form.setTimepointType(sharedStudy.getTimepointType()); + form.setStartDate(sharedStudy.getStartDate()); + form.setDefaultTimepointDuration(sharedStudy.getDefaultTimepointDuration()); + } + return new StudyJspView<>(null, "/org/labkey/study/view/createStudy.jsp", form, errors); + } + + @Override + public void validateCommand(StudyPropertiesForm target, Errors errors) + { + if (target.getTimepointType() == TimepointType.DATE && null == target.getStartDate()) + errors.reject(ERROR_MSG, "Start date must be supplied for a date-based study."); + + target.setLabel(StringUtils.trimToNull(target.getLabel())); + if (null == target.getLabel()) + errors.reject(ERROR_MSG, "Please supply a label"); + + String message; + + if (null != (message = StudyService.get().getSubjectColumnNameValidationErrorMessage(getContainer(), target.getSubjectColumnName()))) + errors.reject(ERROR_MSG, message); + + if (null != (message = StudyService.get().getSubjectNounSingularValidationErrorMessage(getContainer(), target.getSubjectNounSingular()))) + errors.reject(ERROR_MSG, message); + + if (null != (message = StudyService.get().getSubjectNounPluralValidationErrorMessage(getContainer(), target.getSubjectNounPlural()))) + errors.reject(ERROR_MSG, message); + } + + @Override + public boolean handlePost(StudyPropertiesForm form, BindException errors) + { + createStudy(getStudy(), getContainer(), getUser(), form); + return true; + } + + @Override + public ActionURL getSuccessURL(StudyPropertiesForm form) + { + return form.getSuccessActionURL(new ActionURL(ManageStudyAction.class, getContainer())); + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("Create Study"); + } + } + + public static StudyImpl createStudy(@Nullable StudyImpl study, Container c, User user, StudyPropertiesForm form) + { + if (null == study) + { + study = new StudyImpl(c, form.getLabel()); + study.setTimepointType(form.getTimepointType()); + study.setStartDate(form.getStartDate()); + study.setEndDate(form.getEndDate()); + study.setSecurityType(form.getSecurityType()); + study.setSubjectNounSingular(form.getSubjectNounSingular()); + study.setSubjectNounPlural(form.getSubjectNounPlural()); + study.setSubjectColumnName(form.getSubjectColumnName()); + study.setAssayPlan(form.getAssayPlan()); + study.setDescription(form.getDescription()); + study.setDefaultTimepointDuration(Math.max(form.getDefaultTimepointDuration(), 1)); + if (form.getDescriptionRendererType() != null) + study.setDescriptionRendererType(form.getDescriptionRendererType()); + study.setGrant(form.getGrant()); + study.setInvestigator(form.getInvestigator()); + study.setSpecies(form.getSpecies()); + study.setAlternateIdPrefix(form.getAlternateIdPrefix()); + study.setAlternateIdDigits(form.getAlternateIdDigits()); + study.setAllowReqLocRepository(form.isAllowReqLocRepository()); + study.setAllowReqLocClinic(form.isAllowReqLocClinic()); + study.setAllowReqLocSal(form.isAllowReqLocSal()); + study.setAllowReqLocEndpoint(form.isAllowReqLocEndpoint()); + if (c.isProject()) + { + study.setShareDatasetDefinitions(form.isShareDatasets()); + study.setShareVisitDefinitions(form.isShareVisits()); + } + + study = StudyManager.getInstance().createStudy(user, study); + SpecimenMigrationService sms = SpecimenMigrationService.get(); + if (null != sms) + sms.setDefaultRequestabilityRules(c, user); + } + return study; + } + + @RequiresPermission(ManageStudyPermission.class) + public class ManageStudyAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object o, BindException errors) + { + return new StudyJspView<>(getStudy(), "/org/labkey/study/view/manageStudy.jsp", null, errors); + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("manageStudy"); + _addManageStudy(root); + } + } + + @RequiresPermission(DeletePermission.class) + public static class DeleteParticipantAction extends MutatingApiAction + { + @Override + public Object execute(DeleteParticipantForm deleteParticipantForm, BindException errors) throws Exception + { + //Note: In the EHR system, 'Participant' tables are prefixed with "Animal". For example, the equivalent of the + //Participant table is named Animal, and ParticipantGroupMap is AnimalGroupMap, etc. + //Additionally, the participantId column is labeled as "Id" in the Animal table and other "Animal" tables. + + DbSchema schema = StudySchema.getInstance().getSchema(); + + Study study = StudyManager.getInstance().getStudy(getContainer()); + if (study == null) + { + errors.reject(ERROR_MSG, "Study not found in this folder."); + return new ApiSimpleResponse("success", false); + } + String participantId = deleteParticipantForm.getParticipantId(); + String participantIdColumnName = study.getSubjectColumnName(); + String participantTableNamePrefix = study.getSubjectNounSingular(); + + try (DbScope.Transaction transaction = schema.getScope().ensureTransaction()) + { + _log.info("Starting participant deletion for ID: " + participantId); + List datasets = study.getDatasets(); + + //delete participant rows from datasets + for (Dataset dataset : datasets) + { + if (dataset.isDemographicData()) + deleteParticipantFromDemographics(dataset.getTableInfo(getUser()), participantIdColumnName, participantId, errors); + else + deleteParticipantFromDatasets(dataset.getTableInfo(getUser()), participantIdColumnName, participantId, errors); + } + + //delete from study.participantGroupMap + TableInfo participantGroupMapTable = QueryService.get().getUserSchema(getUser(), getContainer(), "study").getTable(participantTableNamePrefix + "GroupMap"); + if (null != participantGroupMapTable) + { + TableSelector ts = new TableSelector(participantGroupMapTable, Set.of(participantIdColumnName, "GroupId"), new SimpleFilter(FieldKey.fromString(participantIdColumnName), participantId), null); + ParticipantGroupManager.ParticipantGroupMap[] pgm = ts.getArray(ParticipantGroupManager.ParticipantGroupMap.class); + deleteFromParticipantGroupMapTable(participantGroupMapTable, participantId, participantIdColumnName, pgm, errors); + } + transaction.commit(); + } + ApiSimpleResponse response = new ApiSimpleResponse(); + response.put("success", !errors.hasErrors()); + if (errors.hasErrors()) + { + _log.error("Failed to delete participant: {}", participantId); + response.put("message", errors.getMessage()); + } + else + { + _log.info("Successfully deleted participant: {}", participantId); + response.put("message", "Successfully deleted participant " + participantId); + } + return response; + } + + private void deleteParticipantFromDemographics(TableInfo ti, String participantIdColumnName, String participantId, BindException errors) + { + ColumnInfo idCol = ti.getColumn(FieldKey.fromParts(participantIdColumnName)); + deleteParticipantRows(ti, Collections.singletonList(Collections.singletonMap(idCol.getName(), participantId)), errors); + } + + private void deleteParticipantFromDatasets(TableInfo ti, String participantIdColumnName, String participantId, BindException errors) + { + TableSelector ts = new TableSelector(ti, Collections.singleton(DatasetDomainKind.LSID), new SimpleFilter(FieldKey.fromString(participantIdColumnName), participantId), null); + deleteParticipantRows(ti, ts.getMapCollection().stream().toList(), errors); + } + + private void deleteParticipantRows(TableInfo ti, List> keys, BindException errors) + { + try + { + ti.getUpdateService().deleteRows(getUser(), getContainer(), keys, null, null); + } + catch (InvalidKeyException | BatchValidationException | QueryUpdateServiceException | SQLException e) + { + String msg = "Failed to delete participant rows from " + ti.getName(); + _log.error(msg, e); + errors.reject(ERROR_MSG, msg + ": " + e.getMessage()); + } + } + + private void deleteFromParticipantGroupMapTable(TableInfo ti, String participantId, String participantColName, ParticipantGroupManager.ParticipantGroupMap[] groups, BindException errors) + { + try + { + SQLFragment sql = new SQLFragment("DELETE FROM study.participantgroupmap WHERE participantid = ?", participantId); + new SqlExecutor(ti.getSchema()).execute(sql); + } + catch (Exception e) + { + String msg = "Failed to delete row from " + ti.getSchema().getName() + "." + ti.getName() + " for " + participantColName + " '" + participantId + "'"; + _log.error(msg, e); + errors.reject(ERROR_MSG, msg + " :" + e.getMessage()); + } + + for (ParticipantGroupManager.ParticipantGroupMap group : groups) + { + ParticipantGroupAuditProvider.ParticipantGroupAuditEvent event = ParticipantGroupAuditProvider.EventFactory.participantDeleted(participantId, getContainer(), group.getLabel(), group.getGroupId()); + AuditLogService.get().addEvent(getUser(), event); + } + } + } + + public static class DeleteParticipantForm + { + private String _participantId; + + public String getParticipantId() + { + return _participantId; + } + + public void setParticipantId(String participantId) + { + this._participantId = participantId; + } + } + + @RequiresPermission(AdminPermission.class) + public class DeleteStudyAction extends FormViewAction + { + @Override + public void validateCommand(DeleteStudyForm form, Errors errors) + { + if (!form.isConfirm()) + errors.reject("deleteStudy", "Need to confirm Study deletion"); + } + + @Override + public ModelAndView getView(DeleteStudyForm form, boolean reshow, BindException errors) + { + return new StudyJspView<>(getStudyRedirectIfNull(), "/org/labkey/study/view/confirmDeleteStudy.jsp", null, errors); + } + + @Override + public boolean handlePost(DeleteStudyForm form, BindException errors) + { + StudyManager.getInstance().deleteAllStudyData(getContainer(), getUser()); + return true; + } + + @Override + public ActionURL getSuccessURL(DeleteStudyForm deleteStudyForm) + { + return getContainer().getFolderType().getStartURL(getContainer(), getUser()); + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("Confirm Delete Study"); + } + } + + public static class DeleteStudyForm + { + private boolean confirm; + + public boolean isConfirm() + { + return confirm; + } + + public void setConfirm(boolean confirm) + { + this.confirm = confirm; + } + } + + public static class RemoveProtocolDocumentForm + { + private String _name; + + public String getName() + { + return _name; + } + + public void setName(String name) + { + _name = name; + } + } + + @RequiresPermission(AdminPermission.class) + public class RemoveProtocolDocumentAction extends FormHandlerAction + { + @Override + public void validateCommand(RemoveProtocolDocumentForm target, Errors errors) + { + } + + @Override + public boolean handlePost(RemoveProtocolDocumentForm removeProtocolDocumentForm, BindException errors) throws Exception + { + Study study = getStudyThrowIfNull(); + study.removeProtocolDocument(removeProtocolDocumentForm.getName(), getUser()); + return true; + } + + @Override + public URLHelper getSuccessURL(RemoveProtocolDocumentForm removeProtocolDocumentForm) + { + return new ActionURL(ManageStudyPropertiesAction.class, getContainer()); + } + } + + @RequiresPermission(ReadPermission.class) + public class ManageStudyPropertiesAction extends FormApiAction + { + @Override + protected @NotNull TableViewForm getCommand(HttpServletRequest request) + { + User user = getUser(); + UserSchema schema = QueryService.get().getUserSchema(user, getContainer(), SchemaKey.fromParts(StudyQuerySchema.SCHEMA_NAME)); + TableViewForm form = new TableViewForm(schema.getTable("StudyProperties")); + form.setViewContext(getViewContext()); + return form; + } + + @Override + public ModelAndView getView(TableViewForm form, BindException errors) + { + Study study = getStudy(); + if (null == study) + throw new RedirectException(new ActionURL(CreateStudyAction.class, getContainer())); + return new StudyJspView<>(getStudy(), "/org/labkey/study/view/manageStudyPropertiesExt.jsp", study, null); + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("manageStudy"); + _addManageStudy(root); + root.addChild("Study Properties"); + } + + @Override + public void validateForm(TableViewForm form, Errors errors) + { + // Skip validation if Spring binding already has an error for subject noun singular + if (errors.getFieldError("SubjectNounSingular") == null) + { + // Issue 47444 and Issue 47881: Validate that subject noun singular doesn't match the name of an existing + // study table or dataset + String subjectNounSingular = form.get("SubjectNounSingular"); + if (null != subjectNounSingular) + { + String message = StudyService.get().getSubjectNounSingularValidationErrorMessage(getContainer(), subjectNounSingular); + if (message != null) + errors.reject(ERROR_MSG, message); + } + } + + // Skip validation if Spring binding already has an error for subject noun plural + if (errors.getFieldError("SubjectNounPlural") == null) + { + String subjectNounPlural = form.get("SubjectNounPlural"); + if (null != subjectNounPlural) + { + String message = StudyService.get().getSubjectNounPluralValidationErrorMessage(getContainer(), subjectNounPlural); + if (message != null) + errors.reject(ERROR_MSG, message); + } + } + + // Skip validation if Spring binding already has an error for subject column name + if (errors.getFieldError("SubjectColumnName") == null) + { + // Issue 43898: Validate that the subject column name is not a user-defined field in one of the datasets + String subjectColName = form.get("SubjectColumnName"); + if (null != subjectColName) + { + String message = StudyService.get().getSubjectColumnNameValidationErrorMessage(getContainer(), subjectColName); + if (message != null) + errors.reject(ERROR_MSG, message); + } + } + } + + @Override + public ApiResponse execute(TableViewForm form, BindException errors) throws Exception + { + if (!getContainer().hasPermission(getUser(),AdminPermission.class)) + throw new UnauthorizedException(); + + Map values = form.getTypedValues(); + values.put("container", getContainer().getId()); + + TableInfo studyProperties = form.getTable(); + QueryUpdateService qus = studyProperties.getUpdateService(); + if (null == qus) + throw new UnauthorizedException(); + try (DbScope.Transaction transaction = StudySchema.getInstance().getSchema().getScope().ensureTransaction()) + { + BatchValidationException batchErrors = new BatchValidationException(); + qus.updateRows(getUser(), getContainer(), Collections.singletonList(values), Collections.singletonList(values), batchErrors, null, null); + if (batchErrors.hasErrors()) + throw batchErrors; + List files = getAttachmentFileList(); + getStudyThrowIfNull().attachProtocolDocument(files, getUser()); + transaction.commit(); + } + catch (BatchValidationException x) + { + x.addToErrors(errors); + return null; + } + catch (AttachmentService.DuplicateFilenameException x) + { + JSONObject json = new JSONObject(); + json.put("failure", true); + json.put("msg", x.getMessage()); + return new ApiSimpleResponse(json); + } + + JSONObject json = new JSONObject(); + json.put("success", true); + return new ApiSimpleResponse(json); + } + } + + + @RequiresPermission(AdminPermission.class) + public class ManageVisitsAction extends FormViewAction + { + @Override + public void validateCommand(StudyPropertiesForm target, Errors errors) + { + StudyImpl study = getStudy(); + if (study.getTimepointType() == TimepointType.DATE) + { + if (target.getTimepointType() == TimepointType.DATE && null == target.getStartDate()) + errors.reject(ERROR_MSG, "Start date must be supplied for a date-based study."); + if (target.getDefaultTimepointDuration() < 1) + errors.reject(ERROR_MSG, "Default timepoint duration must be a positive number."); + } + } + + @Override + public ModelAndView getView(StudyPropertiesForm form, boolean reshow, BindException errors) throws Exception + { + StudyImpl study = getStudy(); + if (null == study) + { + CreateStudyAction action = (CreateStudyAction)initAction(this, new CreateStudyAction()); + return action.getView(form, false, errors); + } + + if (study.getTimepointType() == TimepointType.CONTINUOUS) + return HtmlView.err("Unsupported operation for continuous study"); + + redirectToSharedVisitStudy(study, getViewContext().getActionURL()); + + return new StudyJspView<>(study, _jspName(study), form, errors); + } + + @Override + public boolean handlePost(StudyPropertiesForm form, BindException errors) + { + StudyImpl study = getStudyThrowIfNull().createMutable(); + redirectToSharedVisitStudy(study, getViewContext().getActionURL()); + + if (study.getTimepointType() == TimepointType.DATE) + { + study.setStartDate(form.getStartDate()); + study.setDefaultTimepointDuration(form.getDefaultTimepointDuration()); + } + study.setFailForUndefinedTimepoints(form.isFailForUndefinedTimepoints()); + + StudyManager.getInstance().updateStudy(getUser(), study); + + return true; + } + + @Override + public ActionURL getSuccessURL(StudyPropertiesForm studyPropertiesForm) + { + return new ActionURL(ManageStudyAction.class, getContainer()); + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("manageVisits"); + _addNavTrailVisitAdmin(root); + } + + private String _jspName(Study study) + { + assert study.getTimepointType() != TimepointType.CONTINUOUS; + return study.getTimepointType() == TimepointType.DATE ? "/org/labkey/study/view/manageTimepoints.jsp" : "/org/labkey/study/view/manageVisits.jsp"; + } + } + + @RequiresPermission(AdminPermission.class) + public class ManageTypesAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object o, BindException errors) + { + return new StudyJspView<>(getStudyRedirectIfNull(), "/org/labkey/study/view/manageTypes.jsp", this, errors); + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("manageDatasets"); + _addManageStudy(root); + root.addChild("Manage Datasets"); + } + } + + @RequiresPermission(AdminPermission.class) + public class ManageFilewatchersAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object o, BindException errors) + { + return new StudyJspView<>(getStudyRedirectIfNull(), "/org/labkey/study/view/manageFilewatchers.jsp", this, errors); + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("fileWatcher"); + _addManageStudy(root); + root.addChild("Manage File Watchers"); + } + } + + @RequiresPermission(AdminPermission.class) + public class ManageLocationsAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object o, BindException errors) throws Exception + { + UserSchema schema = QueryService.get().getUserSchema(getUser(), getContainer(), StudyQuerySchema.SCHEMA_NAME); + QuerySettings settings = schema.getSettings(getViewContext(), QueryView.DATAREGIONNAME_DEFAULT, StudyQuerySchema.LOCATION_TABLE_NAME); + + return schema.createView(getViewContext(), settings, errors); + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("manageLocations"); + _addManageStudy(root); + root.addChild("Manage Locations"); + } + } + + @RequiresPermission(AdminPermission.class) + public static class DeleteAllUnusedLocationsAction extends ConfirmAction + { + @Override + public ModelAndView getConfirmView(LocationForm form, BindException errors) + { + List temp = new ArrayList<>(); + for (Container c : getContainers(form)) + { + if (c.hasPermission(getUser(), AdminPermission.class)) + { + LocationManager mgr = LocationManager.get(); + for (LocationImpl loc : mgr.getLocations(c)) + { + if (!mgr.isLocationInUse(loc)) + { + temp.add(c.getName() + "/" + loc.getLabel()); + } + } + } + } + String[] labels = new String[temp.size()]; + for(int i = 0; i("/org/labkey/study/view/confirmDeleteLocation.jsp", form, errors); + } + + @Override + public boolean handlePost(LocationForm form, BindException errors) throws Exception + { + for (Container c : getContainers(form)) + { + if (c.hasPermission(getUser(), AdminPermission.class)) + { + LocationManager mgr = LocationManager.get(); + for (LocationImpl loc : mgr.getLocations(c)) + { + if (!mgr.isLocationInUse(loc)) + { + mgr.deleteLocation(loc); + } + } + } + } + return true; + } + + @Override + public void validateCommand(LocationForm locationEditForm, Errors errors) + { + } + + @NotNull + @Override + public URLHelper getSuccessURL(LocationForm form) + { + return form.getReturnUrlHelper(); + } + + private Collection getContainers(LocationForm form) + { + String containerFilterName = form.getContainerFilter(); + + if (null != containerFilterName) + return LocationTable.getStudyContainers(getContainer(), ContainerFilter.getContainerFilterByName(form.getContainerFilter(), getContainer(), getUser())); + else + return Collections.singleton(getContainer()); + } + } + + public static class LocationForm extends ViewForm + { + private int[] _ids; + private String[] _labels; + private String _containerFilter; + public String[] getLabels() + { + return _labels; + } + + public void setLabels(String[] labels) + { + _labels = labels; + } + + public int[] getIds() + { + return _ids; + } + + public void setIds(int[] ids) + { + _ids = ids; + } + + public String getContainerFilter() + { + return _containerFilter; + } + + public void setContainerFilter(String containerFilter) + { + _containerFilter = containerFilter; + } + } + + + @RequiresPermission(AdminPermission.class) + public class VisitSummaryAction extends FormViewAction + { + private VisitImpl _v; + + @Override + public void validateCommand(VisitForm target, Errors errors) + { + StudyImpl study = getStudyRedirectIfNull(); + if (study.getTimepointType() == TimepointType.CONTINUOUS) + errors.reject(null, "Unsupported operation for continuous date study"); + + Study sharedStudy = StudyManager.getInstance().getSharedStudy(study); + if (sharedStudy != null && sharedStudy.getShareVisitDefinitions()) + errors.reject(null, "Can't edit visits in a study with shared visits"); + + target.validate(errors, study); + if (errors.getErrorCount() > 0) + return; + + VisitImpl visitBean = target.getBean(); + + //check for overlapping visits that the target num is within the range + VisitManager visitMgr = StudyManager.getInstance().getVisitManager(study); + if (visitMgr.isVisitOverlapping(visitBean)) + errors.reject(null, "Visit range overlaps with an existing visit in this study. Please enter a different range."); + } + + @Override + public ModelAndView getView(VisitForm form, boolean reshow, BindException errors) + { + StudyImpl study = getStudyRedirectIfNull(); + if (study.getTimepointType() == TimepointType.CONTINUOUS) + return HtmlView.err("Unsupported operation for continuous date study"); + + redirectToSharedVisitStudy(study, getViewContext().getActionURL()); + + int id = NumberUtils.toInt((String)getViewContext().get("id")); + _v = StudyManager.getInstance().getVisitForRowId(study, id); + if (_v == null) + { + return HttpView.redirect(new ActionURL(BeginAction.class, getContainer())); + } + VisitSummaryBean visitSummary = new VisitSummaryBean(); + visitSummary.setVisit(_v); + + return new StudyJspView<>(study, "/org/labkey/study/view/editVisit.jsp", visitSummary, errors); + } + + @Override + public boolean handlePost(VisitForm form, BindException errors) + { + VisitImpl postedVisit = form.getBean(); + if (!getContainer().getId().equals(postedVisit.getContainer().getId())) + throw new UnauthorizedException(); + + StudyImpl study = getStudyThrowIfNull(); + redirectToSharedVisitStudy(study, getViewContext().getActionURL()); + + // UNDONE: how do I get struts to handle this checkbox? + postedVisit.setShowByDefault(null != StringUtils.trimToNull((String)getViewContext().get("showByDefault"))); + + // UNDONE: reshow is broken for this form, but we have to validate + Collection visits = StudyManager.getInstance().getVisitManager(study).getVisits(); + boolean validRange = true; + // make sure there is no overlapping visit + for (VisitImpl v : visits) + { + if (v.getRowId() == postedVisit.getRowId()) + continue; + BigDecimal maxL = v.getSequenceNumMin().max(postedVisit.getSequenceNumMin()); + BigDecimal minR = v.getSequenceNumMax().min(postedVisit.getSequenceNumMax()); + if (maxL.compareTo(minR) <= 0) + { + errors.reject("visitSummary", getVisitLabel() + " range overlaps with '" + v.getDisplayString() + "'"); + validRange = false; + } + } + + if (!validRange) + { + return false; + } + + StudyManager.getInstance().updateVisit(getUser(), postedVisit); + + HashMap visitTypeMap = new IntHashMap<>(); + for (VisitDataset vds : postedVisit.getVisitDatasets()) + visitTypeMap.put(vds.getDatasetId(), vds.isRequired() ? VisitDatasetType.REQUIRED : VisitDatasetType.OPTIONAL); + + if (form.getDatasetIds() != null) + { + for (int i = 0; i < form.getDatasetIds().length; i++) + { + int datasetId = form.getDatasetIds()[i]; + VisitDatasetType type = VisitDatasetType.valueOf(form.getDatasetStatus()[i]); + VisitDatasetType oldType = visitTypeMap.get(datasetId); + if (oldType == null) + oldType = VisitDatasetType.NOT_ASSOCIATED; + if (type != oldType) + { + StudyManager.getInstance().updateVisitDatasetMapping(getUser(), getContainer(), + postedVisit.getRowId(), datasetId, type); + } + } + } + return true; + } + + @Override + public ActionURL getSuccessURL(VisitForm form) + { + return new ActionURL(ManageVisitsAction.class, getContainer()); + } + + @Override + public void addNavTrail(NavTree root) + { + _addNavTrailVisitAdmin(root); + root.addChild(_v.getDisplayString()); + } + } + + public static class VisitSummaryBean + { + private VisitImpl visit; + + public VisitImpl getVisit() + { + return visit; + } + + public void setVisit(VisitImpl visit) + { + this.visit = visit; + } + } + + @RequiresPermission(ManageStudyPermission.class) + public class StudyScheduleAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object o, BindException errors) throws Exception + { + return StudyModule.studyScheduleWebPartFactory.getWebPartView(getViewContext(), StudyModule.studyScheduleWebPartFactory.createWebPart()); + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("studySchedule"); + _addManageStudy(root); + root.addChild("Study Schedule"); + } + } + + @RequiresPermission(AdminPermission.class) + public class DeleteVisitAction extends FormHandlerAction + { + @Override + public void validateCommand(IdForm target, Errors errors) + { + StudyImpl study = getStudyThrowIfNull(); + if (study.getTimepointType() == TimepointType.CONTINUOUS) + errors.reject(null, "Unsupported operation for continuous date study"); + + + Study sharedStudy = StudyManager.getInstance().getSharedStudy(study); + if (sharedStudy != null && sharedStudy.getShareVisitDefinitions()) + errors.reject(null, "Can't edit visits in a study with shared visits"); + } + + @Override + public boolean handlePost(IdForm form, BindException errors) + { + int visitId = form.getId(); + StudyImpl study = getStudyThrowIfNull(); + + redirectToSharedVisitStudy(study, getViewContext().getActionURL()); + + VisitImpl visit = StudyManager.getInstance().getVisitForRowId(study, visitId); + if (visit != null) + { + StudyManager.getInstance().deleteVisit(study, visit, getUser()); + return true; + } + throw new NotFoundException(); + } + + @Override + public ActionURL getSuccessURL(IdForm idForm) + { + return new ActionURL(ManageVisitsAction.class, getContainer()); + } + } + + + @RequiresPermission(AdminPermission.class) + public class DeleteUnusedVisitsAction extends ConfirmAction + { + @Override + public void validateCommand(IdForm target, Errors errors) + { + StudyImpl study = getStudyThrowIfNull(); + if (study.getTimepointType() == TimepointType.CONTINUOUS) + errors.reject(null, "Unsupported operation for continuous date study"); + + Study sharedStudy = StudyManager.getInstance().getSharedStudy(study); + if (sharedStudy != null && sharedStudy.getShareVisitDefinitions()) + errors.reject(null, "Can't delete visits from a study with shared visits"); + } + + @Override + public ModelAndView getConfirmView(IdForm idForm, BindException errors) + { + if (getPageConfig().getTitle() == null) + setTitle("Delete Unused Visits"); + + StudyImpl study = getStudyThrowIfNull(); + + redirectToSharedVisitStudy(study, getViewContext().getActionURL()); + + Collection visits = getUnusedVisits(); + HtmlStringBuilder sb = HtmlStringBuilder.of(); + + if (visits.isEmpty()) + { + sb.unsafeAppend("No unused visits found.
"); + } + else + { + // Put them in a table to help with StudyTest verification + sb.unsafeAppend("\n"); + sb.unsafeAppend("\n\n"); + + for (VisitImpl visit : visits) + { + sb.unsafeAppend("\n"); + } + + sb.unsafeAppend("
Are you sure you want to delete the unused visits listed below?
 
") + .append(visit.getLabel()) + .append(" (") + .append(visit.getSequenceString()) + .append(")") + .unsafeAppend("
\n"); + } + + return new HtmlView(sb); + } + + @Override + public boolean handlePost(IdForm form, BindException errors) + { + long start = System.currentTimeMillis(); + StudyImpl study = getStudyThrowIfNull(); + + StudyManager.getInstance().deleteVisits(study, getUnusedVisits(), getUser(), true); + + _log.info("Delete unused visits took: " + DateUtil.formatDuration(System.currentTimeMillis() - start)); + + return true; + } + + private @NotNull Collection getUnusedVisits() + { + return new SqlSelector(StudySchema.getInstance().getSchema(), new SQLFragment( + "SELECT * FROM study.Visit v WHERE Container = ? AND rowid NOT IN (SELECT DISTINCT VisitRowId FROM study.ParticipantVisit pv WHERE pv.Container = ?)", + getContainer(), getContainer() + )).getArrayList(VisitImpl.class); + } + + @Override + @NotNull + public ActionURL getSuccessURL(IdForm idForm) + { + return new ActionURL(ManageVisitsAction.class, getContainer()); + } + } + + @RequiresPermission(AdminPermission.class) + public class BulkDeleteVisitsAction extends FormViewAction + { + private TimepointType _timepointType; + private List _visitsToDelete; + + @Override + public ModelAndView getView(DeleteVisitsForm form, boolean reshow, BindException errors) + { + StudyImpl study = getStudyRedirectIfNull(); + _timepointType = study.getTimepointType(); + + Study sharedStudy = StudyManager.getInstance().getSharedStudy(study); + if (sharedStudy != null && sharedStudy.getShareVisitDefinitions()) + return HtmlView.err("Can't delete visits from a study with shared visits."); + + if (_timepointType == TimepointType.CONTINUOUS) + return HtmlView.err("Unsupported operation for continuous study."); + + return new StudyJspView<>(getStudyRedirectIfNull(), "/org/labkey/study/view/bulkVisitDelete.jsp", form, errors); + } + + @Override + public void addNavTrail(NavTree root) + { + _addNavTrailVisitAdmin(root); + root.addChild("Delete " + (_timepointType == TimepointType.DATE ? "Timepoints" : "Visits")); + } + + @Override + public void validateCommand(DeleteVisitsForm form, Errors errors) + { + StudyImpl study = getStudyThrowIfNull(); + + Study sharedStudy = StudyManager.getInstance().getSharedStudy(study); + if (sharedStudy != null && sharedStudy.getShareVisitDefinitions()) + { + errors.reject(null, "Can't delete visits from a study with shared visits."); + return; + } + + int[] visitIds = form.getVisitIds(); + if (visitIds == null || visitIds.length == 0) + { + errors.reject(ERROR_MSG, "No " + (_timepointType == TimepointType.DATE ? "timepoints" : "visits") + " selected."); + return; + } + + _visitsToDelete = new ArrayList<>(); + for (int id : visitIds) + { + VisitImpl visit = StudyManager.getInstance().getVisitForRowId(study, id); + if (visit == null) + errors.reject(ERROR_MSG, "Unable to find visit for id " + id); + else + _visitsToDelete.add(visit); + } + } + + @Override + public boolean handlePost(DeleteVisitsForm form, BindException errors) + { + long start = System.currentTimeMillis(); + StudyImpl study = getStudyThrowIfNull(); + StudyManager.getInstance().deleteVisits(study, _visitsToDelete, getUser(), false); + _log.info("Bulk delete visits took: " + DateUtil.formatDuration(System.currentTimeMillis() - start)); + return true; + } + + @Override + public ActionURL getSuccessURL(DeleteVisitsForm form) + { + return new ActionURL(ManageVisitsAction.class, getContainer()); + } + } + + public static class DeleteVisitsForm extends ReturnUrlForm + { + private int[] _visitIds; + + public int[] getVisitIds() + { + return _visitIds; + } + + public void setVisitIds(int[] visitIds) + { + _visitIds = visitIds; + } + } + + @RequiresPermission(AdminPermission.class) + public class ConfirmDeleteVisitAction extends SimpleViewAction + { + private VisitImpl _visit; + private TimepointType _timepointType; + + @Override + public ModelAndView getView(IdForm form, BindException errors) + { + int visitId = form.getId(); + StudyImpl study = getStudyRedirectIfNull(); + _timepointType = study.getTimepointType(); + + if (_timepointType == TimepointType.CONTINUOUS) + return HtmlView.err("Unsupported operation for continuous study"); + + redirectToSharedVisitStudy(study, getViewContext().getActionURL()); + + _visit = StudyManager.getInstance().getVisitForRowId(study, visitId); + if (null == _visit) + throw new NotFoundException(); + + return new StudyJspView<>(study, "/org/labkey/study/view/confirmDeleteVisit.jsp", _visit, errors); + } + + @Override + public void addNavTrail(NavTree root) + { + String noun = _timepointType == TimepointType.DATE ? "Timepoint" : "Visit"; + root.addChild("Delete " + noun + " -- " + _visit.getDisplayString()); + } + } + + @RequiresPermission(AdminPermission.class) + public class CreateVisitAction extends FormViewAction + { + @Override + public void validateCommand(VisitForm target, Errors errors) + { + StudyImpl study = getStudyThrowIfNull(); + if (study.getTimepointType() == TimepointType.CONTINUOUS) + errors.reject(null, "Unsupported operation for continuous date study"); + + Study sharedStudy = StudyManager.getInstance().getSharedStudy(study); + if (sharedStudy != null && sharedStudy.getShareVisitDefinitions()) + errors.reject(null, "Can't create visits in a study with shared visits"); + + target.validate(errors, study); + if (errors.getErrorCount() > 0) + return; + + //check for overlapping visits + VisitManager visitMgr = StudyManager.getInstance().getVisitManager(study); + if (visitMgr.isVisitOverlapping(target.getBean())) + errors.reject(null, "Visit range overlaps with an existing visit in this study. Please enter a different range."); + } + + @Override + public ModelAndView getView(VisitForm form, boolean reshow, BindException errors) + { + StudyImpl study = getStudyRedirectIfNull(); + + if (study.getTimepointType() == TimepointType.CONTINUOUS) + errors.reject(null, "Unsupported operation for continuous date study"); + + redirectToSharedVisitStudy(study, getViewContext().getActionURL()); + + form.setReshow(reshow); + return new StudyJspView<>(study, "/org/labkey/study/view/createVisit.jsp", form, errors); + } + + @Override + public boolean handlePost(VisitForm form, BindException errors) + { + VisitImpl visit = form.getBean(); + if (visit != null) + StudyManager.getInstance().createVisit(getStudyThrowIfNull(), getUser(), visit); + return true; + } + + @Override + public ActionURL getSuccessURL(VisitForm visitForm) + { + return visitForm.getReturnActionURL(); + } + + @Override + public void addNavTrail(NavTree root) + { + _addNavTrailVisitAdmin(root); + root.addChild("Create New " + getVisitLabel()); + } + } + + /** + * Called from the vaccine design webpart for the study design module + */ + @RequiresPermission(UpdatePermission.class) + public class CreateVisitForVaccineDesign extends MutatingApiAction + { + @Override + public void validateForm(VisitForm form, Errors errors) + { + if (!StudyDesignManager.get().isModuleActive(getContainer())) + { + errors.reject(ERROR_MSG, "This action can only be called if the study design module is active"); + return; + } + + Study study = getStudy(getContainer()); + boolean isDateBased = study.getTimepointType() == TimepointType.DATE; + + form.validate(errors, study); + if (errors.getErrorCount() > 0) + return; + + //check for overlapping visits + VisitManager visitMgr = StudyManager.getInstance().getVisitManager(study); + String range = isDateBased ? "day range" : "sequence range"; + if (visitMgr.isVisitOverlapping(form.getBean())) + errors.reject(null, "The visit " + range + " provided overlaps with an existing visit in this study. Please enter a different " + range + "."); + } + + @Override + public ApiResponse execute(VisitForm form, BindException errors) + { + ApiSimpleResponse response = new ApiSimpleResponse(); + + VisitImpl visit = form.getBean(); + visit = StudyManager.getInstance().createVisit(getStudyThrowIfNull(), getUser(), visit); + + response.put("RowId", visit.getRowId()); + response.put("Label", visit.getDisplayString()); + response.put("SequenceNumMin", visit.getSequenceNumMin()); + response.put("DisplayOrder", visit.getDisplayOrder()); + response.put("Included", true); + response.put("success", true); + + return response; + } + } + + @RequiresPermission(AdminPermission.class) + public class UpdateDatasetVisitMappingAction extends FormViewAction + { + private DatasetDefinition _def; + + @Override + public void validateCommand(DatasetForm form, Errors errors) + { + if (null == form.getDatasetId() || form.getDatasetId() < 1) + { + errors.reject(SpringActionController.ERROR_MSG, "DatasetId must be a positive integer."); + } + else + { + _def = StudyManager.getInstance().getDatasetDefinition(getStudyRedirectIfNull(), form.getDatasetId()); + if (null == _def) + errors.reject(SpringActionController.ERROR_MSG, "Dataset not found."); + } + } + + @Override + public ModelAndView getView(DatasetForm form, boolean reshow, BindException errors) throws Exception + { + validateCommand(form, errors); + + if (errors.hasErrors()) + { + getPageConfig().setTemplate(PageConfig.Template.Dialog); + return new SimpleErrorView(errors); + } + + return new JspView<>("/org/labkey/study/view/updateDatasetVisitMapping.jsp", _def, errors); + } + + @Override + public boolean handlePost(DatasetForm form, BindException errors) + { + DatasetDefinition modified = _def.createMutable(); + if (null != form.getVisitRowIds()) + { + for (int i = 0; i < form.getVisitRowIds().length; i++) + { + int visitRowId = form.getVisitRowIds()[i]; + VisitDatasetType type = VisitDatasetType.valueOf(form.getVisitStatus()[i]); + if (modified.getVisitType(visitRowId) != type) + { + StudyManager.getInstance().updateVisitDatasetMapping(getUser(), getContainer(), + visitRowId, form.getDatasetId(), type); + } + } + } + return true; + } + + @Override + public ActionURL getSuccessURL(DatasetForm datasetForm) + { + return new ActionURL(DatasetDetailsAction.class, getContainer()).addParameter("id", datasetForm.getDatasetId()); + } + + @Override + public void addNavTrail(NavTree root) + { + _addNavTrailDatasetAdmin(root); + if (_def != null) + { + VisitManager visitManager = StudyManager.getInstance().getVisitManager(getStudyThrowIfNull()); + root.addChild("Edit " + _def.getLabel() + " " + visitManager.getPluralLabel()); + } + } + } + + + @RequiresPermission(InsertPermission.class) + public class ImportAction extends AbstractQueryImportAction + { + private ImportDatasetForm _form = null; + private StudyImpl _study = null; + private DatasetDefinition _def = null; + private TableInfo _table = null; + + @Override + protected void initRequest(ImportDatasetForm form) throws ServletException + { + _form = form; + _study = getStudyRedirectIfNull(); + + if ((_study.getParticipantAliasDatasetId() != null) && (_study.getParticipantAliasDatasetId() == form.getDatasetId())) + { + super.setImportMessage("This is the Alias Dataset. You do not need to include information for the date column."); + } + + _def = StudyManager.getInstance().getDatasetDefinition(_study, form.getDatasetId()); + if (null == _def && null != form.getName()) + _def = StudyManager.getInstance().getDatasetDefinitionByName(_study, form.getName()); + if (null == _def) + throw new NotFoundException("Dataset not found"); + if (null == _def.getTypeURI()) + return; + + + User user = getUser(); + // Go through normal getTable() codepath to be sure all metadata is applied + _table = StudyQuerySchema.createSchema(_study, user).getDatasetTable(_def, null); + if (_table == null) + throw new NotFoundException("Dataset not found"); + setTarget(_table); + + if (!_table.hasPermission(user, InsertPermission.class) && getUser().isGuest()) + throw new UnauthorizedException(); + } + + @Override + protected boolean canInsert(User user) + { + return _table.hasPermission(user, InsertPermission.class); + } + + @Override + protected boolean canUpdate(User user) + { + return _table.hasPermission(user, UpdatePermission.class); + } + + @Override + public ModelAndView getView(ImportDatasetForm form, BindException errors) throws Exception + { + initRequest(form); + + // TODO need a shorthand for this check + if (_def.isShared() && _def.getContainer().equals(_def.getDefinitionContainer())) + return new HtmlView("Error", HtmlString.of("Cannot insert dataset data in this folder. Use a sub-study to import data.")); + + if (_def.getTypeURI() == null) + throw new NotFoundException("Dataset is not yet defined."); + + if (null == PipelineService.get().findPipelineRoot(getContainer())) + return new RequirePipelineView(_study, true, errors); + + boolean showImportOptions = OptionalFeatureService.get().isFeatureEnabled(EXPERIMENTAL_ALLOW_MERGE_WITH_MANAGED_KEYS) || _def.getKeyManagementType() == Dataset.KeyManagementType.None; + setShowMergeOption(showImportOptions); + setShowUpdateOption(showImportOptions); + setSuccessMessageSuffix("imported"); //Works for when the merge option is selected (may include updates) vs default "inserted" + return getDefaultImportView(form, errors); + } + + @Override + protected int importData(DataLoader dl, FileStream file, String originalName, BatchValidationException errors, @Nullable AuditBehaviorType auditBehaviorType, @Nullable TransactionAuditProvider.TransactionAuditEvent auditEvent, @Nullable String auditUserComment) + { + if (null == PipelineService.get().findPipelineRoot(getContainer())) + { + errors.addRowError(new ValidationException("Pipeline file system is not setup.")); + return -1; + } + + // Allow for mapping of the ParticipantId and Sequence Num (i.e. timepoint column), + // these are passed in for the "create dataset from a file and import data" case + Map columnMap = new CaseInsensitiveHashMap<>(); + if (null != _form.getParticipantId()) + columnMap.put(_form.getParticipantId(),"ParticipantId"); + if (null != _form.getSequenceNum()) + { + String column = _def.getDomainKind().getKindName().equalsIgnoreCase(DateDatasetDomainKind.KIND_NAME) ? "Date" : "SequenceNum"; + columnMap.put(_form.getSequenceNum(), column); + } + + Pair, UploadLog> result = StudyPublishManager.getInstance().importDatasetTSV(getUser(), _study, _def, dl, getLookupResolutionType(), file, originalName, columnMap, errors, _form.getInsertOption(), auditBehaviorType); + + if (!result.getKey().isEmpty()) + { + // Log the import when SUMMARY is configured, if DETAILED is configured the DetailedAuditLogDataIterator will handle each row change. + // It would be nice in the future to replace the DetailedAuditLogDataIterator with a general purpose AuditLogDataIterator + // that can delegate the audit behavior type to the AuditDataHandler, so this code can go away + // + String comment = "Dataset data imported. " + result.getKey().size() + " rows imported"; + new DatasetDefinition.DatasetAuditHandler(_def).addAuditEvent(getUser(), getContainer(), AuditBehaviorType.SUMMARY, comment, result.getValue()); + } + + return result.getKey().size(); + } + + @Override + public ActionURL getSuccessURL(ImportDatasetForm form) + { + return new ActionURL(DatasetAction.class, getContainer()).addParameter(Dataset.DATASET_KEY, form.getDatasetId()); + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild(_study.getLabel(), new ActionURL(BeginAction.class, getContainer())); + ActionURL datasetURL = new ActionURL(DatasetAction.class, getContainer()). + addParameter(Dataset.DATASET_KEY, _form.getDatasetId()); + root.addChild(_def.getName(), datasetURL); + root.addChild("Import Data"); + } + } + + @RequiresPermission(AdminPermission.class) + public class ImportDatasetSchemaAction extends FormViewAction + { + @Override + public void validateCommand(ImportDatasetSchemaForm target, Errors errors) + { + } + + @Override + public ModelAndView getView(ImportDatasetSchemaForm form, boolean reshow, BindException errors) + { + return new StudyJspView<>(getStudyRedirectIfNull(), "/org/labkey/study/view/importDatasetSchema.jsp", form, errors); + } + + @Override + public boolean handlePost(ImportDatasetSchemaForm form, BindException errors) throws ImportException + { + if (form.getManifest() == null) + errors.reject(null, "Manifest is required."); + + if (form.getMetadata() == null) + errors.reject(null, "Metadata is required."); + + if (errors.hasErrors()) + return false; + + DatasetsDocument.Datasets manifestDatasetsDoc; + + try + { + manifestDatasetsDoc = DatasetsDocument.Factory.parse(form.getManifest(), XmlBeansUtil.getDefaultParseOptions()).getDatasets(); + } + catch (XmlException e) + { + errors.reject(null, "Invalid manifest XML: " + e.getMessage()); + return false; + } + + TablesDocument tablesDoc; + + try + { + tablesDoc = TablesDocument.Factory.parse(form.getMetadata(), XmlBeansUtil.getDefaultParseOptions()); + } + catch (XmlException e) + { + errors.reject(null, "Invalid metadata XML: " + e.getMessage()); + return false; + } + + SchemaReader reader = new SchemaXmlReader(getStudyThrowIfNull(), "metadata XML", tablesDoc, manifestDatasetsDoc); + + ComplianceService complianceService = ComplianceService.get(); + return StudyManager.getInstance().importDatasetSchemas(getStudyThrowIfNull(), getUser(), reader, errors, false, true, complianceService.getCurrentActivity(getViewContext())); + } + + @Override + public ActionURL getSuccessURL(ImportDatasetSchemaForm bulkImportTypesForm) + { + return new ActionURL(ManageTypesAction.class, getContainer()); + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("DatasetBulkDefinition"); + _addNavTrailDatasetAdmin(root); + root.addChild("Import Dataset Schema"); + } + } + + public static class ImportDatasetSchemaForm + { + private String _metadata; + private String _manifest; + + public String getMetadata() + { + return _metadata; + } + + @SuppressWarnings("unused") + public void setMetadata(String metadata) + { + _metadata = metadata; + } + + public String getManifest() + { + return _manifest; + } + + @SuppressWarnings("unused") + public void setManifest(String manifest) + { + _manifest = manifest; + } + } + + @RequiresPermission(UpdatePermission.class) + public class ShowUploadHistoryAction extends SimpleViewAction + { + String _datasetLabel; + + @Override + public ModelAndView getView(IdForm form, BindException errors) + { + TableInfo tInfo = StudySchema.getInstance().getTableInfoUploadLog(); + DataRegion dr = new DataRegion(); + dr.addColumns(tInfo, "RowId,Created,CreatedBy,Status,Description"); + GridView gv = new GridView(dr, errors); + DisplayColumn dc = new SimpleDisplayColumn(null) { + @Override + public void renderGridCellContents(RenderContext ctx, HtmlWriter out) + { + ActionURL url = new ActionURL(DownloadTsvAction.class, ctx.getContainer()).addParameter("id", String.valueOf(ctx.get("RowId"))); + out.write(LinkBuilder.labkeyLink("Download Data File", url)); + } + }; + dr.addDisplayColumn(dc); + + SimpleFilter filter = SimpleFilter.createContainerFilter(getContainer()); + if (form.getId() != 0) + { + filter.addCondition(Dataset.DATASET_KEY, form.getId()); + DatasetDefinition dsd = StudyManager.getInstance().getDatasetDefinition(getStudyRedirectIfNull(), form.getId()); + if (dsd != null) + _datasetLabel = dsd.getLabel(); + } + + gv.setFilter(filter); + return gv; + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("Upload History" + (null != _datasetLabel ? " for " + _datasetLabel : "")); + } + } + + @RequiresPermission(UpdatePermission.class) + public static class DownloadTsvAction extends SimpleViewAction + { + @Override + public ModelAndView getView(IdForm form, BindException errors) throws Exception + { + UploadLog ul = StudyPublishManager.getInstance().getUploadLog(getContainer(), form.getId()); + PageFlowUtil.streamFile(getViewContext().getResponse(), new File(ul.getFilePath()).toPath(), true); + + return null; + } + + @Override + public void addNavTrail(NavTree root) + { + } + } + + @RequiresPermission(ReadPermission.class) + public static class DatasetItemDetailsAction extends SimpleViewAction + { + @Override + public ModelAndView getView(SourceLsidForm form, BindException errors) + { + ActionURL url = LsidManager.get().getDisplayURL(form.getSourceLsid()); + if (url == null) + { + return HtmlView.of("The assay run that produced the data has been deleted."); + } + return HttpView.redirect(url); + } + + @Override + public void addNavTrail(NavTree root) + { + } + } + + public static class PublishHistoryDetailsForm + { + private @Nullable Integer _protocolId; + private @Nullable Integer _sampleTypeId; + private int _datasetId; + private String _sourceLsid; + private int _recordCount; + + public Integer getProtocolId() + { + return _protocolId; + } + + public void setProtocolId(Integer protocolId) + { + _protocolId = protocolId; + } + + public Integer getSampleTypeId() + { + return _sampleTypeId; + } + + public void setSampleTypeId(Integer sampleTypeId) + { + _sampleTypeId = sampleTypeId; + } + + public int getDatasetId() + { + return _datasetId; + } + + public void setDatasetId(int datasetId) + { + _datasetId = datasetId; + } + + public String getSourceLsid() + { + return _sourceLsid; + } + + public void setSourceLsid(String sourceLsid) + { + _sourceLsid = sourceLsid; + } + + public int getRecordCount() + { + return _recordCount; + } + + public void setRecordCount(int recordCount) + { + _recordCount = recordCount; + } + } + + @RequiresPermission(ReadPermission.class) + public class PublishHistoryDetailsAction extends SimpleViewAction + { + @Override + public ModelAndView getView(PublishHistoryDetailsForm form, BindException errors) + { + final StudyImpl study = getStudyRedirectIfNull(); + + VBox view = new VBox(); + + int datasetId = form.getDatasetId(); + final DatasetDefinition def = StudyManager.getInstance().getDatasetDefinition(study, datasetId); + + if (def != null) + { + final StudyQuerySchema querySchema = StudyQuerySchema.createSchema(study, getUser()); + DatasetQuerySettings qs = (DatasetQuerySettings)querySchema.getSettings(getViewContext(), DatasetQueryView.DATAREGION, def.getName()); + + if (!def.canRead(getUser())) + { + //requiresLogin(); + view.addView(new HtmlView(HtmlString.of("User does not have read permission on this dataset."))); + } + else + { + Integer protocolId = form.getProtocolId(); + Integer sampleTypeId = form.getSampleTypeId(); + + if (protocolId == null && sampleTypeId == null) + throw new IllegalArgumentException("Expected either a protocolId or sampleId parameter"); + + String sourceLsid = form.getSourceLsid(); // the assay protocol or sample type LSID + int recordCount = form.getRecordCount(); + + ActionURL deleteURL = new ActionURL(DeletePublishedRowsAction.class, getContainer()); + deleteURL.addParameter("publishSourceId", protocolId != null ? protocolId : sampleTypeId); + deleteURL.addParameter("sourceLsid", sourceLsid); + final ActionButton deleteRows = new ActionButton(deleteURL, "Recall Rows"); + + deleteRows.setRequiresSelection(true, "Recall selected row of this dataset?", "Recall selected rows of this dataset?"); + deleteRows.setActionType(ActionButton.Action.POST); + deleteRows.setDisplayPermission(DeletePermission.class); + + PublishedRecordQueryView qv = new PublishedRecordQueryView(querySchema, qs, sourceLsid, def.getPublishSource(), + protocolId != null ? protocolId : sampleTypeId, recordCount) { + + @Override + protected void populateButtonBar(DataView view, ButtonBar bar) + { + bar.add(deleteRows); + } + }; + + view.addView(qv); + } + } + else + view.addView(new HtmlView(HtmlString.of("The Dataset does not exist."))); + return view; + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("Link to Study History Details"); + } + } + + @RequiresPermission(DeletePermission.class) + public class DeletePublishedRowsAction extends FormHandlerAction + { + private DatasetDefinition _def; + private Collection _allLsids; + private MultiValuedMap> _sourceLsidToLsidPair; + private Long _sourceRowId = null; + + @Override + public void validateCommand(DeleteDatasetRowsForm target, Errors errors) + { + _def = StudyManager.getInstance().getDatasetDefinition(getStudyThrowIfNull(), target.getDatasetId()); + if (_def == null) + throw new IllegalArgumentException("Could not find a dataset definition for id: " + target.getDatasetId()); + if (!target.isDeleteAllData()) + { + _allLsids = DataRegionSelection.getSelected(getViewContext(), true); + + if (_allLsids.isEmpty()) + { + errors.reject("deletePublishedRows", "No rows were selected"); + } + } + else + { + _allLsids = StudyManager.getInstance().getDatasetLSIDs(getUser(), _def); + } + + // Need to handle this by groups of source lsids -- each assay or SampleType container needs logging + _sourceLsidToLsidPair = new ArrayListValuedHashMap<>(); + List rowIds = new ArrayList<>(); + List> data = _def.getDatasetRows(getUser(), _allLsids); + + for (Map row : data) + { + String sourceLSID = (String)row.get(StudyPublishService.SOURCE_LSID_PROPERTY_NAME); + String datasetRowLsid = (String)row.get(StudyPublishService.LSID_PROPERTY_NAME); + Long rowId = MapUtils.getLong(row,StudyPublishService.ROWID_PROPERTY_NAME); + rowIds.add(rowId); + if (sourceLSID != null && datasetRowLsid != null) + _sourceLsidToLsidPair.put(sourceLSID, Pair.of(datasetRowLsid, rowId)); + + if (_sourceRowId == null && rowId != null) + _sourceRowId = rowId; + } + + String errorMsg = StudyPublishService.get().checkForLockedLinks(_def, rowIds); + if (!StringUtils.isEmpty(errorMsg)) + errors.reject(ERROR_MSG, errorMsg); + } + + @Override + public boolean handlePost(DeleteDatasetRowsForm form, BindException errors) + { + String originalSourceLsid = (String)getViewContext().get("sourceLsid"); + + Dataset.PublishSource publishSource = _def.getPublishSource(); + if (form.getPublishSourceId() != null && publishSource != null) + { + for (Map.Entry>> entry : _sourceLsidToLsidPair.asMap().entrySet()) + { + String sourceLsid = entry.getKey(); + Collection> pairs = entry.getValue(); + Container sourceContainer = publishSource.resolveSourceLsidContainer(sourceLsid, _sourceRowId); + if (sourceContainer != null) + StudyPublishService.get().addRecallAuditEvent(sourceContainer, getUser(), _def, pairs.size(), pairs); + } + } + + _def.deleteDatasetRows(getUser(), _allLsids); + + // if the recall was initiated from link to study details view of the publish source, redirect back to the same view + if (publishSource != null && originalSourceLsid != null && form.getPublishSourceId() != null) + { + Container container = publishSource.resolveSourceLsidContainer(originalSourceLsid, _sourceRowId); + if (container != null) + throw new RedirectException(StudyPublishService.get().getPublishHistory(container, publishSource, form.getPublishSourceId())); + } + return true; + } + + @Override + public ActionURL getSuccessURL(DeleteDatasetRowsForm form) + { + return new ActionURL(DatasetAction.class, getContainer()). + addParameter(Dataset.DATASET_KEY, form.getDatasetId()); + } + } + + public static class DeleteDatasetRowsForm + { + private int datasetId; + private boolean deleteAllData; + private Long _publishSourceId; + + public int getDatasetId() + { + return datasetId; + } + + public void setDatasetId(int datasetId) + { + this.datasetId = datasetId; + } + + public boolean isDeleteAllData() + { + return deleteAllData; + } + + public void setDeleteAllData(boolean deleteAllData) + { + this.deleteAllData = deleteAllData; + } + + public Long getPublishSourceId() + { + return _publishSourceId; + } + + public void setPublishSourceId(Long publishSourceId) + { + _publishSourceId = publishSourceId; + } + } + + // Dataset.canDelete() permissions check is below. This accommodates dataset security, where user might not have delete permission in the folder. + @RequiresPermission(ReadPermission.class) + public class DeleteDatasetRowsAction extends FormHandlerAction + { + @Override + public void validateCommand(DeleteDatasetRowsForm target, Errors errors) + { + } + + @Override + public boolean handlePost(DeleteDatasetRowsForm form, BindException errors) throws Exception + { + int datasetId = form.getDatasetId(); + StudyImpl study = getStudyThrowIfNull(); + StudyQuerySchema schema = StudyQuerySchema.createSchema(study, getUser()); + DatasetDefinition dataset = StudyManager.getInstance().getDatasetDefinition(study, datasetId); + TableInfo datasetTable = null==dataset ? null : schema.getDatasetTable(dataset, null); + + if (null == dataset || null == datasetTable) + throw new NotFoundException(); + + if (!datasetTable.hasPermission(getUser(), DeletePermission.class)) + throw new UnauthorizedException("User does not have permission to delete rows from this dataset"); + + // Operate on each individually for audit logging purposes, but transact the whole thing + DbScope scope = StudySchema.getInstance().getSchema().getScope(); + + try (DbScope.Transaction transaction = scope.ensureTransaction()) + { + Set lsids = DataRegionSelection.getSelected(getViewContext(), null, false); + List> keys = new ArrayList<>(lsids.size()); + for (String lsid : lsids) + keys.add(Collections.singletonMap("lsid", lsid)); + + QueryUpdateService qus = datasetTable.getUpdateService(); + assert qus != null; + + qus.deleteRows(getUser(), getContainer(), keys, null, null); + + transaction.commit(); + return true; + } + finally + { + DataRegionSelection.clearAll(getViewContext(), null); + } + } + + @Override + public ActionURL getSuccessURL(DeleteDatasetRowsForm form) + { + return new ActionURL(DatasetAction.class, getContainer()). + addParameter(Dataset.DATASET_KEY, form.getDatasetId()); + } + } + + public static class OverviewBean + { + public StudyImpl study; + public Map visitMapSummary; + public boolean showAll; + public boolean canManage; + public CohortFilter cohortFilter; + public boolean showCohorts; + public QCStateSet qcStates; + public Set stats; + public boolean showSpecimens; + } + + /** + * Tweak the link url for participant view so that it contains enough information to regenerate + * the cached list of participants. + */ + private void setColumnURL(final ActionURL url, final QueryView queryView, + final UserSchema querySchema, final Dataset def) + { + List columns; + try + { + columns = queryView.getDisplayColumns(); + } + catch (QueryParseException qpe) + { + return; + } + + // push any filter, sort params, and viewname + ActionURL base = new ActionURL(ParticipantAction.class, querySchema.getContainer()); + base.addParameter(Dataset.DATASET_KEY, Integer.toString(def.getDatasetId())); + for (Pair param : url.getParameters()) + { + if ((param.getKey().contains(".sort")) || + (param.getKey().contains("~")) || + (DATASET_VIEW_NAME_PARAMETER_NAME.equals(param.getKey()))) + { + base.addParameter(param.getKey(), param.getValue()); + } + } + base.addReturnUrl(url); // Set current URL so participant page can navigate back (nav trail) + + for (DisplayColumn col : columns) + { + String subjectColName = StudyService.get().getSubjectColumnName(def.getContainer()); + if (subjectColName.equalsIgnoreCase(col.getName())) + { + StringExpression old = col.getURLExpression(); + ContainerContext cc = old instanceof DetailsURL ? ((DetailsURL)old).getContainerContext() : null; + DetailsURL dets = new DetailsURL(base, "participantId", col.getColumnInfo().getFieldKey()); + dets.setContainerContext(null != cc ? cc : getContainer()); + col.setURLExpression(dets); + } + } + } + + public static ActionURL getProtocolDocumentDownloadURL(Container c, String name) + { + ActionURL url = new ActionURL(ProtocolDocumentDownloadAction.class, c); + url.addParameter("name", name); + + return url; + } + + @RequiresPermission(ReadPermission.class) + public class ProtocolDocumentDownloadAction extends BaseDownloadAction + { + @Override + public @Nullable Pair getAttachment(AttachmentForm form) + { + StudyImpl study = getStudyRedirectIfNull(); + return new Pair<>(study.getProtocolDocumentAttachmentParent(), form.getName()); + } + } + + private static final String PARTICIPANT_PROPS_CACHE = "Study_participants/propertyCache"; + private static final String DATASET_SORT_COLUMN_CACHE = "Study_participants/datasetSortColumnCache"; + @SuppressWarnings("unchecked") + private static Map> getParticipantPropsMap(ViewContext context) + { + HttpSession session = context.getRequest().getSession(true); + Map> map = (Map>) session.getAttribute(PARTICIPANT_PROPS_CACHE); + if (map == null) + { + map = new HashMap<>(); + session.setAttribute(PARTICIPANT_PROPS_CACHE, map); + } + return map; + } + + public static List getParticipantPropsFromCache(ViewContext context, String typeURI) + { + Map> map = getParticipantPropsMap(context); + List props = map.get(typeURI); + if (props == null) + { + props = OntologyManager.getPropertiesForType(typeURI, context.getContainer()); + map.put(typeURI, props); + } + return props; + } + + @SuppressWarnings("unchecked") + private static Map> getDatasetSortColumnMap(ViewContext context) + { + HttpSession session = context.getRequest().getSession(true); + Map> map = (Map>) session.getAttribute(DATASET_SORT_COLUMN_CACHE); + if (map == null) + { + map = new HashMap<>(); + session.setAttribute(DATASET_SORT_COLUMN_CACHE, map); + } + return map; + } + + public static @NotNull Map getSortedColumnList(ViewContext context, Dataset dsd) + { + Map> map = getDatasetSortColumnMap(context); + Map sortMap = map.get(dsd.getLabel()); + + if (sortMap == null) + { + QueryDefinition qd = QueryService.get().getQueryDef(context.getUser(), dsd.getContainer(), "study", dsd.getName()); + if (qd == null) + { + UserSchema schema = QueryService.get().getUserSchema(context.getUser(), context.getContainer(), "study"); + qd = schema.getQueryDefForTable(dsd.getName()); + } + CustomView cview = qd.getCustomView(context.getUser(), context.getRequest(), null); + if (cview != null) + { + sortMap = new HashMap<>(); + int i = 0; + for (FieldKey key : cview.getColumns()) + { + final String name = key.toString(); + if (!sortMap.containsKey(name)) + sortMap.put(name, i++); + } + map.put(dsd.getLabel(), sortMap); + } + else + { + // there is no custom view for this dataset + sortMap = Collections.emptyMap(); + map.put(dsd.getLabel(), Collections.emptyMap()); + } + } + return new CaseInsensitiveHashMap<>(sortMap); + } + + private static String getParticipantListCacheKey(ViewContext context) + { + // The query string includes all parameters that affect the participant list: dataset id, filters, sorts, etc. + // But need to strip off the participant ID parameter. + return context.cloneActionURL().deleteParameter("participantId").getQueryString(); + } + + public static void removeParticipantListFromSession(ViewContext context) + { + Cache> cache = getParticipantMapFromSession(context); + String key = getParticipantListCacheKey(context); + // Guava Cache doesn't tolerate null keys + if (key != null) + { + _log.debug("Invalidate participant list with key: {}", key); + cache.invalidate(key); + } + } + + @SuppressWarnings("unchecked") + private static Cache> getParticipantMapFromSession(ViewContext context) + { + HttpSession session = context.getRequest().getSession(true); + Cache> map = (Cache>) session.getAttribute(PARTICIPANT_CACHE_PREFIX); + if (map == null) + { + // Use a cache to limit the size (10) and to keep entries for no more than 10 minutes after last access + map = CacheBuilder.newBuilder().maximumSize(10).expireAfterAccess(10, TimeUnit.MINUTES).build(); + session.setAttribute(PARTICIPANT_CACHE_PREFIX, map); + } + return map; + } + + @SuppressWarnings("unchecked") + public static Map getExpandedState(ViewContext viewContext, int datasetId) + { + HttpSession session = viewContext.getRequest().getSession(true); + Map> map = (Map>) session.getAttribute(EXPAND_CONTAINERS_KEY); + if (map == null) + { + map = new HashMap<>(); + session.setAttribute(EXPAND_CONTAINERS_KEY, map); + } + + return map.computeIfAbsent(datasetId, k -> new HashMap<>()); + } + + public static @NotNull List getParticipantListFromSession(ViewContext context, int dataset, String viewName) + { + Cache> cache = getParticipantMapFromSession(context); + String key = getParticipantListCacheKey(context); + List ret = Collections.emptyList(); + + // Short-circuit for navigation from somewhere other than a dataset... esp. since Guava Cache doesn't tolerate null keys + if (null != key && dataset > 0) + { + try + { + ret = cache.get(key, () -> generateParticipantListFromURL(context, dataset, viewName)); + } + catch (ExecutionException ignored) + { + // Shouldn't ever happen since our loader doesn't throw exceptions + } + _log.debug("Get participant list of size {} with key: {}", ret.size(), key); + } + + return ret; + } + + private static List generateParticipantListFromURL(ViewContext context, int dataset, String viewName) + { + List ret; + try + { + final StudyManager studyMgr = StudyManager.getInstance(); + final StudyImpl study = studyMgr.getStudy(context.getContainer()); + + DatasetDefinition def = studyMgr.getDatasetDefinition(study, dataset); + if (null == def) + return Collections.emptyList(); + String typeURI = def.getTypeURI(); + if (null == typeURI) + return Collections.emptyList(); + + StudyQuerySchema querySchema = StudyQuerySchema.createSchema(study, context.getUser()); + QuerySettings qs = querySchema.getSettings(context, DatasetQueryView.DATAREGION, def.getName()); + qs.setViewName(viewName); + + QueryView queryView = querySchema.createView(context, qs, null); + + ret = generateParticipantList(queryView); + } + catch (Exception ignored) + { + ret = Collections.emptyList(); + } + + _log.debug("Generate participant list of size {}", ret.size()); + + return ret; + } + + public static List generateParticipantList(QueryView queryView) + { + final TableInfo table = queryView.getTable(); + + if (table != null) + { + try + { + // Do a single-column query to get the list of participants that match the filter criteria for this + // dataset + FieldKey ptidKey = FieldKey.fromParts(StudyService.get().getSubjectColumnName(queryView.getContainer())); + Map columns = QueryService.get().getColumns(table, Collections.singleton(ptidKey)); + ColumnInfo ptidColumnInfo = columns.get(ptidKey); + // Don't bother unless we actually found the participant column (we always should) + if (ptidColumnInfo != null) + { + // Go through the RenderContext directly to get the ResultSet so that we don't also end up calculating + // row counts or other aggregates we don't care about + DataView dataView = queryView.createDataView(); + RenderContext ctx = dataView.getRenderContext(); + DataRegion dataRegion = dataView.getDataRegion(); + queryView.getSettings().setShowRows(ShowRows.ALL); + try (Results results = ctx.getResults(columns, dataRegion.getDisplayColumns(), table, queryView.getSettings(), dataRegion.getQueryParameters(), Table.ALL_ROWS, dataRegion.getOffset(), dataRegion.getName(), false)) + { + int ptidIndex = ptidColumnInfo.findColumn(results); + + Set participantSet = new LinkedHashSet<>(); + while (results.next() && ptidIndex > 0) + { + String ptid = results.getString(ptidIndex); + participantSet.add(ptid); + } + + return new ArrayList<>(participantSet); + } + } + } + catch (Exception x) + { + throw new RuntimeException(x); + } + } + return Collections.emptyList(); + } + + public class ManageQCStatesBean extends AbstractManageQCStatesBean + { + ManageQCStatesBean(ActionURL returnUrl) + { + super(returnUrl); + _qcStateHandler = new StudyQCStateHandler(); + _manageAction = new ManageQCStatesAction(); + _deleteAction = DeleteQCStateAction.class; + _noun = "dataset"; + _dataNoun = "study"; + } + } + + public static class ManageQCStatesForm extends AbstractManageDataStatesForm + { + private Long _defaultPipelineQCState; + private Long _defaultPublishDataQCState; + private Long _defaultDirectEntryQCState; + private boolean _showPrivateDataByDefault; + + public Long getDefaultPipelineQCState() + { + return _defaultPipelineQCState; + } + + public void setDefaultPipelineQCState(Long defaultPipelineQCState) + { + _defaultPipelineQCState = defaultPipelineQCState; + } + + public Long getDefaultPublishDataQCState() + { + return _defaultPublishDataQCState; + } + + public void setDefaultPublishDataQCState(Long defaultPublishDataQCState) + { + _defaultPublishDataQCState = defaultPublishDataQCState; + } + + public Long getDefaultDirectEntryQCState() + { + return _defaultDirectEntryQCState; + } + + public void setDefaultDirectEntryQCState(Long defaultDirectEntryQCState) + { + _defaultDirectEntryQCState = defaultDirectEntryQCState; + } + + public boolean isShowPrivateDataByDefault() + { + return _showPrivateDataByDefault; + } + + public void setShowPrivateDataByDefault(boolean showPrivateDataByDefault) + { + _showPrivateDataByDefault = showPrivateDataByDefault; + } + } + + public static ActionURL getManageQCStatesURL(Container c, @NotNull ActionURL returnUrl) + { + return new ActionURL(ManageQCStatesAction.class, c).addReturnUrl(returnUrl); + } + + @RequiresPermission(AdminPermission.class) + public class ManageQCStatesAction extends AbstractManageQCStatesAction + { + public ManageQCStatesAction() + { + super(new StudyQCStateHandler(), ManageQCStatesForm.class); + } + + @Override + public ModelAndView getView(ManageQCStatesForm manageQCStatesForm, boolean reshow, BindException errors) + { + return new JspView<>("/org/labkey/api/qc/view/manageQCStates.jsp", + new ManageQCStatesBean(manageQCStatesForm.getReturnActionURL()), errors); + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("manageQC"); + _addManageStudy(root); + root.addChild("Manage Dataset QC States"); + } + + @Override + public URLHelper getSuccessURL(ManageQCStatesForm manageQCStatesForm) + { + ActionURL successUrl = getSuccessURL(manageQCStatesForm, ManageQCStatesAction.class, ManageStudyAction.class); + if (!manageQCStatesForm.isReshowPage() && !manageQCStatesForm.isShowPrivateDataByDefault()) + return getQCStateFilteredURL(successUrl, PUBLIC_STATES_LABEL, DATASET_DATAREGION_NAME, getContainer()); + + return successUrl; + } + + @Override + public boolean hasQcStateDefaultsPanel() + { + return true; + } + + @Override + public HtmlString getQcStateDefaultsPanel(Container container, DataStateHandler qcStateHandler) + { + _study = StudyController.getStudyThrowIfNull(container); + + HtmlStringBuilder panelHtml = HtmlStringBuilder.of(); + panelHtml.unsafeAppend(" "); + panelHtml.unsafeAppend(" "); + panelHtml.unsafeAppend(" "); + panelHtml.unsafeAppend(" "); + panelHtml.unsafeAppend(" "); + panelHtml.unsafeAppend(" "); + panelHtml.append(getQcStateHtml(container, qcStateHandler, "defaultPipelineQCState", _study.getDefaultPipelineQCState())); + panelHtml.unsafeAppend(" "); + panelHtml.unsafeAppend(" "); + panelHtml.unsafeAppend(" "); + panelHtml.append(getQcStateHtml(container, qcStateHandler, "defaultPublishDataQCState", _study.getDefaultPublishDataQCState())); + panelHtml.unsafeAppend(" "); + panelHtml.unsafeAppend(" "); + panelHtml.unsafeAppend(" "); + panelHtml.append(getQcStateHtml(container, qcStateHandler, "defaultDirectEntryQCState", _study.getDefaultDirectEntryQCState())); + panelHtml.unsafeAppend(" "); + panelHtml.unsafeAppend("
These settings allow different default QC states depending on data source."); + panelHtml.unsafeAppend(" If set, all imported data without an explicit QC state will have the selected state automatically assigned.
Pipeline imported datasets:
Data linked to this study:
Directly inserted/updated dataset data:
"); + + return panelHtml.getHtmlString(); + } + + @Override + public boolean hasDataVisibilityPanel() + { + return true; + } + + @Override + public HtmlString getDataVisibilityPanel(Container container, DataStateHandler qcStateHandler) + { + HtmlStringBuilder panelHtml = HtmlStringBuilder.of(); + panelHtml.unsafeAppend(" "); + panelHtml.unsafeAppend(" "); + panelHtml.unsafeAppend(" "); + panelHtml.unsafeAppend(" "); + panelHtml.unsafeAppend(" "); + panelHtml.unsafeAppend(" "); + panelHtml.unsafeAppend(" "); + panelHtml.unsafeAppend(" "); + panelHtml.unsafeAppend("
This setting determines whether users see non-public data by default."); + panelHtml.unsafeAppend(" Users can always explicitly choose to see data in any QC state.
Default visibility:"); + panelHtml.unsafeAppend(" "); + panelHtml.unsafeAppend("
"); + + return panelHtml.getHtmlString(); + } + + @Override + public boolean hasRequiresCommentPanel() + { + return false; + } + + @Override + public HtmlString getRequiresCommentPanel(Container container, DataStateHandler qcStateHandler) + { + throw new IllegalStateException("This action does not support a requires comment panel."); + } + } + + @RequiresPermission(AdminPermission.class) + public class DeleteQCStateAction extends AbstractDeleteDataStateAction + { + public DeleteQCStateAction() + { + super(); + _dataStateHandler = new StudyQCStateHandler(); + } + + @Override + public ActionURL getSuccessURL(DeleteDataStateForm form) + { + ActionURL returnUrl = new ActionURL(ManageQCStatesAction.class, getContainer()); + if (form.getManageReturnUrl() != null) + returnUrl.addParameter(ActionURL.Param.returnUrl, form.getManageReturnUrl()); + return returnUrl; + } + } + + public static class UpdateQCStateForm extends ReturnUrlForm + { + private String _comments; + private boolean _update; + private int _datasetId; + private String _dataRegionSelectionKey; + private Long _newState; + private DatasetQueryView _queryView; + private String _dataRegionName; + + public String getComments() + { + return _comments; + } + + public void setComments(String comments) + { + _comments = comments; + } + + public boolean isUpdate() + { + return _update; + } + + public void setUpdate(boolean update) + { + _update = update; + } + + public int getDatasetId() + { + return _datasetId; + } + + public void setDatasetId(int datasetId) + { + _datasetId = datasetId; + } + + public String getDataRegionSelectionKey() + { + return _dataRegionSelectionKey; + } + + public void setDataRegionSelectionKey(String dataRegionSelectionKey) + { + _dataRegionSelectionKey = dataRegionSelectionKey; + } + + public Long getNewState() + { + return _newState; + } + + public void setNewState(Long newState) + { + _newState = newState; + } + + public void setQueryView(DatasetQueryView queryView) + { + _queryView = queryView; + } + + public DatasetQueryView getQueryView() + { + return _queryView; + } + + public String getDataRegionName() + { + return _dataRegionName; + } + + public void setDataRegionName(String dataRegionName) + { + _dataRegionName = dataRegionName; + } + } + + @RequiresPermission(QCAnalystPermission.class) + public class UpdateQCStateAction extends FormViewAction + { + private UpdateQCStateForm _form; + + @Override + public void validateCommand(UpdateQCStateForm updateQCForm, Errors errors) + { + if (updateQCForm.isUpdate()) + { + if (updateQCForm.getComments() == null || updateQCForm.getComments().isEmpty()) + errors.reject(null, "Comments are required."); + } + } + + @Override + public ModelAndView getView(UpdateQCStateForm updateQCForm, boolean reshow, BindException errors) + { + StudyImpl study = getStudyRedirectIfNull(); + _form = updateQCForm; + int datasetId = updateQCForm.getDatasetId(); + DatasetDefinition def = StudyManager.getInstance().getDatasetDefinition(study, datasetId); + if (def == null) + { + throw new NotFoundException("No dataset found for id: " + datasetId); + } + Set lsids = null; + if (isPost()) + lsids = DataRegionSelection.getSelected(getViewContext(), updateQCForm.getDataRegionSelectionKey(), false); + if (lsids == null || lsids.isEmpty()) + return HtmlView.unsafe("No data rows selected. " + LinkBuilder.labkeyLink("back").onClick("back()")); + + StudyQuerySchema querySchema = StudyQuerySchema.createSchema(study, getUser()); + DatasetQuerySettings qs = new DatasetQuerySettings(getViewContext().getBindPropertyValues(), DatasetQueryView.DATAREGION); + + qs.setSchemaName(querySchema.getSchemaName()); + qs.setQueryName(def.getName()); + qs.setMaxRows(Table.ALL_ROWS); + qs.setShowSourceLinks(false); + qs.setShowEditLinks(false); + + final Set finalLsids = lsids; + + DatasetQueryView queryView = new DatasetQueryView(querySchema, qs, errors) + { + @Override + public DataView createDataView() + { + DataView view = super.createDataView(); + view.getDataRegion().setSortable(false); + view.getDataRegion().setShowFilters(false); + view.getDataRegion().setShowRecordSelectors(false); + view.getDataRegion().setShowPagination(false); + SimpleFilter filter = (SimpleFilter) view.getRenderContext().getBaseFilter(); + if (null == filter) + { + filter = new SimpleFilter(); + view.getRenderContext().setBaseFilter(filter); + } + filter.addInClause(FieldKey.fromParts("lsid"), new ArrayList<>(finalLsids)); + return view; + } + }; + queryView.setShowDetailsColumn(false); + updateQCForm.setQueryView(queryView); + updateQCForm.setDataRegionSelectionKey(DataRegionSelection.getSelectionKeyFromRequest(getViewContext())); + updateQCForm.setDataRegionName(queryView.getSettings().getDataRegionName()); + return new JspView<>("/org/labkey/study/view/updateQCState.jsp", updateQCForm, errors); + } + + @Override + public boolean handlePost(UpdateQCStateForm updateQCForm, BindException errors) + { + if (!updateQCForm.isUpdate()) + return false; + Set lsids = DataRegionSelection.getSelected(getViewContext(), updateQCForm.getDataRegionSelectionKey(), false); + + DataState newState = null; + if (updateQCForm.getNewState() != null) + { + newState = QCStateManager.getInstance().getStateForRowId(getContainer(), updateQCForm.getNewState()); + if (newState == null) + { + errors.reject(null, "The selected state could not be found. It may have been deleted from the database."); + return false; + } + } + StudyManager.getInstance().updateDataQCState(getContainer(), getUser(), + updateQCForm.getDatasetId(), lsids, newState, updateQCForm.getComments()); + + // if everything has succeeded, we can clear our saved checkbox state now: + DataRegionSelection.clearAll(getViewContext(), updateQCForm.getDataRegionSelectionKey()); + return true; + } + + @Override + public ActionURL getSuccessURL(UpdateQCStateForm updateQCForm) + { + ActionURL url = updateQCForm.getReturnActionURL(); + if (null == url) + { + // We've lost the returnUrl... at least redirect back to the dataset + url = new ActionURL(DatasetAction.class, getContainer()); + url.addParameter(Dataset.DATASET_KEY, updateQCForm.getDatasetId()); + } + if (updateQCForm.getNewState() != null) + url.replaceParameter(getQCUrlFilterKey(CompareType.EQUAL, updateQCForm.getDataRegionName()), QCStateManager.getInstance().getStateForRowId(getContainer(), updateQCForm.getNewState().longValue()).getLabel()); + return url; + } + + @Override + public void addNavTrail(NavTree root) + { + root = _addNavTrail(root, _form.getDatasetId(), _form.getReturnActionURL()); + root.addChild("Change QC State"); + } + } + + public static class ResetPipelinePathForm extends PipelinePathForm + { + private String _redirect; + + public String getRedirect() + { + return _redirect; + } + + public void setRedirect(String redirect) + { + _redirect = redirect; + } + } + + @RequiresPermission(AdminPermission.class) + public static class ResetPipelineAction extends FormHandlerAction + { + @Override + public void validateCommand(ResetPipelinePathForm form, Errors errors) + { + } + + @Override + public boolean handlePost(ResetPipelinePathForm form, BindException errors) throws Exception + { + for (FileLike f : form.getValidatedFiles(getContainer())) + { + if (f.isFile() && f.getName().endsWith(".lock")) + { + f.delete(); + } + } + return true; + } + + @Override + public URLHelper getSuccessURL(ResetPipelinePathForm form) + { + String redirect = form.getRedirect(); + if (null != redirect) + { + try + { + return new URLHelper(redirect); + } + catch (URISyntaxException e) + { + _log.warn("ResetPipelineAction redirect string invalid: " + redirect); + } + } + return urlProvider(PipelineStatusUrls.class).urlBegin(getContainer()); + } + } + + @RequiresPermission(ReadPermission.class) + public class DefaultDatasetReportAction extends SimpleRedirectAction + { + @Override + public ActionURL getRedirectURL(Object o) + { + ViewContext context = getViewContext(); //_study.isShowPrivateDataByDefault() + Object unparsedDatasetId = context.get(Dataset.DATASET_KEY); + + try + { + int datasetId = null == unparsedDatasetId ? 0 : Integer.parseInt(unparsedDatasetId.toString()); + + ActionURL url = context.cloneActionURL(); + url.setAction(DatasetReportAction.class); + + String defaultView = getDefaultView(context, datasetId); + if (!StringUtils.isEmpty(defaultView)) + { + ReportIdentifier reportId = ReportService.get().getReportIdentifier(defaultView, getViewContext().getUser(), getViewContext().getContainer()); + if (reportId != null) + url.addParameter(DATASET_REPORT_ID_PARAMETER_NAME, defaultView); + else + url.addParameter(DATASET_VIEW_NAME_PARAMETER_NAME, defaultView); + } + + if (!"1".equals(url.getParameter("skipDataVisibility"))) + { + StudyImpl studyImpl = StudyManager.getInstance().getStudy(getContainer()); + if (studyImpl != null && !studyImpl.isShowPrivateDataByDefault()) + url = getQCStateFilteredURL(url, PUBLIC_STATES_LABEL, DATASET_DATAREGION_NAME, getContainer()); + } + return url; + } + catch (NumberFormatException e) + { + throw new NotFoundException("No such dataset with ID: " + unparsedDatasetId); + } + } + } + + public static ActionURL getViewPreferencesURL(Container c, int id, String viewName) + { + // Issue 26030: we don't distinguish null vs empty string for url parameters. + // Empty string will be converted to null for beans so "" shouldn't be used as the url param for Default Grid View. + return new ActionURL(ViewPreferencesAction.class, c).addParameter(Dataset.DATASET_KEY, id).addParameter("defaultView", viewName != null ? (viewName.isEmpty() ? "defaultGrid": viewName) : null); + } + + public static class ViewPreferencesForm extends DatasetController.DatasetIdForm + { + private String _defaultView; + + public String getDefaultView() + { + return _defaultView; + } + + @SuppressWarnings("unused") + public void setDefaultView(String defaultView) + { + _defaultView = "defaultGrid".equals(defaultView) ? "" : defaultView; + } + } + + @RequiresPermission(ReadPermission.class) + @RequiresLogin // Don't set a default view for guests, Issue 52863 + public class ViewPreferencesAction extends FormViewAction + { + private StudyImpl _study; + private Dataset _def; + + private int init(ViewPreferencesForm form) + { + int dsid = form.getDatasetId(); + _study = getStudyRedirectIfNull(); + _def = StudyManager.getInstance().getDatasetDefinition(_study, dsid); + return dsid; + } + + @Override + public ModelAndView getView(ViewPreferencesForm form, boolean reshow, BindException errors) throws Exception + { + init(form); + if (_def != null) + { + List> views = ReportManager.get().getReportLabelsForDataset(getViewContext(), _def); + ViewPrefsBean bean = new ViewPrefsBean(views, _def); + return new StudyJspView<>(_study, "/org/labkey/study/view/viewPreferences.jsp", bean, errors); + } + throw new NotFoundException("Invalid dataset ID"); + } + + @Override + public boolean handlePost(ViewPreferencesForm form, BindException errors) throws Exception + { + int dsid = init(form); + String defaultView = form.getDefaultView(); + if ((_def != null) && (defaultView != null)) + { + setDefaultView(dsid, defaultView); + return true; + } + throw new NotFoundException("Invalid dataset ID"); + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("customViews"); + + root.addChild(_study.getLabel(), new ActionURL(BeginAction.class, getContainer())); + + ActionURL datasetURL = getViewContext().getActionURL().clone(); + datasetURL.setAction(DatasetAction.class); + + String label = _def.getLabel() != null ? _def.getLabel() : "" + _def.getDatasetId(); + root.addChild(new NavTree(label, datasetURL)); + + root.addChild(new NavTree("View Preferences")); + } + + @Override + public URLHelper getSuccessURL(ViewPreferencesForm viewPreferencesForm) { return null; } + + @Override + public void validateCommand(ViewPreferencesForm target, Errors errors) { } + } + + @RequiresPermission(AdminPermission.class) + public class ImportStudyBatchAction extends SimpleViewAction + { + private String path; + + @Override + public ModelAndView getView(PipelinePathForm form, BindException errors) throws Exception + { + Container c = getContainer(); + + File definitionFile = form.getValidatedSingleFile(c).toNioPathForRead().toFile(); + path = form.getPath(); + if (!path.endsWith("/")) + { + path += "/"; + } + path += definitionFile.getName(); + + if (!definitionFile.isFile()) + { + throw new NotFoundException(); + } + + File lockFile = StudyPipeline.lockForDataset(getStudyRedirectIfNull(), definitionFile); + + if (!definitionFile.canRead()) + errors.reject("importStudyBatch", "Can't read dataset file: " + path); + if (lockFile.exists()) + errors.reject("importStudyBatch", "Lock file exists. Delete file before running import. " + lockFile.getName()); + + VirtualFile datasetsDir = new FileSystemFile(definitionFile.getParentFile()); + DatasetFileReader reader = new DatasetFileReader(datasetsDir, definitionFile.getName(), getStudyRedirectIfNull()); + + if (!errors.hasErrors()) + { + List parseErrors = new ArrayList<>(); + reader.validate(parseErrors); + for (String error : parseErrors) + errors.reject("importStudyBatch", error); + } + + return new StudyJspView<>( + getStudyRedirectIfNull(), "/org/labkey/study/view/importStudyBatch.jsp", new ImportStudyBatchBean(reader, path), errors); + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild(getStudyRedirectIfNull().getLabel(), new ActionURL(StudyController.BeginAction.class, getContainer())); + root.addChild("Import Study Batch - " + path); + } + } + + @RequiresPermission(AdminPermission.class) + public class SubmitStudyBatchAction extends FormHandlerAction + { + private ActionURL _successUrl = null; + + @Override + public void validateCommand(PipelinePathForm target, Errors errors) + { + } + + @Override + public boolean handlePost(PipelinePathForm form, BindException errors) throws Exception + { + Study study = getStudyRedirectIfNull(); + Container c = getContainer(); + String path = form.getPath(); + File f = null; + + PipeRoot root = PipelineService.get().findPipelineRoot(c); + if (path != null) + { + if (root != null) + f = root.resolvePath(path); + } + + try + { + if (f != null) + { + VirtualFile datasetsDir = new FileSystemFile(f.getParentFile()); + DatasetImportUtils.submitStudyBatch(study, datasetsDir, f.getName(), c, getUser(), getViewContext().getActionURL(), root); + } + _successUrl = urlProvider(PipelineStatusUrls.class).urlBegin(getContainer()); + } + catch (DatasetImportUtils.DatasetLockExistsException e) + { + ActionURL importURL = new ActionURL(ImportStudyBatchAction.class, getContainer()); + importURL.addParameter("path", form.getPath()); + _successUrl = importURL; + } + + return true; + } + + @Override + public URLHelper getSuccessURL(PipelinePathForm pipelinePathForm) + { + return _successUrl; + } + } + + @RequiresPermission(ReadPermission.class) + public class TypeNotFoundAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object o, BindException errors) + { + return new StudyJspView(getStudyRedirectIfNull(), "/org/labkey/study/view/typeNotFound.jsp", null, errors); + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("Type Not Found"); + } + } + + @RequiresPermission(AdminPermission.class) + public class UpdateParticipantVisitsAction extends FormViewAction + { + private int _count; + + @Override + public void validateCommand(Object target, Errors errors) + { + } + + @Override + public ModelAndView getView(Object o, boolean reshow, BindException errors) throws Exception + { + if (reshow) + { + return HtmlView.unsafe( + "
" + _count + " rows were updated.

" + + PageFlowUtil.button("Done").href(new ActionURL(ManageVisitsAction.class, getContainer())) + + "

"); + } + else + { + return HtmlView.unsafe( + "
Click the button below to recalculate visit dates for all participants in this study.

" + + PageFlowUtil.button("Recalculate Visit Dates").href(new ActionURL(UpdateParticipantVisitsAction.class, getContainer())).submit(true) + + new CsrfInput(getViewContext()) + + "

"); + } + } + + @Override + public boolean handlePost(Object o, BindException errors) throws Exception + { + var vm = StudyManager.getInstance().getVisitManager(getStudyRedirectIfNull()); + if (vm instanceof SequenceVisitManager svm) + { + // This could be optimized by combining with updateParticipantVisits(). + // However, updateParticipantVisits() handles incremental updates and would need to be refactored a bit + // and this isn't a common code path. + svm.purgeParticipantVisit(getUser()); + } + vm.updateParticipantVisits(getUser(), getStudyRedirectIfNull().getDatasets()); + + TableInfo tinfoParticipantVisit = StudySchema.getInstance().getTableInfoParticipantVisit(); + _count = new SqlSelector(StudySchema.getInstance().getSchema(), + "SELECT COUNT(VisitDate) FROM " + tinfoParticipantVisit + "\nWHERE Container = ?", + getContainer()).getObject(Integer.class); + + return true; + } + + @Override + public URLHelper getSuccessURL(Object o) + { + return null; + } + + @Override + public void addNavTrail(NavTree root) + { + _addNavTrailVisitAdmin(root); + root.addChild("Recalculate Visit Dates"); + } + } + + @RequiresPermission(AdminPermission.class) + public class VisitOrderAction extends FormViewAction + { + @Override + public ModelAndView getView(VisitReorderForm reorderForm, boolean reshow, BindException errors) + { + StudyImpl study = getStudyRedirectIfNull(); + redirectToSharedVisitStudy(study, getViewContext().getActionURL()); + + return new StudyJspView(study, "/org/labkey/study/view/visitOrder.jsp", reorderForm, errors); + } + + @Override + public void addNavTrail(NavTree root) + { + _addNavTrailVisitAdmin(root); + root.addChild("Visit Order"); + } + + @Override + public void validateCommand(VisitReorderForm target, Errors errors) {} + + private Map getVisitIdToOrderIndex(String orderedIds) + { + Map order = null; + if (orderedIds != null && !orderedIds.isEmpty()) + { + order = new HashMap<>(); + String[] idArray = orderedIds.split(","); + for (int i = 0; i < idArray.length; i++) + { + int id = Integer.parseInt(idArray[i]); + // 1-index display orders, since 0 is the database default, and we'd like to know + // that these were set explicitly for all visits: + order.put(id, i + 1); + } + } + return order; + } + + private Map getVisitIdToZeroMap(Collection visits) + { + Map order = new IntHashMap<>(); + for (VisitImpl visit : visits) + order.put(visit.getRowId(), 0); + return order; + } + + @Override + public boolean handlePost(VisitReorderForm form, BindException errors) + { + StudyImpl study = getStudyRedirectIfNull(); + redirectToSharedVisitStudy(study, getViewContext().getActionURL()); + + Map displayOrder = null; + Map chronologicalOrder = null; + Collection visits = StudyManager.getInstance().getVisits(study, Visit.Order.SEQUENCE_NUM); + + if (form.isExplicitDisplayOrder()) + displayOrder = getVisitIdToOrderIndex(form.getDisplayOrder()); + if (displayOrder == null) + displayOrder = getVisitIdToZeroMap(visits); + + if (form.isExplicitChronologicalOrder()) + chronologicalOrder = getVisitIdToOrderIndex(form.getChronologicalOrder()); + if (chronologicalOrder == null) + chronologicalOrder = getVisitIdToZeroMap(visits); + + for (VisitImpl visit : visits) + { + // it's possible that a new visit has been created between when the update page was rendered + // and posted. This will result in a visit that isn't in our ID maps. There's no great way + // to handle this, so we'll just skip setting display/chronological order on these visits for now. + if (displayOrder.containsKey(visit.getRowId()) && chronologicalOrder.containsKey(visit.getRowId())) + { + int displayIndex = displayOrder.get(visit.getRowId()).intValue(); + int chronologicalIndex = chronologicalOrder.get(visit.getRowId()).intValue(); + + if (visit.getDisplayOrder() != displayIndex || visit.getChronologicalOrder() != chronologicalIndex) + { + visit = visit.createMutable(); + visit.setDisplayOrder(displayIndex); + visit.setChronologicalOrder(chronologicalIndex); + StudyManager.getInstance().updateVisit(getUser(), visit); + } + } + } + + // Changing visit order can cause cohort assignments to change when advanced cohort tracking is enabled: + if (study.isAdvancedCohorts()) + CohortManager.getInstance().updateParticipantCohorts(getUser(), study); + return true; + } + + @Override + public ActionURL getSuccessURL(VisitReorderForm reorderForm) + { + return reorderForm.getReturnActionURL(); + } + } + + @RequiresPermission(AdminPermission.class) + public class VisitVisibilityAction extends FormViewAction + { + @Override + public ModelAndView getView(VisitPropertyForm visitPropertyForm, boolean reshow, BindException errors) + { + StudyImpl study = getStudyRedirectIfNull(); + redirectToSharedVisitStudy(study, getViewContext().getActionURL()); + + return new StudyJspView(study, "/org/labkey/study/view/visitVisibility.jsp", visitPropertyForm, errors); + } + + @Override + public void addNavTrail(NavTree root) + { + _addNavTrailVisitAdmin(root); + root.addChild("Properties"); + } + + @Override + public void validateCommand(VisitPropertyForm target, Errors errors) {} + + @Override + public boolean handlePost(VisitPropertyForm form, BindException errors) + { + StudyImpl study = getStudyRedirectIfNull(); + redirectToSharedVisitStudy(study, getViewContext().getActionURL()); + + int[] allIds = form.getIds() == null ? new int[0] : form.getIds(); + int[] visibleIds = form.getVisible() == null ? new int[0] : form.getVisible(); + String[] labels = form.getLabel() == null ? new String[0] : form.getLabel(); + String[] typeStrs = form.getExtraData()== null ? new String[0] : form.getExtraData(); + + Set visible = new IntHashSet(visibleIds.length); + for (int id : visibleIds) + visible.add(id); + if (allIds.length != form.getLabel().length) + throw new IllegalStateException("Arrays must be the same length."); + for (int i = 0; i < allIds.length; i++) + { + VisitImpl def = StudyManager.getInstance().getVisitForRowId(study, allIds[i]); + boolean show = visible.contains(allIds[i]); + String label = (i < labels.length) ? labels[i] : null; + String typeStr = (i < typeStrs.length) ? typeStrs[i] : null; + + Integer cohortId = null; + if (form.getCohort() != null && form.getCohort()[i] != -1) + cohortId = form.getCohort()[i]; + Character type = typeStr != null && !typeStr.isEmpty() ? typeStr.charAt(0) : null; + if (def.isShowByDefault() != show || !nullSafeEqual(label, def.getLabel()) || type != def.getTypeCode() || !nullSafeEqual(cohortId, def.getCohortId())) + { + def = def.createMutable(); + def.setShowByDefault(show); + def.setLabel(label); + def.setCohortId(cohortId); + def.setTypeCode(type); + StudyManager.getInstance().updateVisit(getUser(), def); + } + } + return true; + } + + @Override + public ActionURL getSuccessURL(VisitPropertyForm visitPropertyForm) + { + return new ActionURL(ManageVisitsAction.class, getContainer()); + } + } + + @RequiresPermission(AdminPermission.class) + public class DatasetVisibilityAction extends FormViewAction + { + @Override + public ModelAndView getView(DatasetPropertyForm form, boolean reshow, BindException errors) + { + _study = getStudyRedirectIfNull(); + var sqs = StudyQuerySchema.createSchema(_study, getUser()); + Map bean = new IntHashMap<>(); + for (DatasetDefinition def : _study.getDatasets()) + { + DatasetVisibilityData data = new DatasetVisibilityData(); + data.label = def.getLabel(); + data.categoryId = def.getViewCategory() != null ? def.getViewCategory().getRowId() : null; + data.cohort = def.getCohortId(); + data.visible = def.isShowByDefault(); + data.shared = def.isShared(); + data.inherited = def.isInherited(); + data.status = (String)ReportPropsManager.get().getPropertyValue(def.getEntityId(), getContainer(), "status"); + if ("None".equals(data.status)) + data.status = null; + DatasetTable t = sqs.getDatasetTable(def, null); + if (null != t) + { + long rowCount = new TableSelector(t).getRowCount(); + data.rowCount = rowCount; + data.empty = 0 == rowCount; + } + bean.put(def.getDatasetId(), data); + } + + // Merge with form data + Map formDataset = form.getDataset(); + if (formDataset != null) + { + for (Map.Entry entry : formDataset.entrySet()) + { + DatasetVisibilityData formData = entry.getValue(); + DatasetVisibilityData beanData = bean.get(entry.getKey()); + if (formData == null || beanData == null) + continue; + + beanData.label = formData.label; + beanData.categoryId = formData.categoryId; + beanData.cohort = formData.cohort; + beanData.visible = formData.visible; + } + } + + return new StudyJspView<>( + getStudyRedirectIfNull(), "/org/labkey/study/view/datasetVisibility.jsp", bean, errors); + } + + @Override + public void addNavTrail(NavTree root) + { + _addManageStudy(root); + root.addChild("Manage Datasets", new ActionURL(ManageTypesAction.class, getContainer())); + root.addChild("Properties"); + } + + @Override + public void validateCommand(DatasetPropertyForm form, Errors errors) + { + // Check for bad labels + Set labels = new HashSet<>(); + for (DatasetVisibilityData data : form.getDataset().values()) + { + String label = data.getLabel(); + if (StringUtils.isBlank(label)) + { + errors.reject("datasetVisibility", "Label cannot be blank"); + } + if (labels.contains(label)) + { + errors.reject("datasetVisibility", "Labels must be unique. Found two or more labels called '" + label + "'."); + } + labels.add(label); + } + } + + @Override + public boolean handlePost(DatasetPropertyForm form, BindException errors) throws Exception + { + for (Map.Entry entry : form.getDataset().entrySet()) + { + Integer id = entry.getKey(); + DatasetVisibilityData data = entry.getValue(); + + if (id == null) + throw new IllegalArgumentException("id required"); + + DatasetDefinition def = StudyManager.getInstance().getDatasetDefinition(getStudyThrowIfNull(), id); + if (def == null) + throw new NotFoundException("dataset"); + + String label = data.getLabel(); + boolean show = data.isVisible(); + Integer categoryId = data.getCategoryId(); + Integer cohortId = data.getCohort(); + if (cohortId != null && cohortId.intValue() == -1) + cohortId = null; + + if (def.isShowByDefault() != show || !nullSafeEqual(categoryId, def.getCategoryId()) || !nullSafeEqual(label, def.getLabel()) || !BaseStudyController.nullSafeEqual(cohortId, def.getCohortId())) + { + def = def.createMutable(); + def.setShowByDefault(show); + def.setCategoryId(categoryId); + def.setCohortId(cohortId); + def.setLabel(label); + List saveErrors = new ArrayList<>(); + StudyManager.getInstance().updateDatasetDefinition(getUser(), def, saveErrors); + for (String error : saveErrors) + { + errors.reject(ERROR_MSG, error); + return false; + } + } + ReportPropsManager.get().setPropertyValue(def.getEntityId(), getContainer(), "status", data.getStatus()); + } + + return true; + } + + @Override + public ActionURL getSuccessURL(DatasetPropertyForm form) + { + return new ActionURL(ManageTypesAction.class, getContainer()); + } + } + + // Bean will be an map of these + public static class DatasetVisibilityData + { + // form POSTed values + public String label; + public Integer cohort; // null for none + public String status; + public Integer categoryId; + public boolean visible; + + // not form POSTed -- used to render view + public long rowCount; + public boolean empty; + public boolean shared; + public boolean inherited; + + public String getLabel() + { + return label; + } + + public void setLabel(String label) + { + this.label = label; + } + + public Integer getCohort() + { + return cohort; + } + + public void setCohort(Integer cohort) + { + this.cohort = cohort; + } + + public String getStatus() + { + return status; + } + + public void setStatus(String status) + { + this.status = status; + } + + public boolean isVisible() + { + return visible; + } + + public void setVisible(boolean visible) + { + this.visible = visible; + } + + public Integer getCategoryId() + { + return categoryId; + } + + public void setCategoryId(Integer categoryId) + { + this.categoryId = categoryId; + } + } + + @Marshal(Marshaller.Jackson) + @RequiresPermission(AdminPermission.class) + public static class DeleteDatasetPropertyOverrideAction extends MutatingApiAction + { + @Override + public Object execute(Object o, BindException errors) + { + StudyManager.getInstance().deleteDatasetPropertyOverrides(getUser(), getContainer(), errors); + return errors.hasErrors() ? null : success(); + } + } + + @RequiresPermission(AdminPermission.class) + public class DatasetDisplayOrderAction extends FormViewAction + { + @Override + public ModelAndView getView(DatasetReorderForm form, boolean reshow, BindException errors) + { + return new StudyJspView(getStudyRedirectIfNull(), "/org/labkey/study/view/datasetDisplayOrder.jsp", form, errors); + } + + @Override + public void addNavTrail(NavTree root) + { + _addManageStudy(root); + root.addChild("Manage Datasets", new ActionURL(ManageTypesAction.class, getContainer())); + root.addChild("Display Order"); + } + + @Override + public void validateCommand(DatasetReorderForm target, Errors errors) {} + + @Override + public boolean handlePost(DatasetReorderForm form, BindException errors) + { + String order = form.getOrder(); + + if (order != null && !order.isEmpty() && !form.isResetOrder()) + { + String[] ids = order.split(","); + List orderedIds = new ArrayList<>(ids.length); + + for (String id : ids) + orderedIds.add(Integer.parseInt(id)); + + DatasetReorderer reorderer = new DatasetReorderer(getStudyThrowIfNull(), getUser()); + reorderer.reorderDatasets(orderedIds); + } + else if (form.isResetOrder()) + { + DatasetReorderer reorderer = new DatasetReorderer(getStudyThrowIfNull(), getUser()); + reorderer.resetOrder(); + } + + return true; + } + + @Override + public ActionURL getSuccessURL(DatasetReorderForm visitPropertyForm) + { + return new ActionURL(ManageTypesAction.class, getContainer()); + } + } + + + @RequiresPermission(AdminPermission.class) + public class DeleteDatasetAction extends FormHandlerAction + { + @Override + public void validateCommand(IdForm target, Errors errors) + { + + } + + @Override + public boolean handlePost(IdForm form, BindException errors) throws Exception + { + Study study = getStudyRedirectIfNull(getContainer()); + + DatasetDefinition ds = StudyManager.getInstance().getDatasetDefinition(study, form.getId()); + if (null == ds) + redirectTypeNotFound(form.getId()); + if (!ds.canDeleteDefinition(getUser())) + errors.reject(ERROR_MSG, "Can't delete this dataset: " + ds.getName()); + + if (errors.hasErrors()) + return false; + + DbScope scope = StudySchema.getInstance().getSchema().getScope(); + try (DbScope.Transaction transaction = scope.ensureTransaction()) + { + // performStudyResync==false so we can do this out of the transaction + StudyManager.getInstance().deleteDataset(getStudyRedirectIfNull(), getUser(), ds, false, null); + transaction.commit(); + } + + StudyManager.getInstance().getVisitManager(study).updateParticipantVisits(getUser(), Collections.emptySet()); + return true; + } + + @Override + public URLHelper getSuccessURL(IdForm idForm) + { + throw new RedirectException(new ActionURL(ManageTypesAction.class, getContainer())); + } + } + + + private static final String DEFAULT_PARTICIPANT_VIEW_SOURCE = + """ +
Loading...
+ + + /* Adjust width of first column: */ + """; + + public static class CustomizeParticipantViewForm extends ReturnUrlForm + { + private String _customScript; + private String _participantId; + private boolean _useCustomView; + private boolean _reshow; + private boolean _editable = true; + + public boolean isEditable() + { + return _editable; + } + + public void setEditable(boolean editable) + { + _editable = editable; + } + + public String getCustomScript() + { + return _customScript; + } + + public String getDefaultScript() + { + return DEFAULT_PARTICIPANT_VIEW_SOURCE; + } + + public void setCustomScript(String customScript) + { + _customScript = customScript; + } + + public String getParticipantId() + { + return _participantId; + } + + public void setParticipantId(String participantId) + { + _participantId = participantId; + } + + public boolean isReshow() + { + return _reshow; + } + + public void setReshow(boolean reshow) + { + _reshow = reshow; + } + + public boolean isUseCustomView() + { + return _useCustomView; + } + + public void setUseCustomView(boolean useCustomView) + { + _useCustomView = useCustomView; + } + } + + @RequiresAllOf({AdminPermission.class, BrowserDeveloperPermission.class}) + public class CustomizeParticipantViewAction extends FormViewAction + { + @Override + public void validateCommand(CustomizeParticipantViewForm target, Errors errors) + { + } + + @Override + public ModelAndView getView(CustomizeParticipantViewForm form, boolean reshow, BindException errors) + { + Study study = getStudyRedirectIfNull(); + CustomParticipantView view = StudyManager.getInstance().getCustomParticipantView(study); + if (view != null) + { + form.setCustomScript(view.getBody()); + form.setUseCustomView(view.isActive()); + form.setEditable(!view.isModuleParticipantView()); + } + + return new JspView<>("/org/labkey/study/view/customizeParticipantView.jsp", form); + } + + @Override + public boolean handlePost(CustomizeParticipantViewForm form, BindException errors) + { + Study study = getStudyThrowIfNull(); + CustomParticipantView view = StudyManager.getInstance().getCustomParticipantView(study); + if (view == null) + view = new CustomParticipantView(); + view.setBody(form.getCustomScript()); + view.setActive(form.isUseCustomView()); + view = StudyManager.getInstance().saveCustomParticipantView(study, getUser(), view); + return view != null; + } + + @Override + public ActionURL getSuccessURL(CustomizeParticipantViewForm form) + { + if (form.isReshow()) + { + ActionURL reshowURL = new ActionURL(CustomizeParticipantViewAction.class, getContainer()); + if (form.getParticipantId() != null && !form.getParticipantId().isEmpty()) + reshowURL.addParameter("participantId", form.getParticipantId()); + if (form.getReturnUrl() != null && !form.getReturnUrl().isEmpty()) + reshowURL.addParameter(ActionURL.Param.returnUrl, form.getReturnUrl()); + return reshowURL; + } + else if (form.getReturnUrl() != null && !form.getReturnUrl().isEmpty()) + return new ActionURL(form.getReturnUrl()); + else + return urlProvider(ReportUrls.class).urlManageViews(getContainer()); + } + + @Override + public void addNavTrail(NavTree root) + { + _addManageStudy(root); + root.addChild("Manage Views", urlProvider(ReportUrls.class).urlManageViews(getContainer())); + root.addChild("Customize " + StudyService.get().getSubjectNounSingular(getContainer()) + " View"); + } + } + + public static class StudySnapshotForm extends QuerySnapshotForm + { + private int _snapshotDatasetId = -1; + private String _action; + private Boolean _queryDataset; + + public static final String EDIT_DATASET = "editDataset"; + public static final String CREATE_SNAPSHOT = "createSnapshot"; + public static final String CANCEL = "cancel"; + + public int getSnapshotDatasetId() + { + return _snapshotDatasetId; + } + + public void setSnapshotDatasetId(int snapshotDatasetId) + { + _snapshotDatasetId = snapshotDatasetId; + } + + public Boolean getQueryDataset() + { + return _queryDataset; + } + + public void setQueryDataset(Boolean queryDataset) + { + _queryDataset = queryDataset; + } + + public String getAction() + { + return _action; + } + + public void setAction(String action) + { + _action = action; + } + } + + @RequiresPermission(AdminPermission.class) + public static class CreateSnapshotAction extends FormViewAction + { + ActionURL _successURL; + + @Override + public void validateCommand(StudySnapshotForm form, Errors errors) + { + if (StudySnapshotForm.CANCEL.equals(form.getAction())) + return; + + Study study = StudyManager.getInstance().getStudy(getContainer()); + if (null == study) + throw new NotFoundException("No study in this folder"); + + if (form.getQueryDataset() != null) + { + if (study.getTimepointType() != TimepointType.CONTINUOUS) + { + errors.reject("snapshotQuery.error", "Query based snapshot is only available for continuous studies"); + } + else + { + TableInfo ti = QueryService.get().getUserSchema(getUser(), getContainer(), form.getSchemaName()).getTable(form.getQueryName()); + Set colNames = ti.getColumns().stream().map(ColumnInfo::getName).collect(LabKeyCollectors.toCaseInsensitiveHashSet()); + + List notFound = Arrays.stream(QueryDatasetTable.REQUIRED_COLUMNS) + .filter(value -> !colNames.contains(value)) + .toList(); + + if (!notFound.isEmpty()) + errors.reject("snapshotQuery.error", "The source query is missing the following required columns for a query backed dataset: " + String.join(", ", notFound)); + } + } + + String name = StringUtils.trimToNull(form.getSnapshotName()); + + if (name != null) + { + QuerySnapshotDefinition def = QueryService.get().getSnapshotDef(getContainer(), form.getSchemaName(), name); + if (def != null) + { + errors.reject("snapshotQuery.error", "A Snapshot with the same name already exists"); + return; + } + + // check for a dataset with the same label/name unless it's one that we created + Dataset dataset = StudyManager.getInstance().getDatasetDefinitionByQueryName(study, name); + if (dataset != null) + { + if (dataset.getDatasetId() != form.getSnapshotDatasetId()) + errors.reject("snapshotQuery.error", "A Dataset with the same name/label already exists"); + } + } + else + errors.reject("snapshotQuery.error", "The Query Snapshot name cannot be blank"); + } + + @Override + public ModelAndView getView(StudySnapshotForm form, boolean reshow, BindException errors) + { + if (!reshow || errors.hasErrors()) + { + ActionURL url = getViewContext().getActionURL(); + + if (StringUtils.isEmpty(form.getSnapshotName())) + form.setSnapshotName(url.getParameter("ff_snapshotName")); + form.setUpdateDelay(NumberUtils.toInt(url.getParameter("ff_updateDelay"))); + form.setSnapshotDatasetId(NumberUtils.toInt(url.getParameter("ff_snapshotDatasetId"), -1)); + + return new JspView("/org/labkey/study/view/createDatasetSnapshot.jsp", form, errors); + } + else if (StudySnapshotForm.EDIT_DATASET.equals(form.getAction())) + { + throw new NotFoundException("Unable to edit the created dataset definition."); + } + return null; + } + + private void deletePreviousDatasetDefinition(StudySnapshotForm form) + { + if (form.getSnapshotDatasetId() != -1) + { + StudyImpl study = StudyManager.getInstance().getStudy(getContainer()); + + // a dataset definition was edited previously, but under a different name, need to delete the old one + DatasetDefinition dsDef = StudyManager.getInstance().getDatasetDefinition(study, form.getSnapshotDatasetId()); + if (dsDef != null) + { + StudyManager.getInstance().deleteDataset(study, getUser(), dsDef, true, null); + form.setSnapshotDatasetId(-1); + } + } + } + + private Dataset createDataset(StudySnapshotForm form, BindException errors) throws Exception + { + StudyImpl study = StudyManager.getInstance().getStudy(getContainer()); + Dataset dsDef = StudyManager.getInstance().getDatasetDefinitionByName(study, form.getSnapshotName()); + + if (dsDef == null) + { + deletePreviousDatasetDefinition(form); + + // if this snapshot is being created from an existing dataset, copy key field settings + int datasetId = NumberUtils.toInt(getViewContext().getActionURL().getParameter(Dataset.DATASET_KEY), -1); + String additionalKey = null; + DatasetDefinition.KeyManagementType keyManagementType = KeyManagementType.None; + boolean isDemographicData = false; + boolean useTimeKeyField = false; + List columnsToProvision = new ArrayList<>(); + + if (datasetId != -1) + { + DatasetDefinition sourceDef = study.getDataset(datasetId); + if (sourceDef != null) + { + additionalKey = sourceDef.getKeyPropertyName(); + keyManagementType = sourceDef.getKeyManagementType(); + isDemographicData = sourceDef.isDemographicData(); + useTimeKeyField = sourceDef.getUseTimeKeyField(); + + // make sure we provision any managed key fields + if ((additionalKey != null) && (keyManagementType != KeyManagementType.None)) + { + TableInfo sourceTable = sourceDef.getTableInfo(getUser()); + ColumnInfo col = sourceTable.getColumn(FieldKey.fromParts(additionalKey)); + if (col != null) + columnsToProvision.add(col); + } + } + } + + DatasetDefinition.Builder builder = new DatasetDefinition.Builder(form.getSnapshotName()) + .setStudy(study) + .setKeyPropertyName(additionalKey) + .setDemographicData(isDemographicData) + .setUseTimeKeyField(useTimeKeyField); + + + if (Boolean.TRUE.equals(form.getQueryDataset())) + { + builder.setSourceQueryName(form.getQueryName()) + .setSourceQuerySchema(form.getSchemaName()) + .setSourceQueryContainer(getContainer()) + .setKeyPropertyName("Key"); + } + + DatasetDefinition def = StudyPublishManager.getInstance().createDataset(getUser(), builder); + + form.setSnapshotDatasetId(def.getDatasetId()); + if (keyManagementType != KeyManagementType.None) + { + def = def.createMutable(); + def.setKeyManagementType(keyManagementType); + + StudyManager.getInstance().updateDatasetDefinition(getUser(), def); + } + + // NOTE getDisplayColumns() indirectly causes a query of the datasets, + // Do this before provisionTable() so we don't query the dataset we are about to create + // causes a problem on postgres (bug 11153) + for (DisplayColumn dc : QuerySnapshotService.get(form.getSchemaName()).getDisplayColumns(form, errors)) + { + ColumnInfo col = dc.getColumnInfo(); + if (col != null && !DatasetDefinition.isDefaultFieldName(col.getName(), study)) + columnsToProvision.add(col); + } + + // def may not be provisioned yet, create before we start adding properties + if (def.isQueryDataset()) + { + def.provisionQueryDataset(true); + } + else + { + def.provisionTable(true); + } + + Domain d = def.getDomain(true); + + for (ColumnInfo col : columnsToProvision) + { + DatasetSnapshotProvider.addAsDomainProperty(d, col); + } + d.save(getUser()); + + return def; + } + + return dsDef; + } + + @Override + public boolean handlePost(StudySnapshotForm form, BindException errors) throws Exception + { + DbSchema schema = StudySchema.getInstance().getSchema(); + + try (DbScope.Transaction transaction = schema.getScope().ensureTransaction()) + { + if (StudySnapshotForm.EDIT_DATASET.equals(form.getAction())) + { + Dataset def = createDataset(form, errors); + if (!errors.hasErrors() && def != null) + { + ActionURL returnUrl = getViewContext().cloneActionURL() + .replaceParameter("ff_snapshotName", form.getSnapshotName()) + .replaceParameter("ff_updateDelay", form.getUpdateDelay()) + .replaceParameter("ff_snapshotDatasetId", form.getSnapshotDatasetId()); + + _successURL = new ActionURL(StudyController.EditTypeAction.class, getContainer()) + .addParameter("datasetId", def.getDatasetId()) + .addReturnUrl(returnUrl); + } + } + else if (StudySnapshotForm.CREATE_SNAPSHOT.equals(form.getAction())) + { + Dataset def = createDataset(form, errors); + if (!errors.hasErrors()) + if (Boolean.TRUE.equals(form.getQueryDataset())) + { + _successURL = new ActionURL(StudyController.DatasetAction.class, getContainer()). + addParameter(Dataset.DATASET_KEY, def.getDatasetId()); + } + else + { + _successURL = QuerySnapshotService.get(form.getSchemaName()).createSnapshot(form, errors); + } + } + else if (StudySnapshotForm.CANCEL.equals(form.getAction())) + { + deletePreviousDatasetDefinition(form); + String redirect = getViewContext().getActionURL().getParameter(ActionURL.Param.redirectUrl); + if (redirect != null) + _successURL = new ActionURL(PageFlowUtil.decode(redirect)); + } + + if (!errors.hasErrors()) + transaction.commit(); + } + + return !errors.hasErrors(); + } + + @Override + public ActionURL getSuccessURL(StudySnapshotForm queryForm) + { + return _successURL; + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("querySnapshot"); + root.addChild("Create Query Snapshot"); + } + } + + /** + * Provides a view to update study query snapshots. Since query snapshots are implemented as datasets, the + * dataset properties editor can be shown in this view. + */ + @RequiresPermission(AdminPermission.class) + public static class EditSnapshotAction extends FormViewAction + { + ActionURL _successURL; + + @Override + public void validateCommand(StudySnapshotForm form, Errors errors) + { + } + + @Override + public ModelAndView getView(StudySnapshotForm form, boolean reshow, BindException errors) throws Exception + { + form.setEdit(true); + if (!reshow) + form.init(QueryService.get().getSnapshotDef(getContainer(), form.getSchemaName(), form.getSnapshotName()), getUser()); + + VBox box = new VBox(); + + QuerySnapshotService.Provider provider = QuerySnapshotService.get(form.getSchemaName()); + if (provider != null) + { + box.addView(new JspView("/org/labkey/study/view/editSnapshot.jsp", form)); + box.addView(new JspView("/org/labkey/study/view/createDatasetSnapshot.jsp", form, errors)); + + boolean showHistory = BooleanUtils.toBoolean(getViewContext().getActionURL().getParameter("showHistory")); + if (showHistory) + { + HttpView historyView = provider.createAuditView(form, errors); + if (historyView != null) + box.addView(historyView); + } + } + return box; + } + + @Override + public boolean handlePost(StudySnapshotForm form, BindException errors) throws Exception + { + if (StudySnapshotForm.CANCEL.equals(form.getAction())) + { + String redirect = getViewContext().getActionURL().getParameter(ActionURL.Param.redirectUrl); + if (redirect != null) + _successURL = new ActionURL(PageFlowUtil.decode(redirect)); + } + else if (form.isUpdateSnapshot()) + { + _successURL = QuerySnapshotService.get(form.getSchemaName()).updateSnapshot(form, errors); + + return !errors.hasErrors(); + } + else + { + QuerySnapshotDefinition def = QueryService.get().getSnapshotDef(getContainer(), form.getSchemaName(), form.getSnapshotName()); + if (def != null) + { + def.setUpdateDelay(form.getUpdateDelay()); + _successURL = QuerySnapshotService.get(form.getSchemaName()).updateSnapshotDefinition(getViewContext(), def, errors); + return !errors.hasErrors(); + } + else + { + errors.reject("snapshotQuery.error", "Unable to create QuerySnapshotDefinition"); + return false; + } + } + return true; + } + + @Override + public ActionURL getSuccessURL(StudySnapshotForm form) + { + return _successURL; + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("Edit Query Snapshot"); + } + } + + public static class DatasetPropertyForm implements HasAllowBindParameter + { + private Map _map = MapUtils.lazyMap(new IntHashMap<>(), FactoryUtils.instantiateFactory(DatasetVisibilityData.class)); + + public Map getDataset() + { + return _map; + } + + public void setDataset(Map map) + { + _map = map; + } + + private static final Pattern pat = Pattern.compile("dataset\\[(\\d*)]\\.(\\w*)"); + + @Override + public Predicate allowBindParameter() + { + return (name) -> + { + if (name.startsWith(SpringActionController.FIELD_MARKER)) + name = name.substring(SpringActionController.FIELD_MARKER.length()); + if (HasAllowBindParameter.getDefaultPredicate().test(name)) + return true; + return pat.matcher(name).matches(); + }; + } + } + + public static class RequirePipelineView extends StudyJspView + { + public RequirePipelineView(StudyImpl study, boolean showGoBack, BindException errors) + { + super(study, "/org/labkey/study/view/requirePipeline.jsp", showGoBack, errors); + } + } + + public static class VisitPropertyForm extends PropertyForm + { + private int[] _ids; + private int[] _visible; + + public int[] getIds() + { + return _ids; + } + + public void setIds(int[] ids) + { + _ids = ids; + } + + public int[] getVisible() + { + return _visible; + } + + public void setVisible(int[] visible) + { + _visible = visible; + } + } + + public abstract static class PropertyForm + { + private String[] _label; + private String[] _extraData; + private int[] _cohort; + + public String[] getExtraData() + { + return _extraData; + } + + public void setExtraData(String[] extraData) + { + _extraData = extraData; + } + + public String[] getLabel() + { + return _label; + } + + public void setLabel(String[] label) + { + _label = label; + } + + public int[] getCohort() + { + return _cohort; + } + + public void setCohort(int[] cohort) + { + _cohort = cohort; + } + } + + + public static class DatasetReorderForm + { + private String order; + private boolean resetOrder = false; + + public String getOrder() {return order;} + + public void setOrder(String order) {this.order = order;} + + public boolean isResetOrder() + { + return resetOrder; + } + + public void setResetOrder(boolean resetOrder) + { + this.resetOrder = resetOrder; + } + } + + public static class VisitReorderForm extends ReturnUrlForm + { + private boolean _explicitDisplayOrder; + private boolean _explicitChronologicalOrder; + private String _displayOrder; + private String _chronologicalOrder; + + public String getDisplayOrder() + { + return _displayOrder; + } + + public void setDisplayOrder(String displayOrder) + { + _displayOrder = displayOrder; + } + + public String getChronologicalOrder() + { + return _chronologicalOrder; + } + + public void setChronologicalOrder(String chronologicalOrder) + { + _chronologicalOrder = chronologicalOrder; + } + + public boolean isExplicitDisplayOrder() + { + return _explicitDisplayOrder; + } + + public void setExplicitDisplayOrder(boolean explicitDisplayOrder) + { + _explicitDisplayOrder = explicitDisplayOrder; + } + + public boolean isExplicitChronologicalOrder() + { + return _explicitChronologicalOrder; + } + + public void setExplicitChronologicalOrder(boolean explicitChronologicalOrder) + { + _explicitChronologicalOrder = explicitChronologicalOrder; + } + } + + public static class ImportStudyBatchBean + { + private final DatasetFileReader reader; + private final String path; + + public ImportStudyBatchBean(DatasetFileReader reader, String path) + { + this.reader = reader; + this.path = path; + } + + public DatasetFileReader getReader() + { + return reader; + } + + public String getPath() + { + return path; + } + } + + public static class ViewPrefsBean + { + private final List> _views; + private final Dataset _def; + + public ViewPrefsBean(List> views, Dataset def) + { + _views = views; + _def = def; + } + + public List> getViews(){return _views;} + public Dataset getDatasetDefinition(){return _def;} + } + + + private static final String DEFAULT_DATASET_VIEW = "Study.defaultDatasetView"; + + public static String getDefaultView(ViewContext context, int datasetId) + { + User user = context.getUser(); + // Don't return a default view for guests, Issue 52863 + if (!user.isGuest()) + { + Map viewMap = PropertyManager.getProperties(user, context.getContainer(), DEFAULT_DATASET_VIEW); + + final String key = Integer.toString(datasetId); + if (viewMap.containsKey(key)) + { + return viewMap.get(key); + } + } + return ""; + } + + private void setDefaultView(int datasetId, String view) + { + User user = getUser(); + if (user.isGuest()) + throw new IllegalStateException("Can't set a default view for guests"); + WritablePropertyMap viewMap = PropertyManager.getWritableProperties(user, getContainer(), DEFAULT_DATASET_VIEW, true); + + viewMap.put(Integer.toString(datasetId), view); + viewMap.save(); + } + + private String getVisitLabel() + { + StudyImpl study = getStudy(); + if (study != null) + { + return StudyManager.getInstance().getVisitManager(getStudyRedirectIfNull()).getLabel(); + } + return "Visit"; + } + + + private String getVisitLabelPlural() + { + StudyImpl study = getStudy(); + if (study != null) + { + return StudyManager.getInstance().getVisitManager(getStudyRedirectIfNull()).getPluralLabel(); + } + return "Visits"; + } + + public static class ParticipantForm extends ViewForm implements StudyManager.ParticipantViewConfig + { + private String participantId; + private int datasetId; + private double sequenceNum; + private String action; + private Map aliases; + + @Override + public String getParticipantId(){return participantId;} + + public void setParticipantId(String participantId) + { + this.participantId = participantId; + aliases = StudyManager.getInstance().getAliasMap(StudyManager.getInstance().getStudy(getContainer()), getUser(), participantId); + } + + @Override + public Map getAliases() + { + return null == aliases ? Map.of() : aliases; + } + + @Override + public int getDatasetId(){return datasetId;} + public void setDatasetId(int datasetId){this.datasetId = datasetId;} + + public double getSequenceNum(){return sequenceNum;} + public void setSequenceNum(double sequenceNum){this.sequenceNum = sequenceNum;} + + public String getAction(){return action;} + public void setAction(String action){this.action = action;} + } + + public static class StudyPropertiesForm extends ReturnUrlForm + { + private String _label; + private TimepointType _timepointType; + private Date _startDate; + private Date _endDate; + private SecurityType _securityType; + private String _subjectNounSingular = "Participant"; + private String _subjectNounPlural = "Participants"; + private String _subjectColumnName = "ParticipantId"; + private String _assayPlan; + private String _description; + private String _descriptionRendererType; + private String _grant; + private String _investigator; + private String _species; + private int _defaultTimepointDuration = 0; + private String _alternateIdPrefix; + private int _alternateIdDigits; + private boolean _allowReqLocRepository = true; + private boolean _allowReqLocClinic = true; + private boolean _allowReqLocSal = true; + private boolean _allowReqLocEndpoint = true; + private boolean _shareDatasets = false; + private boolean _shareVisits = false; + private boolean _failForUndefinedTimepoints; + + public String getLabel() + { + return _label; + } + + public void setLabel(String label) + { + _label = label; + } + + public TimepointType getTimepointType() + { + return _timepointType; + } + + public void setTimepointType(TimepointType timepointType) + { + _timepointType = timepointType; + } + + public Date getStartDate() + { + return _startDate; + } + + public void setStartDate(Date startDate) + { + _startDate = startDate; + } + + public void setSecurityString(String security) + { + _securityType = SecurityType.valueOf(security); + } + + public String getSecurityString() + { + return _securityType == null ? null : _securityType.name(); + } + + public void setSecurityType(SecurityType securityType) + { + _securityType = securityType; + } + + public SecurityType getSecurityType() + { + return _securityType; + } + + public String getSubjectNounSingular() + { + return _subjectNounSingular; + } + + public void setSubjectNounSingular(String subjectNounSingular) + { + _subjectNounSingular = subjectNounSingular; + } + + public String getSubjectNounPlural() + { + return _subjectNounPlural; + } + + public void setSubjectNounPlural(String subjectNounPlural) + { + _subjectNounPlural = subjectNounPlural; + } + + public String getSubjectColumnName() + { + return _subjectColumnName; + } + + public void setSubjectColumnName(String subjectColumnName) + { + _subjectColumnName = subjectColumnName; + } + + public String getDescription() + { + return _description; + } + + public void setDescription(String description) + { + _description = description; + } + + public String getDescriptionRendererType() + { + return _descriptionRendererType; + } + + public void setDescriptionRendererType(String descriptionRendererType) + { + _descriptionRendererType = descriptionRendererType; + } + + public String getInvestigator() + { + return _investigator; + } + + public void setInvestigator(String investigator) + { + _investigator = investigator; + } + + public String getGrant() + { + return _grant; + } + + public void setGrant(String grant) + { + _grant = grant; + } + + public int getDefaultTimepointDuration() + { + return _defaultTimepointDuration; + } + + public void setDefaultTimepointDuration(int defaultTimepointDuration) + { + _defaultTimepointDuration = defaultTimepointDuration; + } + + public String getAlternateIdPrefix() + { + return _alternateIdPrefix; + } + + public void setAlternateIdPrefix(String alternateIdPrefix) + { + _alternateIdPrefix = alternateIdPrefix; + } + + public int getAlternateIdDigits() + { + return _alternateIdDigits; + } + + public void setAlternateIdDigits(int alternateIdDigits) + { + _alternateIdDigits = alternateIdDigits; + } + + public boolean isAllowReqLocRepository() + { + return _allowReqLocRepository; + } + + public void setAllowReqLocRepository(boolean allowReqLocRepository) + { + _allowReqLocRepository = allowReqLocRepository; + } + + public boolean isAllowReqLocClinic() + { + return _allowReqLocClinic; + } + + public void setAllowReqLocClinic(boolean allowReqLocClinic) + { + _allowReqLocClinic = allowReqLocClinic; + } + + public boolean isAllowReqLocSal() + { + return _allowReqLocSal; + } + + public void setAllowReqLocSal(boolean allowReqLocSal) + { + _allowReqLocSal = allowReqLocSal; + } + + public boolean isAllowReqLocEndpoint() + { + return _allowReqLocEndpoint; + } + + public void setAllowReqLocEndpoint(boolean allowReqLocEndpoint) + { + _allowReqLocEndpoint = allowReqLocEndpoint; + } + + public Date getEndDate() + { + return _endDate; + } + + public void setEndDate(Date endDate) + { + _endDate = endDate; + } + + public String getAssayPlan() + { + return _assayPlan; + } + + public void setAssayPlan(String assayPlan) + { + _assayPlan = assayPlan; + } + + public String getSpecies() + { + return _species; + } + + public void setSpecies(String species) + { + _species = species; + } + + public boolean isShareDatasets() + { + return _shareDatasets; + } + + public void setShareDatasets(boolean shareDatasets) + { + _shareDatasets = shareDatasets; + } + + public boolean isShareVisits() + { + return _shareVisits; + } + + public void setShareVisits(boolean shareDatasets) + { + _shareVisits = shareDatasets; + } + + public boolean isFailForUndefinedTimepoints() + { + return _failForUndefinedTimepoints; + } + + public void setFailForUndefinedTimepoints(boolean failForUndefinedTimepoints) + { + _failForUndefinedTimepoints = failForUndefinedTimepoints; + } + } + + public static class IdForm + { + private int _id; + + public int getId() {return _id;} + + public void setId(int id) {_id = id;} + } + + public static class SourceLsidForm + { + private String _sourceLsid; + + public String getSourceLsid() {return _sourceLsid;} + + public void setSourceLsid(String sourceLsid) {_sourceLsid = sourceLsid;} + } + + /** + * Adds next and prev buttons to the participant view + */ + public static class ParticipantNavView extends HttpView + { + private final ActionURL _prevURL; + private final ActionURL _nextURL; + private final String _display; + private final String _currentParticipantId; + private boolean _showCustomizeLink = true; + + public ParticipantNavView(ActionURL prevURL, ActionURL nextURL, String currentParticipantId, String display) + { + _prevURL = prevURL; + _nextURL = nextURL; + _display = display; + _currentParticipantId = currentParticipantId; + } + + @Override + protected void renderInternal(Object model, PrintWriter out) + { + Container c = getViewContext().getContainer(); + User user = getViewContext().getUser(); + + String subjectNoun = PageFlowUtil.filter(StudyService.get().getSubjectNounSingular(getViewContext().getContainer())); + out.print("
"); + if (_prevURL != null) + { + LinkBuilder.labkeyLink("Previous " + subjectNoun, _prevURL).appendTo(out); + out.print(" "); + } + + if (_nextURL != null) + { + LinkBuilder.labkeyLink("Next " + subjectNoun, _nextURL).appendTo(out); + out.print(" "); + } + + SearchService ss = SearchService.get(); + + if (null != _currentParticipantId) + { + ActionURL search = urlProvider(SearchUrls.class).getSearchURL(c, "+" + ss.escapeTerm(_currentParticipantId)); + LinkBuilder.labkeyLink("Search for '" + id(_currentParticipantId, c, user) + "'", search).appendTo(out); + out.print(" "); + } + + // Show customize link to site admins (who are always developers) and folder admins who are developers: + Set> permissions = new HashSet<>(); + permissions.add(AdminPermission.class); + permissions.add(PlatformDeveloperPermission.class); + if (_showCustomizeLink && c.hasPermissions(getViewContext().getUser(), permissions)) + { + ActionURL customizeURL = new ActionURL(CustomizeParticipantViewAction.class, c); + customizeURL.addReturnUrl(getViewContext().getActionURL()); + customizeURL.addParameter("participantId", _currentParticipantId); + out.print(""); + LinkBuilder.labkeyLink("Customize View", customizeURL).appendTo(out); + } + + if (_display != null) + { + out.print(""); + out.print(PageFlowUtil.filter(_display)); + } + out.print("
"); + } + + public void setShowCustomizeLink(boolean showCustomizeLink) + { + _showCustomizeLink = showCustomizeLink; + } + } + + public static class ImportDatasetForm + { + private int datasetId = 0; + private String typeURI; + private String tsv; + private String keys; + private String _participantId; + private String _sequenceNum; + private String _name; + private QueryUpdateService.InsertOption _insertOption = QueryUpdateService.InsertOption.IMPORT; + + public int getDatasetId() + { + return datasetId; + } + + public void setDatasetId(int datasetId) + { + this.datasetId = datasetId; + } + + public String getTsv() + { + return tsv; + } + + public void setTsv(String tsv) + { + this.tsv = tsv; + } + + public String getKeys() + { + return keys; + } + + public void setKeys(String keys) + { + this.keys = keys; + } + + public String getTypeURI() + { + return typeURI; + } + + public void setTypeURI(String typeURI) + { + this.typeURI = typeURI; + } + + public String getParticipantId() + { + return _participantId; + } + + public void setParticipantId(String participantId) + { + _participantId = participantId; + } + + public String getSequenceNum() + { + return _sequenceNum; + } + + public void setSequenceNum(String sequenceNum) + { + _sequenceNum = sequenceNum; + } + + public String getName() + { + return _name; + } + + public void setName(String name) + { + _name = name; + } + + public QueryUpdateService.InsertOption getInsertOption() + { + return _insertOption; + } + + public void setInsertOption(QueryUpdateService.InsertOption insertOption) + { + _insertOption = insertOption; + } + } + + public static class DatasetForm + { + private String _name; + private String _label; + private Integer _datasetId; + private String _category; + private boolean _showByDefault; + private String _visitDatePropertyName; + private String[] _visitStatus; + private int[] _visitRowIds; + private String _description; + private Integer _cohortId; + private boolean _demographicData; + private boolean _create; + + public boolean isShowByDefault() + { + return _showByDefault; + } + + public void setShowByDefault(boolean showByDefault) + { + _showByDefault = showByDefault; + } + + public String getCategory() + { + return _category; + } + + public void setCategory(String category) + { + _category = category; + } + + public String getDatasetIdStr() + { + return _datasetId > 0 ? String.valueOf(_datasetId) : ""; + } + + /** + * Don't blow up when posting bad value + */ + public void setDatasetIdStr(String datasetIdStr) + { + try + { + if (null == StringUtils.trimToNull(datasetIdStr)) + _datasetId = 0; + else + _datasetId = Integer.parseInt(datasetIdStr); + } + catch (Exception x) + { + _datasetId = 0; + } + } + + public Integer getDatasetId() + { + return _datasetId; + } + + public void setDatasetId(Integer datasetId) + { + _datasetId = datasetId; + } + + public String getLabel() + { + return _label; + } + + public void setLabel(String label) + { + _label = label; + } + + public String getName() + { + return _name; + } + + public void setName(String name) + { + _name = name; + } + + public String[] getVisitStatus() + { + return _visitStatus; + } + + public void setVisitStatus(String[] visitStatus) + { + _visitStatus = visitStatus; + } + + public int[] getVisitRowIds() + { + return _visitRowIds; + } + + public void setVisitRowIds(int[] visitIds) + { + _visitRowIds = visitIds; + } + + public String getVisitDatePropertyName() + { + return _visitDatePropertyName; + } + + public void setVisitDatePropertyName(String visitDatePropertyName) + { + _visitDatePropertyName = visitDatePropertyName; + } + + public String getDescription() + { + return _description; + } + + public void setDescription(String description) + { + _description = description; + } + + public boolean isDemographicData() + { + return _demographicData; + } + + public void setDemographicData(boolean demographicData) + { + _demographicData = demographicData; + } + + public boolean isCreate() + { + return _create; + } + + public void setCreate(boolean create) + { + _create = create; + } + + public Integer getCohortId() + { + return _cohortId; + } + + public void setCohortId(Integer cohortId) + { + _cohortId = cohortId; + } + } + + @RequiresPermission(ReadPermission.class) + public class DatasetsAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object o, BindException errors) throws Exception + { + return StudyModule.datasetsPartFactory.getWebPartView(getViewContext(), StudyModule.datasetsPartFactory.createWebPart()); + } + + @Override + public void addNavTrail(NavTree root) + { + _addNavTrail(root); + root.addChild("Datasets"); + } + } + + @RequiresPermission(ReadPermission.class) + public static class ViewDataAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object o, BindException errors) throws Exception + { + return new VBox( + StudyModule.reportsPartFactory.getWebPartView(getViewContext(), StudyModule.reportsPartFactory.createWebPart()), + StudyModule.datasetsPartFactory.getWebPartView(getViewContext(), StudyModule.datasetsPartFactory.createWebPart()) + ); + } + + @Override + public void addNavTrail(NavTree root) + { + } + } + + private static class DatasetDetailRedirectForm extends ReturnUrlForm + { + private String _datasetId; + private String _lsid; + + public String getDatasetId() + { + return _datasetId; + } + + public void setDatasetId(String datasetId) + { + _datasetId = datasetId; + } + + public String getLsid() + { + return _lsid; + } + + public void setLsid(String lsid) + { + _lsid = lsid; + } + } + + @RequiresPermission(AdminPermission.class) + public class ManageExternalReloadAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object form, BindException errors) + { + return new StudyJspView<>(getStudyRedirectIfNull(), "/org/labkey/study/view/manageExternalReload.jsp", form, errors); + } + + @Override + public void addNavTrail(NavTree root) + { + _addManageStudy(root); + root.addChild("Manage External Reloading"); + } + } + + @RequiresPermission(ReadPermission.class) + public static class DatasetDetailRedirectAction extends SimpleRedirectAction + { + @Override + public URLHelper getRedirectURL(DatasetDetailRedirectForm form) + { + StudyImpl study = StudyManager.getInstance().getStudy(getContainer()); + if (study == null) + { + throw new NotFoundException("No study found"); + } + // First try the dataset id as an entityid + DatasetDefinition dataset = StudyManager.getInstance().getDatasetDefinitionByEntityId(study, form.getDatasetId()); + if (dataset == null) + { + try + { + // Then try the dataset id as an integer + int id = Integer.parseInt(form.getDatasetId()); + dataset = StudyManager.getInstance().getDatasetDefinition(study, id); + } + catch (NumberFormatException ignored) {} + + if (dataset == null) + { + throw new NotFoundException("Could not find dataset " + form.getDatasetId()); + } + } + + if (form.getLsid() == null) + { + throw new NotFoundException("No LSID specified"); + } + + StudyQuerySchema schema = StudyQuerySchema.createSchema(study, getUser()); + + QueryDefinition queryDef = QueryService.get().createQueryDefForTable(schema, dataset.getName()); + assert queryDef != null : "Dataset was found but couldn't get a corresponding TableInfo"; + + ActionURL url = queryDef.urlFor(QueryAction.detailsQueryRow, getContainer(), Collections.singletonMap("lsid", form.getLsid())); + String referrer = getViewContext().getRequest().getHeader("Referer"); + if (referrer != null) + { + url.addParameter(ActionURL.Param.returnUrl, referrer); + } + + return url; + } + } + + public static class ImportVisitMapForm + { + private String _content; + + public String getContent() + { + return _content; + } + + public void setContent(String content) + { + _content = content; + } + } + + @RequiresPermission(AdminPermission.class) + public class DemoModeAction extends FormViewAction + { + @Override + public URLHelper getSuccessURL(DemoModeForm form) + { + return null; + } + + @Override + public void validateCommand(DemoModeForm form, Errors errors) + { + } + + @Override + public ModelAndView getView(DemoModeForm form, boolean reshow, BindException errors) + { + return new JspView<>("/org/labkey/study/view/demoMode.jsp"); + } + + @Override + public boolean handlePost(DemoModeForm form, BindException errors) + { + DemoMode.setDemoMode(getContainer(), getUser(), form.getMode()); + return false; // Reshow page + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("demoMode"); + _addManageStudy(root); + root.addChild("Demo Mode"); + } + } + + + public static class DemoModeForm + { + private boolean mode; + + public boolean getMode() + { + return mode; + } + + public void setMode(boolean mode) + { + this.mode = mode; + } + } + + + @RequiresPermission(AdminPermission.class) + public class ShowVisitImportMappingAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object o, BindException errors) + { + return new JspView<>("/org/labkey/study/view/visitImportMapping.jsp", new ImportMappingBean(getStudyRedirectIfNull())); + } + + @Override + public void addNavTrail(NavTree root) + { + _addNavTrailVisitAdmin(root); + root.addChild("Visit Import Mapping"); + } + } + + + public static class ImportMappingBean + { + private final Collection _customMapping; + private final Collection _standardMapping; + + public ImportMappingBean(Study study) + { + _customMapping = StudyManager.getInstance().getCustomVisitImportMapping(study); + _standardMapping = StudyManager.getInstance().getStandardVisitImportMapping(study); + } + + public Collection getCustomMapping() + { + return _customMapping; + } + + public Collection getStandardMapping() + { + return _standardMapping; + } + } + + + @RequiresPermission(AdminPermission.class) + public class ImportVisitAliasesAction extends FormViewAction + { + @Override + public URLHelper getSuccessURL(VisitAliasesForm form) + { + return new ActionURL(ShowVisitImportMappingAction.class, getContainer()); + } + + @Override + public void validateCommand(VisitAliasesForm form, Errors errors) + { + } + + @Override + public ModelAndView getView(VisitAliasesForm form, boolean reshow, BindException errors) + { + getPageConfig().setFocusId("tsv"); + return new JspView<>("/org/labkey/study/view/importVisitAliases.jsp", null, errors); + } + + @Override + public boolean handlePost(VisitAliasesForm form, BindException errors) + { + boolean hadCustomMapping = !StudyManager.getInstance().getCustomVisitImportMapping(getStudyThrowIfNull()).isEmpty(); + + try + { + String tsv = form.getTsv(); + + if (null == tsv) + { + errors.reject(ERROR_MSG, "Please insert tab-separated data with two columns, Name and SequenceNum"); + return false; + } + + StudyManager.getInstance().importVisitAliases(getStudyThrowIfNull(), getUser(), new TabLoader(form.getTsv(), true)); + } + catch (RuntimeSQLException e) + { + if (e.isConstraintException()) + { + errors.reject(ERROR_MSG, "The visit import mapping includes duplicate visit names: " + e.getMessage()); + return false; + } + else + { + throw e; + } + } + catch (ValidationException e) + { + errors.reject(ERROR_MSG, e.getMessage()); + return false; + } + + // TODO: Change to audit log + _log.info("The visit import custom mapping was " + (hadCustomMapping ? "replaced" : "imported")); + + return true; + } + + @Override + public void addNavTrail(NavTree root) + { + _addNavTrailVisitAdmin(root); + root.addChild("Import Visit Aliases"); + } + } + + + public static class VisitAliasesForm + { + private String _tsv; + + public String getTsv() + { + return _tsv; + } + + @SuppressWarnings({"UnusedDeclaration"}) + public void setTsv(String tsv) + { + _tsv = tsv; + } + } + + + @RequiresPermission(AdminPermission.class) + public class ClearVisitAliasesAction extends ConfirmAction + { + @Override + public ModelAndView getConfirmView(Object o, BindException errors) + { + if (getPageConfig().getTitle() == null) + setTitle("Clear Custom Mapping"); + + return HtmlView.of("Are you sure you want to delete the visit import custom mapping for this study?"); + } + + @Override + public boolean handlePost(Object o, BindException errors) + { + StudyManager.getInstance().clearVisitAliases(getStudyThrowIfNull()); + // TODO: Change to audit log + _log.info("The visit import custom mapping was cleared"); + + return true; + } + + @Override + public void validateCommand(Object o, Errors errors) + { + } + + @Override + public @NotNull URLHelper getSuccessURL(Object o) + { + return new ActionURL(ShowVisitImportMappingAction.class, getContainer()); + } + } + + @RequiresPermission(ReadPermission.class) @RequiresLogin + public class ManageParticipantCategoriesAction extends SimpleViewAction + { + @Override + public ModelAndView getView(SentGroupForm form, BindException errors) + { + // if the user is viewing a sent participant group, remove any notifications related to it + if (form.getGroupId() != null) + { + NotificationService.get().removeNotifications(getContainer(), form.getGroupId().toString(), + Collections.singletonList(ParticipantCategory.SEND_PARTICIPANT_GROUP_TYPE), getUser().getUserId()); + } + + return new JspView<>("/org/labkey/study/view/manageParticipantCategories.jsp"); + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("participantGroups"); + _addManageStudy(root); + root.addChild("Manage " + getStudyRedirectIfNull().getSubjectNounSingular() + " Groups"); + } + } + + public static class SentGroupForm + { + private Integer _groupId; + + public Integer getGroupId() + { + return _groupId; + } + + public void setGroupId(Integer groupId) + { + _groupId = groupId; + } + } + + @RequiresLogin @RequiresPermission(ReadPermission.class) + public class SendParticipantGroupAction extends FormViewAction + { + List _validRecipients = new ArrayList<>(); + + @Override + public URLHelper getSuccessURL(SendParticipantGroupForm form) + { + return form.getReturnActionURL(form.getDefaultUrl(getContainer())); + } + + @Override + public ModelAndView getView(SendParticipantGroupForm form, boolean reshow, BindException errors) + { + if (form.getRowId() == null) + { + return HtmlView.err("No participant group RowId provided."); + } + else + { + ParticipantGroup group = ParticipantGroupManager.getInstance().getParticipantGroup(getContainer(), getUser(), form.getRowId()); + if (group != null) + { + ParticipantCategoryImpl category = ParticipantGroupManager.getInstance().getParticipantCategory(getContainer(), getUser(), group.getCategoryId()); + if (category != null && category.canRead(getContainer(), getUser())) + { + form.setLabel(group.getLabel()); + return new JspView<>("/org/labkey/study/view/sendParticipantGroup.jsp", form, errors); + } + } + + return HtmlView.err("Could not find participant group for RowId " + form.getRowId() + " or you do not have permission to read it."); + } + } + + @Override + public void validateCommand(SendParticipantGroupForm form, Errors errors) + { + _validRecipients = SecurityManager.parseRecipientListForContainer(getContainer(), form.getRecipientList(), errors); + } + + @Override + public boolean handlePost(SendParticipantGroupForm form, BindException errors) throws Exception + { + if (!errors.hasErrors() && !_validRecipients.isEmpty()) + { + for (User recipient : _validRecipients) + { + NotificationService.get().sendMessageForRecipient( + getContainer(), getUser(), recipient, + form.getMessageSubject(), form.getMessageBody(), form.getSendGroupUrl(getContainer()), + form.getRowId().toString(), ParticipantCategory.SEND_PARTICIPANT_GROUP_TYPE + ); + + String auditMsg = "The following participant group was shared: recipient: " + recipient.getName() + " (" + recipient.getUserId() + ")" + + ", groupId: " + form.getRowId() + ", name: " + form.getLabel(); + StudyService.get().addStudyAuditEvent(getContainer(), getUser(), auditMsg); + } + } + + return !errors.hasErrors(); + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("participantGroups"); + String manageGroupsTitle = "Manage " + getStudyRedirectIfNull().getSubjectNounSingular() + " Groups"; + root.addChild(manageGroupsTitle, new ActionURL(ManageParticipantCategoriesAction.class, getContainer())); + root.addChild("Send Participant Group"); + } + } + + public static class SendParticipantGroupForm extends ReturnUrlForm + { + private Integer _rowId; + private String _label; + private String _recipientList; + private String _messageSubject; + private String _messageBody; + + public Integer getRowId() + { + return _rowId; + } + + public void setRowId(Integer rowId) + { + _rowId = rowId; + } + + public String getLabel() + { + return _label; + } + + public void setLabel(String label) + { + _label = label; + } + + public String getRecipientList() + { + return _recipientList; + } + + public void setRecipientList(String recipientList) + { + _recipientList = recipientList; + } + + public String getMessageSubject() + { + return _messageSubject; + } + + public void setMessageSubject(String messageSubject) + { + _messageSubject = messageSubject; + } + + public String getMessageBody() + { + return _messageBody; + } + + public void setMessageBody(String messageBody) + { + _messageBody = messageBody; + } + + public ActionURL getDefaultUrl(Container container) + { + return new ActionURL(ManageParticipantCategoriesAction.class, container); + } + + public ActionURL getSendGroupUrl(Container container) + { + ActionURL sendGroupUrl = getReturnActionURL(getDefaultUrl(container)); + sendGroupUrl.addParameter("groupId", getRowId()); + return sendGroupUrl; + } + } + + @RequiresPermission(AdminPermission.class) + public class ManageParticipantsAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object form, BindException errors) + { + ChangeAlternateIdsForm changeAlternateIdsForm = getChangeAlternateIdForm(getStudyRedirectIfNull()); + return new JspView<>("/org/labkey/study/view/manageParticipants.jsp", changeAlternateIdsForm); + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("alternateIDs"); + _addManageStudy(root); + String pluralNoun = getStudyRedirectIfNull().getSubjectNounPlural(); + root.addChild("Manage " + pluralNoun, new ActionURL(ManageParticipantsAction.class, getContainer())); + } + } + + @RequiresPermission(AdminPermission.class) + public class MergeParticipantsAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object form, BindException errors) + { + return new JspView<>("/org/labkey/study/view/mergeParticipants.jsp"); + } + + @Override + public void addNavTrail(NavTree root) + { + // Add Manage Participants nav trail + ManageParticipantsAction manageParticipantsAction = new ManageParticipantsAction(); + manageParticipantsAction.setViewContext(getViewContext()); + manageParticipantsAction.setPageConfig(new PageConfig(getViewContext().getRequest())); + manageParticipantsAction.addNavTrail(root); + + String subjectColumnName = getStudyRedirectIfNull().getSubjectColumnName(); + root.addChild("Change or Merge " + subjectColumnName + "s"); + } + } + + @RequiresPermission(ReadPermission.class) + public static class SubjectListAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object o, BindException errors) + { + return new SubjectsWebPart(getViewContext(), true, 0); + } + + @Override + public void addNavTrail(NavTree root) + { + } + } + + @RequiresPermission(ReadPermission.class) + public static class BrowseStudyScheduleAction extends MutatingApiAction + { + @Override + public ApiResponse execute(BrowseStudyForm browseDataForm, BindException errors) throws Exception + { + ApiSimpleResponse response = new ApiSimpleResponse(); + StudyManager manager = StudyManager.getInstance(); + Study study = manager.getStudy(getContainer()); + StudySchedule schedule = new StudySchedule(); + CohortImpl cohort = null; + + if (browseDataForm.getCohortId() != null) + { + cohort = manager.getCohortForRowId(getContainer(), getUser(), browseDataForm.getCohortId()); + } + + if (cohort == null && browseDataForm.getCohortLabel() != null) + { + cohort = manager.getCohortByLabel(getContainer(), getUser(), browseDataForm.getCohortLabel()); + } + + if (study != null) + { + schedule.setVisits(manager.getVisits(study, cohort, getUser(), Visit.Order.DISPLAY)); + schedule.setDatasets( + manager.getDatasetDefinitions(study, cohort, Dataset.TYPE_STANDARD, Dataset.TYPE_PLACEHOLDER), + DataViewService.get().getViews(getViewContext(), Collections.singletonList(DatasetViewProvider.TYPE))); + + response.put("schedule", schedule.toJSON(getUser())); + response.put("success", true); + + return response; + } + else + throw new IllegalStateException("A study does not exist in this folder"); + } + } + + @RequiresPermission(ReadPermission.class) + public static class GetStudyTimepointsAction extends MutatingApiAction + { + @Override + public ApiResponse execute(BrowseStudyForm browseDataForm, BindException errors) + { + ApiSimpleResponse response = new ApiSimpleResponse(); + StudyManager manager = StudyManager.getInstance(); + Study study = manager.getStudy(getContainer()); + StudySchedule schedule = new StudySchedule(); + CohortImpl cohort = null; + + if (browseDataForm.getCohortId() != null) + { + cohort = manager.getCohortForRowId(getContainer(), getUser(), browseDataForm.getCohortId()); + } + + if (cohort == null && browseDataForm.getCohortLabel() != null) + { + cohort = manager.getCohortByLabel(getContainer(), getUser(), browseDataForm.getCohortLabel()); + } + + if (study != null) + { + schedule.setVisits(manager.getVisits(study, cohort, getUser(), Visit.Order.DISPLAY)); + + response.put("schedule", schedule.toJSON(getUser())); + response.put("success", true); + + return response; + } + else + throw new IllegalStateException("A study does not exist in this folder"); + } + } + + @RequiresPermission(AdminPermission.class) + public static class UpdateStudyScheduleAction extends MutatingApiAction + { + @Override + public void validateForm(StudySchedule form, Errors errors) + { + if (form.getSchedule().size() <= 0) + errors.reject(ERROR_MSG, "No study schedule records have been specified"); + } + + @Override + public ApiResponse execute(StudySchedule form, BindException errors) + { + ApiSimpleResponse response = new ApiSimpleResponse(); + Study study = StudyManager.getInstance().getStudy(getContainer()); + + if (study != null) + { + for (Map.Entry> entry : form.getSchedule().entrySet()) + { + Dataset ds = StudyService.get().getDataset(getContainer(), entry.getKey()); + if (ds != null) + { + for (VisitDataset visit : entry.getValue()) + { + VisitDatasetType type = visit.isRequired() ? VisitDatasetType.REQUIRED : VisitDatasetType.NOT_ASSOCIATED; + + StudyManager.getInstance().updateVisitDatasetMapping(getUser(), getContainer(), + visit.getVisitRowId(), ds.getDatasetId(), type); + } + } + } + response.put("success", true); + + return response; + } + else + throw new IllegalStateException("A study does not exist in this folder"); + } + } + + public static class BrowseStudyForm + { + private Integer _cohortId; + private String _cohortLabel; + + public Integer getCohortId() + { + return _cohortId; + } + + public void setCohortId(Integer cohortId) + { + _cohortId = cohortId; + } + + public String getCohortLabel() + { + return _cohortLabel; + } + + public void setCohortLabel(String cohortLabel) + { + _cohortLabel = cohortLabel; + } + } + + @RequiresPermission(AdminPermission.class) + public class DefineDatasetAction extends MutatingApiAction + { + private StudyImpl _study; + + @Override + public void validateForm(DefineDatasetForm form, Errors errors) + { + _study = StudyManager.getInstance().getStudy(getContainer()); + + if (_study != null) + { + switch (form.getType()) + { + case defineManually: + case placeHolder: + if (StringUtils.isEmpty(form.getName())) + errors.reject(ERROR_MSG, "A Dataset name must be specified."); + else if (StudyManager.getInstance().getDatasetDefinitionByName(_study, form.getName()) != null) + errors.reject(ERROR_MSG, "A Dataset named: " + form.getName() + " already exists in this folder."); + break; + + case linkToTarget: + if (form.getExpectationDataset() == null || form.getTargetDataset() == null) + errors.reject(ERROR_MSG, "An expectation Dataset and target Dataset must be specified."); + break; + + case linkManually: + if (form.getExpectationDataset() == null) + errors.reject(ERROR_MSG, "An expectation Dataset must be specified."); + break; + } + } + else + errors.reject(ERROR_MSG, "A study does not exist in this folder"); + } + + @Override + public ApiResponse execute(DefineDatasetForm form, BindException errors) + { + ApiSimpleResponse response = new ApiSimpleResponse(); + DatasetDefinition def; + + DbScope scope = StudySchema.getInstance().getSchema().getScope(); + + try (DbScope.Transaction transaction = scope.ensureTransaction()) + { + Integer categoryId = null; + + if (form.getCategory() != null) + { + ViewCategory category = ViewCategoryManager.getInstance().ensureViewCategory(getContainer(), getUser(), form.getCategory().getLabel()); + categoryId = category.getRowId(); + } + + switch (form.getType()) + { + case defineManually: + { + def = StudyPublishManager.getInstance().createDataset(getUser(), new DatasetDefinition.Builder(form.getName()) + .setStudy(_study) + .setDemographicData(false) + .setCategoryId(categoryId)); + def.provisionTable(false); + + ActionURL redirect = new ActionURL(EditTypeAction.class, getContainer()).addParameter(Dataset.DATASET_KEY, def.getDatasetId()); + response.put("redirectUrl", redirect.getLocalURIString()); + break; + } + case placeHolder: + def = StudyPublishManager.getInstance().createDataset(getUser(), new DatasetDefinition.Builder(form.getName()) + .setStudy(_study) + .setDemographicData(false) + .setType(Dataset.TYPE_PLACEHOLDER) + .setCategoryId(categoryId)); + def.provisionTable(false); + response.put("datasetId", def.getDatasetId()); + break; + + case linkManually: + def = StudyManager.getInstance().getDatasetDefinition(_study, form.getExpectationDataset()); + if (def != null) + { + def = def.createMutable(); + + def.setType(Dataset.TYPE_STANDARD); + def.save(getUser()); + + // add a cancel url to rollback either the manual link or import from file link + ActionURL cancelURL = new ActionURL(CancelDefineDatasetAction.class, getContainer()).addParameter("expectationDataset", form.getExpectationDataset()); + + ActionURL redirect = new ActionURL(EditTypeAction.class, getContainer()).addParameter(Dataset.DATASET_KEY, form.getExpectationDataset()); + redirect.addCancelURL(cancelURL); + response.put("redirectUrl", redirect.getLocalURIString()); + } + else + throw new IllegalArgumentException("The expectation Dataset did not exist"); + break; + + case linkToTarget: + DatasetDefinition expectationDataset = StudyManager.getInstance().getDatasetDefinition(_study, form.getExpectationDataset()); + DatasetDefinition targetDataset = StudyManager.getInstance().getDatasetDefinition(_study, form.getTargetDataset()); + + StudyManager.getInstance().linkPlaceHolderDataset(_study, getUser(), expectationDataset, targetDataset); + break; + } + response.put("success", true); + transaction.commit(); + } + + return response; + } + } + + @RequiresPermission(AdminPermission.class) + public class CancelDefineDatasetAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object form, BindException errors) + { + // switch the dataset back to a placeholder type + Study study = getStudy(getContainer()); + if (study != null) + { + String expectationDataset = getViewContext().getActionURL().getParameter("expectationDataset"); + if (NumberUtils.isDigits(expectationDataset)) + { + DatasetDefinition def = StudyManager.getInstance().getDatasetDefinition(study, NumberUtils.toInt(expectationDataset)); + if (def != null) + { + def = def.createMutable(); + + def.setType(Dataset.TYPE_PLACEHOLDER); + def.save(getUser()); + } + } + } + throw new RedirectException(new ActionURL(StudyScheduleAction.class, getContainer())); + } + + @Override + public void addNavTrail(NavTree root) + { + } + } + + public static class DefineDatasetForm implements ApiJsonForm, HasViewContext + { + enum Type + { + defineManually, + placeHolder, + linkToTarget, + linkManually, + } + + private ViewContext _context; + private DefineDatasetForm.Type _type; + private String _name; + private ViewCategory _category; + private Integer _expectationDataset; + private Integer _targetDataset; + + public Type getType() + { + return _type; + } + + public String getName() + { + return _name; + } + + public ViewCategory getCategory() + { + return _category; + } + + public Integer getExpectationDataset() + { + return _expectationDataset; + } + + public Integer getTargetDataset() + { + return _targetDataset; + } + + @Override + public void bindJson(JSONObject json) + { + JSONObject categoryProp = json.optJSONObject("category"); + if (null != categoryProp) + { + _category = ViewCategory.fromJSON(_context.getContainer(), categoryProp); + } + + _name = json.optString("name", null); + + String type = json.optString("type", null); + if (null != type) + _type = Type.valueOf(type); + + _expectationDataset = asInteger(json.opt("expectationDataset")); + _targetDataset = asInteger(json.opt("targetDataset")); + } + + @Override + public void setViewContext(ViewContext context) + { + _context = context; + } + + @Override + public ViewContext getViewContext() + { + return _context; + } + } + + public static class ChangeAlternateIdsForm + { + private String _prefix = ""; + private int _numDigits = StudyManager.ALTERNATEID_DEFAULT_NUM_DIGITS; + private int _aliasDatasetId = -1; + private String _aliasColumn = ""; + private String _sourceColumn = ""; + + public String getAliasColumn() + { + return _aliasColumn; + } + + public void setAliasColumn(String aliasColumn) + { + _aliasColumn = aliasColumn; + } + + public String getSourceColumn() + { + return _sourceColumn; + } + + public void setSourceColumn(String sourceColumn) + { + _sourceColumn = sourceColumn; + } + + public String getPrefix() + { + return _prefix; + } + + public void setPrefix(String prefix) + { + _prefix = prefix; + } + + public int getNumDigits() + { + return _numDigits; + } + + public void setNumDigits(int numDigits) + { + _numDigits = numDigits; + } + + public int getAliasDatasetId() + { + return _aliasDatasetId; + } + public void setAliasDatasetId(int aliasDatasetId) + { + _aliasDatasetId = aliasDatasetId; + } + } + + @RequiresPermission(AdminPermission.class) + public class ChangeAlternateIdsAction extends MutatingApiAction + { + @Override + public ApiResponse execute(ChangeAlternateIdsForm form, BindException errors) + { + ApiSimpleResponse response = new ApiSimpleResponse(); + StudyImpl study = StudyManager.getInstance().getStudy(getContainer()); + if (study != null) + { + setAlternateIdProperties(study, form.getPrefix(), form.getNumDigits()); + StudyManager.getInstance().clearAlternateParticipantIds(study); + response.put("success", true); + return response; + } + else + throw new IllegalStateException("A study does not exist in this folder"); + } + } + + public static class MapAliasIdsForm + { + private int _datasetId; + private String _aliasColumn = ""; + private String _sourceColumn = ""; + + public int getDatasetId() + { + return _datasetId; + } + + public void setDatasetId(int datasetId) + { + _datasetId = datasetId; + } + + public String getAliasColumn() + { + return _aliasColumn; + } + + public void setAliasColumn(String aliasColumn) + { + _aliasColumn = aliasColumn; + } + + public String getSourceColumn() + { + return _sourceColumn; + } + + public void setSourceColumn(String sourceColumn) + { + _sourceColumn = sourceColumn; + } + } + + @RequiresPermission(AdminPermission.class) + public class MapAliasIdsAction extends MutatingApiAction + { + @Override + public ApiResponse execute(MapAliasIdsForm form, BindException errors) + { + ApiSimpleResponse response = new ApiSimpleResponse(); + StudyImpl study = StudyManager.getInstance().getStudy(getContainer()); + if (study != null) + { + setAliasMappingProperties(study, form.getDatasetId(), form.getAliasColumn(), form.getSourceColumn()); + StudyManager.getInstance().clearAlternateParticipantIds(study); + response.put("success", true); + return response; + } + else + throw new IllegalStateException("A study does not exist in this folder"); + } + } + + + @RequiresPermission(AdminPermission.class) + public static class ExportParticipantTransformsAction extends FormHandlerAction + { + @Override + public void validateCommand(Object target, Errors errors) + { + } + + @Override + public boolean handlePost(Object o, BindException errors) throws Exception + { + Study study = StudyManager.getInstance().getStudy(getContainer()); + if (study != null) + { + // Ensure alternateIds are generated for all participants + StudyManager.getInstance().generateNeededAlternateParticipantIds(study, getUser()); + + TableInfo ti = StudySchema.getInstance().getTableInfoParticipant(); + List cols = new ArrayList<>(); + cols.add(ti.getColumn("participantid")); + cols.add(ti.getColumn("alternateid")); + cols.add(ti.getColumn("dateoffset")); + SimpleFilter filter = new SimpleFilter(); + filter.addCondition(ti.getColumn("container"), getContainer()); + ResultsFactory factory = ()->QueryService.get().select(ti, cols, filter, new Sort("participantid")); + + // NOTE: TSVGridWriter closes PrintWriter and ResultSet + try (TSVGridWriter writer = new TSVGridWriter(factory)) + { + writer.setApplyFormats(false); + writer.setFilenamePrefix("ParticipantTransforms"); + writer.setColumnHeaderType(ColumnHeaderType.DisplayFieldKey); // CONSIDER: Use FieldKey instead + writer.write(getViewContext().getResponse()); + } + + return true; + } + else + throw new IllegalStateException("A study does not exist in this folder"); + } + + @Override + public URLHelper getSuccessURL(Object o) + { + return null; + } + } + + public static ChangeAlternateIdsForm getChangeAlternateIdForm(StudyImpl study) + { + ChangeAlternateIdsForm changeAlternateIdsForm = new ChangeAlternateIdsForm(); + changeAlternateIdsForm.setPrefix(study.getAlternateIdPrefix()); + changeAlternateIdsForm.setNumDigits(study.getAlternateIdDigits()); + if (study.getParticipantAliasDatasetId() != null) + { + changeAlternateIdsForm.setAliasDatasetId(study.getParticipantAliasDatasetId()); + changeAlternateIdsForm.setAliasColumn(study.getParticipantAliasProperty()); + changeAlternateIdsForm.setSourceColumn(study.getParticipantAliasSourceProperty()); + } + + return changeAlternateIdsForm; + } + + private void setAlternateIdProperties(StudyImpl study, String prefix, int numDigits) + { + study = study.createMutable(); + study.setAlternateIdPrefix(prefix); + study.setAlternateIdDigits(numDigits); + StudyManager.getInstance().updateStudy(getUser(), study); + } + + private void setAliasMappingProperties(StudyImpl study, int datasetId, String aliasColumn, String sourceColumn) + { + study = study.createMutable(); + study.setParticipantAliasDatasetId(datasetId); + study.setParticipantAliasProperty(aliasColumn); + study.setParticipantAliasSourceProperty(sourceColumn); + StudyManager.getInstance().updateStudy(getUser(), study); + } + + @RequiresPermission(ManageStudyPermission.class) + public class ImportAlternateIdMappingAction extends AbstractQueryImportAction + { + private Study _study; + private int _requestId = -1; + + @Override + protected void initRequest(IdForm form) throws ServletException + { + _requestId = form.getId(); + setHasColumnHeaders(true); + if (null != getStudy()) + { + _study = getStudy(); + setImportMessage("Upload a mapping of " + _study.getSubjectNounPlural() + " to Alternate IDs and date offsets from a TXT, CSV or Excel file or paste the mapping directly into the text box below. " + + "There must be a header row, which must contain ParticipantId and either AlternateId, DateOffset or both. Click the button below to export the current mapping."); + } + setTarget(StudySchema.getInstance().getTableInfoParticipant()); + setHideTsvCsvCombo(true); + setSuccessMessageSuffix("uploaded"); + } + + @Override + public ModelAndView getView(IdForm form, BindException errors) throws Exception + { + _study = getStudyThrowIfNull(); + initRequest(form); + return getDefaultImportView(form, errors); + } + + @Override + protected boolean skipInsertOptionValidation() + { + return true; // allow QueryUpdateService.InsertOption.INSERT for study.participant + } + + @Override + protected void validatePermission(User user, BindException errors) + { + checkPermissions(); + } + + @Override + protected boolean canInsert(User user) + { + return getContainer().hasPermission(user, ManageStudyPermission.class); + } + + @Override + protected int importData(DataLoader dl, FileStream file, String originalName, BatchValidationException errors, @Nullable AuditBehaviorType auditBehaviorType, TransactionAuditProvider.@Nullable TransactionAuditEvent auditEvent, @Nullable String auditUserComment) throws IOException + { + if (null == _study) + return 0; + int rows = StudyManager.getInstance().setImportedAlternateParticipantIds(_study, dl, errors); + + // Insert a clear warning at the top that the mappings have not been imported, #36517 + if (errors.hasErrors()) + { + List rowErrors = errors.getRowErrors(); + int count = rowErrors.size(); + rowErrors.add(0, new ValidationException("Warning: NONE of participant mappings have been imported because this mapping file contains " + (1 == count ? "an error" : "errors") + "! Please correct the following:")); + } + + return rows; + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("Upload " + _study.getSubjectNounSingular() + " Mapping"); + } + + @Override + protected ActionURL getSuccessURL(IdForm form) + { + return new ActionURL(ManageParticipantsAction.class, getContainer()); + } + } + + @RequiresPermission(AdminPermission.class) + public class SnapshotSettingsAction extends FormViewAction + { + private StudyImpl _study; + + @Override + public ModelAndView getView(SnapshotSettingsForm form, boolean reshow, BindException errors) + { + _study = getStudyRedirectIfNull(); + StudySnapshot snapshot = StudyManager.getInstance().getStudySnapshot(_study.getStudySnapshot()); + + if (null == snapshot) + { + errors.reject(null, "This is not a published study"); + return new SimpleErrorView(errors); + } + else + { + return new JspView<>("/org/labkey/study/view/snapshotSettings.jsp", snapshot); + } + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("studyPubRefresh"); + _addManageStudy(root); + root.addChild((_study.getStudySnapshotType() != null ? _study.getStudySnapshotType().getTitle() : "") + " Study Settings"); + } + + @Override + public void validateCommand(SnapshotSettingsForm form, Errors errors) + { + } + + @Override + public boolean handlePost(SnapshotSettingsForm form, BindException errors) + { + StudyImpl study = getStudyRedirectIfNull(); + StudySnapshot snapshot = StudyManager.getInstance().getStudySnapshot(study.getStudySnapshot()); + assert null != snapshot; + snapshot.setRefresh(form.isRefresh()); + StudyManager.getInstance().updateStudySnapshot(snapshot, getUser()); + return false; + } + + @Override + public URLHelper getSuccessURL(SnapshotSettingsForm form) + { + return new ActionURL(getClass(), getContainer()); + } + } + + public static class SnapshotSettingsForm + { + private boolean _refresh = false; + + public boolean isRefresh() + { + return _refresh; + } + + public void setRefresh(boolean refresh) + { + _refresh = refresh; + } + } + + /** + * Set up the site wide settings for a master patient provider + */ + @RequiresPermission(AdminPermission.class) + public static class MasterPatientProviderAction extends FormViewAction + { + @Override + public void validateCommand(MasterPatientProviderSettings form, Errors errors) + { + if (!form.isValid()) + errors.reject(ERROR_MSG, "All required fields are not specified"); + } + + @Override + public ModelAndView getView(MasterPatientProviderSettings form, boolean reshow, BindException errors) throws Exception + { + return new JspView<>("/org/labkey/study/view/masterPatientProvider.jsp", form, errors); + } + + @Override + public boolean handlePost(MasterPatientProviderSettings form, BindException errors) throws Exception + { + if (form.getType() != null) + { + try (DbScope.Transaction transaction = StudySchema.getInstance().getScope().ensureTransaction()) + { + MasterPatientIndexService svc = MasterPatientIndexService.getProvider(form.getType()); + if (svc != null) + { + WritablePropertyMap map = PropertyManager.getNormalStore().getWritableProperties(MasterPatientProviderSettings.CATEGORY, true); + + map.put(MasterPatientProviderSettings.TYPE, form.getType()); + map.save(); + + svc.setServerSettings(form); + transaction.commit(); + } + } + } + return true; + } + + @Override + public URLHelper getSuccessURL(MasterPatientProviderSettings form) + { + return urlProvider(AdminUrls.class).getAdminConsoleURL(); + } + + @Override + public void addNavTrail(NavTree root) + { + urlProvider(AdminUrls.class).addAdminNavTrail(root, "Configure Master Patient Index", getClass(), getContainer()); + } + } + + @RequiresPermission(AdminPermission.class) + public static class TestMasterPatientProviderAction extends MutatingApiAction + { + @Override + public void validateForm(MasterPatientProviderSettings form, Errors errors) + { + if (!form.isValid()) + errors.reject(ERROR_MSG, "All required fields are not specified"); + } + + @Override + public Object execute(MasterPatientProviderSettings form, BindException errors) throws Exception + { + ApiSimpleResponse response = new ApiSimpleResponse(); + + if (form.getType() != null) + { + MasterPatientIndexService svc = MasterPatientIndexService.getProvider(form.getType()); + if (svc != null) + { + if (svc.checkServerSettings(form)) + { + response.put("success", true); + response.put("message", "The specified settings are valid."); + } + else + { + response.put("success", false); + response.put("message", "The specified settings are not valid."); + } + } + } + return response; + } + } + + public static class MasterPatientProviderSettings extends MasterPatientIndexService.ServerSettings + { + public static final String CATEGORY = "MASTER_PATIENT_PROVIDER"; + public static final String TYPE = "TYPE"; + + private String _type; + + public String getType() + { + return _type; + } + + public void setType(String type) + { + _type = type; + } + } + + @RequiresPermission(AdminPermission.class) + public class ConfigureMasterPatientSettingsAction extends FormViewAction + { + private MasterPatientIndexService _svc; + + @Override + public void validateCommand(MasterPatientIndexService.FolderSettings form, Errors errors) + { + if (!form.isValid()) + errors.reject(ERROR_MSG, "All required fields are not specified"); + } + + @Override + public ModelAndView getView(MasterPatientIndexService.FolderSettings form, boolean reshow, BindException errors) throws Exception + { + return new JspView<>("/org/labkey/study/view/manageMasterPatientConfig.jsp", getService(), errors); + } + + @Override + public boolean handlePost(MasterPatientIndexService.FolderSettings form, BindException errors) throws Exception + { + MasterPatientIndexService svc = getService(); + if (svc != null) + { + form.setReloadUser(getUser().getUserId()); + svc.setFolderSettings(getContainer(), form); + } + return true; + } + + @Override + public URLHelper getSuccessURL(MasterPatientIndexService.FolderSettings form) + { + return new ActionURL(ManageStudyAction.class, getContainer()); + } + + @Override + public void addNavTrail(NavTree root) + { + MasterPatientIndexService svc = getService(); + if (svc != null) + root.addChild("Manage " + svc.getName() + " Configuration"); + else + root.addChild("Manage Master Patient Index Configuration"); + } + + private MasterPatientIndexService getService() + { + if (_svc == null) + { + _svc = MasterPatientIndexMaintenanceTask.getConfiguredService(); + } + return _svc; + } + } + + @RequiresPermission(AdminPermission.class) + public static class RefreshMasterPatientIndexAction extends MutatingApiAction + { + @Override + public ApiResponse execute(Object o, BindException errors) throws Exception + { + ApiSimpleResponse response = new ApiSimpleResponse(); + try + { + ViewBackgroundInfo info = new ViewBackgroundInfo(getContainer(), getUser(), getViewContext().getActionURL()); + MasterPatientIndexService svc = MasterPatientIndexMaintenanceTask.getConfiguredService(); + + MasterPatientIndexService.FolderSettings settings = svc.getFolderSettings(getContainer()); + if (settings.isEnabled()) + { + PipelineJob job = new MasterPatientIndexUpdateTask(info, PipelineService.get().findPipelineRoot(getContainer()), svc); + + PipelineService.get().queueJob(job); + + response.put("success", true); + response.put(ActionURL.Param.returnUrl.name(), urlProvider(PipelineUrls.class).urlBegin(getContainer())); + } + else + { + response.put("success", false); + response.put("message", "The specified configuration is not enabled."); + } + } + catch (PipelineValidationException e) + { + throw new IOException(e); + } + return response; + } + } + + @RequiresPermission(AdminPermission.class) + public static class DeleteMasterPatientRecordsAction extends MutatingApiAction + { + @Override + public ApiResponse execute(DeleteMPIForm form, BindException errors) throws Exception + { + ApiSimpleResponse response = new ApiSimpleResponse(); + + List> params = form.getParams(); + MasterPatientIndexService svc = MasterPatientIndexMaintenanceTask.getConfiguredService(); + if (svc != null && !params.isEmpty()) + { + int count = svc.deleteMatchingRecords(params); + + response.put("success", true); + response.put("count", count); + } + return response; + } + } + + public static class DeleteMPIForm implements ApiJsonForm + { + private final List> _params = new ArrayList<>(); + + public List> getParams() + { + return _params; + } + + @Override + public void bindJson(JSONObject json) + { + for (String key : json.keySet()) + { + _params.add(new Pair<>(key, String.valueOf(json.get(key)))); + } + } + } + + // Render the HTML description if a study exists in this folder. Used by the client-side CSP validator. + @RequiresPermission(ReadPermission.class) + public static class DescriptionAction extends SimpleViewAction + { + private StudyImpl _study; + + @Override + public ModelAndView getView(Object o, BindException errors) throws Exception + { + _study = getStudy(getContainer()); + return null != _study ? new HtmlView(_study.getDescriptionHtml()) : new EmptyView(); + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild(_study != null ? "Overview: " + _study.getLabel() : "No Study"); + } + } +} diff --git a/study/src/org/labkey/study/pipeline/StudyPipeline.java b/study/src/org/labkey/study/pipeline/StudyPipeline.java index 9755a5e8bc7..5510bd4d54c 100644 --- a/study/src/org/labkey/study/pipeline/StudyPipeline.java +++ b/study/src/org/labkey/study/pipeline/StudyPipeline.java @@ -1,125 +1,126 @@ -/* - * Copyright (c) 2008-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.labkey.study.pipeline; - -import org.labkey.api.module.Module; -import org.labkey.api.pipeline.PipeRoot; -import org.labkey.api.pipeline.PipelineAction; -import org.labkey.api.pipeline.PipelineDirectory; -import org.labkey.api.pipeline.PipelineProvider; -import org.labkey.api.security.permissions.InsertPermission; -import org.labkey.api.study.Study; -import org.labkey.api.util.Path; -import org.labkey.api.view.ActionURL; -import org.labkey.api.view.ViewContext; -import org.labkey.study.controllers.StudyController; -import org.labkey.study.model.StudyManager; - -import java.io.File; -import java.util.ArrayList; -import java.util.List; - - -/** - * User: Matthew - * Date: Jan 12, 2006 - * Time: 1:16:44 PM - */ - -public class StudyPipeline extends PipelineProvider -{ - public StudyPipeline(Module owningModule) - { - super("Study", owningModule); - } - - @Override - public void updateFileProperties(final ViewContext context, PipeRoot pr, PipelineDirectory directory, boolean includeAll) - { - if (!context.getContainer().hasPermission(context.getUser(), InsertPermission.class)) - return; - - if (context.getContainer().isDataspace()) - return; - - Study study = StudyManager.getInstance().getStudy(context.getContainer()); - - if (study == null) - return; - - File[] files = directory.listFiles(new FileEntryFilter() { - @Override - public boolean accept(File f) - { - return f.getName().endsWith(".dataset"); - } - }); - - handleDatasetFiles(context, study, directory, files, includeAll); - } - - public static File lockForDataset(Study study, File f) - { - String path = f.getPath(); - return new File(path + "." + "_" + study.getContainer().getRowId() + ".lock"); - } - - public static File lockForDataset(Study study, Path path) - { - return new File(path + "." + "_" + study.getContainer().getRowId() + ".lock"); - } - - private void handleDatasetFiles(ViewContext context, Study study, PipelineDirectory directory, File[] files, boolean includeAll) - { - List lockFiles = new ArrayList<>(); - List datasetFiles = new ArrayList<>(); - - for (File f : files) - { - File lock = lockForDataset(study, f); - if (lock.exists()) - { - if (lock.canRead() && lock.canWrite()) - { - lockFiles.add(lock); - } - } - else - { - datasetFiles.add(f); - } - } - - if (!lockFiles.isEmpty()) - { - ActionURL urlReset = directory.cloneHref(); - urlReset.setAction(StudyController.ResetPipelineAction.class); - urlReset.replaceParameter("redirect", context.getActionURL().getLocalURIString()); - urlReset.replaceParameter("path", directory.getPathParameter()); - - String actionId = StudyController.ResetPipelineAction.class.getName() + ":Delete lock"; - directory.addAction(new PipelineAction(actionId, "Delete lock", urlReset, lockFiles.toArray(new File[0]), true)); - } - - files = new File[0]; - if (!datasetFiles.isEmpty()) - files = datasetFiles.toArray(new File[0]); - - String actionId = createActionId(StudyController.ImportStudyBatchAction.class, "Import Datasets"); - addAction(actionId, StudyController.ImportStudyBatchAction.class, "Import Datasets", directory, files, false, false, includeAll); - } -} +/* + * Copyright (c) 2008-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.labkey.study.pipeline; + +import org.labkey.api.module.Module; +import org.labkey.api.pipeline.PipeRoot; +import org.labkey.api.pipeline.PipelineAction; +import org.labkey.api.pipeline.PipelineDirectory; +import org.labkey.api.pipeline.PipelineProvider; +import org.labkey.api.security.permissions.InsertPermission; +import org.labkey.api.study.Study; +import org.labkey.api.util.Path; +import org.labkey.api.view.ActionURL; +import org.labkey.api.view.ViewContext; +import org.labkey.study.controllers.StudyController; +import org.labkey.study.model.StudyManager; +import org.labkey.vfs.FileLike; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + + +/** + * User: Matthew + * Date: Jan 12, 2006 + * Time: 1:16:44 PM + */ + +public class StudyPipeline extends PipelineProvider +{ + public StudyPipeline(Module owningModule) + { + super("Study", owningModule); + } + + @Override + public void updateFileProperties(final ViewContext context, PipeRoot pr, PipelineDirectory directory, boolean includeAll) + { + if (!context.getContainer().hasPermission(context.getUser(), InsertPermission.class)) + return; + + if (context.getContainer().isDataspace()) + return; + + Study study = StudyManager.getInstance().getStudy(context.getContainer()); + + if (study == null) + return; + + File[] files = directory.listFiles(new FileEntryFilter() { + @Override + public boolean accept(File f) + { + return f.getName().endsWith(".dataset"); + } + }); + + handleDatasetFiles(context, study, directory, files, includeAll); + } + + public static File lockForDataset(Study study, File f) + { + String path = f.getPath(); + return new File(path + "." + "_" + study.getContainer().getRowId() + ".lock"); + } + + public static File lockForDataset(Study study, Path path) + { + return new File(path + "." + "_" + study.getContainer().getRowId() + ".lock"); + } + + private void handleDatasetFiles(ViewContext context, Study study, PipelineDirectory directory, File[] files, boolean includeAll) + { + List lockFiles = new ArrayList<>(); + List datasetFiles = new ArrayList<>(); + + for (File f : files) + { + File lock = lockForDataset(study, f); + if (lock.exists()) + { + if (lock.canRead() && lock.canWrite()) + { + lockFiles.add(lock); + } + } + else + { + datasetFiles.add(f); + } + } + + if (!lockFiles.isEmpty()) + { + ActionURL urlReset = directory.cloneHref(); + urlReset.setAction(StudyController.ResetPipelineAction.class); + urlReset.replaceParameter("redirect", context.getActionURL().getLocalURIString()); + urlReset.replaceParameter("path", directory.getPathParameter()); + + String actionId = StudyController.ResetPipelineAction.class.getName() + ":Delete lock"; + directory.addAction(new PipelineAction(actionId, "Delete lock", urlReset, lockFiles.toArray(new File[0]), true)); + } + + files = new File[0]; + if (!datasetFiles.isEmpty()) + files = datasetFiles.toArray(new File[0]); + + String actionId = createActionId(StudyController.ImportStudyBatchAction.class, "Import Datasets"); + addAction(actionId, StudyController.ImportStudyBatchAction.class, "Import Datasets", directory, files, false, false, includeAll); + } +} From 92cb95854aa4ff6d5fa662f44b25ce2816ac896c Mon Sep 17 00:00:00 2001 From: labkey-jeckels Date: Sun, 19 Oct 2025 08:35:46 -0700 Subject: [PATCH 02/16] Distinguish between failures --- .../labkey/assay/AssayIntegrationTestCase.jsp | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/assay/src/org/labkey/assay/AssayIntegrationTestCase.jsp b/assay/src/org/labkey/assay/AssayIntegrationTestCase.jsp index 674ad8d39fd..d66cbfd7755 100644 --- a/assay/src/org/labkey/assay/AssayIntegrationTestCase.jsp +++ b/assay/src/org/labkey/assay/AssayIntegrationTestCase.jsp @@ -96,6 +96,8 @@ <%@ page import="java.io.IOException" %> <%@ page import="org.apache.commons.collections.MapUtils" %> <%@ page import="org.labkey.vfs.FileSystemLike" %> +<%@ page import="static org.junit.Assert.assertEquals" %> +<%@ page import="static org.junit.Assert.assertNotEquals" %> <%@ page extends="org.labkey.api.jsp.JspTest.BVT" %> <%! @@ -559,18 +561,18 @@ runsQUS.updateRows(user, c, Collections.singletonList(updated), null, errors, null, null); // verify runs modified is changed, but created is not Map modifiedRunResults = new TableSelector(runsTable, selectColumns, new SimpleFilter("rowId", runRowId), null).getMap(); - assertTrue(modifiedRunResults.get("Created").equals(runOriginalCreated)); - assertFalse(modifiedRunResults.get("Modified").equals(runOriginalModified)); + assertEquals("modifiedRunResults should have the same Created", modifiedRunResults.get("Created"), runOriginalCreated); + assertNotEquals("modifiedRunResults should have a different Modified", modifiedRunResults.get("Modified"), runOriginalModified); // verify results created/modified matches run's created in query table Map queryResultAfterRunModify = new TableSelector(resultsTable, selectColumns, new SimpleFilter(runFieldKey, runRowId), null).getMap(); - assertTrue(queryResultAfterRunModify.get("Created").equals(runOriginalCreated)); - assertTrue(queryResultAfterRunModify.get("Modified").equals(runOriginalCreated)); - assertFalse(queryResultAfterRunModify.get("Modified").equals(modifiedRunResults.get("Modified"))); + assertEquals("queryResultAfterRunModify should have the same Created", queryResultAfterRunModify.get("Created"), runOriginalCreated); + assertEquals("queryResultAfterRunModify should have the same Modified", queryResultAfterRunModify.get("Modified"), runOriginalCreated); + assertNotEquals("queryResultAfterRunModify should have a different Modified", queryResultAfterRunModify.get("Modified"), modifiedRunResults.get("Modified")); // verify created/modified in provisioned result table is still not populated after run edit dbResult = getRealResult(resultsTable.getSchema(), realResultsTable.getName(), resultRowId); - assertTrue(dbResult.get("Created") == null && dbResult.get("Modified") == null); + assertTrue("Created and Modified in the provisioned result table weren't as expected", dbResult.get("Created") == null && dbResult.get("Modified") == null); // now edit the result QueryUpdateService resultsQUS = resultsTable.getUpdateService(); @@ -582,18 +584,18 @@ // verify result created matches run's created in query table, but result modified now differs from run's created Map modifiedResults = new TableSelector(resultsTable, selectColumns, new SimpleFilter(runFieldKey, runRowId), null).getMap(); - assertTrue(modifiedResults.get("Created").equals(runOriginalCreated)); - assertFalse(modifiedResults.get("Created").equals(modifiedResults.get("Modified"))); - assertFalse(modifiedResults.get("Modified").equals(runOriginalCreated)); - assertFalse(modifiedResults.get("Modified").equals(runOriginalModified)); - assertFalse(modifiedResults.get("Modified").equals(modifiedRunResults.get("Modified"))); + assertEquals("modifiedResults Created didn't match runOriginalCreated", modifiedResults.get("Created"), runOriginalCreated); + assertNotEquals("modifiedResults Created shouldn't match modifiedResult Modified", modifiedResults.get("Created"), modifiedResults.get("Modified")); + assertNotEquals("modifiedResults Modified shouldn't match runOriginalCreated", modifiedResults.get("Modified"), runOriginalCreated); + assertNotEquals("modifiedResults Modified shouldn't match runOriginalModified", modifiedResults.get("Modified"), runOriginalModified); + assertNotEquals("modifiedResults Modified shouldn't match modifiedRunResults Modified", modifiedResults.get("Modified"), modifiedRunResults.get("Modified")); // verify modified in provisioned result table no longer null after result edit dbResult = getRealResult(resultsTable.getSchema(), realResultsTable.getName(), resultRowId); - assertTrue(dbResult.get("Created") == null && dbResult.get("CreatedBy") == null); - assertFalse(dbResult.get("Modified") == null || dbResult.get("ModifiedBy") == null); - assertTrue(dbResult.get("Modified").equals(modifiedResults.get("Modified"))); - assertTrue(dbResult.get("ModifiedBy").equals(modifiedResults.get("ModifiedBy"))); + assertTrue("dbResult shouldn't have Created or CreatedBy", dbResult.get("Created") == null && dbResult.get("CreatedBy") == null); + assertFalse("dbResult didn't have a Modified or ModifiedBy", dbResult.get("Modified") == null || dbResult.get("ModifiedBy") == null); + assertEquals("dbResults Modified didn't match", dbResult.get("Modified"), modifiedResults.get("Modified")); + assertEquals("dbResults ModifiedBy didn't match", dbResult.get("ModifiedBy"), modifiedResults.get("ModifiedBy")); } @Test From 6c4aaccee7c01b2509e373144135575c1fc68f9e Mon Sep 17 00:00:00 2001 From: labkey-jeckels Date: Sun, 19 Oct 2025 16:42:36 -0700 Subject: [PATCH 03/16] Test fixes, --- api/src/org/labkey/api/reports/report/r/RReportJob.java | 2 +- api/src/org/labkey/api/util/MaintenancePipelineJob.java | 2 +- .../src/org/labkey/experiment/XarExportPipelineJob.java | 2 +- .../experiment/controllers/exp/ExperimentController.java | 4 ++-- .../src/org/labkey/experiment/pipeline/XarGeneratorTask.java | 2 +- .../src/org/labkey/experiment/xar/CompressedXarSource.java | 2 +- .../labkey/study/controllers/publish/PublishController.java | 2 +- .../org/labkey/study/visitmanager/PurgeParticipantsJob.java | 2 +- 8 files changed, 9 insertions(+), 9 deletions(-) diff --git a/api/src/org/labkey/api/reports/report/r/RReportJob.java b/api/src/org/labkey/api/reports/report/r/RReportJob.java index 79c220902a5..8090a2bada0 100644 --- a/api/src/org/labkey/api/reports/report/r/RReportJob.java +++ b/api/src/org/labkey/api/reports/report/r/RReportJob.java @@ -100,7 +100,7 @@ protected void init(@NotNull String executingContainerId) { _jobIdentifier.set(getJobGUID()); FileLike logFile = report.getReportDirFileLike(executingContainerId).resolveChild(LOG_FILE_NAME); - setLogFile(logFile.toNioPathForWrite()); + setLogFile(logFile); } } diff --git a/api/src/org/labkey/api/util/MaintenancePipelineJob.java b/api/src/org/labkey/api/util/MaintenancePipelineJob.java index eaee550dffe..ae0d00215ef 100644 --- a/api/src/org/labkey/api/util/MaintenancePipelineJob.java +++ b/api/src/org/labkey/api/util/MaintenancePipelineJob.java @@ -42,7 +42,7 @@ protected MaintenancePipelineJob(@JsonProperty("_tasks") Collection tasks) { super("SystemMaintenance", info, pipeRoot); - setLogFile(pipeRoot.getLogDirectoryFileLike(true).resolveChild(FileUtil.makeFileNameWithTimestamp("system_maintenance", "log")).toNioPathForWrite()); + setLogFile(pipeRoot.getLogDirectoryFileLike(true).resolveChild(FileUtil.makeFileNameWithTimestamp("system_maintenance", "log"))); _tasks = tasks; } diff --git a/experiment/src/org/labkey/experiment/XarExportPipelineJob.java b/experiment/src/org/labkey/experiment/XarExportPipelineJob.java index bec82562dfc..660d6bc1ef2 100644 --- a/experiment/src/org/labkey/experiment/XarExportPipelineJob.java +++ b/experiment/src/org/labkey/experiment/XarExportPipelineJob.java @@ -73,7 +73,7 @@ public XarExportPipelineJob(ViewBackgroundInfo info, PipeRoot root, String fileN _exportFile = exportedXarsDir.resolveChild(_fileName).toNioPathForWrite().toFile(); - setLogFile(exportedXarsDir.resolveChild(fileName + ".log").toNioPathForWrite()); + setLogFile(exportedXarsDir.resolveChild(fileName + ".log")); header("Experiment export to " + _exportFile.getName()); } diff --git a/experiment/src/org/labkey/experiment/controllers/exp/ExperimentController.java b/experiment/src/org/labkey/experiment/controllers/exp/ExperimentController.java index af9b2588188..b318b2f99c9 100644 --- a/experiment/src/org/labkey/experiment/controllers/exp/ExperimentController.java +++ b/experiment/src/org/labkey/experiment/controllers/exp/ExperimentController.java @@ -6809,7 +6809,7 @@ public boolean handlePost(ImportXarForm form, BindException errors) throws Excep if (form.getModule() != null) { FileLike logFile = form.getPipeRoot(getContainer()).getLogDirectoryFileLike(true).resolveChild("module-resource-xar.log"); - job.setLogFile(logFile.toNioPathForWrite()); + job.setLogFile(logFile); } PipelineService.get().queueJob(job); @@ -6849,7 +6849,7 @@ public Object execute(ImportXarForm form, BindException errors) throws Exception if (form.getModule() != null) { FileLike logFile = form.getPipeRoot(getContainer()).getLogDirectoryFileLike(true).resolveChild("module-resource-xar.log"); - job.setLogFile(logFile.toNioPathForWrite()); + job.setLogFile(logFile); } PipelineService.get().queueJob(job); diff --git a/experiment/src/org/labkey/experiment/pipeline/XarGeneratorTask.java b/experiment/src/org/labkey/experiment/pipeline/XarGeneratorTask.java index 615540a7980..3816282d7e5 100644 --- a/experiment/src/org/labkey/experiment/pipeline/XarGeneratorTask.java +++ b/experiment/src/org/labkey/experiment/pipeline/XarGeneratorTask.java @@ -248,6 +248,6 @@ public void writeToDisk(ExpRun run) throws PipelineJobException private FileLike getLoadingXarFile() { FileLike xarPath = _factory.getXarFile(getJob()); - return xarPath.resolveChild(xarPath + ".loading"); + return xarPath.getParent().resolveChild(xarPath.getName() + ".loading"); } } diff --git a/experiment/src/org/labkey/experiment/xar/CompressedXarSource.java b/experiment/src/org/labkey/experiment/xar/CompressedXarSource.java index e38db3f8585..2c8ed5d4738 100644 --- a/experiment/src/org/labkey/experiment/xar/CompressedXarSource.java +++ b/experiment/src/org/labkey/experiment/xar/CompressedXarSource.java @@ -68,7 +68,7 @@ public CompressedXarSource(FileLike xarFile, PipelineJob job, Container targetCo @Override public void init() throws ExperimentException, IOException { - FileLike outputDir = _xarFile.resolveChild(_xarFile + ".exploded"); + FileLike outputDir = _xarFile.getParent().resolveChild(_xarFile.getName() + ".exploded"); FileUtil.deleteDir(outputDir); if (outputDir.exists()) { diff --git a/study/src/org/labkey/study/controllers/publish/PublishController.java b/study/src/org/labkey/study/controllers/publish/PublishController.java index 16a5e29c0e9..c675c1e3aaa 100644 --- a/study/src/org/labkey/study/controllers/publish/PublishController.java +++ b/study/src/org/labkey/study/controllers/publish/PublishController.java @@ -314,7 +314,7 @@ public AutoLinkPipelineJob(ViewBackgroundInfo info, @NotNull PipeRoot pipeRoot, _runIds = form.getRunId(); _autoLinkCategory = form.getAutoLinkCategory(); - setLogFile(FileUtil.appendName(pipeRoot.getRootPath(), FileUtil.makeFileNameWithTimestamp("auto_link_to_study", "log")).toPath()); + setLogFile(pipeRoot.resolvePathToFileLike(FileUtil.makeFileNameWithTimestamp("auto_link_to_study", "log"))); } @Override diff --git a/study/src/org/labkey/study/visitmanager/PurgeParticipantsJob.java b/study/src/org/labkey/study/visitmanager/PurgeParticipantsJob.java index 118dc10328a..59a7d774e41 100644 --- a/study/src/org/labkey/study/visitmanager/PurgeParticipantsJob.java +++ b/study/src/org/labkey/study/visitmanager/PurgeParticipantsJob.java @@ -29,7 +29,7 @@ public PurgeParticipantsJob() PurgeParticipantsJob(ViewBackgroundInfo info, PipeRoot pipeRoot) { super("StudyParticipantPurge", info, pipeRoot); - setLogFile(pipeRoot.getLogDirectoryFileLike(true).resolveChild(FileUtil.makeFileNameWithTimestamp("purge_participants", "log")).toNioPathForWrite()); + setLogFile(pipeRoot.getLogDirectoryFileLike(true).resolveChild(FileUtil.makeFileNameWithTimestamp("purge_participants", "log"))); } @Override From 4a95775e1d86e08a10439ee28c71517cc6fd43c9 Mon Sep 17 00:00:00 2001 From: labkey-jeckels Date: Mon, 20 Oct 2025 10:14:33 -0700 Subject: [PATCH 04/16] Test fixes, fix line endings --- api/src/org/labkey/api/exp/XarContext.java | 696 +- api/src/org/labkey/api/exp/XarSource.java | 590 +- .../labkey/api/files/FileContentService.java | 728 +- .../org/labkey/api/pipeline/AnalyzeForm.java | 492 +- .../org/labkey/api/pipeline/PipelineJob.java | 4062 ++-- .../labkey/api/pipeline/PipelineProtocol.java | 430 +- .../api/pipeline/PipelineProtocolFactory.java | 470 +- .../labkey/api/pipeline/PipelineService.java | 550 +- .../api/pipeline/PipelineStatusFile.java | 288 +- .../labkey/api/pipeline/RecordedAction.java | 1098 +- .../api/pipeline/RecordedActionSet.java | 204 +- .../labkey/api/pipeline/WorkDirectory.java | 290 +- .../api/pipeline/browse/PipelinePathForm.java | 386 +- .../file/AbstractFileAnalysisJob.java | 950 +- .../file/AbstractFileAnalysisProtocol.java | 562 +- .../AbstractFileAnalysisProtocolFactory.java | 748 +- .../pipeline/file/FileAnalysisJobSupport.java | 348 +- .../file/FileAnalysisTaskPipeline.java | 242 +- .../labkey/api/study/SpecimenTransform.java | 174 +- api/src/org/labkey/api/util/FileType.java | 1602 +- api/src/org/labkey/api/util/FileUtil.java | 4870 ++--- ...PossiblyGZIPpedFileInputStreamFactory.java | 120 +- api/src/org/labkey/api/writer/ZipUtil.java | 408 +- .../controllers/exp/ExperimentController.java | 16742 ++++++++-------- .../pipeline/ExperimentPipelineJob.java | 354 +- .../experiment/pipeline/MoveRunsTask.java | 572 +- .../pipeline/XarGeneratorSource.java | 96 +- .../experiment/pipeline/XarGeneratorTask.java | 506 +- .../samples/AbstractExpFolderImporter.java | 2 +- .../samples/SampleStatusFolderImporter.java | 2 +- .../xar/FolderXarImporterFactory.java | 498 +- .../filecontent/FileContentServiceImpl.java | 3936 ++-- query/package-lock.json | 10541 ---------- query/src/client/Hello/BrowserApp.tsx | 601 - query/src/client/Hello/app.tsx | 10 - query/src/client/Hello/hello.tsx | 6 - query/src/client/entryPoints.js | 8 - query/tsconfig.json | 5 - .../study/controllers/StudyController.java | 15658 +++++++-------- .../importer/CreateChildStudyPipelineJob.java | 2 +- .../labkey/study/pipeline/StudyPipeline.java | 252 +- 41 files changed, 29464 insertions(+), 40635 deletions(-) delete mode 100644 query/package-lock.json delete mode 100644 query/src/client/Hello/BrowserApp.tsx delete mode 100644 query/src/client/Hello/app.tsx delete mode 100644 query/src/client/Hello/hello.tsx delete mode 100644 query/src/client/entryPoints.js delete mode 100644 query/tsconfig.json diff --git a/api/src/org/labkey/api/exp/XarContext.java b/api/src/org/labkey/api/exp/XarContext.java index 3be02f9accc..1c368d4935f 100644 --- a/api/src/org/labkey/api/exp/XarContext.java +++ b/api/src/org/labkey/api/exp/XarContext.java @@ -1,348 +1,348 @@ -/* - * Copyright (c) 2008-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.labkey.api.exp; - -import org.jetbrains.annotations.Nullable; -import org.labkey.api.collections.CaseInsensitiveHashMap; -import org.labkey.api.data.Container; -import org.labkey.api.data.ContainerManager; -import org.labkey.api.exp.api.ExpData; -import org.labkey.api.exp.api.ExpRun; -import org.labkey.api.pipeline.PipelineJob; -import org.labkey.api.pipeline.PipelineJobService; -import org.labkey.api.pipeline.RemoteExecutionEngine; -import org.labkey.api.security.User; -import org.labkey.api.settings.AppProps; -import org.labkey.api.util.GUID; -import org.labkey.api.util.NetworkDrive; -import org.labkey.api.util.Path; -import org.labkey.vfs.FileLike; - -import java.io.File; -import java.net.URI; -import java.net.URISyntaxException; -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -/** - * Helper to expand LSID templates and translate file paths from URIs during a XAR import. - * User: jeckels - * Date: Dec 7, 2006 - */ -public class XarContext -{ - private final Map _originalURLs; - private final Map _originalCaseInsensitiveURLs; - private final String _jobDescription; - private final Container _container; - private final User _user; - private PipelineJob _job; - - private final Map _substitutions; - - public static final String XAR_JOB_ID_NAME = "XarJobId"; // XarJobId is the same for all tasks in the job - public static final String XAR_JOB_ID_NAME_SUB = "${XarJobId}"; - private static final String XAR_FILE_ID_NAME = "XarFileId"; // XarFileId differs per import task for the same job - private static final String EXPERIMENT_RUN_ID_NAME = "ExperimentRun.RowId"; - private static final String CONTAINER_ID_NAME = "Container.RowId"; - private static final String SHARED_CONTAINER_ID_NAME = "SharedContainer.RowId"; - private static final String FOLDER_LSID_BASE_NAME = "FolderLSIDBase"; - private static final String RUN_LSID_BASE_NAME = "RunLSIDBase"; - private static final String LSID_AUTHORITY_NAME = "LSIDAuthority"; - - public static final String XAR_FILE_ID_SUBSTITUTION = createSubstitution(XAR_FILE_ID_NAME); - public static final String EXPERIMENT_RUN_ID_SUBSTITUTION = createSubstitution(EXPERIMENT_RUN_ID_NAME); - public static final String CONTAINER_ID_SUBSTITUTION = createSubstitution(CONTAINER_ID_NAME); - public static final String SHARED_CONTAINER_ID_SUBSTITUTION = createSubstitution(SHARED_CONTAINER_ID_NAME); - public static final String FOLDER_LSID_BASE_SUBSTITUTION = createSubstitution(FOLDER_LSID_BASE_NAME); - public static final String RUN_LSID_BASE_SUBSTITUTION = createSubstitution(RUN_LSID_BASE_NAME); - public static final String LSID_AUTHORITY_SUBSTITUTION = createSubstitution(LSID_AUTHORITY_NAME); - private static final String EXPERIMENT_RUN_LSID_NAME = "ExperimentRun.LSID"; - private static final String EXPERIMENT_RUN_NAME_NAME = "ExperimentRun.Name"; - - public XarContext(XarContext parent) - { - _jobDescription = parent._jobDescription; - _originalURLs = new HashMap<>(parent._originalURLs); - _originalCaseInsensitiveURLs = new CaseInsensitiveHashMap<>(parent._originalURLs); - _substitutions = new HashMap<>(parent._substitutions); - _container = parent.getContainer(); - _user = parent.getUser(); - } - - public XarContext(PipelineJob job) - { - this(job.getDescription(), job.getContainer(), job.getUser(), job); - } - - public XarContext(String jobDescription, Container c, User user) - { - this(jobDescription, c, user, null); - } - - public XarContext(String jobDescription, Container c, User user, @Nullable PipelineJob job) - { - this(jobDescription, c, user, job, AppProps.getInstance().getDefaultLsidAuthority(), null); - } - - public XarContext(String jobDescription, Container c, User user, @Nullable PipelineJob job, String defaultLsidAuthority) - { - this(jobDescription, c, user, job, defaultLsidAuthority, null); - } - - public XarContext(String jobDescription, Container c, User user, @Nullable PipelineJob job, @Nullable Map substitutions) - { - this(jobDescription, c, user, job, AppProps.getInstance().getDefaultLsidAuthority(), substitutions); - } - - public XarContext(String jobDescription, Container c, User user, @Nullable PipelineJob job, String defaultLsidAuthority, @Nullable Map substitutions) - { - _jobDescription = jobDescription; - _originalURLs = new HashMap<>(); - _originalCaseInsensitiveURLs = new CaseInsensitiveHashMap<>(); - _substitutions = new HashMap<>(); - if (substitutions != null) - _substitutions.putAll(substitutions); - - _job = job; - - String path = c.getPath(); - if (path.startsWith("/")) - { - path = path.substring(1); - } - path = path.replace('/', '.'); - - _substitutions.put("Container.path", path); - _substitutions.put(CONTAINER_ID_NAME, Integer.toString(c.getRowId())); - _substitutions.put(SHARED_CONTAINER_ID_NAME, Integer.toString(ContainerManager.getSharedContainer().getRowId())); - - _substitutions.put(XAR_FILE_ID_NAME, "Xar-" + GUID.makeGUID()); - if (user != null) - { - _substitutions.put("UserEmail", user.getEmail()); - _substitutions.put("UserName", user.getFullName()); - } - _substitutions.put(FOLDER_LSID_BASE_NAME, "urn:lsid:" + LSID_AUTHORITY_SUBSTITUTION + ":${LSIDNamespace.Prefix}.Folder-" + CONTAINER_ID_SUBSTITUTION); - _substitutions.put(RUN_LSID_BASE_NAME, "urn:lsid:" + LSID_AUTHORITY_SUBSTITUTION + ":${LSIDNamespace.Prefix}.Run-" + EXPERIMENT_RUN_ID_SUBSTITUTION); - - _substitutions.put(LSID_AUTHORITY_NAME, defaultLsidAuthority); - - _container = c; - _user = user; - } - - public String getJobDescription() - { - return _jobDescription; - } - - public void addData(ExpData data, String originalURL) - { - originalURL = originalURL.replace('\\', '/'); - _originalURLs.put(originalURL, data); - _originalCaseInsensitiveURLs.put(originalURL, data); - - if (originalURL.startsWith("file:/") && originalURL.length() > "file:/X:/".length()) - { - int index = "file:/".length(); - if (Character.isLetter(originalURL.charAt(index++)) && - ':' == originalURL.charAt(index++) && - '/' == originalURL.charAt(index++)) - { - String originalWithoutDriveLetter = "file:/" + originalURL.substring(index); - _originalURLs.put(originalWithoutDriveLetter, data); - _originalCaseInsensitiveURLs.put(originalWithoutDriveLetter, data); - } - } - } - - private static final Pattern CYGDRIVE_PATTERN = Pattern.compile("/cygdrive/([a-z])/(.*)"); - - public File findFile(String path, File relativeFile) - { - File f = findFile(path); - if (f != null) - { - return f; - } - - // If file can't be reached and it doesn't already have a drive letter, then attempt to append - // the drive letter, if any, of the relativeFile - if (null == NetworkDrive.getDrive(path)) - { - String drivePrefix = NetworkDrive.getDrive(relativeFile.toString()); - String pathWithDrive = path; - if (null != drivePrefix) - { - if (!path.isEmpty() && path.charAt(0) != '\\' && path.charAt(0) != '/') - { - pathWithDrive = drivePrefix + "/" + path; - } - else - { - pathWithDrive = drivePrefix + path; - } - } - - f = new File(pathWithDrive); - if (NetworkDrive.exists(f)) - { - return f; - } - } - - f = new File(relativeFile, path); - if (NetworkDrive.exists(f)) - { - return f; - } - - // Check if it's in the current directory, stripping off any extra path from the file name - int index = path.lastIndexOf("/"); - f = resolveFile(path, relativeFile, index); - if (f != null) return f; - - // Do the same for Windows paths - index = path.lastIndexOf("\\"); - f = resolveFile(path, relativeFile, index); - if (f != null) return f; - - // Finally, try using the pipeline's path mapper if we have one to - // translate from a cluster path to a webserver path - // Path mappers deal with URIs, not file paths - String uri = "file:" + (path.startsWith("/") ? path : "/" + path); - // This PathMapper considers "local" from a cluster node's point of view. - for (RemoteExecutionEngine engine : PipelineJobService.get().getRemoteExecutionEngines()) - { - String mappedURI = engine.getConfig().getPathMapper().localToRemote(uri); - // If we have translated Windows paths, they won't be legal URIs, so convert slashes - mappedURI = mappedURI.replace('\\', '/'); - try - { - f = new File(new URI(mappedURI)); - if (NetworkDrive.exists(f)) - { - return f; - } - } - catch (URISyntaxException ignored) {} - } - - return null; - } - - @Nullable - private File resolveFile(String path, File relativeFile, int index) - { - File f; - if (index != -1) - { - String filename = path.substring(index + 1); - if (!filename.isEmpty()) - { - f = new File(relativeFile, filename); - if (NetworkDrive.exists(f)) - { - return f; - } - } - } - return null; - } - - public File findFile(String path) - { - String lookupPath = path; - if (!lookupPath.contains(":/")) - { - lookupPath = "file:/" + lookupPath; - } - - // First, check if the XAR contains a file that was originally at that path - lookupPath = lookupPath.replace('\\', '/'); - ExpData data = _originalURLs.get(lookupPath); - if (data != null) - { - return data.getFile(); - } - - // Second, try looking for a case-insensitive match - data = _originalCaseInsensitiveURLs.get(lookupPath); - if (data != null) - { - return data.getFile(); - } - - // Next, check if the file exists on the file system at that exact location - File f = new File(path); - if (NetworkDrive.exists(f)) - { - return f; - } - - // Try resolving the Cygwin paths like /cygdrive/c/somepath/somefile.extension - // to c:/somepath/somefile.extension - Matcher matcher = CYGDRIVE_PATTERN.matcher(path); - if (matcher.matches()) - { - return findFile(matcher.group(1) + ":/" + matcher.group(2)); - } - - return null; - } - - public Map getSubstitutions() - { - return Collections.unmodifiableMap(_substitutions); - } - - public void addSubstitution(String name, String value) - { - _substitutions.put(name, value); - } - - public void setCurrentRun(ExpRun run) - { - addSubstitution(EXPERIMENT_RUN_ID_NAME, Long.toString(run.getRowId())); - addSubstitution(EXPERIMENT_RUN_LSID_NAME, run.getLSID()); - addSubstitution(EXPERIMENT_RUN_NAME_NAME, run.getName()); - } - - public static String createSubstitution(String name) - { - return "${" + name + "}"; - } - - public Container getContainer() - { - return _container; - } - - public User getUser() - { - return _user; - } - - public @Nullable PipelineJob getJob() - { - return _job; - } -} +/* + * Copyright (c) 2008-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.labkey.api.exp; + +import org.jetbrains.annotations.Nullable; +import org.labkey.api.collections.CaseInsensitiveHashMap; +import org.labkey.api.data.Container; +import org.labkey.api.data.ContainerManager; +import org.labkey.api.exp.api.ExpData; +import org.labkey.api.exp.api.ExpRun; +import org.labkey.api.pipeline.PipelineJob; +import org.labkey.api.pipeline.PipelineJobService; +import org.labkey.api.pipeline.RemoteExecutionEngine; +import org.labkey.api.security.User; +import org.labkey.api.settings.AppProps; +import org.labkey.api.util.GUID; +import org.labkey.api.util.NetworkDrive; +import org.labkey.api.util.Path; +import org.labkey.vfs.FileLike; + +import java.io.File; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Helper to expand LSID templates and translate file paths from URIs during a XAR import. + * User: jeckels + * Date: Dec 7, 2006 + */ +public class XarContext +{ + private final Map _originalURLs; + private final Map _originalCaseInsensitiveURLs; + private final String _jobDescription; + private final Container _container; + private final User _user; + private PipelineJob _job; + + private final Map _substitutions; + + public static final String XAR_JOB_ID_NAME = "XarJobId"; // XarJobId is the same for all tasks in the job + public static final String XAR_JOB_ID_NAME_SUB = "${XarJobId}"; + private static final String XAR_FILE_ID_NAME = "XarFileId"; // XarFileId differs per import task for the same job + private static final String EXPERIMENT_RUN_ID_NAME = "ExperimentRun.RowId"; + private static final String CONTAINER_ID_NAME = "Container.RowId"; + private static final String SHARED_CONTAINER_ID_NAME = "SharedContainer.RowId"; + private static final String FOLDER_LSID_BASE_NAME = "FolderLSIDBase"; + private static final String RUN_LSID_BASE_NAME = "RunLSIDBase"; + private static final String LSID_AUTHORITY_NAME = "LSIDAuthority"; + + public static final String XAR_FILE_ID_SUBSTITUTION = createSubstitution(XAR_FILE_ID_NAME); + public static final String EXPERIMENT_RUN_ID_SUBSTITUTION = createSubstitution(EXPERIMENT_RUN_ID_NAME); + public static final String CONTAINER_ID_SUBSTITUTION = createSubstitution(CONTAINER_ID_NAME); + public static final String SHARED_CONTAINER_ID_SUBSTITUTION = createSubstitution(SHARED_CONTAINER_ID_NAME); + public static final String FOLDER_LSID_BASE_SUBSTITUTION = createSubstitution(FOLDER_LSID_BASE_NAME); + public static final String RUN_LSID_BASE_SUBSTITUTION = createSubstitution(RUN_LSID_BASE_NAME); + public static final String LSID_AUTHORITY_SUBSTITUTION = createSubstitution(LSID_AUTHORITY_NAME); + private static final String EXPERIMENT_RUN_LSID_NAME = "ExperimentRun.LSID"; + private static final String EXPERIMENT_RUN_NAME_NAME = "ExperimentRun.Name"; + + public XarContext(XarContext parent) + { + _jobDescription = parent._jobDescription; + _originalURLs = new HashMap<>(parent._originalURLs); + _originalCaseInsensitiveURLs = new CaseInsensitiveHashMap<>(parent._originalURLs); + _substitutions = new HashMap<>(parent._substitutions); + _container = parent.getContainer(); + _user = parent.getUser(); + } + + public XarContext(PipelineJob job) + { + this(job.getDescription(), job.getContainer(), job.getUser(), job); + } + + public XarContext(String jobDescription, Container c, User user) + { + this(jobDescription, c, user, null); + } + + public XarContext(String jobDescription, Container c, User user, @Nullable PipelineJob job) + { + this(jobDescription, c, user, job, AppProps.getInstance().getDefaultLsidAuthority(), null); + } + + public XarContext(String jobDescription, Container c, User user, @Nullable PipelineJob job, String defaultLsidAuthority) + { + this(jobDescription, c, user, job, defaultLsidAuthority, null); + } + + public XarContext(String jobDescription, Container c, User user, @Nullable PipelineJob job, @Nullable Map substitutions) + { + this(jobDescription, c, user, job, AppProps.getInstance().getDefaultLsidAuthority(), substitutions); + } + + public XarContext(String jobDescription, Container c, User user, @Nullable PipelineJob job, String defaultLsidAuthority, @Nullable Map substitutions) + { + _jobDescription = jobDescription; + _originalURLs = new HashMap<>(); + _originalCaseInsensitiveURLs = new CaseInsensitiveHashMap<>(); + _substitutions = new HashMap<>(); + if (substitutions != null) + _substitutions.putAll(substitutions); + + _job = job; + + String path = c.getPath(); + if (path.startsWith("/")) + { + path = path.substring(1); + } + path = path.replace('/', '.'); + + _substitutions.put("Container.path", path); + _substitutions.put(CONTAINER_ID_NAME, Integer.toString(c.getRowId())); + _substitutions.put(SHARED_CONTAINER_ID_NAME, Integer.toString(ContainerManager.getSharedContainer().getRowId())); + + _substitutions.put(XAR_FILE_ID_NAME, "Xar-" + GUID.makeGUID()); + if (user != null) + { + _substitutions.put("UserEmail", user.getEmail()); + _substitutions.put("UserName", user.getFullName()); + } + _substitutions.put(FOLDER_LSID_BASE_NAME, "urn:lsid:" + LSID_AUTHORITY_SUBSTITUTION + ":${LSIDNamespace.Prefix}.Folder-" + CONTAINER_ID_SUBSTITUTION); + _substitutions.put(RUN_LSID_BASE_NAME, "urn:lsid:" + LSID_AUTHORITY_SUBSTITUTION + ":${LSIDNamespace.Prefix}.Run-" + EXPERIMENT_RUN_ID_SUBSTITUTION); + + _substitutions.put(LSID_AUTHORITY_NAME, defaultLsidAuthority); + + _container = c; + _user = user; + } + + public String getJobDescription() + { + return _jobDescription; + } + + public void addData(ExpData data, String originalURL) + { + originalURL = originalURL.replace('\\', '/'); + _originalURLs.put(originalURL, data); + _originalCaseInsensitiveURLs.put(originalURL, data); + + if (originalURL.startsWith("file:/") && originalURL.length() > "file:/X:/".length()) + { + int index = "file:/".length(); + if (Character.isLetter(originalURL.charAt(index++)) && + ':' == originalURL.charAt(index++) && + '/' == originalURL.charAt(index++)) + { + String originalWithoutDriveLetter = "file:/" + originalURL.substring(index); + _originalURLs.put(originalWithoutDriveLetter, data); + _originalCaseInsensitiveURLs.put(originalWithoutDriveLetter, data); + } + } + } + + private static final Pattern CYGDRIVE_PATTERN = Pattern.compile("/cygdrive/([a-z])/(.*)"); + + public File findFile(String path, File relativeFile) + { + File f = findFile(path); + if (f != null) + { + return f; + } + + // If file can't be reached and it doesn't already have a drive letter, then attempt to append + // the drive letter, if any, of the relativeFile + if (null == NetworkDrive.getDrive(path)) + { + String drivePrefix = NetworkDrive.getDrive(relativeFile.toString()); + String pathWithDrive = path; + if (null != drivePrefix) + { + if (!path.isEmpty() && path.charAt(0) != '\\' && path.charAt(0) != '/') + { + pathWithDrive = drivePrefix + "/" + path; + } + else + { + pathWithDrive = drivePrefix + path; + } + } + + f = new File(pathWithDrive); + if (NetworkDrive.exists(f)) + { + return f; + } + } + + f = new File(relativeFile, path); + if (NetworkDrive.exists(f)) + { + return f; + } + + // Check if it's in the current directory, stripping off any extra path from the file name + int index = path.lastIndexOf("/"); + f = resolveFile(path, relativeFile, index); + if (f != null) return f; + + // Do the same for Windows paths + index = path.lastIndexOf("\\"); + f = resolveFile(path, relativeFile, index); + if (f != null) return f; + + // Finally, try using the pipeline's path mapper if we have one to + // translate from a cluster path to a webserver path + // Path mappers deal with URIs, not file paths + String uri = "file:" + (path.startsWith("/") ? path : "/" + path); + // This PathMapper considers "local" from a cluster node's point of view. + for (RemoteExecutionEngine engine : PipelineJobService.get().getRemoteExecutionEngines()) + { + String mappedURI = engine.getConfig().getPathMapper().localToRemote(uri); + // If we have translated Windows paths, they won't be legal URIs, so convert slashes + mappedURI = mappedURI.replace('\\', '/'); + try + { + f = new File(new URI(mappedURI)); + if (NetworkDrive.exists(f)) + { + return f; + } + } + catch (URISyntaxException ignored) {} + } + + return null; + } + + @Nullable + private File resolveFile(String path, File relativeFile, int index) + { + File f; + if (index != -1) + { + String filename = path.substring(index + 1); + if (!filename.isEmpty()) + { + f = new File(relativeFile, filename); + if (NetworkDrive.exists(f)) + { + return f; + } + } + } + return null; + } + + public File findFile(String path) + { + String lookupPath = path; + if (!lookupPath.contains(":/")) + { + lookupPath = "file:/" + lookupPath; + } + + // First, check if the XAR contains a file that was originally at that path + lookupPath = lookupPath.replace('\\', '/'); + ExpData data = _originalURLs.get(lookupPath); + if (data != null) + { + return data.getFile(); + } + + // Second, try looking for a case-insensitive match + data = _originalCaseInsensitiveURLs.get(lookupPath); + if (data != null) + { + return data.getFile(); + } + + // Next, check if the file exists on the file system at that exact location + File f = new File(path); + if (NetworkDrive.exists(f)) + { + return f; + } + + // Try resolving the Cygwin paths like /cygdrive/c/somepath/somefile.extension + // to c:/somepath/somefile.extension + Matcher matcher = CYGDRIVE_PATTERN.matcher(path); + if (matcher.matches()) + { + return findFile(matcher.group(1) + ":/" + matcher.group(2)); + } + + return null; + } + + public Map getSubstitutions() + { + return Collections.unmodifiableMap(_substitutions); + } + + public void addSubstitution(String name, String value) + { + _substitutions.put(name, value); + } + + public void setCurrentRun(ExpRun run) + { + addSubstitution(EXPERIMENT_RUN_ID_NAME, Long.toString(run.getRowId())); + addSubstitution(EXPERIMENT_RUN_LSID_NAME, run.getLSID()); + addSubstitution(EXPERIMENT_RUN_NAME_NAME, run.getName()); + } + + public static String createSubstitution(String name) + { + return "${" + name + "}"; + } + + public Container getContainer() + { + return _container; + } + + public User getUser() + { + return _user; + } + + public @Nullable PipelineJob getJob() + { + return _job; + } +} diff --git a/api/src/org/labkey/api/exp/XarSource.java b/api/src/org/labkey/api/exp/XarSource.java index de8c4e90b6c..b3e4475bfe4 100644 --- a/api/src/org/labkey/api/exp/XarSource.java +++ b/api/src/org/labkey/api/exp/XarSource.java @@ -1,295 +1,295 @@ -/* - * Copyright (c) 2008-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.api.exp; - -import org.apache.xmlbeans.XmlException; -import org.fhcrc.cpas.exp.xml.ExperimentArchiveDocument; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.labkey.api.data.Container; -import org.labkey.api.exp.api.ExpData; -import org.labkey.api.exp.api.ExpDataClass; -import org.labkey.api.exp.api.ExpMaterial; -import org.labkey.api.exp.api.ExpProtocol; -import org.labkey.api.exp.api.ExpProtocolApplication; -import org.labkey.api.exp.api.ExpRun; -import org.labkey.api.exp.api.ExpSampleType; -import org.labkey.api.exp.api.ExperimentService; -import org.labkey.api.pipeline.PipeRoot; -import org.labkey.api.pipeline.PipelineJob; -import org.labkey.api.security.User; -import org.labkey.api.util.FileUtil; -import org.labkey.vfs.FileLike; - -import java.io.IOException; -import java.io.Serializable; -import java.net.URI; -import java.net.URISyntaxException; -import java.nio.file.Path; -import java.util.HashMap; -import java.util.Map; - -/** - * Something that knows how to a produce a XAR (experiment archive), whether it's a from an existing file or - * being dynamically generated on demand. - * User: jeckels - * Date: Oct 14, 2005 - */ -public abstract class XarSource implements Serializable -{ - public static final String LOG_FILE_NAME_SUFFIX = ".log"; - - private Long _experimentRunId; - private final Map _xarProtocols = new HashMap<>(); - private final Map _databaseProtocols = new HashMap<>(); - private final Map> _materials = new HashMap<>(); - private final Map> _data = new HashMap<>(); - - private final Map _xarSampleTypes = new HashMap<>(); - private final Map _xarDataClasses = new HashMap<>(); - - protected final Map _dataFileURLs = new HashMap<>(); - - @NotNull - private final XarContext _xarContext; - - public XarSource(String description, Container container, User user, @Nullable PipelineJob job) - { - _xarContext = new XarContext(description, container, user, job); - } - - public XarSource(String description, Container container, User user, @Nullable PipelineJob job, @Nullable Map substitutions) - { - _xarContext = new XarContext(description, container, user, job, substitutions); - } - - public XarSource(PipelineJob job) - { - _xarContext = new XarContext(job); - } - - public abstract ExperimentArchiveDocument getDocument() throws XmlException, IOException; - - public abstract Path getRootPath(); - - public Path getJobRootPath() { return getRootPath(); } - - /** - * Should be true if this was uploaded XML that was not part of a full XAR - */ - public abstract boolean shouldIgnoreDataFiles(); - - /** - * Transforms the dataFileURL, which may be relative, to a canonical, absolute URI - */ - public final String getCanonicalDataFileURL(String dataFileURL) throws XarFormatException - { - if (dataFileURL == null) - { - return null; - } - String result = _dataFileURLs.get(dataFileURL); - if (result == null) - { - String urlToLookup = dataFileURL; - try - { - URI uri = new URI(dataFileURL); - if (FileUtil.FILE_SCHEME.equalsIgnoreCase(uri.getScheme()) || FileUtil.hasCloudScheme(uri)) - { - urlToLookup = FileUtil.uriToString(uri); - } - } - catch (IllegalArgumentException | URISyntaxException ignored) {} - result = canonicalizeDataFileURL(urlToLookup); - _dataFileURLs.put(dataFileURL, result); - _dataFileURLs.put(urlToLookup, result); - } - return result; - } - - protected abstract String canonicalizeDataFileURL(String dataFileURL) throws XarFormatException; - - public abstract FileLike getLogFilePath() throws IOException; - - /** - * Called before trying to import this XAR to let the source set up any resources that are required - */ - public void init() throws IOException, ExperimentException - { - } - - public void setExperimentRunRowId(Long experimentRowId) - { - _experimentRunId = experimentRowId; - } - - public ExpRun getExperimentRun() - { - if (_experimentRunId != null) - { - return ExperimentService.get().getExpRun(_experimentRunId.intValue()); - } - return null; - } - - public void addData(String experimentRunLSID, ExpData data, @Nullable String additionalDataLSID) - { - Map map = _data.computeIfAbsent(experimentRunLSID, k -> new HashMap<>()); - map.put(data.getLSID(), data); - if (additionalDataLSID != null) - { - map.put(additionalDataLSID, data); - } - } - - public void addMaterial(String experimentRunLSID, ExpMaterial material, @Nullable String additionalMaterialLSID) - { - Map map = _materials.computeIfAbsent(experimentRunLSID, k -> new HashMap<>()); - map.put(material.getLSID(), material); - if (additionalMaterialLSID != null) - { - map.put(additionalMaterialLSID, material); - } - } - - public ExpData getData(ExpRun experimentRun, ExpProtocolApplication protApp, String dataLSID) throws XarFormatException - { - String experimentRunLSID = experimentRun == null ? null : experimentRun.getLSID(); - Map map = _data.computeIfAbsent(experimentRunLSID, k -> new HashMap<>()); - ExpData result = map.get(dataLSID); - if (result == null) - { - if (experimentRun == null) - { - result = ExperimentService.get().getExpData(dataLSID); - } - if (result == null) - { - // Try for a non-run scoped variant - result = _data.computeIfAbsent(null, k -> new HashMap<>()).get(dataLSID); - } - if (result == null) - { - throw new XarFormatException(createIllegalReferenceMessage(experimentRun, protApp, dataLSID, ExpData.DEFAULT_CPAS_TYPE)); - } - map.put(result.getLSID(), result); - } - return result; - } - - private String createIllegalReferenceMessage(ExpRun experimentRun, ExpProtocolApplication protApp, String lsid, String type) - { - String message = "Illegal reference to " + type + " '" + lsid + "'"; - if (protApp != null) - { - message += " from ProtocolApplication '" + protApp.getLSID() + "'"; - } - if (experimentRun != null) - { - message += " in ExperimentRun '" + experimentRun.getLSID() + "'"; - } - return message; - } - - - public ExpMaterial getMaterial(ExpRun experimentRun, ExpProtocolApplication protApp, String materialLSID) throws XarFormatException - { - String experimentRunLSID = experimentRun == null ? null : experimentRun.getLSID(); - Map map = _materials.computeIfAbsent(experimentRunLSID, k -> new HashMap<>()); - ExpMaterial result = map.get(materialLSID); - if (result == null) - { - // Try for a non-run scoped variant - result = _materials.computeIfAbsent(null, k -> new HashMap<>()).get(materialLSID); - if (null == result) - { - result = ExperimentService.get().getExpMaterial(materialLSID); - } - if (result == null) - { - throw new XarFormatException(createIllegalReferenceMessage(experimentRun, protApp, materialLSID, ExpMaterial.DEFAULT_CPAS_TYPE)); - } - map.put(result.getLSID(), result); - } - return result; - } - - - public void addProtocol(ExpProtocol protocol) - { - _xarProtocols.put(protocol.getLSID(), protocol); - } - - public ExpProtocol getProtocol(String lsid, String errorDescription) throws XarFormatException - { - ExpProtocol result = _xarProtocols.get(lsid); - if (result == null) - { - result = _databaseProtocols.get(lsid); - } - if (result == null) - { - result = ExperimentService.get().getExpProtocol(lsid); - _databaseProtocols.put(lsid, result); - } - if (result == null) - { - throw new XarFormatException("Could not find " + errorDescription + " protocol with LSID " + lsid); - } - return result; - } - - public boolean allowImport(PipeRoot pr, Container container, Path path) - { - try - { - return (pr != null && pr.isUnderRoot(path)) || - (!FileUtil.pathToString(path).equalsIgnoreCase(FileUtil.relativizeUnix(getRootPath(), path, true))); - } - catch (IOException e) - { - return false; - } - } - - @NotNull - public XarContext getXarContext() - { - return _xarContext; - } - - public void addSampleType(String sampleTypeLSID, ExpSampleType sampleType) - { - _xarSampleTypes.put(sampleTypeLSID, sampleType); - } - - public ExpSampleType getSampleType(String sampleTypeLSID) - { - return _xarSampleTypes.get(sampleTypeLSID); - } - - public void addDataClass(String sampleTypeLSID, ExpDataClass dataClass) - { - _xarDataClasses.put(sampleTypeLSID, dataClass); - } - - public ExpDataClass getDataClass(String sampleTypeLSID) - { - return _xarDataClasses.get(sampleTypeLSID); - } - -} +/* + * Copyright (c) 2008-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.api.exp; + +import org.apache.xmlbeans.XmlException; +import org.fhcrc.cpas.exp.xml.ExperimentArchiveDocument; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.labkey.api.data.Container; +import org.labkey.api.exp.api.ExpData; +import org.labkey.api.exp.api.ExpDataClass; +import org.labkey.api.exp.api.ExpMaterial; +import org.labkey.api.exp.api.ExpProtocol; +import org.labkey.api.exp.api.ExpProtocolApplication; +import org.labkey.api.exp.api.ExpRun; +import org.labkey.api.exp.api.ExpSampleType; +import org.labkey.api.exp.api.ExperimentService; +import org.labkey.api.pipeline.PipeRoot; +import org.labkey.api.pipeline.PipelineJob; +import org.labkey.api.security.User; +import org.labkey.api.util.FileUtil; +import org.labkey.vfs.FileLike; + +import java.io.IOException; +import java.io.Serializable; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; + +/** + * Something that knows how to a produce a XAR (experiment archive), whether it's a from an existing file or + * being dynamically generated on demand. + * User: jeckels + * Date: Oct 14, 2005 + */ +public abstract class XarSource implements Serializable +{ + public static final String LOG_FILE_NAME_SUFFIX = ".log"; + + private Long _experimentRunId; + private final Map _xarProtocols = new HashMap<>(); + private final Map _databaseProtocols = new HashMap<>(); + private final Map> _materials = new HashMap<>(); + private final Map> _data = new HashMap<>(); + + private final Map _xarSampleTypes = new HashMap<>(); + private final Map _xarDataClasses = new HashMap<>(); + + protected final Map _dataFileURLs = new HashMap<>(); + + @NotNull + private final XarContext _xarContext; + + public XarSource(String description, Container container, User user, @Nullable PipelineJob job) + { + _xarContext = new XarContext(description, container, user, job); + } + + public XarSource(String description, Container container, User user, @Nullable PipelineJob job, @Nullable Map substitutions) + { + _xarContext = new XarContext(description, container, user, job, substitutions); + } + + public XarSource(PipelineJob job) + { + _xarContext = new XarContext(job); + } + + public abstract ExperimentArchiveDocument getDocument() throws XmlException, IOException; + + public abstract Path getRootPath(); + + public Path getJobRootPath() { return getRootPath(); } + + /** + * Should be true if this was uploaded XML that was not part of a full XAR + */ + public abstract boolean shouldIgnoreDataFiles(); + + /** + * Transforms the dataFileURL, which may be relative, to a canonical, absolute URI + */ + public final String getCanonicalDataFileURL(String dataFileURL) throws XarFormatException + { + if (dataFileURL == null) + { + return null; + } + String result = _dataFileURLs.get(dataFileURL); + if (result == null) + { + String urlToLookup = dataFileURL; + try + { + URI uri = new URI(dataFileURL); + if (FileUtil.FILE_SCHEME.equalsIgnoreCase(uri.getScheme()) || FileUtil.hasCloudScheme(uri)) + { + urlToLookup = FileUtil.uriToString(uri); + } + } + catch (IllegalArgumentException | URISyntaxException ignored) {} + result = canonicalizeDataFileURL(urlToLookup); + _dataFileURLs.put(dataFileURL, result); + _dataFileURLs.put(urlToLookup, result); + } + return result; + } + + protected abstract String canonicalizeDataFileURL(String dataFileURL) throws XarFormatException; + + public abstract FileLike getLogFilePath() throws IOException; + + /** + * Called before trying to import this XAR to let the source set up any resources that are required + */ + public void init() throws IOException, ExperimentException + { + } + + public void setExperimentRunRowId(Long experimentRowId) + { + _experimentRunId = experimentRowId; + } + + public ExpRun getExperimentRun() + { + if (_experimentRunId != null) + { + return ExperimentService.get().getExpRun(_experimentRunId.intValue()); + } + return null; + } + + public void addData(String experimentRunLSID, ExpData data, @Nullable String additionalDataLSID) + { + Map map = _data.computeIfAbsent(experimentRunLSID, k -> new HashMap<>()); + map.put(data.getLSID(), data); + if (additionalDataLSID != null) + { + map.put(additionalDataLSID, data); + } + } + + public void addMaterial(String experimentRunLSID, ExpMaterial material, @Nullable String additionalMaterialLSID) + { + Map map = _materials.computeIfAbsent(experimentRunLSID, k -> new HashMap<>()); + map.put(material.getLSID(), material); + if (additionalMaterialLSID != null) + { + map.put(additionalMaterialLSID, material); + } + } + + public ExpData getData(ExpRun experimentRun, ExpProtocolApplication protApp, String dataLSID) throws XarFormatException + { + String experimentRunLSID = experimentRun == null ? null : experimentRun.getLSID(); + Map map = _data.computeIfAbsent(experimentRunLSID, k -> new HashMap<>()); + ExpData result = map.get(dataLSID); + if (result == null) + { + if (experimentRun == null) + { + result = ExperimentService.get().getExpData(dataLSID); + } + if (result == null) + { + // Try for a non-run scoped variant + result = _data.computeIfAbsent(null, k -> new HashMap<>()).get(dataLSID); + } + if (result == null) + { + throw new XarFormatException(createIllegalReferenceMessage(experimentRun, protApp, dataLSID, ExpData.DEFAULT_CPAS_TYPE)); + } + map.put(result.getLSID(), result); + } + return result; + } + + private String createIllegalReferenceMessage(ExpRun experimentRun, ExpProtocolApplication protApp, String lsid, String type) + { + String message = "Illegal reference to " + type + " '" + lsid + "'"; + if (protApp != null) + { + message += " from ProtocolApplication '" + protApp.getLSID() + "'"; + } + if (experimentRun != null) + { + message += " in ExperimentRun '" + experimentRun.getLSID() + "'"; + } + return message; + } + + + public ExpMaterial getMaterial(ExpRun experimentRun, ExpProtocolApplication protApp, String materialLSID) throws XarFormatException + { + String experimentRunLSID = experimentRun == null ? null : experimentRun.getLSID(); + Map map = _materials.computeIfAbsent(experimentRunLSID, k -> new HashMap<>()); + ExpMaterial result = map.get(materialLSID); + if (result == null) + { + // Try for a non-run scoped variant + result = _materials.computeIfAbsent(null, k -> new HashMap<>()).get(materialLSID); + if (null == result) + { + result = ExperimentService.get().getExpMaterial(materialLSID); + } + if (result == null) + { + throw new XarFormatException(createIllegalReferenceMessage(experimentRun, protApp, materialLSID, ExpMaterial.DEFAULT_CPAS_TYPE)); + } + map.put(result.getLSID(), result); + } + return result; + } + + + public void addProtocol(ExpProtocol protocol) + { + _xarProtocols.put(protocol.getLSID(), protocol); + } + + public ExpProtocol getProtocol(String lsid, String errorDescription) throws XarFormatException + { + ExpProtocol result = _xarProtocols.get(lsid); + if (result == null) + { + result = _databaseProtocols.get(lsid); + } + if (result == null) + { + result = ExperimentService.get().getExpProtocol(lsid); + _databaseProtocols.put(lsid, result); + } + if (result == null) + { + throw new XarFormatException("Could not find " + errorDescription + " protocol with LSID " + lsid); + } + return result; + } + + public boolean allowImport(PipeRoot pr, Container container, Path path) + { + try + { + return (pr != null && pr.isUnderRoot(path)) || + (!FileUtil.pathToString(path).equalsIgnoreCase(FileUtil.relativizeUnix(getRootPath(), path, true))); + } + catch (IOException e) + { + return false; + } + } + + @NotNull + public XarContext getXarContext() + { + return _xarContext; + } + + public void addSampleType(String sampleTypeLSID, ExpSampleType sampleType) + { + _xarSampleTypes.put(sampleTypeLSID, sampleType); + } + + public ExpSampleType getSampleType(String sampleTypeLSID) + { + return _xarSampleTypes.get(sampleTypeLSID); + } + + public void addDataClass(String sampleTypeLSID, ExpDataClass dataClass) + { + _xarDataClasses.put(sampleTypeLSID, dataClass); + } + + public ExpDataClass getDataClass(String sampleTypeLSID) + { + return _xarDataClasses.get(sampleTypeLSID); + } + +} diff --git a/api/src/org/labkey/api/files/FileContentService.java b/api/src/org/labkey/api/files/FileContentService.java index 14a04bab81e..ee10724bdd4 100644 --- a/api/src/org/labkey/api/files/FileContentService.java +++ b/api/src/org/labkey/api/files/FileContentService.java @@ -1,364 +1,364 @@ -/* - * Copyright (c) 2009-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.labkey.api.files; - -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.labkey.api.attachments.AttachmentDirectory; -import org.labkey.api.data.Container; -import org.labkey.api.data.SQLFragment; -import org.labkey.api.data.TableInfo; -import org.labkey.api.exp.api.DataType; -import org.labkey.api.exp.api.ExpData; -import org.labkey.api.exp.query.ExpDataTable; -import org.labkey.api.query.QueryUpdateService; -import org.labkey.api.security.User; -import org.labkey.api.services.ServiceRegistry; -import org.labkey.api.util.FileUtil; -import org.labkey.api.webdav.WebdavResource; -import org.labkey.vfs.FileLike; - -import java.io.File; -import java.net.URI; -import java.nio.file.Path; -import java.util.Collection; -import java.util.List; -import java.util.Map; - -/** - * User: klum - * Date: Dec 9, 2009 - */ -public interface FileContentService -{ - String UPLOADED_FILE_NAMESPACE_PREFIX = "UploadedFile"; - DataType UPLOADED_FILE = new DataType(UPLOADED_FILE_NAMESPACE_PREFIX); - - String FILES_LINK = "@files"; - String FILE_SETS_LINK = "@filesets"; - String PIPELINE_LINK = "@pipeline"; - String SCRIPTS_LINK = "@scripts"; - String CLOUD_LINK = "@cloud"; - String ASSAY_FILES = "@assayfiles"; - - String CLOUD_ROOT_PREFIX = "/@cloud"; - - static @Nullable FileContentService get() - { - return ServiceRegistry.get().getService(FileContentService.class); - } - - static void setInstance(FileContentService impl) - { - ServiceRegistry.get().registerService(FileContentService.class, impl); - } - - /** - * Returns a list of Container in which the path resides. - */ - @NotNull - List getContainersForFilePath(java.nio.file.Path path); - - /** - * Returns the file root of the specified container. If not explicitly defined, - * it will default to a path relative to the first parent container with an override - */ - @Nullable - File getFileRoot(@NotNull Container c); - - @Nullable - java.nio.file.Path getFileRootPath(@NotNull Container c); - - /** - * Returns the file root of the specified content type for a container - */ - @Nullable - File getFileRoot(@NotNull Container c, @NotNull ContentType type); - - @Nullable - java.nio.file.Path getFileRootPath(@NotNull Container c, @NotNull ContentType type); - - @Nullable - URI getFileRootUri(@NotNull Container c, @NotNull ContentType type, @Nullable String filePath); - - void setFileRoot(@NotNull Container c, @Nullable File root); - - void setFileRootPath(@NotNull Container c, @Nullable String root); - - void setCloudRoot(@NotNull Container c, String cloudRootName); - - boolean isCloudRoot(Container container); - - String getCloudRootName(Container c); - - void disableFileRoot(Container container); - - boolean isFileRootDisabled(Container container); - - /** - * A file root can use a default root based on a single site wide root that mirrors the folder structure of - * a project. - */ - boolean isUseDefaultRoot(Container container); - - void setIsUseDefaultRoot(Container container, boolean useDefaultRoot); - - - @NotNull - File getSiteDefaultRoot(); - - @NotNull - Path getSiteDefaultRootPath(); - - @Nullable - String getProblematicFileRootMessage(); - - void setSiteDefaultRoot(File root, User user); - - void setFileRootSetViaStartupProperty(boolean fileRootSetViaStartupProperty); - - boolean isFileRootSetViaStartupProperty(); - - /** - * Create an attachmentParent object that will allow storing files in the file system - * - * @param c Container this will be attached to - * @param name Name of the parent used in getMappedAttachmentDirectory - * @param path Path to the file. If relative is true, this is the name of a subdirectory of the directory mapped to this c - * container. If relative is false, this is a fully qualified path name - * @param relative if true, path is a relative path from the directory mapped from the container - * @return the created attachment parent - */ - AttachmentDirectory registerDirectory(Container c, String name, String path, boolean relative); - - /** - * Forget about a named directory - * - * @param c Container for this attachmentParent - * @param label Name of the parent used in registerDirectory - */ - void unregisterDirectory(Container c, String label); - - /** - * Return an AttachmentParent for files in the directory mapped to this container - * - * @param c Container in the file system - * @param createDir Create the mapped directory if it doesn't exist - * @return AttachmentParent that can be passed to other methods of this interface - */ - @Nullable - AttachmentDirectory getMappedAttachmentDirectory(Container c, boolean createDir) throws UnsetRootDirectoryException, MissingRootDirectoryException; - - @Nullable - AttachmentDirectory getMappedAttachmentDirectory(Container c, ContentType contentType, boolean createDir) throws UnsetRootDirectoryException, MissingRootDirectoryException; - - /** - * Return a named AttachmentParent for files in the directory mapped to this container - * - * @param c Container in the file system - * @return AttachmentParent that can be passed to other methods of this interface - */ - AttachmentDirectory getRegisteredDirectory(Container c, String label); - - /** - * Return a named AttachmentParent for files in the directory mapped to this container - * - * @param c Container in the file system - * @return AttachmentParent that can be passed to other methods of this interface - */ - AttachmentDirectory getRegisteredDirectoryFromEntityId(Container c, String entityId); - - /** - * Return true if the supplied string is a valid project root - * - * @param root String to use as the file path - * @return boolean - */ - boolean isValidProjectRoot(String root); - - /** - * Return all AttachmentParents for files in the directory mapped to this container - * - * @param c Container in the file system - * @return Collection of attachment directories that have previously been registered - */ - @NotNull Collection getRegisteredDirectories(Container c); - - enum ContentType { - files, - pipeline, - assay, - scripts, - assayfiles - } - - String getFolderName(ContentType type); - - FilesAdminOptions getAdminOptions(Container c); - - void setAdminOptions(Container c, FilesAdminOptions options); - - void setAdminOptions(Container c, String properties); - - /** - * Returns the default file root of the specified container. This will default to a path - * relative to the first parent container with an override - */ - File getDefaultRoot(Container c, boolean createDir); - Path getDefaultRootPath(@NotNull Container c, boolean createDir); - - class DefaultRootInfo - { - private final java.nio.file.Path _path; - private final String _prettyStr; - private final boolean _isCloud; - private final String _cloudName; - - public DefaultRootInfo(java.nio.file.Path path, String prettyStr, boolean isCloud, String cloudName) - { - _path = path; - _prettyStr = prettyStr; - _isCloud = isCloud; - _cloudName = cloudName; - } - - public java.nio.file.Path getPath() - { - return _path; - } - - public String getPrettyStr() - { - return _prettyStr; - } - - public boolean isCloud() - { - return _isCloud; - } - - public String getCloudName() - { - return _cloudName; - } - } - - DefaultRootInfo getDefaultRootInfo(Container container); - - String getDomainURI(Container c); - - String getDomainURI(Container c, FilesAdminOptions.fileConfig config); - - ExpData getDataObject(WebdavResource resource, Container c); - QueryUpdateService getFilePropsUpdateService(TableInfo tinfo, Container container); - - void moveFileRoot(File prev, File dest, @Nullable User user, @Nullable Container container); - default void moveFileRoot(Path prev, Path dest, @Nullable User user, @Nullable Container container) - { - if (!FileUtil.hasCloudScheme(prev) && !FileUtil.hasCloudScheme(dest)) - { - moveFileRoot(prev.toFile(), dest.toFile(), user, container); - } - } - - /** Notifies all registered FileListeners that a file or directory has been created */ - void fireFileCreateEvent(@NotNull File created, @Nullable User user, @Nullable Container container); - default void fireFileCreateEvent(@NotNull Path created, @Nullable User user, @Nullable Container container) - { - if (!FileUtil.hasCloudScheme(created)) - fireFileCreateEvent(created.toFile(), user, container); - } - /** - * Notifies all registered FileListeners that a file or directory has moved - * @return number of rows updated across all listeners - */ - int fireFileMoveEvent(@NotNull File src, @NotNull File dest, @Nullable User user, @Nullable Container container); - default int fireFileMoveEvent(@NotNull Path src, @NotNull Path dest, @Nullable User user, @Nullable Container container) - { - if (!FileUtil.hasCloudScheme(src) && !FileUtil.hasCloudScheme(dest)) - return fireFileMoveEvent(src.toFile(), dest.toFile(), user, container); - return 0; - } - default int fireFileMoveEvent(@NotNull Path src, @NotNull Path dest, @Nullable User user, @Nullable Container sourceContainer, @Nullable Container targetContainer) - { - return fireFileMoveEvent(src, dest, user, sourceContainer); - } - - /** Notifies all registered FileListeners that a file or directory has been replaced */ - default void fireFileReplacedEvent(@NotNull Path replaced, @Nullable User user, @Nullable Container container){} - - /** Notifies all registered FileListeners that a file or directory has been deleted */ - default void fireFileDeletedEvent(@NotNull Path deleted, @Nullable User user, @Nullable Container container){} - - /** Add a listener that will be notified when files are created or are moved */ - void addFileListener(FileListener listener); - - Map> listFiles(@NotNull Container container); - - /** - * Returns a SQLFragment for file paths that this FileListener is aware of when the user is a site admin, or empty - * results otherwise. - * The expected columns are: - *
    - *
  • Container
  • - *
  • Created
  • - *
  • CreatedBy
  • - *
  • Modified
  • - *
  • ModifiedBy
  • - *
  • FilePath
  • - *
  • SourceKey
  • - *
  • SourceName
  • - *
- */ - SQLFragment listFilesQuery(@NotNull User currentUser); - - void setWebfilesEnabled(boolean enabled, User user); - - /** - * Return file's virtual folder path that's relative to container's file root. Roots are matched in order of @assayfiles, @files, @pipeline and then each @filesets. - * @param dataFileUrl The data file Url of file - * @param container Container in the file system - * @return folder relative to file root - */ - String getDataFileRelativeFileRootPath(@NotNull String dataFileUrl, Container container); - - enum PathType { full, serverRelative, folderRelative } - - @Nullable - URI getWebDavUrl(@NotNull Path path, @NotNull Container container, @NotNull PathType type); - - @Nullable - URI getWebDavUrl(@NotNull FileLike path, @NotNull Container container, @NotNull PathType type); - - /** - * Ensure an entry in the exp.data table exists for all files in the container's file root. - */ - void ensureFileData(@NotNull ExpDataTable table); - - /** - * Allows a module to register a directory pattern to be checked in the files webpart in order to zip the matching directory before uploading. - * @param directoryPattern DirectoryPattern - * */ - void addZiploaderPattern(DirectoryPattern directoryPattern); - - /** - * Returns a list of DirectoryPattern objects for the active modules in the given container. - * */ - List getZiploaderPatterns(Container container); - - File getMoveTargetFile(String absoluteFilePath, @NotNull Container sourceContainer, @NotNull Container targetContainer); -} +/* + * Copyright (c) 2009-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.labkey.api.files; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.labkey.api.attachments.AttachmentDirectory; +import org.labkey.api.data.Container; +import org.labkey.api.data.SQLFragment; +import org.labkey.api.data.TableInfo; +import org.labkey.api.exp.api.DataType; +import org.labkey.api.exp.api.ExpData; +import org.labkey.api.exp.query.ExpDataTable; +import org.labkey.api.query.QueryUpdateService; +import org.labkey.api.security.User; +import org.labkey.api.services.ServiceRegistry; +import org.labkey.api.util.FileUtil; +import org.labkey.api.webdav.WebdavResource; +import org.labkey.vfs.FileLike; + +import java.io.File; +import java.net.URI; +import java.nio.file.Path; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +/** + * User: klum + * Date: Dec 9, 2009 + */ +public interface FileContentService +{ + String UPLOADED_FILE_NAMESPACE_PREFIX = "UploadedFile"; + DataType UPLOADED_FILE = new DataType(UPLOADED_FILE_NAMESPACE_PREFIX); + + String FILES_LINK = "@files"; + String FILE_SETS_LINK = "@filesets"; + String PIPELINE_LINK = "@pipeline"; + String SCRIPTS_LINK = "@scripts"; + String CLOUD_LINK = "@cloud"; + String ASSAY_FILES = "@assayfiles"; + + String CLOUD_ROOT_PREFIX = "/@cloud"; + + static @Nullable FileContentService get() + { + return ServiceRegistry.get().getService(FileContentService.class); + } + + static void setInstance(FileContentService impl) + { + ServiceRegistry.get().registerService(FileContentService.class, impl); + } + + /** + * Returns a list of Container in which the path resides. + */ + @NotNull + List getContainersForFilePath(java.nio.file.Path path); + + /** + * Returns the file root of the specified container. If not explicitly defined, + * it will default to a path relative to the first parent container with an override + */ + @Nullable + File getFileRoot(@NotNull Container c); + + @Nullable + java.nio.file.Path getFileRootPath(@NotNull Container c); + + /** + * Returns the file root of the specified content type for a container + */ + @Nullable + File getFileRoot(@NotNull Container c, @NotNull ContentType type); + + @Nullable + java.nio.file.Path getFileRootPath(@NotNull Container c, @NotNull ContentType type); + + @Nullable + URI getFileRootUri(@NotNull Container c, @NotNull ContentType type, @Nullable String filePath); + + void setFileRoot(@NotNull Container c, @Nullable File root); + + void setFileRootPath(@NotNull Container c, @Nullable String root); + + void setCloudRoot(@NotNull Container c, String cloudRootName); + + boolean isCloudRoot(Container container); + + String getCloudRootName(Container c); + + void disableFileRoot(Container container); + + boolean isFileRootDisabled(Container container); + + /** + * A file root can use a default root based on a single site wide root that mirrors the folder structure of + * a project. + */ + boolean isUseDefaultRoot(Container container); + + void setIsUseDefaultRoot(Container container, boolean useDefaultRoot); + + + @NotNull + File getSiteDefaultRoot(); + + @NotNull + Path getSiteDefaultRootPath(); + + @Nullable + String getProblematicFileRootMessage(); + + void setSiteDefaultRoot(File root, User user); + + void setFileRootSetViaStartupProperty(boolean fileRootSetViaStartupProperty); + + boolean isFileRootSetViaStartupProperty(); + + /** + * Create an attachmentParent object that will allow storing files in the file system + * + * @param c Container this will be attached to + * @param name Name of the parent used in getMappedAttachmentDirectory + * @param path Path to the file. If relative is true, this is the name of a subdirectory of the directory mapped to this c + * container. If relative is false, this is a fully qualified path name + * @param relative if true, path is a relative path from the directory mapped from the container + * @return the created attachment parent + */ + AttachmentDirectory registerDirectory(Container c, String name, String path, boolean relative); + + /** + * Forget about a named directory + * + * @param c Container for this attachmentParent + * @param label Name of the parent used in registerDirectory + */ + void unregisterDirectory(Container c, String label); + + /** + * Return an AttachmentParent for files in the directory mapped to this container + * + * @param c Container in the file system + * @param createDir Create the mapped directory if it doesn't exist + * @return AttachmentParent that can be passed to other methods of this interface + */ + @Nullable + AttachmentDirectory getMappedAttachmentDirectory(Container c, boolean createDir) throws UnsetRootDirectoryException, MissingRootDirectoryException; + + @Nullable + AttachmentDirectory getMappedAttachmentDirectory(Container c, ContentType contentType, boolean createDir) throws UnsetRootDirectoryException, MissingRootDirectoryException; + + /** + * Return a named AttachmentParent for files in the directory mapped to this container + * + * @param c Container in the file system + * @return AttachmentParent that can be passed to other methods of this interface + */ + AttachmentDirectory getRegisteredDirectory(Container c, String label); + + /** + * Return a named AttachmentParent for files in the directory mapped to this container + * + * @param c Container in the file system + * @return AttachmentParent that can be passed to other methods of this interface + */ + AttachmentDirectory getRegisteredDirectoryFromEntityId(Container c, String entityId); + + /** + * Return true if the supplied string is a valid project root + * + * @param root String to use as the file path + * @return boolean + */ + boolean isValidProjectRoot(String root); + + /** + * Return all AttachmentParents for files in the directory mapped to this container + * + * @param c Container in the file system + * @return Collection of attachment directories that have previously been registered + */ + @NotNull Collection getRegisteredDirectories(Container c); + + enum ContentType { + files, + pipeline, + assay, + scripts, + assayfiles + } + + String getFolderName(ContentType type); + + FilesAdminOptions getAdminOptions(Container c); + + void setAdminOptions(Container c, FilesAdminOptions options); + + void setAdminOptions(Container c, String properties); + + /** + * Returns the default file root of the specified container. This will default to a path + * relative to the first parent container with an override + */ + File getDefaultRoot(Container c, boolean createDir); + Path getDefaultRootPath(@NotNull Container c, boolean createDir); + + class DefaultRootInfo + { + private final java.nio.file.Path _path; + private final String _prettyStr; + private final boolean _isCloud; + private final String _cloudName; + + public DefaultRootInfo(java.nio.file.Path path, String prettyStr, boolean isCloud, String cloudName) + { + _path = path; + _prettyStr = prettyStr; + _isCloud = isCloud; + _cloudName = cloudName; + } + + public java.nio.file.Path getPath() + { + return _path; + } + + public String getPrettyStr() + { + return _prettyStr; + } + + public boolean isCloud() + { + return _isCloud; + } + + public String getCloudName() + { + return _cloudName; + } + } + + DefaultRootInfo getDefaultRootInfo(Container container); + + String getDomainURI(Container c); + + String getDomainURI(Container c, FilesAdminOptions.fileConfig config); + + ExpData getDataObject(WebdavResource resource, Container c); + QueryUpdateService getFilePropsUpdateService(TableInfo tinfo, Container container); + + void moveFileRoot(File prev, File dest, @Nullable User user, @Nullable Container container); + default void moveFileRoot(Path prev, Path dest, @Nullable User user, @Nullable Container container) + { + if (!FileUtil.hasCloudScheme(prev) && !FileUtil.hasCloudScheme(dest)) + { + moveFileRoot(prev.toFile(), dest.toFile(), user, container); + } + } + + /** Notifies all registered FileListeners that a file or directory has been created */ + void fireFileCreateEvent(@NotNull File created, @Nullable User user, @Nullable Container container); + default void fireFileCreateEvent(@NotNull Path created, @Nullable User user, @Nullable Container container) + { + if (!FileUtil.hasCloudScheme(created)) + fireFileCreateEvent(created.toFile(), user, container); + } + /** + * Notifies all registered FileListeners that a file or directory has moved + * @return number of rows updated across all listeners + */ + int fireFileMoveEvent(@NotNull File src, @NotNull File dest, @Nullable User user, @Nullable Container container); + default int fireFileMoveEvent(@NotNull Path src, @NotNull Path dest, @Nullable User user, @Nullable Container container) + { + if (!FileUtil.hasCloudScheme(src) && !FileUtil.hasCloudScheme(dest)) + return fireFileMoveEvent(src.toFile(), dest.toFile(), user, container); + return 0; + } + default int fireFileMoveEvent(@NotNull Path src, @NotNull Path dest, @Nullable User user, @Nullable Container sourceContainer, @Nullable Container targetContainer) + { + return fireFileMoveEvent(src, dest, user, sourceContainer); + } + + /** Notifies all registered FileListeners that a file or directory has been replaced */ + default void fireFileReplacedEvent(@NotNull Path replaced, @Nullable User user, @Nullable Container container){} + + /** Notifies all registered FileListeners that a file or directory has been deleted */ + default void fireFileDeletedEvent(@NotNull Path deleted, @Nullable User user, @Nullable Container container){} + + /** Add a listener that will be notified when files are created or are moved */ + void addFileListener(FileListener listener); + + Map> listFiles(@NotNull Container container); + + /** + * Returns a SQLFragment for file paths that this FileListener is aware of when the user is a site admin, or empty + * results otherwise. + * The expected columns are: + *
    + *
  • Container
  • + *
  • Created
  • + *
  • CreatedBy
  • + *
  • Modified
  • + *
  • ModifiedBy
  • + *
  • FilePath
  • + *
  • SourceKey
  • + *
  • SourceName
  • + *
+ */ + SQLFragment listFilesQuery(@NotNull User currentUser); + + void setWebfilesEnabled(boolean enabled, User user); + + /** + * Return file's virtual folder path that's relative to container's file root. Roots are matched in order of @assayfiles, @files, @pipeline and then each @filesets. + * @param dataFileUrl The data file Url of file + * @param container Container in the file system + * @return folder relative to file root + */ + String getDataFileRelativeFileRootPath(@NotNull String dataFileUrl, Container container); + + enum PathType { full, serverRelative, folderRelative } + + @Nullable + URI getWebDavUrl(@NotNull Path path, @NotNull Container container, @NotNull PathType type); + + @Nullable + URI getWebDavUrl(@NotNull FileLike path, @NotNull Container container, @NotNull PathType type); + + /** + * Ensure an entry in the exp.data table exists for all files in the container's file root. + */ + void ensureFileData(@NotNull ExpDataTable table); + + /** + * Allows a module to register a directory pattern to be checked in the files webpart in order to zip the matching directory before uploading. + * @param directoryPattern DirectoryPattern + * */ + void addZiploaderPattern(DirectoryPattern directoryPattern); + + /** + * Returns a list of DirectoryPattern objects for the active modules in the given container. + * */ + List getZiploaderPatterns(Container container); + + File getMoveTargetFile(String absoluteFilePath, @NotNull Container sourceContainer, @NotNull Container targetContainer); +} diff --git a/api/src/org/labkey/api/pipeline/AnalyzeForm.java b/api/src/org/labkey/api/pipeline/AnalyzeForm.java index fba680500e5..0fe86132e9a 100644 --- a/api/src/org/labkey/api/pipeline/AnalyzeForm.java +++ b/api/src/org/labkey/api/pipeline/AnalyzeForm.java @@ -1,246 +1,246 @@ -/* - * Copyright (c) 2017 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.api.pipeline; - -import org.labkey.api.data.Container; -import org.labkey.api.pipeline.browse.PipelinePathForm; -import org.labkey.api.pipeline.file.AbstractFileAnalysisProtocol; -import org.labkey.api.security.User; -import org.labkey.api.util.FileType; -import org.labkey.api.util.FileUtil; -import org.labkey.vfs.FileLike; - -import java.nio.file.Path; - -/** - * User: tgaluhn - * Date: 2/1/2017 - * - * Moved from AnalysisController - */ -public class AnalyzeForm extends PipelinePathForm -{ - public enum Params - { - path, taskId, file - } - - private String taskId = ""; - private String protocolName = ""; - private String protocolDescription = ""; - private String[] fileInputStatus = null; - private String configureXml; - private String configureJson; - private boolean saveProtocol = false; - private boolean runAnalysis = false; - private boolean activeJobs = false; - private Boolean allowNonExistentFiles; - private Boolean includeWorkbooks = false; - private boolean allowProtocolRedefinition = false; - private String pipelineDescription; - - private static final String UNKNOWN_STATUS = "UNKNOWN"; - - public AnalyzeForm() - {} - - public AnalyzeForm(Container container, User user, String taskId, String protocolName) - { - setContainer(container); - setUser(user); - setTaskId(taskId); - setProtocolName(protocolName); - } - - public void initStatus(AbstractFileAnalysisProtocol protocol, FileLike dirData, FileLike dirAnalysis) - { - if (fileInputStatus != null) - return; - - activeJobs = false; - - int len = getFile().length; - fileInputStatus = new String[len + 1]; - for (int i = 0; i < len; i++) - fileInputStatus[i] = initStatusFile(protocol, dirData, dirAnalysis, getFile()[i], true); - - // TODO comment why this special status is added at the end (or make this a separate variable) - fileInputStatus[len] = initStatusFile(protocol, dirData, dirAnalysis, null, false); - } - - private String initStatusFile(AbstractFileAnalysisProtocol protocol, FileLike dirData, FileLike dirAnalysis, - String fileInputName, boolean statusSingle) - { - if (protocol == null) - { - return UNKNOWN_STATUS; - } - - FileLike fileStatus = null; - - if (!statusSingle) - { - fileStatus = PipelineJob.FT_LOG.newFile(dirAnalysis, - protocol.getJoinedBaseName()); - } - else if (fileInputName != null) - { - FileLike fileInput = dirData.resolveChild(fileInputName); - FileType ft = protocol.findInputType(fileInput); - if (ft != null) - fileStatus = PipelineJob.FT_LOG.newFile(dirAnalysis, ft.getBaseName(fileInput)); - } - - if (fileStatus != null) - { - PipelineStatusFile sf = PipelineService.get().getStatusFile(getContainer(), fileStatus); - if (sf == null) - return null; - - activeJobs = activeJobs || sf.isActive(); - return sf.getStatus(); - } - - // Failed to get status. Assume job is active, and return unknown status. - activeJobs = true; - return UNKNOWN_STATUS; - } - - public String getTaskId() - { - return taskId; - } - - public void setTaskId(String taskId) - { - this.taskId = taskId; - } - - public String getConfigureXml() - { - return configureXml; - } - - public void setConfigureXml(String configureXml) - { - this.configureXml = (configureXml == null ? "" : configureXml); - } - - public String getConfigureJson() - { - return configureJson; - } - - public void setConfigureJson(String configureJson) - { - this.configureJson = configureJson; - } - - public String getProtocolName() - { - return protocolName; - } - - public void setProtocolName(String protocolName) - { - this.protocolName = (protocolName == null ? "" : protocolName); - } - - public String getProtocolDescription() - { - return protocolDescription; - } - - public void setProtocolDescription(String protocolDescription) - { - this.protocolDescription = (protocolDescription == null ? "" : protocolDescription); - } - - public String[] getFileInputStatus() - { - return fileInputStatus; - } - - public boolean isActiveJobs() - { - return activeJobs; - } - - public Boolean getIncludeWorkbooks() - { - return includeWorkbooks; - } - - public void setIncludeWorkbooks(Boolean includeWorkbooks) - { - this.includeWorkbooks = includeWorkbooks; - } - - public boolean isSaveProtocol() - { - return saveProtocol; - } - - public void setSaveProtocol(boolean saveProtocol) - { - this.saveProtocol = saveProtocol; - } - - public boolean isRunAnalysis() - { - return runAnalysis; - } - - public void setRunAnalysis(boolean runAnalysis) - { - this.runAnalysis = runAnalysis; - } - - public Boolean isAllowNonExistentFiles() - { - return allowNonExistentFiles; - } - - public void setAllowNonExistentFiles(Boolean allowNonExistentFiles) - { - this.allowNonExistentFiles = allowNonExistentFiles; - } - - public boolean isAllowProtocolRedefinition() - { - return allowProtocolRedefinition; - } - - public void setAllowProtocolRedefinition(boolean allowProtocolRedefinition) - { - this.allowProtocolRedefinition = allowProtocolRedefinition; - } - - public static String getDefaultXMLParameters() - { - return ""; - } - - public String getPipelineDescription() - { - return pipelineDescription; - } - - public void setPipelineDescription(String pipelineDescription) - { - this.pipelineDescription = pipelineDescription; - } -} +/* + * Copyright (c) 2017 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.api.pipeline; + +import org.labkey.api.data.Container; +import org.labkey.api.pipeline.browse.PipelinePathForm; +import org.labkey.api.pipeline.file.AbstractFileAnalysisProtocol; +import org.labkey.api.security.User; +import org.labkey.api.util.FileType; +import org.labkey.api.util.FileUtil; +import org.labkey.vfs.FileLike; + +import java.nio.file.Path; + +/** + * User: tgaluhn + * Date: 2/1/2017 + * + * Moved from AnalysisController + */ +public class AnalyzeForm extends PipelinePathForm +{ + public enum Params + { + path, taskId, file + } + + private String taskId = ""; + private String protocolName = ""; + private String protocolDescription = ""; + private String[] fileInputStatus = null; + private String configureXml; + private String configureJson; + private boolean saveProtocol = false; + private boolean runAnalysis = false; + private boolean activeJobs = false; + private Boolean allowNonExistentFiles; + private Boolean includeWorkbooks = false; + private boolean allowProtocolRedefinition = false; + private String pipelineDescription; + + private static final String UNKNOWN_STATUS = "UNKNOWN"; + + public AnalyzeForm() + {} + + public AnalyzeForm(Container container, User user, String taskId, String protocolName) + { + setContainer(container); + setUser(user); + setTaskId(taskId); + setProtocolName(protocolName); + } + + public void initStatus(AbstractFileAnalysisProtocol protocol, FileLike dirData, FileLike dirAnalysis) + { + if (fileInputStatus != null) + return; + + activeJobs = false; + + int len = getFile().length; + fileInputStatus = new String[len + 1]; + for (int i = 0; i < len; i++) + fileInputStatus[i] = initStatusFile(protocol, dirData, dirAnalysis, getFile()[i], true); + + // TODO comment why this special status is added at the end (or make this a separate variable) + fileInputStatus[len] = initStatusFile(protocol, dirData, dirAnalysis, null, false); + } + + private String initStatusFile(AbstractFileAnalysisProtocol protocol, FileLike dirData, FileLike dirAnalysis, + String fileInputName, boolean statusSingle) + { + if (protocol == null) + { + return UNKNOWN_STATUS; + } + + FileLike fileStatus = null; + + if (!statusSingle) + { + fileStatus = PipelineJob.FT_LOG.newFile(dirAnalysis, + protocol.getJoinedBaseName()); + } + else if (fileInputName != null) + { + FileLike fileInput = dirData.resolveChild(fileInputName); + FileType ft = protocol.findInputType(fileInput); + if (ft != null) + fileStatus = PipelineJob.FT_LOG.newFile(dirAnalysis, ft.getBaseName(fileInput)); + } + + if (fileStatus != null) + { + PipelineStatusFile sf = PipelineService.get().getStatusFile(getContainer(), fileStatus); + if (sf == null) + return null; + + activeJobs = activeJobs || sf.isActive(); + return sf.getStatus(); + } + + // Failed to get status. Assume job is active, and return unknown status. + activeJobs = true; + return UNKNOWN_STATUS; + } + + public String getTaskId() + { + return taskId; + } + + public void setTaskId(String taskId) + { + this.taskId = taskId; + } + + public String getConfigureXml() + { + return configureXml; + } + + public void setConfigureXml(String configureXml) + { + this.configureXml = (configureXml == null ? "" : configureXml); + } + + public String getConfigureJson() + { + return configureJson; + } + + public void setConfigureJson(String configureJson) + { + this.configureJson = configureJson; + } + + public String getProtocolName() + { + return protocolName; + } + + public void setProtocolName(String protocolName) + { + this.protocolName = (protocolName == null ? "" : protocolName); + } + + public String getProtocolDescription() + { + return protocolDescription; + } + + public void setProtocolDescription(String protocolDescription) + { + this.protocolDescription = (protocolDescription == null ? "" : protocolDescription); + } + + public String[] getFileInputStatus() + { + return fileInputStatus; + } + + public boolean isActiveJobs() + { + return activeJobs; + } + + public Boolean getIncludeWorkbooks() + { + return includeWorkbooks; + } + + public void setIncludeWorkbooks(Boolean includeWorkbooks) + { + this.includeWorkbooks = includeWorkbooks; + } + + public boolean isSaveProtocol() + { + return saveProtocol; + } + + public void setSaveProtocol(boolean saveProtocol) + { + this.saveProtocol = saveProtocol; + } + + public boolean isRunAnalysis() + { + return runAnalysis; + } + + public void setRunAnalysis(boolean runAnalysis) + { + this.runAnalysis = runAnalysis; + } + + public Boolean isAllowNonExistentFiles() + { + return allowNonExistentFiles; + } + + public void setAllowNonExistentFiles(Boolean allowNonExistentFiles) + { + this.allowNonExistentFiles = allowNonExistentFiles; + } + + public boolean isAllowProtocolRedefinition() + { + return allowProtocolRedefinition; + } + + public void setAllowProtocolRedefinition(boolean allowProtocolRedefinition) + { + this.allowProtocolRedefinition = allowProtocolRedefinition; + } + + public static String getDefaultXMLParameters() + { + return ""; + } + + public String getPipelineDescription() + { + return pipelineDescription; + } + + public void setPipelineDescription(String pipelineDescription) + { + this.pipelineDescription = pipelineDescription; + } +} diff --git a/api/src/org/labkey/api/pipeline/PipelineJob.java b/api/src/org/labkey/api/pipeline/PipelineJob.java index 9ea7b07c162..6a8b367c93e 100644 --- a/api/src/org/labkey/api/pipeline/PipelineJob.java +++ b/api/src/org/labkey/api/pipeline/PipelineJob.java @@ -1,2031 +1,2031 @@ -/* - * Copyright (c) 2008-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.api.pipeline; - -import com.fasterxml.jackson.annotation.JsonAutoDetect; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.PropertyAccessor; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.SerializationFeature; -import com.fasterxml.jackson.databind.module.SimpleModule; -import datadog.trace.api.CorrelationIdentifier; -import datadog.trace.api.Trace; -import org.apache.commons.io.FileUtils; -import org.apache.commons.lang3.StringUtils; -import org.apache.logging.log4j.Level; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.apache.logging.log4j.Marker; -import org.apache.logging.log4j.ThreadContext; -import org.apache.logging.log4j.message.Message; -import org.apache.logging.log4j.simple.SimpleLogger; -import org.apache.logging.log4j.util.PropertiesUtil; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.labkey.api.action.NullSafeBindException; -import org.labkey.api.assay.AssayFileWriter; -import org.labkey.api.data.Container; -import org.labkey.api.exp.api.ExpRun; -import org.labkey.api.gwt.client.util.PropertyUtil; -import org.labkey.api.pipeline.file.FileAnalysisJobSupport; -import org.labkey.api.query.FieldKey; -import org.labkey.api.query.QueryKey; -import org.labkey.api.query.SchemaKey; -import org.labkey.api.reader.Readers; -import org.labkey.api.security.User; -import org.labkey.api.util.DateUtil; -import org.labkey.api.util.ExceptionUtil; -import org.labkey.api.util.FileType; -import org.labkey.api.util.FileUtil; -import org.labkey.api.util.GUID; -import org.labkey.api.util.Job; -import org.labkey.api.util.JsonUtil; -import org.labkey.api.util.NetworkDrive; -import org.labkey.api.util.QuietCloser; -import org.labkey.api.util.URLHelper; -import org.labkey.api.util.logging.LogHelper; -import org.labkey.api.view.ActionURL; -import org.labkey.api.view.ViewBackgroundInfo; -import org.labkey.api.writer.ContainerUser; -import org.labkey.api.writer.PrintWriters; -import org.labkey.remoteapi.query.Filter; -import org.labkey.vfs.FileLike; -import org.labkey.vfs.FileSystemLike; -import org.quartz.CronExpression; - -import java.io.BufferedReader; -import java.io.BufferedWriter; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.FileWriter; -import java.io.IOException; -import java.io.InputStream; -import java.io.PrintWriter; -import java.io.Serializable; -import java.net.URI; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardOpenOption; -import java.sql.Time; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.Date; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicLong; - -/** - * A job represents the invocation of a pipeline on a certain set of inputs. It can be monolithic (a single run() method) - * or be comprised of multiple tasks ({@link Task}) that can be checkpointed and restarted individually. - */ -@JsonIgnoreProperties(value={"_logFilePathName"}, allowGetters = true) //Property removed. Added here for backwards compatibility -abstract public class PipelineJob extends Job implements Serializable, ContainerUser -{ - public static final FileType FT_LOG = new FileType(Arrays.asList(".log"), ".log", Arrays.asList("text/plain")); - - public static final String PIPELINE_EMAIL_ADDRESS_PARAM = "pipeline, email address"; - public static final String PIPELINE_USERNAME_PARAM = "pipeline, username"; - public static final String PIPELINE_PROTOCOL_NAME_PARAM = "pipeline, protocol name"; - public static final String PIPELINE_PROTOCOL_DESCRIPTION_PARAM = "pipeline, protocol description"; - public static final String PIPELINE_LOAD_FOLDER_PARAM = "pipeline, load folder"; - public static final String PIPELINE_JOB_INFO_PARAM = "pipeline, jobInfo"; - public static final String PIPELINE_TASK_INFO_PARAM = "pipeline, taskInfo"; - public static final String PIPELINE_TASK_OUTPUT_PARAMS_PARAM = "pipeline, taskOutputParams"; - - protected static Logger _log = LogHelper.getLogger(PipelineJob.class, "Execution and queuing of pipeline jobs"); - // Send start/stop messages to a separate logger because the default logger for this class is set to - // only write ERROR level events to the system log - private static final Logger _logJobStopStart = LogManager.getLogger(Job.class); - - public static Logger getJobLogger(Class clazz) - { - return LogManager.getLogger(PipelineJob.class.getName() + ".." + clazz.getName()); - } - - public RecordedActionSet getActionSet() - { - return _actionSet; - } - - /** - * Clear out the set of recorded actions - * @param run run that represents the previous set of recorded actions - */ - public void clearActionSet(ExpRun run) - { - _actionSet = new RecordedActionSet(); - } - - public FileLike getLogFileLike() - { - return FileSystemLike.wrapFile(getLogFilePath()); - } - - public enum TaskStatus - { - /** Job is in the queue, waiting for its turn to run */ - waiting - { - @Override - public boolean isActive() { return true; } - - @Override - public boolean matches(String statusText) - { - if (statusText == null) - return false; - else if (!TaskStatus.splitWaiting.matches(statusText) && statusText.toLowerCase().endsWith("waiting")) - return true; - return super.matches(statusText); - } - }, - /** Job is doing its work */ - running - { - @Override - public boolean isActive() { return true; } - }, - /** Terminal state, job is finished and completed without errors */ - complete - { - @Override - public boolean isActive() { return false; } - }, - /** Terminal state (but often retryable), job is done running and completed with error(s) */ - error - { - @Override - public boolean isActive() { return false; } - }, - /** Job is in the process of being cancelled, but may still be running or queued at the moment */ - cancelling - { - @Override - public boolean isActive() { return true; } - }, - /** Terminal state, indicating that a user cancelled the job before it completed or errored */ - cancelled - { - @Override - public boolean isActive() { return false; } - }, - splitWaiting - { - @Override - public boolean isActive() { return false; } - - @Override - public String toString() { return "SPLIT WAITING"; } - }; - - /** @return whether this step is considered to be actively running */ - public abstract boolean isActive(); - - public String toString() - { - return super.toString().toUpperCase(); - } - - public boolean matches(String statusText) - { - return toString().equalsIgnoreCase(statusText); - } - - public final String getNotificationType() - { - return getClass().getName() + "." + name(); - } - } - - /** - * Implements a runnable to complete a part of the - * processing associated with a particular PipelineJob. This is often the execution of an external tool, - * the importing of files into the database, etc. - */ - abstract static public class Task - { - private final PipelineJob _job; - protected FactoryType _factory; - - public Task(FactoryType factory, PipelineJob job) - { - _job = job; - _factory = factory; - } - - public PipelineJob getJob() - { - return _job; - } - - /** - * Do the work of the task. The task should not set the status of the job to complete - this will be handled - * by the caller. - * @return the files used as inputs and generated as outputs, and the steps that operated on them - * @throws PipelineJobException if something went wrong during the execution of the job. The caller will - * handle setting the job's status to ERROR. - */ - @NotNull - public abstract RecordedActionSet run() throws PipelineJobException; - } - - /* - * JMS message header names - */ - private static final String HEADER_PREFIX = "LABKEY_"; - public static final String LABKEY_JOBTYPE_PROPERTY = HEADER_PREFIX + "JOBTYPE"; - public static final String LABKEY_JOBID_PROPERTY = HEADER_PREFIX + "JOBID"; - public static final String LABKEY_CONTAINERID_PROPERTY = HEADER_PREFIX + "CONTAINERID"; - public static final String LABKEY_TASKPIPELINE_PROPERTY = HEADER_PREFIX + "TASKPIPELINE"; - public static final String LABKEY_TASKID_PROPERTY = HEADER_PREFIX + "TASKID"; - public static final String LABKEY_TASKSTATUS_PROPERTY = HEADER_PREFIX + "TASKSTATUS"; - /** The execution location to which the job's current task is assigned */ - public static final String LABKEY_LOCATION_PROPERTY = HEADER_PREFIX + "LOCATION"; - - private String _provider; - private ViewBackgroundInfo _info; - private String _jobGUID; - private String _parentGUID; - private TaskId _activeTaskId; - @NotNull - private TaskStatus _activeTaskStatus; - private int _activeTaskRetries; - @NotNull - private PipeRoot _pipeRoot; - volatile private boolean _interrupted; - private boolean _submitted; - private int _errors; - private RecordedActionSet _actionSet = new RecordedActionSet(); - - private String _loggerLevel = Level.DEBUG.toString(); - - // Don't save these - protected transient Logger _logger; - private transient boolean _settingStatus; - private transient PipelineQueue _queue; - - private Path _logFile; - private LocalDirectory _localDirectory; - - // Default constructor for serialization - protected PipelineJob() - { - } - - /** Although having a null provider is legal, it is recommended that one be used - * so that it can respond to events as needed */ - public PipelineJob(@Nullable String provider, ViewBackgroundInfo info, @NotNull PipeRoot root) - { - _info = info; - _provider = provider; - _jobGUID = GUID.makeGUID(); - _activeTaskStatus = TaskStatus.waiting; - - - _pipeRoot = root; - - _actionSet = new RecordedActionSet(); - } - - public PipelineJob(PipelineJob job) - { - // Not yet queued - _queue = null; - - // New ID - _jobGUID = GUID.makeGUID(); - - // Copy everything else - _info = job._info; - _provider = job._provider; - _parentGUID = job._jobGUID; - _pipeRoot = job._pipeRoot; - _interrupted = job._interrupted; - _submitted = job._submitted; - _errors = job._errors; - _loggerLevel = job._loggerLevel; - _logger = job._logger; - _logFile = job._logFile; - - _activeTaskId = job._activeTaskId; - _activeTaskStatus = job._activeTaskStatus; - - _actionSet = new RecordedActionSet(job.getActionSet()); - _localDirectory = job._localDirectory; - } - - public String getProvider() - { - return _provider; - } - - @Deprecated - public void setProvider(String provider) - { - _provider = provider; - } - - public int getErrors() - { - return _errors; - } - - public void setErrors(int errors) - { - if (errors > 0) - _activeTaskStatus = TaskStatus.error; - - _errors = errors; - } - - /** - * This job has been restored from a checkpoint for the purpose of - * a retry. Record retry information before it is checkpointed again. - */ - public void retryUpdate() - { - _errors++; - _activeTaskRetries++; - } - - public Map getParameters() - { - return Collections.emptyMap(); - } - - public String getJobGUID() - { - return _jobGUID; - } - - public String getParentGUID() - { - return _parentGUID; - } - - @Nullable - public TaskId getActiveTaskId() - { - return _activeTaskId; - } - - public boolean setActiveTaskId(@Nullable TaskId activeTaskId) - { - return setActiveTaskId(activeTaskId, true); - } - - public boolean setActiveTaskId(@Nullable TaskId activeTaskId, boolean updateStatus) - { - if (activeTaskId == null || !activeTaskId.equals(_activeTaskId)) - { - _activeTaskId = activeTaskId; - _activeTaskRetries = 0; - } - if (_activeTaskId == null) - _activeTaskStatus = TaskStatus.complete; - else - _activeTaskStatus = TaskStatus.waiting; - - return !updateStatus || updateStatusForTask(); - } - - @NotNull - public TaskStatus getActiveTaskStatus() - { - return _activeTaskStatus; - } - - /** @return whether the status was set successfully */ - public boolean setActiveTaskStatus(@NotNull TaskStatus activeTaskStatus) - { - _activeTaskStatus = activeTaskStatus; - return updateStatusForTask(); - } - - public TaskFactory getActiveTaskFactory() - { - if (getActiveTaskId() == null) - return null; - - return PipelineJobService.get().getTaskFactory(getActiveTaskId()); - } - - @NotNull - public PipeRoot getPipeRoot() - { - return _pipeRoot; - } - - @Deprecated //Please switch to the FileLike version - public void setLogFile(File logFile) - { - setLogFile(logFile.toPath()); - } - - public void setLogFile(FileLike logFile) - { - setLogFile(logFile.toNioPathForWrite()); - } - - @Deprecated //Please switch to the FileLike version - public void setLogFile(Path logFile) - { - // Set Log file path and clear/reset logger - _logFile = logFile.toAbsolutePath().normalize(); - _logger = null; //This should trigger getting the new Logger next time getLogger is called - } - - public File getLogFile() - { - Path logFilePath = getLogFilePath(); - if (null != logFilePath && !FileUtil.hasCloudScheme(logFilePath)) - return logFilePath.toFile(); - return null; - } - - public Path getLogFilePath() - { - return _logFile; - } - - /** - * Get the remote log path (if local dir set) else return getLogFilePath - * - * TODO: Better name getStatusKeyPath? or similar - */ - public Path getRemoteLogPath() - { - LocalDirectory dir = getLocalDirectory(); - if (dir == null) - return getLogFilePath(); - - return dir.getRemoteLogFilePath(); - } - - /** Finds a file name that hasn't been used yet, appending ".2", ".3", etc as needed */ - public static File findUniqueLogFile(File primaryFile, String baseName) - { - String validBaseName = FileUtil.makeLegalName(baseName); - // need to look in current and archived dirs for any unused log file names (issue 20987) - File fileLog = FT_LOG.newFile(primaryFile.getParentFile(), validBaseName); - File archivedDir = FileUtil.appendName(primaryFile.getParentFile(), AssayFileWriter.ARCHIVED_DIR_NAME); - File fileLogArchived = FT_LOG.newFile(archivedDir, validBaseName); - - int index = 1; - while (NetworkDrive.exists(fileLog) || NetworkDrive.exists(fileLogArchived)) - { - fileLog = FT_LOG.newFile(primaryFile.getParentFile(), validBaseName + "." + (index)); - fileLogArchived = FT_LOG.newFile(archivedDir, validBaseName + "." + (index++)); - } - - return fileLog; - } - - - public LocalDirectory getLocalDirectory() - { - return _localDirectory; - } - - protected void setLocalDirectory(LocalDirectory localDirectory) - { - _localDirectory = localDirectory; - } - - public static PipelineJob readFromFile(File file) throws IOException, PipelineJobException - { - StringBuilder serializedJob = new StringBuilder(); - try (InputStream fIn = new FileInputStream(file)) - { - BufferedReader reader = Readers.getReader(fIn); - String line; - while ((line = reader.readLine()) != null) - { - serializedJob.append(line); - } - } - - PipelineJob job = PipelineJob.deserializeJob(serializedJob.toString()); - if (null == job) - { - throw new PipelineJobException("Unable to deserialize job"); - } - return job; - } - - - public void writeToFile(File file) throws IOException - { - File newFile = new File(file.getPath() + ".new"); - File origFile = new File(file.getPath() + ".orig"); - - String serializedJob = serializeJob(true); - - try (FileOutputStream fOut = new FileOutputStream(newFile)) - { - PrintWriter writer = PrintWriters.getPrintWriter(fOut); - writer.write(serializedJob); - writer.flush(); - } - - if (NetworkDrive.exists(file)) - { - if (origFile.exists()) - { - // Might be left over from some bad previous run - origFile.delete(); - } - // Don't use File.renameTo() because it doesn't always work depending on the underlying file system - FileUtils.moveFile(file, origFile); - FileUtils.moveFile(newFile, file); - origFile.delete(); - } - else - { - FileUtils.moveFile(newFile, file); - } - PipelineJobService.get().getWorkDirFactory().setPermissions(file); - } - - public boolean updateStatusForTask() - { - TaskFactory factory = getActiveTaskFactory(); - TaskStatus status = getActiveTaskStatus(); - - if (factory != null && !TaskStatus.error.equals(status) && !TaskStatus.cancelled.equals(status)) - return setStatus(factory.getStatusName() + " " + status.toString().toUpperCase()); - else - return setStatus(status); - } - - /** Used for setting status to one of the standard states */ - public boolean setStatus(@NotNull TaskStatus status) - { - return setStatus(status.toString()); - } - - /** - * Used for setting status to a custom state, which is considered to be equivalent to TaskStatus.running - * unless it matches one of the standard states - * @throws CancelledException if the job was cancelled by a user and should stop execution - */ - public boolean setStatus(@NotNull String status) - { - return setStatus(status, null); - } - - /** - * Used for setting status to one of the standard states - * @param info more verbose detail on the job's status, such as a percent complete - * @throws CancelledException if the job was cancelled by a user and should stop execution - */ - public boolean setStatus(@NotNull TaskStatus status, @Nullable String info) - { - return setStatus(status.toString(), info); - } - - /** - * @param info more verbose detail on the job's status, such as a percent complete - * @throws CancelledException if the job was cancelled by a user and should stop execution - */ - public boolean setStatus(@NotNull String status, @Nullable String info) - { - return setStatus(status, info, false); - } - - /** - * Used for setting status to a custom state, which is considered to be equivalent to TaskStatus.running - * unless it matches one of the standard states - * @throws CancelledException if the job was cancelled by a user and should stop execution - */ - public boolean setStatus(@NotNull String status, @Nullable String info, boolean allowInsert) - { - if (_settingStatus) - return true; - - _settingStatus = true; - try - { - boolean statusSet = PipelineJobService.get().getStatusWriter().setStatus(this, status, info, allowInsert); - if (!statusSet) - { - setActiveTaskStatus(TaskStatus.error); - } - return statusSet; - } - // Rethrow so it doesn't get handled like other RuntimeExceptions - catch (CancelledException e) - { - _activeTaskStatus = TaskStatus.cancelled; - throw e; - } - catch (RuntimeException e) - { - Path f = this.getLogFilePath(); - error("Failed to set status to '" + status + "' for '" + - (f == null ? "" : f.toString()) + "'.", e); - throw e; - } - catch (Exception e) - { - Path f = this.getLogFilePath(); - error("Failed to set status to '" + status + "' for '" + - (f == null ? "" : f.toString()) + "'.", e); - } - finally - { - _settingStatus = false; - } - return false; - } - - public void restoreQueue(PipelineQueue queue) - { - // Recursive split and join combinations may cause the queue - // to be restored to a job with a queue already. Would be good - // to have better safe-guards against double-queueing of jobs. - if (queue == _queue) - return; - if (null != _queue) - throw new IllegalStateException(); - _queue = queue; - } - - public void restoreLocalDirectory() - { - if (null != _localDirectory) - setLogFile(_localDirectory.restore()); - } - - public void validateParameters() throws PipelineValidationException - { - TaskPipeline taskPipeline = getTaskPipeline(); - if (taskPipeline != null) - { - for (TaskId taskId : taskPipeline.getTaskProgression()) - { - TaskFactory taskFactory = PipelineJobService.get().getTaskFactory(taskId); - if (taskFactory == null) - throw new PipelineValidationException("Task '" + taskId + "' not found"); - taskFactory.validateParameters(this); - } - } - } - - public boolean setQueue(PipelineQueue queue, TaskStatus initialState) - { - return setQueue(queue, initialState.toString()); - } - - public boolean setQueue(PipelineQueue queue, String initialState) - { - restoreQueue(queue); - - // Initialize the task pipeline - TaskPipeline taskPipeline = getTaskPipeline(); - if (taskPipeline != null) - { - // Save the current job state marshalled to XML, in case of error. - String serializedJob = serializeJob(true); - - // Note runStateMachine returns false, if the job cannot be run locally. - // The job may still need to be put on a JMS queue for remote processing. - // Therefore, the return value cannot be used to determine whether the - // job should be queued. - runStateMachine(); - - // If an error occurred trying to find the first runnable state, then - // store the original job state to allow retry. - if (getActiveTaskStatus() == TaskStatus.error) - { - try - { - PipelineJob originalJob = PipelineJob.deserializeJob(serializedJob); - if (null != originalJob) - originalJob.store(); - else - warn("Failed to checkpoint '" + getDescription() + "' job."); - - } - catch (Exception e) - { - warn("Failed to checkpoint '" + getDescription() + "' job.", e); - } - return false; - } - - // If initialization put this job into a state where it is - // waiting, then it should not be put on the queue. - return !isSplitWaiting(); - } - // Initialize status for non-task pipeline jobs. - else if (_logFile != null) - { - setStatus(initialState); - try - { - store(); - } - catch (Exception e) - { - warn("Failed to checkpoint '" + getDescription() + "' job before queuing.", e); - } - } - - return true; - } - - public void clearQueue() - { - _queue = null; - } - - abstract public URLHelper getStatusHref(); - - abstract public String getDescription(); - - public String toString() - { - return super.toString() + " " + StringUtils.trimToEmpty(getDescription()); - } - - public T getJobSupport(Class inter) - { - if (inter.isInstance(this)) - return (T) this; - - throw new UnsupportedOperationException("Job type " + getClass().getName() + - " does not implement " + inter.getName()); - } - - /** - * Override to provide a TaskPipeline with the option of - * running some tasks remotely. Override the run() function - * to implement the job as a single monolithic task. - * - * @return a task pipeline to run for this job - */ - @Nullable - public TaskPipeline getTaskPipeline() - { - return null; - } - - public boolean isActiveTaskLocal() - { - TaskFactory factory = getActiveTaskFactory(); - return (factory != null && - TaskFactory.WEBSERVER.equalsIgnoreCase(factory.getExecutionLocation())); - } - - public void runActiveTask() throws IOException, PipelineJobException - { - TaskFactory factory = getActiveTaskFactory(); - if (factory == null) - return; - - if (!factory.isJobComplete(this)) - { - Task task = factory.createTask(this); - if (task == null) - return; // Bad task key. - - if (!setActiveTaskStatus(TaskStatus.running)) - { - // The user has deleted (cancelled) the job. - // Throwing this exception will cause the job to go to the ERROR state and stop running - throw new PipelineJobException("Job no longer in database - aborting"); - } - - WorkDirectory workDirectory = null; - RecordedActionSet actions; - - boolean success = false; - try - { - logStartStopInfo("Starting to run task '" + factory.getId() + "' for job '" + this + "' with log file " + getLogFilePath()); - getLogger().info("Starting to run task '" + factory.getId() + "' at location '" + factory.getExecutionLocation() + "'"); - if (PipelineJobService.get().getLocationType() != PipelineJobService.LocationType.WebServer) - { - PipelineJobService.RemoteServerProperties remoteProps = PipelineJobService.get().getRemoteServerProperties(); - if (remoteProps != null) - { - getLogger().info("on host: '" + remoteProps.getHostName() + "'"); - } - } - - if (task instanceof WorkDirectoryTask wdTask) - { - workDirectory = factory.createWorkDirectory(getJobGUID(), getJobSupport(FileAnalysisJobSupport.class), getLogger()); - wdTask.setWorkDirectory(workDirectory); - } - - actions = task.run(); - success = true; - } - finally - { - getLogger().info((success ? "Successfully completed" : "Failed to complete") + " task '" + factory.getId() + "'"); - logStartStopInfo((success ? "Successfully completed" : "Failed to complete") + " task '" + factory.getId() + "' for job '" + this + "' with log file " + getLogFile()); - - try - { - if (workDirectory != null) - { - workDirectory.remove(success); - ((WorkDirectoryTask)task).setWorkDirectory(null); - } - } - catch (IOException e) - { - // Don't let this cleanup error mask an original error that causes the job to fail - if (success) - { - // noinspection ThrowFromFinallyBlock - throw e; - } - else - { - if (e.getMessage() != null) - { - error(e.getMessage()); - } - else - { - error("Failed to clean up work directory after error condition, see full error information below.", e); - } - } - } - } - _actionSet.add(actions); - - // An error occurred running the task. Do not complete. - if (TaskStatus.error.equals(getActiveTaskStatus())) - return; - } - else - { - logStartStopInfo("Skipping already completed task '" + factory.getId() + "' for job '" + this + "' with log file " + getLogFile()); - getLogger().info("Skipping already completed task '" + factory.getId() + "' at location '" + factory.getExecutionLocation() + "'"); - } - - if (getActiveTaskStatus() != TaskStatus.complete && getActiveTaskStatus() != TaskStatus.cancelled) - setActiveTaskStatus(TaskStatus.complete); - } - - public static void logStartStopInfo(String message) - { - _logJobStopStart.info(message); - } - - public boolean runStateMachine() - { - TaskPipeline pipeline = getTaskPipeline(); - if (pipeline == null) - { - assert false : "Either override getTaskPipeline() or run() for " + getClass(); - - // Best we can do is to complete the job. - setActiveTaskId(null); - return false; - } - - TaskId[] progression = pipeline.getTaskProgression(); - int i = 0; - if (_activeTaskId != null) - { - i = indexOfActiveTask(progression); - if (i == -1) - { - error("Active task " + _activeTaskId + " not found in task pipeline."); - return false; - } - } - - switch (_activeTaskStatus) - { - case waiting: - return findRunnableTask(progression, i); - - case complete: - // See if the job has already completed. - if (_activeTaskId == null) - return false; - - return findRunnableTask(progression, i + 1); - - case error: - // Make sure the status is in error state, so that any auto-retry that - // may occur will record the error. And, if no retry occurs, then this - // job must be in error state. - try - { - PipelineJobService.get().getStatusWriter().ensureError(this); - } - catch (Exception e) - { - warn("Failed to ensure error status on task error.", e); - } - - // Run auto-retry, and retry if appropriate. - autoRetry(); - return false; - - case running: - case cancelled: - case cancelling: - default: - return false; // Do not run the active task. - } - } - - private int indexOfActiveTask(TaskId[] progression) - { - for (int i = 0; i < progression.length; i++) - { - TaskFactory factory = PipelineJobService.get().getTaskFactory(progression[i]); - if (factory == null) - { - throw new IllegalStateException("Could not find factory for " + progression[i]); - } - if (factory.getId().equals(_activeTaskId) || - factory.getActiveId(this).equals(_activeTaskId)) - return i; - } - return -1; - } - - private boolean findRunnableTask(TaskId[] progression, int i) - { - // Search for next task that is not already complete - TaskFactory factory = null; - while (i < progression.length) - { - try - { - factory = PipelineJobService.get().getTaskFactory(progression[i]); - if (factory == null) - { - throw new IllegalStateException("Could not find factory for " + progression[i]); - } - // Stop, if this task requires a change in join state - if ((factory.isJoin() && isSplitJob()) || (!factory.isJoin() && isSplittable())) - break; - // Stop, if this task is part of processing this job, and not complete - if (factory.isParticipant(this) && !factory.isJobComplete(this)) - break; - } - catch (IOException e) - { - error(e.getMessage()); - return false; - } - - i++; - } - - if (i < progression.length) - { - if (factory.isJoin() && isSplitJob()) - { - setActiveTaskId(factory.getId(), false); // ID is just a marker for state machine - join(); - return false; - } - else if (!factory.isJoin() && isSplittable()) - { - setActiveTaskId(factory.getId(), false); // ID is just a marker for state machine - split(); - return false; - } - - // Set next task to be run - if (!setActiveTaskId(factory.getActiveId(this))) - { - return false; - } - - // If it is local, then it can be run - return isActiveTaskLocal(); - } - else - { - // Job is complete - if (isSplitJob()) - { - setActiveTaskId(null, false); - join(); - } - else - { - setActiveTaskId(null); - } - return false; - } - } - - public boolean isAutoRetry() - { - TaskFactory factory = getActiveTaskFactory(); - return null != factory && _activeTaskRetries < factory.getAutoRetry() && factory.isAutoRetryEnabled(this); - } - - public boolean autoRetry() - { - try - { - if (isAutoRetry()) - { - info("Attempting to auto-retry"); - PipelineJobService.get().getJobStore().retry(getJobGUID()); - // Retry has been queued - return true; - } - } - catch (IOException | NoSuchJobException e) - { - warn("Failed to start automatic retry.", e); - } - return false; - } - - /** - * Subclasses that override this method instead of defining a task pipeline are responsible for setting the job's - * status at the end of their execution to either COMPLETE or ERROR - */ - @Override @Trace - public void run() - { - assert ThreadContext.isEmpty(); // Prevent/detect leaks - // Connect log messages with the active trace and span - ThreadContext.put(CorrelationIdentifier.getTraceIdKey(), CorrelationIdentifier.getTraceId()); - ThreadContext.put(CorrelationIdentifier.getSpanIdKey(), CorrelationIdentifier.getSpanId()); - - try - { - // The act of queueing the job runs the state machine for the first time. - do - { - try - { - runActiveTask(); - } - catch (IOException | PipelineJobException e) - { - error(e.getMessage(), e); - } - catch (CancelledException e) - { - throw e; - } - catch (RuntimeException e) - { - error(e.getMessage(), e); - ExceptionUtil.logExceptionToMothership(null, e); - // Rethrow to let the standard Mule exception handler fire and deal with the job state - throw e; - } - } - while (runStateMachine()); - } - catch (CancelledException e) - { - _activeTaskStatus = TaskStatus.cancelled; - // Don't need to do anything else, job has already been set to CANCELLED - } - finally - { - PipelineService.get().getPipelineQueue().almostDone(this); - - ThreadContext.remove(CorrelationIdentifier.getTraceIdKey()); - ThreadContext.remove(CorrelationIdentifier.getSpanIdKey()); - } - } - - // Should be called in run()'s finally by any class that overrides run(), if class uses LocalDirectory - protected void finallyCleanUpLocalDirectory() - { - if (null != _localDirectory && isDone()) - { - try - { - Path remoteLogFilePath = _localDirectory.cleanUpLocalDirectory(); - - //Update job log entry's log location to remote path - if (null != remoteLogFilePath) - { - //NOTE: any errors here can't be recorded to job log as it may no longer be local and writable - setLogFile(remoteLogFilePath); - setStatus(getActiveTaskStatus()); // Force writing to statusFiles - } - } - catch (JobLogInaccessibleException e) - { - // Can't write to job log as the log file is either null or inaccessible. - ExceptionUtil.logExceptionToMothership(null, e); - } - catch (Exception e) - { - // Attempt to record the error to the log. Move failed, so log should still be local and writable. - error("Error trying to move log file", e); - } - } - } - - /** - * Override and return true for job that may be split. Also, override - * the createSplitJobs() method to return the sub-jobs. - * - * @return true if the job may be split - */ - public boolean isSplittable() - { - return false; - } - - /** - * @return true if this is a split job, as determined by whether it has a parent. - */ - public boolean isSplitJob() - { - return getParentGUID() != null; - } - - /** - * @return true if this is a join job waiting for split jobs to complete. - */ - public boolean isSplitWaiting() - { - // Return false, if this job cannot be split. - if (!isSplittable()) - return false; - - // A join job with an active task that is not a join task, - // is waiting for a split to complete. - TaskFactory factory = getActiveTaskFactory(); - return (factory != null && !factory.isJoin()); - } - - /** - * Override and return instances of sub-jobs for a splittable job. - * - * @return sub-jobs requiring separate processing - */ - public List createSplitJobs() - { - return Collections.singletonList(this); - } - - /** - * Handles merging accumulated changes from split jobs into this job, which - * is a joined job. - * - * @param job the split job that has run to completion - */ - public void mergeSplitJob(PipelineJob job) - { - // Add experiment actions recorded. - _actionSet.add(job.getActionSet()); - - // Add any errors that happened in the split job. - _errors += job._errors; - } - - public void store() throws NoSuchJobException - { - PipelineJobService.get().getJobStore().storeJob(this); - } - - private void split() - { - try - { - PipelineJobService.get().getJobStore().split(this); - } - catch (IOException e) - { - error(e.getMessage(), e); - } - } - - private void join() - { - try - { - PipelineJobService.get().getJobStore().join(this); - } - catch (IOException | NoSuchJobException e) - { - error(e.getMessage(), e); - } - } - - ///////////////////////////////////////////////////////////////////////// - // Support for running processes - - @Nullable - private PrintWriter createPrintWriter(@Nullable File outputFile, boolean append) throws PipelineJobException - { - if (outputFile == null) - return null; - - try - { - return new PrintWriter(new BufferedWriter(new FileWriter(outputFile, append))); - } - catch (IOException e) - { - throw new PipelineJobException("Could not create the " + outputFile + " file.", e); - } - } - - public void runSubProcess(ProcessBuilder pb, File dirWork) throws PipelineJobException - { - runSubProcess(pb, dirWork, null, 0, false); - } - - /** - * If logLineInterval is greater than 1, the first logLineInterval lines of output will be written to the - * job's main log file. - */ - public void runSubProcess(ProcessBuilder pb, File dirWork, File outputFile, int logLineInterval, boolean append) - throws PipelineJobException - { - runSubProcess(pb, dirWork, outputFile, logLineInterval, append, 0, null); - } - - public void runSubProcess(ProcessBuilder pb, File dirWork, File outputFile, int logLineInterval, boolean append, long timeout, TimeUnit timeoutUnit) - throws PipelineJobException - { - Process proc; - - String commandName = pb.command().get(0); - commandName = commandName.substring( - Math.max(commandName.lastIndexOf('/'), commandName.lastIndexOf('\\')) + 1); - header(commandName + " output"); - - // Update PATH environment variable to make sure all files in the tools - // directory and the directory of the executable or on the path. - String toolDir = PipelineJobService.get().getAppProperties().getToolsDirectory(); - if (!StringUtils.isEmpty(toolDir)) - { - String path = System.getenv("PATH"); - if (path == null) - { - path = toolDir; - } - else - { - path = toolDir + File.pathSeparatorChar + path; - } - - // If the command has a path, then prepend its parent directory to the PATH - // environment variable as well. - String exePath = pb.command().get(0); - if (exePath != null && !exePath.isEmpty() && exePath.indexOf(File.separatorChar) != -1) - { - File fileExe = new File(exePath); - String exeDir = fileExe.getParent(); - if (!exeDir.equals(toolDir) && fileExe.exists()) - path = fileExe.getParent() + File.pathSeparatorChar + path; - } - - pb.environment().put("PATH", path); - - String dyld = System.getenv("DYLD_LIBRARY_PATH"); - if (dyld == null) - { - dyld = toolDir; - } - else - { - dyld = toolDir + File.pathSeparatorChar + dyld; - } - pb.environment().put("DYLD_LIBRARY_PATH", dyld); - } - - // tell more modern TPP tools to run headless (so no perl calls etc) bpratt 4-14-09 - pb.environment().put("XML_ONLY", "1"); - // tell TPP tools not to mess with tmpdirs, we handle this at higher level - pb.environment().put("WEBSERVER_TMP",""); - - try - { - pb.directory(dirWork); - - // TODO: Errors should go to log even when output is redirected to a file. - pb.redirectErrorStream(true); - - info("Working directory is " + dirWork.getAbsolutePath()); - info("running: " + StringUtils.join(pb.command().iterator(), " ")); - - proc = pb.start(); - } - catch (SecurityException se) - { - throw new PipelineJobException("Failed starting process '" + pb.command() + "'. Permissions do not allow execution.", se); - } - catch (IOException eio) - { - throw new PipelineJobException("Failed starting process '" + pb.command() + "'", eio); - } - - - try (QuietCloser ignored = PipelineJobService.get().trackForCancellation(proc)) - { - // create thread pool for collecting the process output - ExecutorService pool = Executors.newSingleThreadExecutor(); - - try (PrintWriter fileWriter = createPrintWriter(outputFile, append)) - { - // collect output using separate thread so we can enforce a timeout on the process - Future output = pool.submit(() -> { - try (BufferedReader procReader = Readers.getReader(proc.getInputStream())) - { - String line; - int count = 0; - while ((line = procReader.readLine()) != null) - { - count++; - if (fileWriter == null) - info(line); - else - { - if (logLineInterval > 0 && count < logLineInterval) - info(line); - else if (count == logLineInterval) - info("Writing additional tool output lines to " + outputFile.getName()); - fileWriter.println(line); - } - } - return count; - } - }); - - try - { - if (timeout > 0) - { - if (!proc.waitFor(timeout, timeoutUnit)) - { - proc.destroyForcibly().waitFor(); - - error("Process killed after exceeding timeout of " + timeout + " " + timeoutUnit.name().toLowerCase()); - } - } - else - { - proc.waitFor(); - } - - int result = proc.exitValue(); - if (result != 0) - { - throw new ToolExecutionException("Failed running " + pb.command().get(0) + ", exit code " + result, result); - } - - int count = output.get(); - if (fileWriter != null) - info(count + " lines written total to " + outputFile.getName()); - } - catch (InterruptedException ei) - { - throw new PipelineJobException("Interrupted process for '" + dirWork.getPath() + "'.", ei); - } - catch (ExecutionException e) - { - // Exception thrown in output collecting thread - Throwable cause = e.getCause(); - if (cause instanceof IOException) - throw new PipelineJobException("Failed writing output for process in '" + dirWork.getPath() + "'.", cause); - - throw new PipelineJobException(cause); - } - } - finally - { - pool.shutdownNow(); - } - } - } - - public String getLogLevel() - { - return _loggerLevel; - } - - public void setLogLevel(String level) - { - if (!_loggerLevel.equals(level)) - { - _loggerLevel = level; - _logger = null; // Reset the logger - } - } - - public Logger getClassLogger() - { - return _log; - } - - private static class OutputLogger extends SimpleLogger - { - private final PipelineJob _job; - private boolean _isSettingStatus; - private final Path _file; - private final String LINE_SEP = System.lineSeparator(); - private final String datePattern = "dd MMM yyyy HH:mm:ss,SSS"; - - protected OutputLogger(PipelineJob job, Path file, String name, Level level) - { - super(name, level, false, false, false, false, "", null, new PropertiesUtil(PropertiesUtil.getSystemProperties()), null); - _job = job; - _file = file; - } - - // called from LogOutputStream.flush() - @Override - public void log(Level level, String message) - { - _job.getClassLogger().log(level, message); - write(message, null, level.toString()); - } - - private String getSystemLogMessage(Object message) - { - StringBuilder sb = new StringBuilder(); - sb.append("(from pipeline job log file "); - sb.append(_job.getLogFile().toString()); - if (message != null) - { - sb.append(": "); - String stringMessage = message.toString(); - // Limit the maximum line length - final int maxLength = 10000; - if (stringMessage.length() > maxLength) - { - stringMessage = stringMessage.substring(0, maxLength) + "..."; - } - sb.append(stringMessage); - } - sb.append(")"); - return sb.toString(); - } - - public void setErrorStatus(Object message) - { - if (_isSettingStatus || _job._activeTaskStatus == TaskStatus.cancelled) - return; - - _isSettingStatus = true; - try - { - _job.setStatus(TaskStatus.error, message == null ? "ERROR" : message.toString()); - } - finally - { - _isSettingStatus = false; - } - } - - @Override - public void logMessage(String fqcn, Level mgsLevel, Marker marker, Message msg, Throwable throwable) - { - if (_job.getClassLogger().isEnabled(mgsLevel, marker)) - { - _job.getClassLogger().log(mgsLevel, marker, new Message() - { - @Override - public String getFormattedMessage() - { - return getSystemLogMessage(msg.getFormattedMessage()); - } - - @Override - public Object[] getParameters() - { - return msg.getParameters(); - } - - @Override - public Throwable getThrowable() - { - return msg.getThrowable(); - } - }, throwable); - } - - // Write to the job's log before setting the error status, which may end up throwing a CancelledException - // to signal that we need to bail out right away - write(msg.getFormattedMessage(), throwable, mgsLevel.getStandardLevel().name()); - - if (mgsLevel.isMoreSpecificThan(Level.ERROR)) - { - setErrorStatus(msg.getFormattedMessage()); - } - } - - private void write(String message, @Nullable Throwable t, String level) - { - String formattedDate = DateUtil.formatDateTime(new Date(), datePattern); - - try (PrintWriter writer = new PrintWriter(Files.newBufferedWriter(_file, StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.APPEND))) - { - var line = formattedDate + " " + - String.format("%-5s", level) + - ": " + - message; - writer.write(line); - writer.write(LINE_SEP); - if (null != t) - { - t.printStackTrace(writer); - } - } - catch (IOException e) - { - Path parentFile = _file.getParent(); - if (parentFile != null && !NetworkDrive.exists(parentFile)) - { - try - { - FileUtil.createDirectories(parentFile); - write(message, t, level); - } - catch (IOException dirE) - { - _log.error("Failed appending to file. Unable to create parent directories", e); - } - } - else - _log.error("Failed appending to file.", e); - } - } - } - - public static class JobLogInaccessibleException extends IllegalStateException - { - public JobLogInaccessibleException(String message) - { - super(message); - } - } - - // Multiple threads log messages, so synchronize to make sure that no one gets a partially initialized logger - public synchronized Logger getLogger() - { - if (_logger == null) - { - if (null == _logFile || FileUtil.hasCloudScheme(_logFile)) - throw new JobLogInaccessibleException("LogFile null or cloud."); - - // Create appending logger. - String loggerName = PipelineJob.class.getSimpleName() + ".Logger." + _logFile.toString(); - _logger = new OutputLogger(this, _logFile, loggerName, Level.toLevel(_loggerLevel)); - } - - return _logger; - } - - public void error(String message) - { - error(message, null); - } - - public void error(String message, @Nullable Throwable t) - { - setErrors(getErrors() + 1); - if (getLogger() != null) - getLogger().error(message, t); - } - - public void debug(String message) - { - debug(message, null); - } - - public void debug(String message, @Nullable Throwable t) - { - if (getLogger() != null) - getLogger().debug(message, t); - } - - public void warn(String message) - { - warn(message, null); - } - - public void warn(String message, @Nullable Throwable t) - { - if (getLogger() != null) - getLogger().warn(message, t); - } - - public void info(String message) - { - info(message, null); - } - - public void info(String message, @Nullable Throwable t) - { - if (getLogger() != null) - getLogger().info(message, t); - } - - public void header(String message) - { - info(message); - info("======================================="); - } - - ///////////////////////////////////////////////////////////////////////// - // ViewBackgroundInfo access - // WARNING: Some access of ViewBackgroundInfo is not supported when - // the job is running outside the LabKey Server. - - /** - * Gets the container ID from the ViewBackgroundInfo. - * - * @return the ID for the container in which the job was started - */ - public String getContainerId() - { - return getInfo().getContainerId(); - } - - /** - * Gets the User instance from the ViewBackgroundInfo. - * WARNING: Not supported if job is not running in the LabKey web server. - * - * @return the user who started the job - * @throws IllegalStateException if invoked on a remote pipeline server - */ - @Override - public User getUser() - { - if (!PipelineJobService.get().isWebServer()) - { - throw new IllegalStateException("User lookup not available on remote pipeline servers"); - } - return getInfo().getUser(); - } - - /** - * Gets the Container instance from the ViewBackgroundInfo. - * WARNING: Not supported if job is not running in the LabKey web server. - * - * @return the container in which the job was started - * @throws IllegalStateException if invoked on a remote pipeline server - */ - @Override - public Container getContainer() - { - if (!PipelineJobService.get().isWebServer()) - { - throw new IllegalStateException("User lookup not available on remote pipeline servers"); - } - return getInfo().getContainer(); - } - - /** - * Gets the ActionURL instance from the ViewBackgroundInfo. - * WARNING: Not supported if job is not running in the LabKey Server. - * - * @return the URL of the request that started the job - */ - public ActionURL getActionURL() - { - return getInfo().getURL(); - } - - /** - * Gets the ViewBackgroundInfo associated with this job in its contstructor. - * WARNING: Although this function is supported outside the LabKey Server, certain - * accessors on the ViewBackgroundInfo itself are not. - * - * @return information from the starting request, for use in background processing - */ - public ViewBackgroundInfo getInfo() - { - return _info; - } - - ///////////////////////////////////////////////////////////////////////// - // Scheduling interface - // TODO: Figure out how these apply to the Enterprise Pipeline - - protected boolean canInterrupt() - { - return false; - } - - public synchronized boolean interrupt() - { - PipelineJobService.get().cancelForJob(getJobGUID()); - if (!canInterrupt()) - return false; - _interrupted = true; - return true; - } - - public synchronized boolean checkInterrupted() - { - return _interrupted; - } - - public boolean allowMultipleSimultaneousJobs() - { - return false; - } - - synchronized public void setSubmitted() - { - _submitted = true; - notifyAll(); - } - - synchronized private boolean isSubmitted() - { - return _submitted; - } - - synchronized private void waitUntilSubmitted() - { - while (!_submitted) - { - try - { - wait(); - } - catch (InterruptedException ignored) {} - } - } - - ///////////////////////////////////////////////////////////////////////// - // JobRunner.Job interface - - @Override - public Object get() throws InterruptedException, ExecutionException - { - waitUntilSubmitted(); - return super.get(); - } - - @Override - public Object get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException - { - return get(); - } - - @Override - protected void starting(Thread thread) - { - _queue.starting(this, thread); - } - - @Override - public boolean cancel(boolean mayInterruptIfRunning) - { - if (isSubmitted()) - { - PipelineJobService.get().cancelForJob(getJobGUID()); - return super.cancel(mayInterruptIfRunning); - } - return true; - } - - @Override - public boolean isDone() - { - if (!isSubmitted()) - return false; - return super.isDone(); - } - - @Override - public boolean isCancelled() - { - if (!isSubmitted()) - return false; - return super.isCancelled(); - } - - @Override - public void done(Throwable throwable) - { - if (null != throwable) - { - try - { - error("Uncaught exception in PipelineJob: " + this, throwable); - } - catch (Exception ignored) {} - } - if (_queue != null) - { - _queue.done(this); - } - - PipelineJobNotificationProvider notificationProvider = PipelineService.get().getPipelineJobNotificationProvider(getJobNotificationProvider(), this); - if (notificationProvider != null) - notificationProvider.onJobDone(this); - - finallyCleanUpLocalDirectory(); //Since this potentially contains the job log, it should be run after the notifications tasks are executed - } - - protected String getJobNotificationProvider() - { - return null; - } - - protected String getNotificationType(PipelineJob.TaskStatus status) - { - return status.getNotificationType(); - } - - public String serializeJob(boolean ensureDeserialize) - { - return PipelineJobService.get().getJobStore().serializeToJSON(this, ensureDeserialize); - } - - public static String getClassNameFromJson(String serialized) - { - // Expect [ "org.labkey....", {.... - if (StringUtils.startsWith(serialized, "[")) - { - return StringUtils.substringBetween(serialized, "\""); - } - else - { - throw new RuntimeException("Unexpected serialized JSON"); - } - } - - @Nullable - public static PipelineJob deserializeJob(@NotNull String serialized) - { - try - { - String className = PipelineJob.getClassNameFromJson(serialized); - return PipelineJobService.get().getJobStore().deserializeFromJSON(serialized, (Class)Class.forName(className)); - } - catch (ClassNotFoundException e) - { - _log.error("Deserialized class not found.", e); - } - return null; - } - - public static ObjectMapper createObjectMapper() - { - ObjectMapper mapper = JsonUtil.DEFAULT_MAPPER.copy() - .setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE) - .setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY) - .disable(SerializationFeature.FAIL_ON_EMPTY_BEANS) - .enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL); - - SimpleModule module = new SimpleModule(); - module.addSerializer(new SqlTimeSerialization.SqlTimeSerializer()); - module.addDeserializer(Time.class, new SqlTimeSerialization.SqlTimeDeserializer()); - module.addDeserializer(AtomicLong.class, new AtomicLongDeserializer()); - module.addSerializer(NullSafeBindException.class, new NullSafeBindExceptionSerializer()); - module.addSerializer(QueryKey.class, new QueryKeySerialization.Serializer()); - module.addDeserializer(SchemaKey.class, new QueryKeySerialization.SchemaKeyDeserializer()); - module.addDeserializer(FieldKey.class, new QueryKeySerialization.FieldKeyDeserializer()); - module.addSerializer(Path.class, new PathSerialization.Serializer()); - module.addDeserializer(Path.class, new PathSerialization.Deserializer()); - module.addSerializer(CronExpression.class, new CronExpressionSerialization.Serializer()); - module.addDeserializer(CronExpression.class, new CronExpressionSerialization.Deserializer()); - module.addSerializer(URI.class, new URISerialization.Serializer()); - module.addDeserializer(URI.class, new URISerialization.Deserializer()); - module.addSerializer(File.class, new FileSerialization.Serializer()); - module.addDeserializer(File.class, new FileSerialization.Deserializer()); - module.addDeserializer(Filter.class, new FilterDeserializer()); - - mapper.registerModule(module); - return mapper; - } - - public abstract static class TestSerialization extends org.junit.Assert - { - public void testSerialize(PipelineJob job, @Nullable Logger log) - { - PipelineStatusFile.JobStore jobStore = PipelineJobService.get().getJobStore(); - try - { - if (null != log) - log.info("Hi Logger is here!"); - String json = jobStore.serializeToJSON(job, true); - if (null != log) - log.info(json); - PipelineJob job2 = jobStore.deserializeFromJSON(json, job.getClass()); - if (null != log) - log.info(job2.toString()); - - List errors = job.compareJobs(job2); - if (!errors.isEmpty()) - { - fail("Pipeline objects don't match: " + StringUtils.join(errors, ",")); - } - } - catch (Exception e) - { - if (null != log) - log.error("Class not found", e); - } - } - } - - @Override - public boolean equals(Object o) - { - // Fix issue 35876: Second run of a split XTandem pipeline job not completing - don't rely on the job being - // represented in memory as a single object - if (this == o) return true; - if (!(o instanceof PipelineJob that)) return false; - return Objects.equals(_jobGUID, that._jobGUID); - } - - @Override - public int hashCode() - { - return Objects.hash(_jobGUID); - } - - public List compareJobs(PipelineJob job2) - { - PipelineJob job1 = this; - List errors = new ArrayList<>(); - if (!PropertyUtil.nullSafeEquals(job1._activeTaskId, job2._activeTaskId)) - errors.add("_activeTaskId"); - if (job1._activeTaskRetries != job2._activeTaskRetries) - errors.add("_activeTaskRetries"); - if (!PropertyUtil.nullSafeEquals(job1._activeTaskStatus, job2._activeTaskStatus)) - errors.add("_activeTaskStatus"); - if (job1._errors != job2._errors) - errors.add("_errors"); - if (job1._interrupted != job2._interrupted) - errors.add("_interrupted"); - if (!PropertyUtil.nullSafeEquals(job1._jobGUID, job2._jobGUID)) - errors.add("_jobGUID"); - if (!PropertyUtil.nullSafeEquals(job1._logFile, job2._logFile)) - { - if (null == job1._logFile || null == job2._logFile) - errors.add("_logFile"); - else if (!FileUtil.getAbsoluteCaseSensitiveFile(job1._logFile.toFile()).getAbsolutePath().equalsIgnoreCase(FileUtil.getAbsoluteCaseSensitiveFile(job2._logFile.toFile()).getAbsolutePath())) - errors.add("_logFile"); - } - if (!PropertyUtil.nullSafeEquals(job1._parentGUID, job2._parentGUID)) - errors.add("_parentGUID"); - if (!PropertyUtil.nullSafeEquals(job1._provider, job2._provider)) - errors.add("_provider"); - if (job1._submitted != job2._submitted) - errors.add("_submitted"); - - return errors; - } - - /** - * @return Path String for a local working directory, temporary if root is cloud based - */ - protected Path getWorkingDirectoryString() - { - return !getPipeRoot().isCloudRoot() ? getPipeRoot().getRootNioPath() : FileUtil.getTempDirectory().toPath(); - } - - /** - * Generate a LocalDirectory and log file, temporary if need be, for use by the job - * Note: Override getDefaultLocalDirectoryString if piperoot isn't the desired local directory - * - * @param pipeRoot Pipeline's root directory - * @param moduleName supplying the pipeline - * @param baseLogFileName base name of the log file - */ - protected final void setupLocalDirectoryAndJobLog(PipeRoot pipeRoot, String moduleName, String baseLogFileName) - { - LocalDirectory localDirectory = LocalDirectory.create(pipeRoot, moduleName, baseLogFileName, getWorkingDirectoryString()); - setLocalDirectory(localDirectory); - setLogFile(localDirectory.determineLogFile()); - } -} +/* + * Copyright (c) 2008-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.api.pipeline; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.PropertyAccessor; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.module.SimpleModule; +import datadog.trace.api.CorrelationIdentifier; +import datadog.trace.api.Trace; +import org.apache.commons.io.FileUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.logging.log4j.Marker; +import org.apache.logging.log4j.ThreadContext; +import org.apache.logging.log4j.message.Message; +import org.apache.logging.log4j.simple.SimpleLogger; +import org.apache.logging.log4j.util.PropertiesUtil; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.labkey.api.action.NullSafeBindException; +import org.labkey.api.assay.AssayFileWriter; +import org.labkey.api.data.Container; +import org.labkey.api.exp.api.ExpRun; +import org.labkey.api.gwt.client.util.PropertyUtil; +import org.labkey.api.pipeline.file.FileAnalysisJobSupport; +import org.labkey.api.query.FieldKey; +import org.labkey.api.query.QueryKey; +import org.labkey.api.query.SchemaKey; +import org.labkey.api.reader.Readers; +import org.labkey.api.security.User; +import org.labkey.api.util.DateUtil; +import org.labkey.api.util.ExceptionUtil; +import org.labkey.api.util.FileType; +import org.labkey.api.util.FileUtil; +import org.labkey.api.util.GUID; +import org.labkey.api.util.Job; +import org.labkey.api.util.JsonUtil; +import org.labkey.api.util.NetworkDrive; +import org.labkey.api.util.QuietCloser; +import org.labkey.api.util.URLHelper; +import org.labkey.api.util.logging.LogHelper; +import org.labkey.api.view.ActionURL; +import org.labkey.api.view.ViewBackgroundInfo; +import org.labkey.api.writer.ContainerUser; +import org.labkey.api.writer.PrintWriters; +import org.labkey.remoteapi.query.Filter; +import org.labkey.vfs.FileLike; +import org.labkey.vfs.FileSystemLike; +import org.quartz.CronExpression; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.FileWriter; +import java.io.IOException; +import java.io.InputStream; +import java.io.PrintWriter; +import java.io.Serializable; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.sql.Time; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +/** + * A job represents the invocation of a pipeline on a certain set of inputs. It can be monolithic (a single run() method) + * or be comprised of multiple tasks ({@link Task}) that can be checkpointed and restarted individually. + */ +@JsonIgnoreProperties(value={"_logFilePathName"}, allowGetters = true) //Property removed. Added here for backwards compatibility +abstract public class PipelineJob extends Job implements Serializable, ContainerUser +{ + public static final FileType FT_LOG = new FileType(Arrays.asList(".log"), ".log", Arrays.asList("text/plain")); + + public static final String PIPELINE_EMAIL_ADDRESS_PARAM = "pipeline, email address"; + public static final String PIPELINE_USERNAME_PARAM = "pipeline, username"; + public static final String PIPELINE_PROTOCOL_NAME_PARAM = "pipeline, protocol name"; + public static final String PIPELINE_PROTOCOL_DESCRIPTION_PARAM = "pipeline, protocol description"; + public static final String PIPELINE_LOAD_FOLDER_PARAM = "pipeline, load folder"; + public static final String PIPELINE_JOB_INFO_PARAM = "pipeline, jobInfo"; + public static final String PIPELINE_TASK_INFO_PARAM = "pipeline, taskInfo"; + public static final String PIPELINE_TASK_OUTPUT_PARAMS_PARAM = "pipeline, taskOutputParams"; + + protected static Logger _log = LogHelper.getLogger(PipelineJob.class, "Execution and queuing of pipeline jobs"); + // Send start/stop messages to a separate logger because the default logger for this class is set to + // only write ERROR level events to the system log + private static final Logger _logJobStopStart = LogManager.getLogger(Job.class); + + public static Logger getJobLogger(Class clazz) + { + return LogManager.getLogger(PipelineJob.class.getName() + ".." + clazz.getName()); + } + + public RecordedActionSet getActionSet() + { + return _actionSet; + } + + /** + * Clear out the set of recorded actions + * @param run run that represents the previous set of recorded actions + */ + public void clearActionSet(ExpRun run) + { + _actionSet = new RecordedActionSet(); + } + + public FileLike getLogFileLike() + { + return FileSystemLike.wrapFile(getLogFilePath()); + } + + public enum TaskStatus + { + /** Job is in the queue, waiting for its turn to run */ + waiting + { + @Override + public boolean isActive() { return true; } + + @Override + public boolean matches(String statusText) + { + if (statusText == null) + return false; + else if (!TaskStatus.splitWaiting.matches(statusText) && statusText.toLowerCase().endsWith("waiting")) + return true; + return super.matches(statusText); + } + }, + /** Job is doing its work */ + running + { + @Override + public boolean isActive() { return true; } + }, + /** Terminal state, job is finished and completed without errors */ + complete + { + @Override + public boolean isActive() { return false; } + }, + /** Terminal state (but often retryable), job is done running and completed with error(s) */ + error + { + @Override + public boolean isActive() { return false; } + }, + /** Job is in the process of being cancelled, but may still be running or queued at the moment */ + cancelling + { + @Override + public boolean isActive() { return true; } + }, + /** Terminal state, indicating that a user cancelled the job before it completed or errored */ + cancelled + { + @Override + public boolean isActive() { return false; } + }, + splitWaiting + { + @Override + public boolean isActive() { return false; } + + @Override + public String toString() { return "SPLIT WAITING"; } + }; + + /** @return whether this step is considered to be actively running */ + public abstract boolean isActive(); + + public String toString() + { + return super.toString().toUpperCase(); + } + + public boolean matches(String statusText) + { + return toString().equalsIgnoreCase(statusText); + } + + public final String getNotificationType() + { + return getClass().getName() + "." + name(); + } + } + + /** + * Implements a runnable to complete a part of the + * processing associated with a particular PipelineJob. This is often the execution of an external tool, + * the importing of files into the database, etc. + */ + abstract static public class Task + { + private final PipelineJob _job; + protected FactoryType _factory; + + public Task(FactoryType factory, PipelineJob job) + { + _job = job; + _factory = factory; + } + + public PipelineJob getJob() + { + return _job; + } + + /** + * Do the work of the task. The task should not set the status of the job to complete - this will be handled + * by the caller. + * @return the files used as inputs and generated as outputs, and the steps that operated on them + * @throws PipelineJobException if something went wrong during the execution of the job. The caller will + * handle setting the job's status to ERROR. + */ + @NotNull + public abstract RecordedActionSet run() throws PipelineJobException; + } + + /* + * JMS message header names + */ + private static final String HEADER_PREFIX = "LABKEY_"; + public static final String LABKEY_JOBTYPE_PROPERTY = HEADER_PREFIX + "JOBTYPE"; + public static final String LABKEY_JOBID_PROPERTY = HEADER_PREFIX + "JOBID"; + public static final String LABKEY_CONTAINERID_PROPERTY = HEADER_PREFIX + "CONTAINERID"; + public static final String LABKEY_TASKPIPELINE_PROPERTY = HEADER_PREFIX + "TASKPIPELINE"; + public static final String LABKEY_TASKID_PROPERTY = HEADER_PREFIX + "TASKID"; + public static final String LABKEY_TASKSTATUS_PROPERTY = HEADER_PREFIX + "TASKSTATUS"; + /** The execution location to which the job's current task is assigned */ + public static final String LABKEY_LOCATION_PROPERTY = HEADER_PREFIX + "LOCATION"; + + private String _provider; + private ViewBackgroundInfo _info; + private String _jobGUID; + private String _parentGUID; + private TaskId _activeTaskId; + @NotNull + private TaskStatus _activeTaskStatus; + private int _activeTaskRetries; + @NotNull + private PipeRoot _pipeRoot; + volatile private boolean _interrupted; + private boolean _submitted; + private int _errors; + private RecordedActionSet _actionSet = new RecordedActionSet(); + + private String _loggerLevel = Level.DEBUG.toString(); + + // Don't save these + protected transient Logger _logger; + private transient boolean _settingStatus; + private transient PipelineQueue _queue; + + private Path _logFile; + private LocalDirectory _localDirectory; + + // Default constructor for serialization + protected PipelineJob() + { + } + + /** Although having a null provider is legal, it is recommended that one be used + * so that it can respond to events as needed */ + public PipelineJob(@Nullable String provider, ViewBackgroundInfo info, @NotNull PipeRoot root) + { + _info = info; + _provider = provider; + _jobGUID = GUID.makeGUID(); + _activeTaskStatus = TaskStatus.waiting; + + + _pipeRoot = root; + + _actionSet = new RecordedActionSet(); + } + + public PipelineJob(PipelineJob job) + { + // Not yet queued + _queue = null; + + // New ID + _jobGUID = GUID.makeGUID(); + + // Copy everything else + _info = job._info; + _provider = job._provider; + _parentGUID = job._jobGUID; + _pipeRoot = job._pipeRoot; + _interrupted = job._interrupted; + _submitted = job._submitted; + _errors = job._errors; + _loggerLevel = job._loggerLevel; + _logger = job._logger; + _logFile = job._logFile; + + _activeTaskId = job._activeTaskId; + _activeTaskStatus = job._activeTaskStatus; + + _actionSet = new RecordedActionSet(job.getActionSet()); + _localDirectory = job._localDirectory; + } + + public String getProvider() + { + return _provider; + } + + @Deprecated + public void setProvider(String provider) + { + _provider = provider; + } + + public int getErrors() + { + return _errors; + } + + public void setErrors(int errors) + { + if (errors > 0) + _activeTaskStatus = TaskStatus.error; + + _errors = errors; + } + + /** + * This job has been restored from a checkpoint for the purpose of + * a retry. Record retry information before it is checkpointed again. + */ + public void retryUpdate() + { + _errors++; + _activeTaskRetries++; + } + + public Map getParameters() + { + return Collections.emptyMap(); + } + + public String getJobGUID() + { + return _jobGUID; + } + + public String getParentGUID() + { + return _parentGUID; + } + + @Nullable + public TaskId getActiveTaskId() + { + return _activeTaskId; + } + + public boolean setActiveTaskId(@Nullable TaskId activeTaskId) + { + return setActiveTaskId(activeTaskId, true); + } + + public boolean setActiveTaskId(@Nullable TaskId activeTaskId, boolean updateStatus) + { + if (activeTaskId == null || !activeTaskId.equals(_activeTaskId)) + { + _activeTaskId = activeTaskId; + _activeTaskRetries = 0; + } + if (_activeTaskId == null) + _activeTaskStatus = TaskStatus.complete; + else + _activeTaskStatus = TaskStatus.waiting; + + return !updateStatus || updateStatusForTask(); + } + + @NotNull + public TaskStatus getActiveTaskStatus() + { + return _activeTaskStatus; + } + + /** @return whether the status was set successfully */ + public boolean setActiveTaskStatus(@NotNull TaskStatus activeTaskStatus) + { + _activeTaskStatus = activeTaskStatus; + return updateStatusForTask(); + } + + public TaskFactory getActiveTaskFactory() + { + if (getActiveTaskId() == null) + return null; + + return PipelineJobService.get().getTaskFactory(getActiveTaskId()); + } + + @NotNull + public PipeRoot getPipeRoot() + { + return _pipeRoot; + } + + @Deprecated //Please switch to the FileLike version + public void setLogFile(File logFile) + { + setLogFile(logFile.toPath()); + } + + public void setLogFile(FileLike logFile) + { + setLogFile(logFile.toNioPathForWrite()); + } + + @Deprecated //Please switch to the FileLike version + public void setLogFile(Path logFile) + { + // Set Log file path and clear/reset logger + _logFile = logFile.toAbsolutePath().normalize(); + _logger = null; //This should trigger getting the new Logger next time getLogger is called + } + + public File getLogFile() + { + Path logFilePath = getLogFilePath(); + if (null != logFilePath && !FileUtil.hasCloudScheme(logFilePath)) + return logFilePath.toFile(); + return null; + } + + public Path getLogFilePath() + { + return _logFile; + } + + /** + * Get the remote log path (if local dir set) else return getLogFilePath + * + * TODO: Better name getStatusKeyPath? or similar + */ + public Path getRemoteLogPath() + { + LocalDirectory dir = getLocalDirectory(); + if (dir == null) + return getLogFilePath(); + + return dir.getRemoteLogFilePath(); + } + + /** Finds a file name that hasn't been used yet, appending ".2", ".3", etc as needed */ + public static File findUniqueLogFile(File primaryFile, String baseName) + { + String validBaseName = FileUtil.makeLegalName(baseName); + // need to look in current and archived dirs for any unused log file names (issue 20987) + File fileLog = FT_LOG.newFile(primaryFile.getParentFile(), validBaseName); + File archivedDir = FileUtil.appendName(primaryFile.getParentFile(), AssayFileWriter.ARCHIVED_DIR_NAME); + File fileLogArchived = FT_LOG.newFile(archivedDir, validBaseName); + + int index = 1; + while (NetworkDrive.exists(fileLog) || NetworkDrive.exists(fileLogArchived)) + { + fileLog = FT_LOG.newFile(primaryFile.getParentFile(), validBaseName + "." + (index)); + fileLogArchived = FT_LOG.newFile(archivedDir, validBaseName + "." + (index++)); + } + + return fileLog; + } + + + public LocalDirectory getLocalDirectory() + { + return _localDirectory; + } + + protected void setLocalDirectory(LocalDirectory localDirectory) + { + _localDirectory = localDirectory; + } + + public static PipelineJob readFromFile(File file) throws IOException, PipelineJobException + { + StringBuilder serializedJob = new StringBuilder(); + try (InputStream fIn = new FileInputStream(file)) + { + BufferedReader reader = Readers.getReader(fIn); + String line; + while ((line = reader.readLine()) != null) + { + serializedJob.append(line); + } + } + + PipelineJob job = PipelineJob.deserializeJob(serializedJob.toString()); + if (null == job) + { + throw new PipelineJobException("Unable to deserialize job"); + } + return job; + } + + + public void writeToFile(File file) throws IOException + { + File newFile = new File(file.getPath() + ".new"); + File origFile = new File(file.getPath() + ".orig"); + + String serializedJob = serializeJob(true); + + try (FileOutputStream fOut = new FileOutputStream(newFile)) + { + PrintWriter writer = PrintWriters.getPrintWriter(fOut); + writer.write(serializedJob); + writer.flush(); + } + + if (NetworkDrive.exists(file)) + { + if (origFile.exists()) + { + // Might be left over from some bad previous run + origFile.delete(); + } + // Don't use File.renameTo() because it doesn't always work depending on the underlying file system + FileUtils.moveFile(file, origFile); + FileUtils.moveFile(newFile, file); + origFile.delete(); + } + else + { + FileUtils.moveFile(newFile, file); + } + PipelineJobService.get().getWorkDirFactory().setPermissions(file); + } + + public boolean updateStatusForTask() + { + TaskFactory factory = getActiveTaskFactory(); + TaskStatus status = getActiveTaskStatus(); + + if (factory != null && !TaskStatus.error.equals(status) && !TaskStatus.cancelled.equals(status)) + return setStatus(factory.getStatusName() + " " + status.toString().toUpperCase()); + else + return setStatus(status); + } + + /** Used for setting status to one of the standard states */ + public boolean setStatus(@NotNull TaskStatus status) + { + return setStatus(status.toString()); + } + + /** + * Used for setting status to a custom state, which is considered to be equivalent to TaskStatus.running + * unless it matches one of the standard states + * @throws CancelledException if the job was cancelled by a user and should stop execution + */ + public boolean setStatus(@NotNull String status) + { + return setStatus(status, null); + } + + /** + * Used for setting status to one of the standard states + * @param info more verbose detail on the job's status, such as a percent complete + * @throws CancelledException if the job was cancelled by a user and should stop execution + */ + public boolean setStatus(@NotNull TaskStatus status, @Nullable String info) + { + return setStatus(status.toString(), info); + } + + /** + * @param info more verbose detail on the job's status, such as a percent complete + * @throws CancelledException if the job was cancelled by a user and should stop execution + */ + public boolean setStatus(@NotNull String status, @Nullable String info) + { + return setStatus(status, info, false); + } + + /** + * Used for setting status to a custom state, which is considered to be equivalent to TaskStatus.running + * unless it matches one of the standard states + * @throws CancelledException if the job was cancelled by a user and should stop execution + */ + public boolean setStatus(@NotNull String status, @Nullable String info, boolean allowInsert) + { + if (_settingStatus) + return true; + + _settingStatus = true; + try + { + boolean statusSet = PipelineJobService.get().getStatusWriter().setStatus(this, status, info, allowInsert); + if (!statusSet) + { + setActiveTaskStatus(TaskStatus.error); + } + return statusSet; + } + // Rethrow so it doesn't get handled like other RuntimeExceptions + catch (CancelledException e) + { + _activeTaskStatus = TaskStatus.cancelled; + throw e; + } + catch (RuntimeException e) + { + Path f = this.getLogFilePath(); + error("Failed to set status to '" + status + "' for '" + + (f == null ? "" : f.toString()) + "'.", e); + throw e; + } + catch (Exception e) + { + Path f = this.getLogFilePath(); + error("Failed to set status to '" + status + "' for '" + + (f == null ? "" : f.toString()) + "'.", e); + } + finally + { + _settingStatus = false; + } + return false; + } + + public void restoreQueue(PipelineQueue queue) + { + // Recursive split and join combinations may cause the queue + // to be restored to a job with a queue already. Would be good + // to have better safe-guards against double-queueing of jobs. + if (queue == _queue) + return; + if (null != _queue) + throw new IllegalStateException(); + _queue = queue; + } + + public void restoreLocalDirectory() + { + if (null != _localDirectory) + setLogFile(_localDirectory.restore()); + } + + public void validateParameters() throws PipelineValidationException + { + TaskPipeline taskPipeline = getTaskPipeline(); + if (taskPipeline != null) + { + for (TaskId taskId : taskPipeline.getTaskProgression()) + { + TaskFactory taskFactory = PipelineJobService.get().getTaskFactory(taskId); + if (taskFactory == null) + throw new PipelineValidationException("Task '" + taskId + "' not found"); + taskFactory.validateParameters(this); + } + } + } + + public boolean setQueue(PipelineQueue queue, TaskStatus initialState) + { + return setQueue(queue, initialState.toString()); + } + + public boolean setQueue(PipelineQueue queue, String initialState) + { + restoreQueue(queue); + + // Initialize the task pipeline + TaskPipeline taskPipeline = getTaskPipeline(); + if (taskPipeline != null) + { + // Save the current job state marshalled to XML, in case of error. + String serializedJob = serializeJob(true); + + // Note runStateMachine returns false, if the job cannot be run locally. + // The job may still need to be put on a JMS queue for remote processing. + // Therefore, the return value cannot be used to determine whether the + // job should be queued. + runStateMachine(); + + // If an error occurred trying to find the first runnable state, then + // store the original job state to allow retry. + if (getActiveTaskStatus() == TaskStatus.error) + { + try + { + PipelineJob originalJob = PipelineJob.deserializeJob(serializedJob); + if (null != originalJob) + originalJob.store(); + else + warn("Failed to checkpoint '" + getDescription() + "' job."); + + } + catch (Exception e) + { + warn("Failed to checkpoint '" + getDescription() + "' job.", e); + } + return false; + } + + // If initialization put this job into a state where it is + // waiting, then it should not be put on the queue. + return !isSplitWaiting(); + } + // Initialize status for non-task pipeline jobs. + else if (_logFile != null) + { + setStatus(initialState); + try + { + store(); + } + catch (Exception e) + { + warn("Failed to checkpoint '" + getDescription() + "' job before queuing.", e); + } + } + + return true; + } + + public void clearQueue() + { + _queue = null; + } + + abstract public URLHelper getStatusHref(); + + abstract public String getDescription(); + + public String toString() + { + return super.toString() + " " + StringUtils.trimToEmpty(getDescription()); + } + + public T getJobSupport(Class inter) + { + if (inter.isInstance(this)) + return (T) this; + + throw new UnsupportedOperationException("Job type " + getClass().getName() + + " does not implement " + inter.getName()); + } + + /** + * Override to provide a TaskPipeline with the option of + * running some tasks remotely. Override the run() function + * to implement the job as a single monolithic task. + * + * @return a task pipeline to run for this job + */ + @Nullable + public TaskPipeline getTaskPipeline() + { + return null; + } + + public boolean isActiveTaskLocal() + { + TaskFactory factory = getActiveTaskFactory(); + return (factory != null && + TaskFactory.WEBSERVER.equalsIgnoreCase(factory.getExecutionLocation())); + } + + public void runActiveTask() throws IOException, PipelineJobException + { + TaskFactory factory = getActiveTaskFactory(); + if (factory == null) + return; + + if (!factory.isJobComplete(this)) + { + Task task = factory.createTask(this); + if (task == null) + return; // Bad task key. + + if (!setActiveTaskStatus(TaskStatus.running)) + { + // The user has deleted (cancelled) the job. + // Throwing this exception will cause the job to go to the ERROR state and stop running + throw new PipelineJobException("Job no longer in database - aborting"); + } + + WorkDirectory workDirectory = null; + RecordedActionSet actions; + + boolean success = false; + try + { + logStartStopInfo("Starting to run task '" + factory.getId() + "' for job '" + this + "' with log file " + getLogFilePath()); + getLogger().info("Starting to run task '" + factory.getId() + "' at location '" + factory.getExecutionLocation() + "'"); + if (PipelineJobService.get().getLocationType() != PipelineJobService.LocationType.WebServer) + { + PipelineJobService.RemoteServerProperties remoteProps = PipelineJobService.get().getRemoteServerProperties(); + if (remoteProps != null) + { + getLogger().info("on host: '" + remoteProps.getHostName() + "'"); + } + } + + if (task instanceof WorkDirectoryTask wdTask) + { + workDirectory = factory.createWorkDirectory(getJobGUID(), getJobSupport(FileAnalysisJobSupport.class), getLogger()); + wdTask.setWorkDirectory(workDirectory); + } + + actions = task.run(); + success = true; + } + finally + { + getLogger().info((success ? "Successfully completed" : "Failed to complete") + " task '" + factory.getId() + "'"); + logStartStopInfo((success ? "Successfully completed" : "Failed to complete") + " task '" + factory.getId() + "' for job '" + this + "' with log file " + getLogFile()); + + try + { + if (workDirectory != null) + { + workDirectory.remove(success); + ((WorkDirectoryTask)task).setWorkDirectory(null); + } + } + catch (IOException e) + { + // Don't let this cleanup error mask an original error that causes the job to fail + if (success) + { + // noinspection ThrowFromFinallyBlock + throw e; + } + else + { + if (e.getMessage() != null) + { + error(e.getMessage()); + } + else + { + error("Failed to clean up work directory after error condition, see full error information below.", e); + } + } + } + } + _actionSet.add(actions); + + // An error occurred running the task. Do not complete. + if (TaskStatus.error.equals(getActiveTaskStatus())) + return; + } + else + { + logStartStopInfo("Skipping already completed task '" + factory.getId() + "' for job '" + this + "' with log file " + getLogFile()); + getLogger().info("Skipping already completed task '" + factory.getId() + "' at location '" + factory.getExecutionLocation() + "'"); + } + + if (getActiveTaskStatus() != TaskStatus.complete && getActiveTaskStatus() != TaskStatus.cancelled) + setActiveTaskStatus(TaskStatus.complete); + } + + public static void logStartStopInfo(String message) + { + _logJobStopStart.info(message); + } + + public boolean runStateMachine() + { + TaskPipeline pipeline = getTaskPipeline(); + if (pipeline == null) + { + assert false : "Either override getTaskPipeline() or run() for " + getClass(); + + // Best we can do is to complete the job. + setActiveTaskId(null); + return false; + } + + TaskId[] progression = pipeline.getTaskProgression(); + int i = 0; + if (_activeTaskId != null) + { + i = indexOfActiveTask(progression); + if (i == -1) + { + error("Active task " + _activeTaskId + " not found in task pipeline."); + return false; + } + } + + switch (_activeTaskStatus) + { + case waiting: + return findRunnableTask(progression, i); + + case complete: + // See if the job has already completed. + if (_activeTaskId == null) + return false; + + return findRunnableTask(progression, i + 1); + + case error: + // Make sure the status is in error state, so that any auto-retry that + // may occur will record the error. And, if no retry occurs, then this + // job must be in error state. + try + { + PipelineJobService.get().getStatusWriter().ensureError(this); + } + catch (Exception e) + { + warn("Failed to ensure error status on task error.", e); + } + + // Run auto-retry, and retry if appropriate. + autoRetry(); + return false; + + case running: + case cancelled: + case cancelling: + default: + return false; // Do not run the active task. + } + } + + private int indexOfActiveTask(TaskId[] progression) + { + for (int i = 0; i < progression.length; i++) + { + TaskFactory factory = PipelineJobService.get().getTaskFactory(progression[i]); + if (factory == null) + { + throw new IllegalStateException("Could not find factory for " + progression[i]); + } + if (factory.getId().equals(_activeTaskId) || + factory.getActiveId(this).equals(_activeTaskId)) + return i; + } + return -1; + } + + private boolean findRunnableTask(TaskId[] progression, int i) + { + // Search for next task that is not already complete + TaskFactory factory = null; + while (i < progression.length) + { + try + { + factory = PipelineJobService.get().getTaskFactory(progression[i]); + if (factory == null) + { + throw new IllegalStateException("Could not find factory for " + progression[i]); + } + // Stop, if this task requires a change in join state + if ((factory.isJoin() && isSplitJob()) || (!factory.isJoin() && isSplittable())) + break; + // Stop, if this task is part of processing this job, and not complete + if (factory.isParticipant(this) && !factory.isJobComplete(this)) + break; + } + catch (IOException e) + { + error(e.getMessage()); + return false; + } + + i++; + } + + if (i < progression.length) + { + if (factory.isJoin() && isSplitJob()) + { + setActiveTaskId(factory.getId(), false); // ID is just a marker for state machine + join(); + return false; + } + else if (!factory.isJoin() && isSplittable()) + { + setActiveTaskId(factory.getId(), false); // ID is just a marker for state machine + split(); + return false; + } + + // Set next task to be run + if (!setActiveTaskId(factory.getActiveId(this))) + { + return false; + } + + // If it is local, then it can be run + return isActiveTaskLocal(); + } + else + { + // Job is complete + if (isSplitJob()) + { + setActiveTaskId(null, false); + join(); + } + else + { + setActiveTaskId(null); + } + return false; + } + } + + public boolean isAutoRetry() + { + TaskFactory factory = getActiveTaskFactory(); + return null != factory && _activeTaskRetries < factory.getAutoRetry() && factory.isAutoRetryEnabled(this); + } + + public boolean autoRetry() + { + try + { + if (isAutoRetry()) + { + info("Attempting to auto-retry"); + PipelineJobService.get().getJobStore().retry(getJobGUID()); + // Retry has been queued + return true; + } + } + catch (IOException | NoSuchJobException e) + { + warn("Failed to start automatic retry.", e); + } + return false; + } + + /** + * Subclasses that override this method instead of defining a task pipeline are responsible for setting the job's + * status at the end of their execution to either COMPLETE or ERROR + */ + @Override @Trace + public void run() + { + assert ThreadContext.isEmpty(); // Prevent/detect leaks + // Connect log messages with the active trace and span + ThreadContext.put(CorrelationIdentifier.getTraceIdKey(), CorrelationIdentifier.getTraceId()); + ThreadContext.put(CorrelationIdentifier.getSpanIdKey(), CorrelationIdentifier.getSpanId()); + + try + { + // The act of queueing the job runs the state machine for the first time. + do + { + try + { + runActiveTask(); + } + catch (IOException | PipelineJobException e) + { + error(e.getMessage(), e); + } + catch (CancelledException e) + { + throw e; + } + catch (RuntimeException e) + { + error(e.getMessage(), e); + ExceptionUtil.logExceptionToMothership(null, e); + // Rethrow to let the standard Mule exception handler fire and deal with the job state + throw e; + } + } + while (runStateMachine()); + } + catch (CancelledException e) + { + _activeTaskStatus = TaskStatus.cancelled; + // Don't need to do anything else, job has already been set to CANCELLED + } + finally + { + PipelineService.get().getPipelineQueue().almostDone(this); + + ThreadContext.remove(CorrelationIdentifier.getTraceIdKey()); + ThreadContext.remove(CorrelationIdentifier.getSpanIdKey()); + } + } + + // Should be called in run()'s finally by any class that overrides run(), if class uses LocalDirectory + protected void finallyCleanUpLocalDirectory() + { + if (null != _localDirectory && isDone()) + { + try + { + Path remoteLogFilePath = _localDirectory.cleanUpLocalDirectory(); + + //Update job log entry's log location to remote path + if (null != remoteLogFilePath) + { + //NOTE: any errors here can't be recorded to job log as it may no longer be local and writable + setLogFile(remoteLogFilePath); + setStatus(getActiveTaskStatus()); // Force writing to statusFiles + } + } + catch (JobLogInaccessibleException e) + { + // Can't write to job log as the log file is either null or inaccessible. + ExceptionUtil.logExceptionToMothership(null, e); + } + catch (Exception e) + { + // Attempt to record the error to the log. Move failed, so log should still be local and writable. + error("Error trying to move log file", e); + } + } + } + + /** + * Override and return true for job that may be split. Also, override + * the createSplitJobs() method to return the sub-jobs. + * + * @return true if the job may be split + */ + public boolean isSplittable() + { + return false; + } + + /** + * @return true if this is a split job, as determined by whether it has a parent. + */ + public boolean isSplitJob() + { + return getParentGUID() != null; + } + + /** + * @return true if this is a join job waiting for split jobs to complete. + */ + public boolean isSplitWaiting() + { + // Return false, if this job cannot be split. + if (!isSplittable()) + return false; + + // A join job with an active task that is not a join task, + // is waiting for a split to complete. + TaskFactory factory = getActiveTaskFactory(); + return (factory != null && !factory.isJoin()); + } + + /** + * Override and return instances of sub-jobs for a splittable job. + * + * @return sub-jobs requiring separate processing + */ + public List createSplitJobs() + { + return Collections.singletonList(this); + } + + /** + * Handles merging accumulated changes from split jobs into this job, which + * is a joined job. + * + * @param job the split job that has run to completion + */ + public void mergeSplitJob(PipelineJob job) + { + // Add experiment actions recorded. + _actionSet.add(job.getActionSet()); + + // Add any errors that happened in the split job. + _errors += job._errors; + } + + public void store() throws NoSuchJobException + { + PipelineJobService.get().getJobStore().storeJob(this); + } + + private void split() + { + try + { + PipelineJobService.get().getJobStore().split(this); + } + catch (IOException e) + { + error(e.getMessage(), e); + } + } + + private void join() + { + try + { + PipelineJobService.get().getJobStore().join(this); + } + catch (IOException | NoSuchJobException e) + { + error(e.getMessage(), e); + } + } + + ///////////////////////////////////////////////////////////////////////// + // Support for running processes + + @Nullable + private PrintWriter createPrintWriter(@Nullable File outputFile, boolean append) throws PipelineJobException + { + if (outputFile == null) + return null; + + try + { + return new PrintWriter(new BufferedWriter(new FileWriter(outputFile, append))); + } + catch (IOException e) + { + throw new PipelineJobException("Could not create the " + outputFile + " file.", e); + } + } + + public void runSubProcess(ProcessBuilder pb, File dirWork) throws PipelineJobException + { + runSubProcess(pb, dirWork, null, 0, false); + } + + /** + * If logLineInterval is greater than 1, the first logLineInterval lines of output will be written to the + * job's main log file. + */ + public void runSubProcess(ProcessBuilder pb, File dirWork, File outputFile, int logLineInterval, boolean append) + throws PipelineJobException + { + runSubProcess(pb, dirWork, outputFile, logLineInterval, append, 0, null); + } + + public void runSubProcess(ProcessBuilder pb, File dirWork, File outputFile, int logLineInterval, boolean append, long timeout, TimeUnit timeoutUnit) + throws PipelineJobException + { + Process proc; + + String commandName = pb.command().get(0); + commandName = commandName.substring( + Math.max(commandName.lastIndexOf('/'), commandName.lastIndexOf('\\')) + 1); + header(commandName + " output"); + + // Update PATH environment variable to make sure all files in the tools + // directory and the directory of the executable or on the path. + String toolDir = PipelineJobService.get().getAppProperties().getToolsDirectory(); + if (!StringUtils.isEmpty(toolDir)) + { + String path = System.getenv("PATH"); + if (path == null) + { + path = toolDir; + } + else + { + path = toolDir + File.pathSeparatorChar + path; + } + + // If the command has a path, then prepend its parent directory to the PATH + // environment variable as well. + String exePath = pb.command().get(0); + if (exePath != null && !exePath.isEmpty() && exePath.indexOf(File.separatorChar) != -1) + { + File fileExe = new File(exePath); + String exeDir = fileExe.getParent(); + if (!exeDir.equals(toolDir) && fileExe.exists()) + path = fileExe.getParent() + File.pathSeparatorChar + path; + } + + pb.environment().put("PATH", path); + + String dyld = System.getenv("DYLD_LIBRARY_PATH"); + if (dyld == null) + { + dyld = toolDir; + } + else + { + dyld = toolDir + File.pathSeparatorChar + dyld; + } + pb.environment().put("DYLD_LIBRARY_PATH", dyld); + } + + // tell more modern TPP tools to run headless (so no perl calls etc) bpratt 4-14-09 + pb.environment().put("XML_ONLY", "1"); + // tell TPP tools not to mess with tmpdirs, we handle this at higher level + pb.environment().put("WEBSERVER_TMP",""); + + try + { + pb.directory(dirWork); + + // TODO: Errors should go to log even when output is redirected to a file. + pb.redirectErrorStream(true); + + info("Working directory is " + dirWork.getAbsolutePath()); + info("running: " + StringUtils.join(pb.command().iterator(), " ")); + + proc = pb.start(); + } + catch (SecurityException se) + { + throw new PipelineJobException("Failed starting process '" + pb.command() + "'. Permissions do not allow execution.", se); + } + catch (IOException eio) + { + throw new PipelineJobException("Failed starting process '" + pb.command() + "'", eio); + } + + + try (QuietCloser ignored = PipelineJobService.get().trackForCancellation(proc)) + { + // create thread pool for collecting the process output + ExecutorService pool = Executors.newSingleThreadExecutor(); + + try (PrintWriter fileWriter = createPrintWriter(outputFile, append)) + { + // collect output using separate thread so we can enforce a timeout on the process + Future output = pool.submit(() -> { + try (BufferedReader procReader = Readers.getReader(proc.getInputStream())) + { + String line; + int count = 0; + while ((line = procReader.readLine()) != null) + { + count++; + if (fileWriter == null) + info(line); + else + { + if (logLineInterval > 0 && count < logLineInterval) + info(line); + else if (count == logLineInterval) + info("Writing additional tool output lines to " + outputFile.getName()); + fileWriter.println(line); + } + } + return count; + } + }); + + try + { + if (timeout > 0) + { + if (!proc.waitFor(timeout, timeoutUnit)) + { + proc.destroyForcibly().waitFor(); + + error("Process killed after exceeding timeout of " + timeout + " " + timeoutUnit.name().toLowerCase()); + } + } + else + { + proc.waitFor(); + } + + int result = proc.exitValue(); + if (result != 0) + { + throw new ToolExecutionException("Failed running " + pb.command().get(0) + ", exit code " + result, result); + } + + int count = output.get(); + if (fileWriter != null) + info(count + " lines written total to " + outputFile.getName()); + } + catch (InterruptedException ei) + { + throw new PipelineJobException("Interrupted process for '" + dirWork.getPath() + "'.", ei); + } + catch (ExecutionException e) + { + // Exception thrown in output collecting thread + Throwable cause = e.getCause(); + if (cause instanceof IOException) + throw new PipelineJobException("Failed writing output for process in '" + dirWork.getPath() + "'.", cause); + + throw new PipelineJobException(cause); + } + } + finally + { + pool.shutdownNow(); + } + } + } + + public String getLogLevel() + { + return _loggerLevel; + } + + public void setLogLevel(String level) + { + if (!_loggerLevel.equals(level)) + { + _loggerLevel = level; + _logger = null; // Reset the logger + } + } + + public Logger getClassLogger() + { + return _log; + } + + private static class OutputLogger extends SimpleLogger + { + private final PipelineJob _job; + private boolean _isSettingStatus; + private final Path _file; + private final String LINE_SEP = System.lineSeparator(); + private final String datePattern = "dd MMM yyyy HH:mm:ss,SSS"; + + protected OutputLogger(PipelineJob job, Path file, String name, Level level) + { + super(name, level, false, false, false, false, "", null, new PropertiesUtil(PropertiesUtil.getSystemProperties()), null); + _job = job; + _file = file; + } + + // called from LogOutputStream.flush() + @Override + public void log(Level level, String message) + { + _job.getClassLogger().log(level, message); + write(message, null, level.toString()); + } + + private String getSystemLogMessage(Object message) + { + StringBuilder sb = new StringBuilder(); + sb.append("(from pipeline job log file "); + sb.append(_job.getLogFile().toString()); + if (message != null) + { + sb.append(": "); + String stringMessage = message.toString(); + // Limit the maximum line length + final int maxLength = 10000; + if (stringMessage.length() > maxLength) + { + stringMessage = stringMessage.substring(0, maxLength) + "..."; + } + sb.append(stringMessage); + } + sb.append(")"); + return sb.toString(); + } + + public void setErrorStatus(Object message) + { + if (_isSettingStatus || _job._activeTaskStatus == TaskStatus.cancelled) + return; + + _isSettingStatus = true; + try + { + _job.setStatus(TaskStatus.error, message == null ? "ERROR" : message.toString()); + } + finally + { + _isSettingStatus = false; + } + } + + @Override + public void logMessage(String fqcn, Level mgsLevel, Marker marker, Message msg, Throwable throwable) + { + if (_job.getClassLogger().isEnabled(mgsLevel, marker)) + { + _job.getClassLogger().log(mgsLevel, marker, new Message() + { + @Override + public String getFormattedMessage() + { + return getSystemLogMessage(msg.getFormattedMessage()); + } + + @Override + public Object[] getParameters() + { + return msg.getParameters(); + } + + @Override + public Throwable getThrowable() + { + return msg.getThrowable(); + } + }, throwable); + } + + // Write to the job's log before setting the error status, which may end up throwing a CancelledException + // to signal that we need to bail out right away + write(msg.getFormattedMessage(), throwable, mgsLevel.getStandardLevel().name()); + + if (mgsLevel.isMoreSpecificThan(Level.ERROR)) + { + setErrorStatus(msg.getFormattedMessage()); + } + } + + private void write(String message, @Nullable Throwable t, String level) + { + String formattedDate = DateUtil.formatDateTime(new Date(), datePattern); + + try (PrintWriter writer = new PrintWriter(Files.newBufferedWriter(_file, StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.APPEND))) + { + var line = formattedDate + " " + + String.format("%-5s", level) + + ": " + + message; + writer.write(line); + writer.write(LINE_SEP); + if (null != t) + { + t.printStackTrace(writer); + } + } + catch (IOException e) + { + Path parentFile = _file.getParent(); + if (parentFile != null && !NetworkDrive.exists(parentFile)) + { + try + { + FileUtil.createDirectories(parentFile); + write(message, t, level); + } + catch (IOException dirE) + { + _log.error("Failed appending to file. Unable to create parent directories", e); + } + } + else + _log.error("Failed appending to file.", e); + } + } + } + + public static class JobLogInaccessibleException extends IllegalStateException + { + public JobLogInaccessibleException(String message) + { + super(message); + } + } + + // Multiple threads log messages, so synchronize to make sure that no one gets a partially initialized logger + public synchronized Logger getLogger() + { + if (_logger == null) + { + if (null == _logFile || FileUtil.hasCloudScheme(_logFile)) + throw new JobLogInaccessibleException("LogFile null or cloud."); + + // Create appending logger. + String loggerName = PipelineJob.class.getSimpleName() + ".Logger." + _logFile.toString(); + _logger = new OutputLogger(this, _logFile, loggerName, Level.toLevel(_loggerLevel)); + } + + return _logger; + } + + public void error(String message) + { + error(message, null); + } + + public void error(String message, @Nullable Throwable t) + { + setErrors(getErrors() + 1); + if (getLogger() != null) + getLogger().error(message, t); + } + + public void debug(String message) + { + debug(message, null); + } + + public void debug(String message, @Nullable Throwable t) + { + if (getLogger() != null) + getLogger().debug(message, t); + } + + public void warn(String message) + { + warn(message, null); + } + + public void warn(String message, @Nullable Throwable t) + { + if (getLogger() != null) + getLogger().warn(message, t); + } + + public void info(String message) + { + info(message, null); + } + + public void info(String message, @Nullable Throwable t) + { + if (getLogger() != null) + getLogger().info(message, t); + } + + public void header(String message) + { + info(message); + info("======================================="); + } + + ///////////////////////////////////////////////////////////////////////// + // ViewBackgroundInfo access + // WARNING: Some access of ViewBackgroundInfo is not supported when + // the job is running outside the LabKey Server. + + /** + * Gets the container ID from the ViewBackgroundInfo. + * + * @return the ID for the container in which the job was started + */ + public String getContainerId() + { + return getInfo().getContainerId(); + } + + /** + * Gets the User instance from the ViewBackgroundInfo. + * WARNING: Not supported if job is not running in the LabKey web server. + * + * @return the user who started the job + * @throws IllegalStateException if invoked on a remote pipeline server + */ + @Override + public User getUser() + { + if (!PipelineJobService.get().isWebServer()) + { + throw new IllegalStateException("User lookup not available on remote pipeline servers"); + } + return getInfo().getUser(); + } + + /** + * Gets the Container instance from the ViewBackgroundInfo. + * WARNING: Not supported if job is not running in the LabKey web server. + * + * @return the container in which the job was started + * @throws IllegalStateException if invoked on a remote pipeline server + */ + @Override + public Container getContainer() + { + if (!PipelineJobService.get().isWebServer()) + { + throw new IllegalStateException("User lookup not available on remote pipeline servers"); + } + return getInfo().getContainer(); + } + + /** + * Gets the ActionURL instance from the ViewBackgroundInfo. + * WARNING: Not supported if job is not running in the LabKey Server. + * + * @return the URL of the request that started the job + */ + public ActionURL getActionURL() + { + return getInfo().getURL(); + } + + /** + * Gets the ViewBackgroundInfo associated with this job in its contstructor. + * WARNING: Although this function is supported outside the LabKey Server, certain + * accessors on the ViewBackgroundInfo itself are not. + * + * @return information from the starting request, for use in background processing + */ + public ViewBackgroundInfo getInfo() + { + return _info; + } + + ///////////////////////////////////////////////////////////////////////// + // Scheduling interface + // TODO: Figure out how these apply to the Enterprise Pipeline + + protected boolean canInterrupt() + { + return false; + } + + public synchronized boolean interrupt() + { + PipelineJobService.get().cancelForJob(getJobGUID()); + if (!canInterrupt()) + return false; + _interrupted = true; + return true; + } + + public synchronized boolean checkInterrupted() + { + return _interrupted; + } + + public boolean allowMultipleSimultaneousJobs() + { + return false; + } + + synchronized public void setSubmitted() + { + _submitted = true; + notifyAll(); + } + + synchronized private boolean isSubmitted() + { + return _submitted; + } + + synchronized private void waitUntilSubmitted() + { + while (!_submitted) + { + try + { + wait(); + } + catch (InterruptedException ignored) {} + } + } + + ///////////////////////////////////////////////////////////////////////// + // JobRunner.Job interface + + @Override + public Object get() throws InterruptedException, ExecutionException + { + waitUntilSubmitted(); + return super.get(); + } + + @Override + public Object get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException + { + return get(); + } + + @Override + protected void starting(Thread thread) + { + _queue.starting(this, thread); + } + + @Override + public boolean cancel(boolean mayInterruptIfRunning) + { + if (isSubmitted()) + { + PipelineJobService.get().cancelForJob(getJobGUID()); + return super.cancel(mayInterruptIfRunning); + } + return true; + } + + @Override + public boolean isDone() + { + if (!isSubmitted()) + return false; + return super.isDone(); + } + + @Override + public boolean isCancelled() + { + if (!isSubmitted()) + return false; + return super.isCancelled(); + } + + @Override + public void done(Throwable throwable) + { + if (null != throwable) + { + try + { + error("Uncaught exception in PipelineJob: " + this, throwable); + } + catch (Exception ignored) {} + } + if (_queue != null) + { + _queue.done(this); + } + + PipelineJobNotificationProvider notificationProvider = PipelineService.get().getPipelineJobNotificationProvider(getJobNotificationProvider(), this); + if (notificationProvider != null) + notificationProvider.onJobDone(this); + + finallyCleanUpLocalDirectory(); //Since this potentially contains the job log, it should be run after the notifications tasks are executed + } + + protected String getJobNotificationProvider() + { + return null; + } + + protected String getNotificationType(PipelineJob.TaskStatus status) + { + return status.getNotificationType(); + } + + public String serializeJob(boolean ensureDeserialize) + { + return PipelineJobService.get().getJobStore().serializeToJSON(this, ensureDeserialize); + } + + public static String getClassNameFromJson(String serialized) + { + // Expect [ "org.labkey....", {.... + if (StringUtils.startsWith(serialized, "[")) + { + return StringUtils.substringBetween(serialized, "\""); + } + else + { + throw new RuntimeException("Unexpected serialized JSON"); + } + } + + @Nullable + public static PipelineJob deserializeJob(@NotNull String serialized) + { + try + { + String className = PipelineJob.getClassNameFromJson(serialized); + return PipelineJobService.get().getJobStore().deserializeFromJSON(serialized, (Class)Class.forName(className)); + } + catch (ClassNotFoundException e) + { + _log.error("Deserialized class not found.", e); + } + return null; + } + + public static ObjectMapper createObjectMapper() + { + ObjectMapper mapper = JsonUtil.DEFAULT_MAPPER.copy() + .setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE) + .setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY) + .disable(SerializationFeature.FAIL_ON_EMPTY_BEANS) + .enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL); + + SimpleModule module = new SimpleModule(); + module.addSerializer(new SqlTimeSerialization.SqlTimeSerializer()); + module.addDeserializer(Time.class, new SqlTimeSerialization.SqlTimeDeserializer()); + module.addDeserializer(AtomicLong.class, new AtomicLongDeserializer()); + module.addSerializer(NullSafeBindException.class, new NullSafeBindExceptionSerializer()); + module.addSerializer(QueryKey.class, new QueryKeySerialization.Serializer()); + module.addDeserializer(SchemaKey.class, new QueryKeySerialization.SchemaKeyDeserializer()); + module.addDeserializer(FieldKey.class, new QueryKeySerialization.FieldKeyDeserializer()); + module.addSerializer(Path.class, new PathSerialization.Serializer()); + module.addDeserializer(Path.class, new PathSerialization.Deserializer()); + module.addSerializer(CronExpression.class, new CronExpressionSerialization.Serializer()); + module.addDeserializer(CronExpression.class, new CronExpressionSerialization.Deserializer()); + module.addSerializer(URI.class, new URISerialization.Serializer()); + module.addDeserializer(URI.class, new URISerialization.Deserializer()); + module.addSerializer(File.class, new FileSerialization.Serializer()); + module.addDeserializer(File.class, new FileSerialization.Deserializer()); + module.addDeserializer(Filter.class, new FilterDeserializer()); + + mapper.registerModule(module); + return mapper; + } + + public abstract static class TestSerialization extends org.junit.Assert + { + public void testSerialize(PipelineJob job, @Nullable Logger log) + { + PipelineStatusFile.JobStore jobStore = PipelineJobService.get().getJobStore(); + try + { + if (null != log) + log.info("Hi Logger is here!"); + String json = jobStore.serializeToJSON(job, true); + if (null != log) + log.info(json); + PipelineJob job2 = jobStore.deserializeFromJSON(json, job.getClass()); + if (null != log) + log.info(job2.toString()); + + List errors = job.compareJobs(job2); + if (!errors.isEmpty()) + { + fail("Pipeline objects don't match: " + StringUtils.join(errors, ",")); + } + } + catch (Exception e) + { + if (null != log) + log.error("Class not found", e); + } + } + } + + @Override + public boolean equals(Object o) + { + // Fix issue 35876: Second run of a split XTandem pipeline job not completing - don't rely on the job being + // represented in memory as a single object + if (this == o) return true; + if (!(o instanceof PipelineJob that)) return false; + return Objects.equals(_jobGUID, that._jobGUID); + } + + @Override + public int hashCode() + { + return Objects.hash(_jobGUID); + } + + public List compareJobs(PipelineJob job2) + { + PipelineJob job1 = this; + List errors = new ArrayList<>(); + if (!PropertyUtil.nullSafeEquals(job1._activeTaskId, job2._activeTaskId)) + errors.add("_activeTaskId"); + if (job1._activeTaskRetries != job2._activeTaskRetries) + errors.add("_activeTaskRetries"); + if (!PropertyUtil.nullSafeEquals(job1._activeTaskStatus, job2._activeTaskStatus)) + errors.add("_activeTaskStatus"); + if (job1._errors != job2._errors) + errors.add("_errors"); + if (job1._interrupted != job2._interrupted) + errors.add("_interrupted"); + if (!PropertyUtil.nullSafeEquals(job1._jobGUID, job2._jobGUID)) + errors.add("_jobGUID"); + if (!PropertyUtil.nullSafeEquals(job1._logFile, job2._logFile)) + { + if (null == job1._logFile || null == job2._logFile) + errors.add("_logFile"); + else if (!FileUtil.getAbsoluteCaseSensitiveFile(job1._logFile.toFile()).getAbsolutePath().equalsIgnoreCase(FileUtil.getAbsoluteCaseSensitiveFile(job2._logFile.toFile()).getAbsolutePath())) + errors.add("_logFile"); + } + if (!PropertyUtil.nullSafeEquals(job1._parentGUID, job2._parentGUID)) + errors.add("_parentGUID"); + if (!PropertyUtil.nullSafeEquals(job1._provider, job2._provider)) + errors.add("_provider"); + if (job1._submitted != job2._submitted) + errors.add("_submitted"); + + return errors; + } + + /** + * @return Path String for a local working directory, temporary if root is cloud based + */ + protected Path getWorkingDirectoryString() + { + return !getPipeRoot().isCloudRoot() ? getPipeRoot().getRootNioPath() : FileUtil.getTempDirectory().toPath(); + } + + /** + * Generate a LocalDirectory and log file, temporary if need be, for use by the job + * Note: Override getDefaultLocalDirectoryString if piperoot isn't the desired local directory + * + * @param pipeRoot Pipeline's root directory + * @param moduleName supplying the pipeline + * @param baseLogFileName base name of the log file + */ + protected final void setupLocalDirectoryAndJobLog(PipeRoot pipeRoot, String moduleName, String baseLogFileName) + { + LocalDirectory localDirectory = LocalDirectory.create(pipeRoot, moduleName, baseLogFileName, getWorkingDirectoryString()); + setLocalDirectory(localDirectory); + setLogFile(localDirectory.determineLogFile()); + } +} diff --git a/api/src/org/labkey/api/pipeline/PipelineProtocol.java b/api/src/org/labkey/api/pipeline/PipelineProtocol.java index 9aec39a64cb..c1fc87fd905 100644 --- a/api/src/org/labkey/api/pipeline/PipelineProtocol.java +++ b/api/src/org/labkey/api/pipeline/PipelineProtocol.java @@ -1,215 +1,215 @@ -/* - * Copyright (c) 2008-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.api.pipeline; - -import org.apache.commons.beanutils.PropertyUtils; -import org.apache.xmlbeans.XmlOptions; -import org.fhcrc.cpas.pipeline.protocol.xml.PipelineProtocolPropsDocument; -import org.labkey.api.util.FileUtil; -import org.labkey.api.util.NetworkDrive; -import org.labkey.api.writer.PrintWriters; -import org.labkey.vfs.FileLike; - -import java.beans.PropertyDescriptor; -import java.io.IOException; -import java.io.PrintWriter; -import java.lang.reflect.InvocationTargetException; -import java.util.HashMap; -import java.util.Map; - -/** - * A protocol captures settings to be used when running a pipeline. Values are often passed to tools - * being invoked on the command line or similar. - * Created: Oct 7, 2005 - * @author bmaclean - */ -public abstract class PipelineProtocol -{ - public static final String _xmlNamespace = "http://cpas.fhcrc.org/pipeline/protocol/xml"; - - private String name; - private String template; - - public PipelineProtocol(String name) - { - this.name = name; - } - - public String getName() - { - return name; - } - - public void setName(String name) - { - this.name = name; - } - - public abstract PipelineProtocolFactory getFactory(); - - public void validateToSave(PipeRoot root, boolean validateName, boolean abortOnExists) throws PipelineValidationException - { - if (validateName) - { - validate(root); - } - - if (getFactory().exists(root, name, false)) - { - if (abortOnExists) - { - throw new PipelineValidationException("A protocol named '" + name + "' already exists."); - } - } - else if (getFactory().exists(root, name, true)) - { - throw new PipelineValidationException("An archived protocol named '" + name + "' already exists."); - } - } - - public void validate(PipeRoot root) throws PipelineValidationException - { - validateProtocolName(); - } - - protected void validateProtocolName() throws PipelineValidationException - { - if (name == null || name.trim().isEmpty()) - throw new PipelineValidationException("Missing protocol name."); - else if (!getFactory().isValidProtocolName(name)) - throw new PipelineValidationException("The name '" + name + "' is not a valid protocol name."); - } - - public FileLike getDefinitionFile(PipeRoot root) - { - return getFactory().getProtocolFile(root, name, false); - } - - public void saveDefinition(PipeRoot root) throws IOException - { - save(getDefinitionFile(root)); - } - - public void setProperty(String propertyName, String value) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException - { - PropertyUtils.setProperty(this, propertyName, value); - } - - /** - * Method that returns properties to be saved in the properties document representing - * this protocol. This method is used by the default save method to identify what should be - * saved in the protocol document. - * @return Map of properties to be saved by the default save routine. - */ - protected Map getSaveProperties() - { - PropertyDescriptor props[] = PropertyUtils.getPropertyDescriptors(this); - Map propMap = new HashMap<>(); - - for (PropertyDescriptor prop : props) - { - String name = prop.getName(); - if ("class".equals(name)) - continue; - if (!PropertyUtils.isReadable(this, name) || - !PropertyUtils.isWriteable(this, name)) - continue; - - try - { - Object value = PropertyUtils.getProperty(this, name); - if (value != null) - { - propMap.put(name, value.toString()); - } - } - catch (Exception e) - { - } - - } - - return propMap; - } - - private void ensureDir(FileLike dir) throws IOException - { - try - { - if (!dir.exists()) - { - FileUtil.createDirectories(dir); - } - } - catch (IOException e) - { - throw new IOException("Failed to create directory '" + dir + "'."); - } - } - - public void save(FileLike file) throws IOException - { - FileLike dir = file.getParent(); - try - { - ensureDir(dir); - } - catch (IOException e) - { - NetworkDrive.ensureDrive(dir.toString()); - ensureDir(dir); - } - - PipelineProtocolPropsDocument doc = - PipelineProtocolPropsDocument.Factory.newInstance(); - PipelineProtocolPropsDocument.PipelineProtocolProps ppp = - doc.addNewPipelineProtocolProps(); - ppp.setType(getClass().getName()); - - Map propMap = getSaveProperties(); - - for (Map.Entry prop : propMap.entrySet()) - { - PipelineProtocolPropsDocument.PipelineProtocolProps.Property p = - ppp.addNewProperty(); - p.setName(prop.getKey()); - p.setStringValue(prop.getValue()); - } - - if (null != template) - ppp.setTemplate(template); - - Map mapNS = new HashMap<>(); - mapNS.put("", _xmlNamespace); - XmlOptions opts = new XmlOptions() - .setSavePrettyPrint() - .setSaveImplicitNamespaces(mapNS); - try (PrintWriter pw = PrintWriters.getPrintWriter(file.toNioPathForWrite())) - { - doc.save(pw, opts); - } - } - - public String getTemplate() - { - return template; - } - - public void setTemplate(String template) - { - this.template = template; - } -} +/* + * Copyright (c) 2008-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.api.pipeline; + +import org.apache.commons.beanutils.PropertyUtils; +import org.apache.xmlbeans.XmlOptions; +import org.fhcrc.cpas.pipeline.protocol.xml.PipelineProtocolPropsDocument; +import org.labkey.api.util.FileUtil; +import org.labkey.api.util.NetworkDrive; +import org.labkey.api.writer.PrintWriters; +import org.labkey.vfs.FileLike; + +import java.beans.PropertyDescriptor; +import java.io.IOException; +import java.io.PrintWriter; +import java.lang.reflect.InvocationTargetException; +import java.util.HashMap; +import java.util.Map; + +/** + * A protocol captures settings to be used when running a pipeline. Values are often passed to tools + * being invoked on the command line or similar. + * Created: Oct 7, 2005 + * @author bmaclean + */ +public abstract class PipelineProtocol +{ + public static final String _xmlNamespace = "http://cpas.fhcrc.org/pipeline/protocol/xml"; + + private String name; + private String template; + + public PipelineProtocol(String name) + { + this.name = name; + } + + public String getName() + { + return name; + } + + public void setName(String name) + { + this.name = name; + } + + public abstract PipelineProtocolFactory getFactory(); + + public void validateToSave(PipeRoot root, boolean validateName, boolean abortOnExists) throws PipelineValidationException + { + if (validateName) + { + validate(root); + } + + if (getFactory().exists(root, name, false)) + { + if (abortOnExists) + { + throw new PipelineValidationException("A protocol named '" + name + "' already exists."); + } + } + else if (getFactory().exists(root, name, true)) + { + throw new PipelineValidationException("An archived protocol named '" + name + "' already exists."); + } + } + + public void validate(PipeRoot root) throws PipelineValidationException + { + validateProtocolName(); + } + + protected void validateProtocolName() throws PipelineValidationException + { + if (name == null || name.trim().isEmpty()) + throw new PipelineValidationException("Missing protocol name."); + else if (!getFactory().isValidProtocolName(name)) + throw new PipelineValidationException("The name '" + name + "' is not a valid protocol name."); + } + + public FileLike getDefinitionFile(PipeRoot root) + { + return getFactory().getProtocolFile(root, name, false); + } + + public void saveDefinition(PipeRoot root) throws IOException + { + save(getDefinitionFile(root)); + } + + public void setProperty(String propertyName, String value) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException + { + PropertyUtils.setProperty(this, propertyName, value); + } + + /** + * Method that returns properties to be saved in the properties document representing + * this protocol. This method is used by the default save method to identify what should be + * saved in the protocol document. + * @return Map of properties to be saved by the default save routine. + */ + protected Map getSaveProperties() + { + PropertyDescriptor props[] = PropertyUtils.getPropertyDescriptors(this); + Map propMap = new HashMap<>(); + + for (PropertyDescriptor prop : props) + { + String name = prop.getName(); + if ("class".equals(name)) + continue; + if (!PropertyUtils.isReadable(this, name) || + !PropertyUtils.isWriteable(this, name)) + continue; + + try + { + Object value = PropertyUtils.getProperty(this, name); + if (value != null) + { + propMap.put(name, value.toString()); + } + } + catch (Exception e) + { + } + + } + + return propMap; + } + + private void ensureDir(FileLike dir) throws IOException + { + try + { + if (!dir.exists()) + { + FileUtil.createDirectories(dir); + } + } + catch (IOException e) + { + throw new IOException("Failed to create directory '" + dir + "'."); + } + } + + public void save(FileLike file) throws IOException + { + FileLike dir = file.getParent(); + try + { + ensureDir(dir); + } + catch (IOException e) + { + NetworkDrive.ensureDrive(dir.toString()); + ensureDir(dir); + } + + PipelineProtocolPropsDocument doc = + PipelineProtocolPropsDocument.Factory.newInstance(); + PipelineProtocolPropsDocument.PipelineProtocolProps ppp = + doc.addNewPipelineProtocolProps(); + ppp.setType(getClass().getName()); + + Map propMap = getSaveProperties(); + + for (Map.Entry prop : propMap.entrySet()) + { + PipelineProtocolPropsDocument.PipelineProtocolProps.Property p = + ppp.addNewProperty(); + p.setName(prop.getKey()); + p.setStringValue(prop.getValue()); + } + + if (null != template) + ppp.setTemplate(template); + + Map mapNS = new HashMap<>(); + mapNS.put("", _xmlNamespace); + XmlOptions opts = new XmlOptions() + .setSavePrettyPrint() + .setSaveImplicitNamespaces(mapNS); + try (PrintWriter pw = PrintWriters.getPrintWriter(file.toNioPathForWrite())) + { + doc.save(pw, opts); + } + } + + public String getTemplate() + { + return template; + } + + public void setTemplate(String template) + { + this.template = template; + } +} diff --git a/api/src/org/labkey/api/pipeline/PipelineProtocolFactory.java b/api/src/org/labkey/api/pipeline/PipelineProtocolFactory.java index 464922863b4..96ad3285f33 100644 --- a/api/src/org/labkey/api/pipeline/PipelineProtocolFactory.java +++ b/api/src/org/labkey/api/pipeline/PipelineProtocolFactory.java @@ -1,235 +1,235 @@ -/* - * Copyright (c) 2008-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.api.pipeline; - -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.apache.xmlbeans.XmlOptions; -import org.fhcrc.cpas.pipeline.protocol.xml.PipelineProtocolPropsDocument; -import org.labkey.api.util.FileUtil; -import org.labkey.api.util.NetworkDrive; -import org.labkey.vfs.FileLike; - -import java.io.File; -import java.io.IOException; -import java.nio.file.Files; -import java.util.Arrays; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; - -/** - * Knows how to deserialize protocol definitions that have been persisted on the server (as XML on the file system - * - * Created: Oct 7, 2005 - * @author bmaclean - */ -public abstract class PipelineProtocolFactory -{ - protected static final String _pipelineProtocolDir = "protocols"; - private static final String _archivedProtocolDir = "archived"; - - private static final Logger LOG = LogManager.getLogger(PipelineProtocolFactory.class); - - public static FileLike getProtocolRootDir(PipeRoot root) - { - FileLike systemDir = root.ensureSystemFileLike(); - return systemDir.resolveChild(_pipelineProtocolDir); - } - - public static File locateProtocolRootDir(File rootDir, File systemDir) - { - File protocolRootDir = FileUtil.appendName(systemDir, _pipelineProtocolDir); - File protocolRootDirLegacy = FileUtil.appendName(rootDir, _pipelineProtocolDir); - if (NetworkDrive.exists(protocolRootDirLegacy)) - protocolRootDirLegacy.renameTo(protocolRootDir); - return protocolRootDir; - } - - public abstract String getName(); - - public T load(PipeRoot root, String name, boolean archived) throws IOException - { - FileLike file = getProtocolFile(root, name, archived); - try - { - Map mapNS = new HashMap<>(); - mapNS.put("", PipelineProtocol._xmlNamespace); - XmlOptions opts = new XmlOptions().setLoadSubstituteNamespaces(mapNS); - - PipelineProtocolPropsDocument doc = - PipelineProtocolPropsDocument.Factory.parse(file.openInputStream(), opts); - PipelineProtocolPropsDocument.PipelineProtocolProps ppp = - doc.getPipelineProtocolProps(); - String type = ppp.getType(); - - // Recognize very old files - if (type.startsWith("org.fhcrc.cpas.ms2.")) - { - type = type.replace("org.fhcrc.cpas.ms2.", "org.labkey.ms2."); - } - if (type.startsWith("org.labkey.ms2.protocol.")) - { - type = type.replace("org.labkey.ms2.protocol.", "org.labkey.ms2.pipeline."); - } - - PipelineProtocol protocol = (PipelineProtocol) Class.forName(type).getDeclaredConstructor().newInstance(); - PipelineProtocolPropsDocument.PipelineProtocolProps.Property[] props = - ppp.getPropertyArray(); - if (ppp.isSetTemplate()) - { - String template = ppp.getTemplate(); - protocol.setTemplate(template); - } - - for (PipelineProtocolPropsDocument.PipelineProtocolProps.Property prop : props) - { - protocol.setProperty(prop.getName(), prop.getStringValue()); - } - - return (T) protocol; - } - catch (Exception e) - { - throw new IOException("Failed to load protocol document " + file + ".", e); - } - } - - public boolean isValidProtocolName(String name) - { - return FileUtil.isLegalName(name); - } - - public boolean exists(PipeRoot root, String name, boolean archived) - { - return getProtocolFile(root, name, archived).exists(); - } - - public FileLike getProtocolDir(PipeRoot root, boolean archived) - { - FileLike protocolDir = getProtocolRootDir(root).resolveChild(getName()); - if (archived) - protocolDir = protocolDir.resolveChild(_archivedProtocolDir); - return protocolDir; - } - - public FileLike getProtocolFile(PipeRoot root, String name, boolean archived) - { - return getProtocolDir(root, archived).resolveChild(name + ".xml"); - } - - /** @return sorted list of protocol names */ - public String[] getProtocolNames(PipeRoot root, FileLike dirData, boolean archived) - { - HashSet setNames = new HashSet<>(); - - // Add .xml files - List files = getProtocolDir(root, archived).getChildren(f -> f.getName().endsWith(".xml") && !f.isDirectory()); - for (FileLike file : files) - { - final String name = file.getName(); - setNames.add(name.substring(0, name.lastIndexOf('.'))); - } - - // Add all directories that already exist in the analysis root. - if (dirData != null && !archived) - { - files = dirData.resolveChild(getName()).getChildren(FileLike::isDirectory); - - for (FileLike file : files) - setNames.add(file.getName()); - } - - String[] vals = setNames.toArray(new String[0]); - Arrays.sort(vals, String.CASE_INSENSITIVE_ORDER); - return vals; - } - - /** - * Move the file for the specified protocol to or from the archived directory - * @param root pipeline root for the container - * @param name the protocol name - * @param moveToArchive true if archiving the protocol; false for unarchiving - * @return true if the file was successfully moved or does not exist; false on error moving or if the archived directory - * can't be created - */ - public boolean changeArchiveStatus(PipeRoot root, String name, boolean moveToArchive) throws IOException - { - // Is the file's current location opposite the destination? No sense in moving it if it's already where the caller wants it. - if (exists(root, name, !moveToArchive)) - { - if (moveToArchive) - { - FileLike archiveDir = getProtocolDir(root, true); - if (!archiveDir.exists()) - { - FileUtil.createDirectories(archiveDir); - } - else if (archiveDir.isFile()) - { - LOG.error("Unable to create archived directory because a file with that name exists in the protocol directory: " - + getProtocolDir(root, false)); - return false; - } - } - - try - { - Files.move(getProtocolFile(root, name, !moveToArchive).toNioPathForWrite(), getProtocolFile(root, name, moveToArchive).toNioPathForWrite()); - } - catch (IOException e) - { - return false; - } - - return true; - } - return true; // We don't care if the file doesn't exist (maybe was already in the destination?) - } - - /** - * Delete the xml file of the specified protocol. Tries to resolve the file in the main folder first. - * If the file doesn't exist there, look in the archived folder - * @param root pipeline root for the container - * @param name the protocol name - * @return true if the file was successfully deleted or does not exist - */ - public boolean deleteProtocolFile(PipeRoot root, String name) - { - FileLike protocolFile = getProtocolFile(root, name, false); - - //If it doesn't exist, check archive - if (!protocolFile.exists()) - protocolFile = getProtocolFile(root, name, true); - - //If it still doesn't exist, move on - if (!protocolFile.exists()) - { - return true; // We don't care if the file doesn't exist - } - - try - { - return protocolFile.delete(); - } - catch (IOException e) - { - LOG.debug("Error attempting to delete protocol file " + protocolFile, e); - return false; - } - } -} +/* + * Copyright (c) 2008-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.api.pipeline; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.xmlbeans.XmlOptions; +import org.fhcrc.cpas.pipeline.protocol.xml.PipelineProtocolPropsDocument; +import org.labkey.api.util.FileUtil; +import org.labkey.api.util.NetworkDrive; +import org.labkey.vfs.FileLike; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; + +/** + * Knows how to deserialize protocol definitions that have been persisted on the server (as XML on the file system + * + * Created: Oct 7, 2005 + * @author bmaclean + */ +public abstract class PipelineProtocolFactory +{ + protected static final String _pipelineProtocolDir = "protocols"; + private static final String _archivedProtocolDir = "archived"; + + private static final Logger LOG = LogManager.getLogger(PipelineProtocolFactory.class); + + public static FileLike getProtocolRootDir(PipeRoot root) + { + FileLike systemDir = root.ensureSystemFileLike(); + return systemDir.resolveChild(_pipelineProtocolDir); + } + + public static File locateProtocolRootDir(File rootDir, File systemDir) + { + File protocolRootDir = FileUtil.appendName(systemDir, _pipelineProtocolDir); + File protocolRootDirLegacy = FileUtil.appendName(rootDir, _pipelineProtocolDir); + if (NetworkDrive.exists(protocolRootDirLegacy)) + protocolRootDirLegacy.renameTo(protocolRootDir); + return protocolRootDir; + } + + public abstract String getName(); + + public T load(PipeRoot root, String name, boolean archived) throws IOException + { + FileLike file = getProtocolFile(root, name, archived); + try + { + Map mapNS = new HashMap<>(); + mapNS.put("", PipelineProtocol._xmlNamespace); + XmlOptions opts = new XmlOptions().setLoadSubstituteNamespaces(mapNS); + + PipelineProtocolPropsDocument doc = + PipelineProtocolPropsDocument.Factory.parse(file.openInputStream(), opts); + PipelineProtocolPropsDocument.PipelineProtocolProps ppp = + doc.getPipelineProtocolProps(); + String type = ppp.getType(); + + // Recognize very old files + if (type.startsWith("org.fhcrc.cpas.ms2.")) + { + type = type.replace("org.fhcrc.cpas.ms2.", "org.labkey.ms2."); + } + if (type.startsWith("org.labkey.ms2.protocol.")) + { + type = type.replace("org.labkey.ms2.protocol.", "org.labkey.ms2.pipeline."); + } + + PipelineProtocol protocol = (PipelineProtocol) Class.forName(type).getDeclaredConstructor().newInstance(); + PipelineProtocolPropsDocument.PipelineProtocolProps.Property[] props = + ppp.getPropertyArray(); + if (ppp.isSetTemplate()) + { + String template = ppp.getTemplate(); + protocol.setTemplate(template); + } + + for (PipelineProtocolPropsDocument.PipelineProtocolProps.Property prop : props) + { + protocol.setProperty(prop.getName(), prop.getStringValue()); + } + + return (T) protocol; + } + catch (Exception e) + { + throw new IOException("Failed to load protocol document " + file + ".", e); + } + } + + public boolean isValidProtocolName(String name) + { + return FileUtil.isLegalName(name); + } + + public boolean exists(PipeRoot root, String name, boolean archived) + { + return getProtocolFile(root, name, archived).exists(); + } + + public FileLike getProtocolDir(PipeRoot root, boolean archived) + { + FileLike protocolDir = getProtocolRootDir(root).resolveChild(getName()); + if (archived) + protocolDir = protocolDir.resolveChild(_archivedProtocolDir); + return protocolDir; + } + + public FileLike getProtocolFile(PipeRoot root, String name, boolean archived) + { + return getProtocolDir(root, archived).resolveChild(name + ".xml"); + } + + /** @return sorted list of protocol names */ + public String[] getProtocolNames(PipeRoot root, FileLike dirData, boolean archived) + { + HashSet setNames = new HashSet<>(); + + // Add .xml files + List files = getProtocolDir(root, archived).getChildren(f -> f.getName().endsWith(".xml") && !f.isDirectory()); + for (FileLike file : files) + { + final String name = file.getName(); + setNames.add(name.substring(0, name.lastIndexOf('.'))); + } + + // Add all directories that already exist in the analysis root. + if (dirData != null && !archived) + { + files = dirData.resolveChild(getName()).getChildren(FileLike::isDirectory); + + for (FileLike file : files) + setNames.add(file.getName()); + } + + String[] vals = setNames.toArray(new String[0]); + Arrays.sort(vals, String.CASE_INSENSITIVE_ORDER); + return vals; + } + + /** + * Move the file for the specified protocol to or from the archived directory + * @param root pipeline root for the container + * @param name the protocol name + * @param moveToArchive true if archiving the protocol; false for unarchiving + * @return true if the file was successfully moved or does not exist; false on error moving or if the archived directory + * can't be created + */ + public boolean changeArchiveStatus(PipeRoot root, String name, boolean moveToArchive) throws IOException + { + // Is the file's current location opposite the destination? No sense in moving it if it's already where the caller wants it. + if (exists(root, name, !moveToArchive)) + { + if (moveToArchive) + { + FileLike archiveDir = getProtocolDir(root, true); + if (!archiveDir.exists()) + { + FileUtil.createDirectories(archiveDir); + } + else if (archiveDir.isFile()) + { + LOG.error("Unable to create archived directory because a file with that name exists in the protocol directory: " + + getProtocolDir(root, false)); + return false; + } + } + + try + { + Files.move(getProtocolFile(root, name, !moveToArchive).toNioPathForWrite(), getProtocolFile(root, name, moveToArchive).toNioPathForWrite()); + } + catch (IOException e) + { + return false; + } + + return true; + } + return true; // We don't care if the file doesn't exist (maybe was already in the destination?) + } + + /** + * Delete the xml file of the specified protocol. Tries to resolve the file in the main folder first. + * If the file doesn't exist there, look in the archived folder + * @param root pipeline root for the container + * @param name the protocol name + * @return true if the file was successfully deleted or does not exist + */ + public boolean deleteProtocolFile(PipeRoot root, String name) + { + FileLike protocolFile = getProtocolFile(root, name, false); + + //If it doesn't exist, check archive + if (!protocolFile.exists()) + protocolFile = getProtocolFile(root, name, true); + + //If it still doesn't exist, move on + if (!protocolFile.exists()) + { + return true; // We don't care if the file doesn't exist + } + + try + { + return protocolFile.delete(); + } + catch (IOException e) + { + LOG.debug("Error attempting to delete protocol file " + protocolFile, e); + return false; + } + } +} diff --git a/api/src/org/labkey/api/pipeline/PipelineService.java b/api/src/org/labkey/api/pipeline/PipelineService.java index 5e98e88dab5..823ab57515c 100644 --- a/api/src/org/labkey/api/pipeline/PipelineService.java +++ b/api/src/org/labkey/api/pipeline/PipelineService.java @@ -1,275 +1,275 @@ -/* - * Copyright (c) 2008-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.api.pipeline; - -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.labkey.api.admin.ImportOptions; -import org.labkey.api.data.Container; -import org.labkey.api.data.ContainerFilter; -import org.labkey.api.data.TableInfo; -import org.labkey.api.exp.api.ExpRun; -import org.labkey.api.pipeline.file.AbstractFileAnalysisProtocolFactory; -import org.labkey.api.pipeline.view.SetupForm; -import org.labkey.api.query.QueryView; -import org.labkey.api.security.User; -import org.labkey.api.services.ServiceRegistry; -import org.labkey.api.study.FolderArchiveSource; -import org.labkey.api.trigger.TriggerConfiguration; -import org.labkey.api.view.ActionURL; -import org.labkey.api.view.HttpView; -import org.labkey.api.view.ViewBackgroundInfo; -import org.labkey.api.view.ViewContext; -import org.labkey.vfs.FileLike; -import org.springframework.validation.BindException; - -import java.io.File; -import java.io.IOException; -import java.net.URI; -import java.nio.file.Path; -import java.sql.SQLException; -import java.util.Collection; -import java.util.Date; -import java.util.List; -import java.util.Map; - -/** - * Capabilities provided by the Pipeline module to other modules. These methods are only available to code - * that is running within the web server. {@link PipelineJobService} provides basic pipeline job and task - * functionality that is also available on remote execution pipeline servers. - */ -public interface PipelineService extends PipelineStatusFile.StatusReader, PipelineStatusFile.StatusWriter -{ - String MODULE_NAME = "Pipeline"; - String UNZIP_DIR = ".unzip"; // '.' prefix prevents search indexing - String EXPORT_DIR = "export"; - String CACHE_DIR = ".cache"; - - String PRIMARY_ROOT = "PRIMARY"; - - static PipelineService get() - { - return ServiceRegistry.get().getService(PipelineService.class); - } - - static void setInstance(PipelineService instance) - { - ServiceRegistry.get().registerService(PipelineService.class, instance); - } - - /** - * Statically register a single PipelineProvider that's implemented in code. - * @param provider PipelineProvider to register - * @param aliases Alternate names for this provider - */ - void registerPipelineProvider(PipelineProvider provider, String... aliases); - - void registerPipelineJobNotificationProvider(PipelineJobNotificationProvider provider); - - /** - * Register a supplier of (likely multiple) PipelineProviders. Suppliers are called any time the service returns all - * providers or resolves a single provider. This allows the provider list to be dynamic, for example, as file-based - * assay definitions change. - * @param supplier PipelineProviderSupplier - */ - void registerPipelineProviderSupplier(PipelineProviderSupplier supplier); - - /** - * Looks up the container hierarchy until it finds a pipeline root defined which is being - * inherited by the specified container - * @return null if there's no specific pipeline override and the default root is unavailable or misconfigured - */ - @Nullable - PipeRoot findPipelineRoot(Container container); - - /** - * Looks up the container hierarchy until it finds a pipeline root defined which is being - * inherited by the specified container - * @return null if there's no specific pipeline override and the default root is unavailable or misconfigured - */ - @Nullable - PipeRoot findPipelineRoot(Container container, String type); - - - /** @return true if this container (or an inherited parent container) has a pipeline root that exists on disk */ - boolean hasValidPipelineRoot(Container container); - - @NotNull - Map getAllPipelineRoots(); - - @Nullable - PipeRoot getPipelineRootSetting(Container container); - - /** - * Gets the pipeline root that was explicitly configured for this container, or falls back to the default file root. - * Does NOT look up the container hierarchy for a pipeline override defined in a parent container. In most - * places where not explicitly doing pipeline configuration, use findPipelineRoot() instead. - */ - @Nullable - PipeRoot getPipelineRootSetting(Container container, String type); - - void setPipelineRoot(User user, Container container, String type, boolean searchable, URI... roots) throws SQLException; - - boolean canModifyPipelineRoot(User user, Container container); - - @NotNull - List getPipelineProviders(); - - @Nullable - PipelineProvider getPipelineProvider(String name); - - boolean isEnterprisePipeline(); - - /** Generate command-line arguments to launch org.labkey.pipeline.cluster.ClusterStartup. Intended for automated tests */ - List getClusterStartupArguments() throws IOException; - - enum JmsType { none, inProcess, external, unknown } - @NotNull - JmsType getJmsType(); - - - @NotNull - PipelineQueue getPipelineQueue(); - - /** - * Add a PipelineJob to this queue to be run. - * - * @param job Job to be run - */ - void queueJob(PipelineJob job) throws PipelineValidationException; - - void queueJob(PipelineJob job, String jobNotificationProvider) throws PipelineValidationException; - - /** - * This will update the active task status of this job and re-queue that job if the task is complete - */ - void setPipelineJobStatus(PipelineJob job, PipelineJob.TaskStatus status) throws PipelineJobException; - - /** Update the path to the log file for this job */ - void setPipelineJobStatusFilePath(PipelineJob job, Path otherFile); - - void setPipelineProperty(Container container, String name, String value); - - String getPipelineProperty(Container container, String name); - - @NotNull - String startFileAnalysis(AnalyzeForm form, @Nullable Map variableMap, ViewContext viewContext) throws IOException, PipelineValidationException; - - @NotNull - String startFileAnalysis(AnalyzeForm form, @Nullable Map variableMap, ViewBackgroundInfo context) throws IOException, PipelineValidationException; - - @NotNull - String startFileAnalysis(AnalyzeForm form, @Nullable Map variableMap, ViewBackgroundInfo context, boolean timestampLog) throws IOException, PipelineValidationException; - - - /** Configurations for the pipeline job webpart ButtonBar */ - enum PipelineButtonOption { Minimal, Assay, Standard } - - QueryView getPipelineQueryView(ViewContext context, PipelineButtonOption buttonOption); - - HttpView getSetupView(SetupForm form); - - boolean savePipelineSetup(ViewContext context, SetupForm form, BindException errors) throws Exception; - - // TODO: This should be on PipelineProtocolFactory - String getLastProtocolSetting(PipelineProtocolFactory factory, Container container, User user); - - // TODO: This should be on PipelineProtocolFactory - void rememberLastProtocolSetting(PipelineProtocolFactory factory, Container container, - User user, String protocolName); - boolean hasSiteDefaultRoot(Container container); - - TableInfo getJobsTable(User user, Container container); - - TableInfo getJobsTable(User user, Container container, @Nullable ContainerFilter cf); - - boolean runFolderImportJob(Container c, User user, ActionURL url, Path folderXml, String originalFilename, PipeRoot pipelineRoot, ImportOptions options); - - /** - * Register a folder archive source implementation. A FolderArchiveSource creates folder artifacts that can be - * imported automatically via the folder import framework. The source of the artifacts could be an external - * repository or server. - */ - void registerFolderArchiveSource(FolderArchiveSource reloadSource); - - Collection getFolderArchiveSources(Container container); - - @Nullable - FolderArchiveSource getFolderArchiveSource(String name); - - boolean runGenerateFolderArchiveAndImportJob(Container c, User user, ActionURL url, String sourceName); - boolean runGenerateFolderArchiveAndImportJob(Container c, User user, ActionURL url, ImportOptions options); - - Long getJobId(User u, Container c, String jobGUID); - String getJobGUID(User u, Container c, long rowId); - - PathAnalysisProperties getFileAnalysisProperties(Container c, String taskId, String path); - - TriggerConfiguration getTriggerConfig(Container c, String name); - void saveTriggerConfig(Container c, User user, TriggerConfiguration config) throws Exception; - void setTriggeredTime(Container container, User user, int triggerConfigId, Path filePath, Date date); - - class PathAnalysisProperties - { - private final PipeRoot _pipeRoot; - private final FileLike _dirData; - private final AbstractFileAnalysisProtocolFactory _factory; - - public PathAnalysisProperties(PipeRoot pipeRoot, FileLike dirData, AbstractFileAnalysisProtocolFactory factory) - { - _pipeRoot = pipeRoot; - _dirData = dirData; - _factory = factory; - } - - public PipeRoot getPipeRoot() - { - return _pipeRoot; - } - - @Nullable - public FileLike getDirData() - { - return _dirData; - } - - public AbstractFileAnalysisProtocolFactory getFactory() - { - return _factory; - } - } - - boolean isProtocolDefined(AnalyzeForm form); - - @Nullable - File getProtocolParametersFile(ExpRun expRun); - - void deleteStatusFile(Container c, User u, boolean deleteExpRuns, Collection rowIds) throws PipelineProvider.HandlerException; - - PipelineJobNotificationProvider getPipelineJobNotificationProvider(@Nullable String name); - - PipelineJobNotificationProvider getPipelineJobNotificationProvider(@Nullable String name, PipelineJob job); - - Collection> getActivePipelineJobs(User u, Container c, String providerName); - - Collection> getActivePipelineJobs(User u, Container c, String providerName, @Nullable ContainerFilter cf); - - interface PipelineProviderSupplier - { - @NotNull Collection getAll(); - @Nullable PipelineProvider findPipelineProvider(String name); - } -} +/* + * Copyright (c) 2008-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.api.pipeline; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.labkey.api.admin.ImportOptions; +import org.labkey.api.data.Container; +import org.labkey.api.data.ContainerFilter; +import org.labkey.api.data.TableInfo; +import org.labkey.api.exp.api.ExpRun; +import org.labkey.api.pipeline.file.AbstractFileAnalysisProtocolFactory; +import org.labkey.api.pipeline.view.SetupForm; +import org.labkey.api.query.QueryView; +import org.labkey.api.security.User; +import org.labkey.api.services.ServiceRegistry; +import org.labkey.api.study.FolderArchiveSource; +import org.labkey.api.trigger.TriggerConfiguration; +import org.labkey.api.view.ActionURL; +import org.labkey.api.view.HttpView; +import org.labkey.api.view.ViewBackgroundInfo; +import org.labkey.api.view.ViewContext; +import org.labkey.vfs.FileLike; +import org.springframework.validation.BindException; + +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.nio.file.Path; +import java.sql.SQLException; +import java.util.Collection; +import java.util.Date; +import java.util.List; +import java.util.Map; + +/** + * Capabilities provided by the Pipeline module to other modules. These methods are only available to code + * that is running within the web server. {@link PipelineJobService} provides basic pipeline job and task + * functionality that is also available on remote execution pipeline servers. + */ +public interface PipelineService extends PipelineStatusFile.StatusReader, PipelineStatusFile.StatusWriter +{ + String MODULE_NAME = "Pipeline"; + String UNZIP_DIR = ".unzip"; // '.' prefix prevents search indexing + String EXPORT_DIR = "export"; + String CACHE_DIR = ".cache"; + + String PRIMARY_ROOT = "PRIMARY"; + + static PipelineService get() + { + return ServiceRegistry.get().getService(PipelineService.class); + } + + static void setInstance(PipelineService instance) + { + ServiceRegistry.get().registerService(PipelineService.class, instance); + } + + /** + * Statically register a single PipelineProvider that's implemented in code. + * @param provider PipelineProvider to register + * @param aliases Alternate names for this provider + */ + void registerPipelineProvider(PipelineProvider provider, String... aliases); + + void registerPipelineJobNotificationProvider(PipelineJobNotificationProvider provider); + + /** + * Register a supplier of (likely multiple) PipelineProviders. Suppliers are called any time the service returns all + * providers or resolves a single provider. This allows the provider list to be dynamic, for example, as file-based + * assay definitions change. + * @param supplier PipelineProviderSupplier + */ + void registerPipelineProviderSupplier(PipelineProviderSupplier supplier); + + /** + * Looks up the container hierarchy until it finds a pipeline root defined which is being + * inherited by the specified container + * @return null if there's no specific pipeline override and the default root is unavailable or misconfigured + */ + @Nullable + PipeRoot findPipelineRoot(Container container); + + /** + * Looks up the container hierarchy until it finds a pipeline root defined which is being + * inherited by the specified container + * @return null if there's no specific pipeline override and the default root is unavailable or misconfigured + */ + @Nullable + PipeRoot findPipelineRoot(Container container, String type); + + + /** @return true if this container (or an inherited parent container) has a pipeline root that exists on disk */ + boolean hasValidPipelineRoot(Container container); + + @NotNull + Map getAllPipelineRoots(); + + @Nullable + PipeRoot getPipelineRootSetting(Container container); + + /** + * Gets the pipeline root that was explicitly configured for this container, or falls back to the default file root. + * Does NOT look up the container hierarchy for a pipeline override defined in a parent container. In most + * places where not explicitly doing pipeline configuration, use findPipelineRoot() instead. + */ + @Nullable + PipeRoot getPipelineRootSetting(Container container, String type); + + void setPipelineRoot(User user, Container container, String type, boolean searchable, URI... roots) throws SQLException; + + boolean canModifyPipelineRoot(User user, Container container); + + @NotNull + List getPipelineProviders(); + + @Nullable + PipelineProvider getPipelineProvider(String name); + + boolean isEnterprisePipeline(); + + /** Generate command-line arguments to launch org.labkey.pipeline.cluster.ClusterStartup. Intended for automated tests */ + List getClusterStartupArguments() throws IOException; + + enum JmsType { none, inProcess, external, unknown } + @NotNull + JmsType getJmsType(); + + + @NotNull + PipelineQueue getPipelineQueue(); + + /** + * Add a PipelineJob to this queue to be run. + * + * @param job Job to be run + */ + void queueJob(PipelineJob job) throws PipelineValidationException; + + void queueJob(PipelineJob job, String jobNotificationProvider) throws PipelineValidationException; + + /** + * This will update the active task status of this job and re-queue that job if the task is complete + */ + void setPipelineJobStatus(PipelineJob job, PipelineJob.TaskStatus status) throws PipelineJobException; + + /** Update the path to the log file for this job */ + void setPipelineJobStatusFilePath(PipelineJob job, Path otherFile); + + void setPipelineProperty(Container container, String name, String value); + + String getPipelineProperty(Container container, String name); + + @NotNull + String startFileAnalysis(AnalyzeForm form, @Nullable Map variableMap, ViewContext viewContext) throws IOException, PipelineValidationException; + + @NotNull + String startFileAnalysis(AnalyzeForm form, @Nullable Map variableMap, ViewBackgroundInfo context) throws IOException, PipelineValidationException; + + @NotNull + String startFileAnalysis(AnalyzeForm form, @Nullable Map variableMap, ViewBackgroundInfo context, boolean timestampLog) throws IOException, PipelineValidationException; + + + /** Configurations for the pipeline job webpart ButtonBar */ + enum PipelineButtonOption { Minimal, Assay, Standard } + + QueryView getPipelineQueryView(ViewContext context, PipelineButtonOption buttonOption); + + HttpView getSetupView(SetupForm form); + + boolean savePipelineSetup(ViewContext context, SetupForm form, BindException errors) throws Exception; + + // TODO: This should be on PipelineProtocolFactory + String getLastProtocolSetting(PipelineProtocolFactory factory, Container container, User user); + + // TODO: This should be on PipelineProtocolFactory + void rememberLastProtocolSetting(PipelineProtocolFactory factory, Container container, + User user, String protocolName); + boolean hasSiteDefaultRoot(Container container); + + TableInfo getJobsTable(User user, Container container); + + TableInfo getJobsTable(User user, Container container, @Nullable ContainerFilter cf); + + boolean runFolderImportJob(Container c, User user, ActionURL url, Path folderXml, String originalFilename, PipeRoot pipelineRoot, ImportOptions options); + + /** + * Register a folder archive source implementation. A FolderArchiveSource creates folder artifacts that can be + * imported automatically via the folder import framework. The source of the artifacts could be an external + * repository or server. + */ + void registerFolderArchiveSource(FolderArchiveSource reloadSource); + + Collection getFolderArchiveSources(Container container); + + @Nullable + FolderArchiveSource getFolderArchiveSource(String name); + + boolean runGenerateFolderArchiveAndImportJob(Container c, User user, ActionURL url, String sourceName); + boolean runGenerateFolderArchiveAndImportJob(Container c, User user, ActionURL url, ImportOptions options); + + Long getJobId(User u, Container c, String jobGUID); + String getJobGUID(User u, Container c, long rowId); + + PathAnalysisProperties getFileAnalysisProperties(Container c, String taskId, String path); + + TriggerConfiguration getTriggerConfig(Container c, String name); + void saveTriggerConfig(Container c, User user, TriggerConfiguration config) throws Exception; + void setTriggeredTime(Container container, User user, int triggerConfigId, Path filePath, Date date); + + class PathAnalysisProperties + { + private final PipeRoot _pipeRoot; + private final FileLike _dirData; + private final AbstractFileAnalysisProtocolFactory _factory; + + public PathAnalysisProperties(PipeRoot pipeRoot, FileLike dirData, AbstractFileAnalysisProtocolFactory factory) + { + _pipeRoot = pipeRoot; + _dirData = dirData; + _factory = factory; + } + + public PipeRoot getPipeRoot() + { + return _pipeRoot; + } + + @Nullable + public FileLike getDirData() + { + return _dirData; + } + + public AbstractFileAnalysisProtocolFactory getFactory() + { + return _factory; + } + } + + boolean isProtocolDefined(AnalyzeForm form); + + @Nullable + File getProtocolParametersFile(ExpRun expRun); + + void deleteStatusFile(Container c, User u, boolean deleteExpRuns, Collection rowIds) throws PipelineProvider.HandlerException; + + PipelineJobNotificationProvider getPipelineJobNotificationProvider(@Nullable String name); + + PipelineJobNotificationProvider getPipelineJobNotificationProvider(@Nullable String name, PipelineJob job); + + Collection> getActivePipelineJobs(User u, Container c, String providerName); + + Collection> getActivePipelineJobs(User u, Container c, String providerName, @Nullable ContainerFilter cf); + + interface PipelineProviderSupplier + { + @NotNull Collection getAll(); + @Nullable PipelineProvider findPipelineProvider(String name); + } +} diff --git a/api/src/org/labkey/api/pipeline/PipelineStatusFile.java b/api/src/org/labkey/api/pipeline/PipelineStatusFile.java index 4ce12d218f5..63a656e2ccc 100644 --- a/api/src/org/labkey/api/pipeline/PipelineStatusFile.java +++ b/api/src/org/labkey/api/pipeline/PipelineStatusFile.java @@ -1,144 +1,144 @@ -/* - * Copyright (c) 2008-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.api.pipeline; - -import org.jetbrains.annotations.Nullable; -import org.labkey.api.data.Container; -import org.labkey.vfs.FileLike; - -import java.io.File; -import java.io.IOException; -import java.nio.file.Path; -import java.sql.SQLException; -import java.util.Date; -import java.util.List; - -/** - * The serializable data object for the current state of a pipeline job. - * - * @author brendanx - */ -public interface PipelineStatusFile -{ - interface StatusReader - { - @Deprecated - PipelineStatusFile getStatusFile(File logFile); - PipelineStatusFile getStatusFile(Container container, Path logFile); - default PipelineStatusFile getStatusFile(Container container, FileLike logFile) - { - return getStatusFile(container, logFile.toNioPathForRead()); - } - - PipelineStatusFile getStatusFile(long rowId); - - PipelineStatusFile getStatusFile(String jobGuid); - - List getQueuedStatusFiles() throws SQLException; - - List getQueuedStatusFiles(Container c) throws SQLException; - } - - interface StatusWriter - { - boolean setStatus(PipelineJob job, String status, @Nullable String statusInfo, boolean allowInsert) throws Exception; - - void ensureError(PipelineJob job) throws Exception; - - /** - * If a location can be serviced by multiple servers, we record the hostname of which server is RUNNING a given task. - * This is currently only supported for Remote Servers, but could be expanded for clusters. - * - * @param hostName The hostname the status writer should use when updating pipeline.StatusFiles - */ - void setHostName(String hostName); - } - - interface JobStore - { - void storeJob(PipelineJob job) throws NoSuchJobException; - - PipelineJob getJob(String jobId); - - PipelineJob getJob(long rowId); - - void retry(String jobId) throws IOException, NoSuchJobException; - - void retry(PipelineStatusFile sf) throws IOException, NoSuchJobException; - - void split(PipelineJob job) throws IOException; - - void join(PipelineJob job) throws IOException, NoSuchJobException; - - String serializeToJSON(PipelineJob job, boolean ensureDeserialize); - - PipelineJob deserializeFromJSON(String xml, Class cls); - } - - Container lookupContainer(); - - boolean isActive(); - - Date getCreated(); - - Date getModified(); - - long getRowId(); - - String getJobId(); - - String getJobParentId(); - - /** - * @return the name of the {@link PipelineProvider} for this job. Used to provide hooks for - * doing work before deletion of the job, etc - */ - @Nullable - String getProvider(); - - String getStatus(); - - void setStatus(String status); - - String getInfo(); - - void setInfo(String info); - - String getFilePath(); - - String getDataUrl(); - - String getDescription(); - - String getEmail(); - - boolean isHadError(); - - String getJobStore(); - - PipelineJob createJobInstance(); - - void save(); - - /** - * - * @return which of multiple hostnames for a location is RUNNING a task. Only set for tasks in a RUNNING state on a remote - * server. If active task is in an inactive state or running on the web server or a cluster, this will be null. - */ - @Nullable - String getActiveHostName(); -} - +/* + * Copyright (c) 2008-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.api.pipeline; + +import org.jetbrains.annotations.Nullable; +import org.labkey.api.data.Container; +import org.labkey.vfs.FileLike; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.sql.SQLException; +import java.util.Date; +import java.util.List; + +/** + * The serializable data object for the current state of a pipeline job. + * + * @author brendanx + */ +public interface PipelineStatusFile +{ + interface StatusReader + { + @Deprecated + PipelineStatusFile getStatusFile(File logFile); + PipelineStatusFile getStatusFile(Container container, Path logFile); + default PipelineStatusFile getStatusFile(Container container, FileLike logFile) + { + return getStatusFile(container, logFile.toNioPathForRead()); + } + + PipelineStatusFile getStatusFile(long rowId); + + PipelineStatusFile getStatusFile(String jobGuid); + + List getQueuedStatusFiles() throws SQLException; + + List getQueuedStatusFiles(Container c) throws SQLException; + } + + interface StatusWriter + { + boolean setStatus(PipelineJob job, String status, @Nullable String statusInfo, boolean allowInsert) throws Exception; + + void ensureError(PipelineJob job) throws Exception; + + /** + * If a location can be serviced by multiple servers, we record the hostname of which server is RUNNING a given task. + * This is currently only supported for Remote Servers, but could be expanded for clusters. + * + * @param hostName The hostname the status writer should use when updating pipeline.StatusFiles + */ + void setHostName(String hostName); + } + + interface JobStore + { + void storeJob(PipelineJob job) throws NoSuchJobException; + + PipelineJob getJob(String jobId); + + PipelineJob getJob(long rowId); + + void retry(String jobId) throws IOException, NoSuchJobException; + + void retry(PipelineStatusFile sf) throws IOException, NoSuchJobException; + + void split(PipelineJob job) throws IOException; + + void join(PipelineJob job) throws IOException, NoSuchJobException; + + String serializeToJSON(PipelineJob job, boolean ensureDeserialize); + + PipelineJob deserializeFromJSON(String xml, Class cls); + } + + Container lookupContainer(); + + boolean isActive(); + + Date getCreated(); + + Date getModified(); + + long getRowId(); + + String getJobId(); + + String getJobParentId(); + + /** + * @return the name of the {@link PipelineProvider} for this job. Used to provide hooks for + * doing work before deletion of the job, etc + */ + @Nullable + String getProvider(); + + String getStatus(); + + void setStatus(String status); + + String getInfo(); + + void setInfo(String info); + + String getFilePath(); + + String getDataUrl(); + + String getDescription(); + + String getEmail(); + + boolean isHadError(); + + String getJobStore(); + + PipelineJob createJobInstance(); + + void save(); + + /** + * + * @return which of multiple hostnames for a location is RUNNING a task. Only set for tasks in a RUNNING state on a remote + * server. If active task is in an inactive state or running on the web server or a cluster, this will be null. + */ + @Nullable + String getActiveHostName(); +} + diff --git a/api/src/org/labkey/api/pipeline/RecordedAction.java b/api/src/org/labkey/api/pipeline/RecordedAction.java index 6461b63fb25..23abf52bcdb 100644 --- a/api/src/org/labkey/api/pipeline/RecordedAction.java +++ b/api/src/org/labkey/api/pipeline/RecordedAction.java @@ -1,549 +1,549 @@ -/* - * Copyright (c) 2008-2018 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.api.pipeline; - -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.fasterxml.jackson.databind.annotation.JsonSerialize; -import org.labkey.api.exp.PropertyDescriptor; -import org.labkey.api.exp.PropertyType; -import org.labkey.api.util.FileUtil; -import org.labkey.api.util.Pair; -import org.labkey.vfs.FileLike; - -import java.io.File; -import java.io.Serializable; -import java.net.URI; -import java.util.Collections; -import java.util.Date; -import java.util.HashSet; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.Map; -import java.util.Set; - -/** - * Used to record an action performed by the pipeline. Consumed by XarGeneratorTask, which will create a full - * experiment run to document the steps performed. - * User: jeckels - * Date: Jul 25, 2008 - */ -public class RecordedAction -{ - public static final ParameterType COMMAND_LINE_PARAM = new ParameterType("Command line", "terms.labkey.org#CommandLine", PropertyType.STRING); - - private final Set _inputs = new LinkedHashSet<>(); - private final Set _outputs = new LinkedHashSet<>(); - - @JsonSerialize(keyUsing = ObjectKeySerialization.Serializer.class) - @JsonDeserialize(keyUsing = ObjectKeySerialization.Deserializer.class) - private Map _params = new LinkedHashMap<>(); - @JsonSerialize(keyUsing = ObjectKeySerialization.Serializer.class) - @JsonDeserialize(keyUsing = ObjectKeySerialization.Deserializer.class) - private Map _outputParams = new LinkedHashMap<>(); - - @JsonSerialize(keyUsing = ObjectKeySerialization.Serializer.class) - @JsonDeserialize(keyUsing = ObjectKeySerialization.Deserializer.class) - private Map _props = new LinkedHashMap<>(); - private String _name; - private String _description; - private Date _activityDate; - private Date _startTime; - private Date _endTime; - private Integer _recordCount; - private String _runName; - private String _comments; - - // Provenance map (list of from and to lsid pairs) - private Set> _provenanceMap = new HashSet<>(); - // Set of lsids - private Set _materialInputs = new HashSet<>(); - private Set _materialOutputs = new HashSet<>(); - - // set of lsids - private Set _objectInputs = new HashSet<>(); - private Set _objectOutputs = new HashSet<>(); - - private boolean _isStart; - private boolean _isEnd; - - /** No-args constructor to support de-serialization in Java 7 and beyond */ - @SuppressWarnings({"UnusedDeclaration"}) - public RecordedAction() {} - - public RecordedAction(String name) - { - setName(name); - setDescription(name); - } - - public void addInput(File input, String role) - { - addInput(input.toURI(), role); - } - - public void addInput(FileLike input, String role) - { - addInput(input.toNioPathForRead().toFile(), role); - } - - private boolean uriExists(URI toTest, Set set) - { - for (DataFile df : set) - { - if (toTest.equals(df.getURI())) - { - return true; - } - } - - return false; - } - - public void addInput(URI input, String role) - { - addInput(input, role, true); - } - - public void addInputIfNotPresent(File input, String role) - { - addInput(input.toURI(), role, false); - } - - /** - * Exp.data has a constraint that will only allow a given file - * once per action, so by default this will throw an exception - * if the same file is added twice as an input. Alternately, - * addInputIfNotPresent() which will silently ignore duplicate files. - */ - private void addInput(URI input, String role, boolean throwIfExists) - { - if (!uriExists(input, _inputs)) - { - _inputs.add(new DataFile(input, role, false, false)); - } - else if (throwIfExists) - { - throw new IllegalArgumentException("Already has been added as an input for the action " + getName() + ":" + FileUtil.uriToString(input)); - } - } - - /** - * Exp.data has a constraint that will only allow a given file - * once per action, so by default this will throw an exception - * if the same file is added twice as an output. Alternately, - * addOutputIfNotPresent() which will silently ignore duplicate files. - */ - public void addOutput(File output, String role, boolean transientFile) - { - addOutput(output.toURI(), role, transientFile, false); - } - - public void addOutputIfNotPresent(File output, String role, boolean transientFile) - { - addOutput(output.toURI(), role, transientFile, false, false); - } - - public void addOutput(File output, String role, boolean transientFile, boolean generated) - { - addOutput(output.toURI(), role, transientFile, generated); - } - - public void addOutput(URI output, String role, boolean transientFile) - { - addOutput(output, role, transientFile, false); - } - - public void addOutput(URI output, String role, boolean transientFile, boolean generated) - { - addOutput(output, role, transientFile, generated, true); - } - - private void addOutput(URI output, String role, boolean transientFile, boolean generated, boolean throwIfExists) - { - if (!uriExists(output, _outputs)) - { - _outputs.add(new DataFile(output, role, transientFile, generated)); - } - else if (throwIfExists) - { - throw new IllegalArgumentException("Already has been added as an output for the action " + getName() + ":" + FileUtil.uriToString(output)); - } - } - - public Set getInputs() - { - return Collections.unmodifiableSet(_inputs); - } - - public Set getOutputs() - { - return Collections.unmodifiableSet(_outputs); - } - - public String getName() - { - return _name; - } - - public void setName(String name) - { - _name = name; - } - - public String getRunName() - { - return _runName; - } - - public void setRunName(String runName) - { - _runName = runName; - } - - public String getDescription() - { - return _description; - } - - public void setDescription(String description) - { - _description = description; - } - - public Date getActivityDate() - { - return _activityDate; - } - - public void setActivityDate(Date activityDate) - { - _activityDate = activityDate; - } - - public void setStartTime(Date startTime) - { - _startTime = startTime; - } - - public Date getStartTime() - { - return _startTime; - } - - public void setEndTime(Date endTime) - { - _endTime = endTime; - } - - public Date getEndTime() - { - return _endTime; - } - - public void setRecordCount(Integer recordCount) - { - _recordCount = recordCount; - } - - public Integer getRecordCount() - { - return _recordCount; - } - - public void addParameter(ParameterType type, Object value) - { - _params.put(type, value); - } - - public void addOutputParameter(ParameterType type, Object value) - { - _outputParams.put(type, value); - } - - public void addProperty(PropertyDescriptor pd, Object value ) - { - _props.put(pd,value); - } - - public Map getParams() - { - return Collections.unmodifiableMap(_params); - } - - public Map getOutputParams() - { - return Collections.unmodifiableMap(_outputParams); - } - - public Map getProps() - { - return Collections.unmodifiableMap(_props); - } - - public Set> getProvenanceMap() - { - return _provenanceMap; - } - - public void setProvenanceMap(Set> provenanceMap) - { - _provenanceMap = provenanceMap; - } - - public Set getMaterialInputs() - { - return _materialInputs; - } - - public void setMaterialInputs(Set materialInputs) - { - _materialInputs = materialInputs; - } - - public Set getMaterialOutputs() - { - return _materialOutputs; - } - - public void setMaterialOutputs(Set materialOutputs) - { - _materialOutputs = materialOutputs; - } - - public Set getObjectInputs() - { - return _objectInputs; - } - - public void setObjectInputs(Set objectInputs) - { - _objectInputs = objectInputs; - } - - public Set getObjectOutputs() - { - return _objectOutputs; - } - - public void setObjectOutputs(Set objectOutputs) - { - _objectOutputs = objectOutputs; - } - - public void setProps(Map props) - { - _props = props; - } - - public boolean isStart() - { - return _isStart; - } - - public void setStart(boolean start) - { - _isStart = start; - } - - public boolean isEnd() - { - return _isEnd; - } - - public void setEnd(boolean end) - { - _isEnd = end; - } - - public String getComments() - { - return _comments; - } - - public void setComments(String comments) - { - _comments = comments; - } - - public static class ParameterType implements Serializable - { - public static String createUri(String name) - { - return "terms.labkey.org#" + name.replaceAll("\\s",""); - } - - private String _uri; - private String _name; - private PropertyType _type; - - // No-args constructor to support de-serialization in Java 7 - @SuppressWarnings({"UnusedDeclaration"}) - public ParameterType() - { - } - - public ParameterType(String name, PropertyType type) - { - this(name, createUri(name), type); - } - - public ParameterType(String name, String uri, PropertyType type) - { - _name = name; - _uri = uri; - _type = type; - } - - public String getURI() - { - return _uri; - } - - public String getName() - { - return _name; - } - - public PropertyType getType() - { - return _type; - } - } - - public static class DataFile - { - private URI _uri; - private String _role; - private boolean _transient; - private boolean _generated; - - // No-args constructor to support de-serialization in Java 7 - @SuppressWarnings({"UnusedDeclaration"}) - public DataFile() - { - } - - public DataFile(URI uri, String role, boolean transientFile, boolean generated) - { - _uri = uri; - _role = role; - _transient = transientFile; - _generated = generated; - } - - public URI getURI() - { - return _uri; - } - - public String getRole() - { - return _role; - } - - public boolean isTransient() - { - return _transient; - } - - public boolean equals(Object o) - { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - - DataFile that = (DataFile) o; - - if (_role != null ? !_role.equals(that._role) : that._role != null) return false; - return !(_uri != null ? !_uri.equals(that._uri) : that._uri != null); - } - - public int hashCode() - { - int result; - result = (_uri != null ? _uri.hashCode() : 0); - result = 31 * result + (_role != null ? _role.hashCode() : 0); - return result; - } - - public boolean isGenerated() - { - return _generated; - } - } - - public String toString() - { - return _description + " Inputs: " + _inputs + " Outputs: " + _outputs; - } - - public boolean updateForMovedFile(File original, File moved) - { - boolean changed = false; - - if (potentiallySwapFiles(original, moved, _inputs)) - { - changed = true; - } - - if (potentiallySwapFiles(original, moved, _outputs)) - { - changed = true; - } - - return changed; - } - - private boolean potentiallySwapFiles(File original, File moved, Set toInspect) - { - boolean changed = false; - for (DataFile df : toInspect) - { - if (original.toURI().equals(df.getURI())) - { - df._uri = moved.toURI(); - changed = true; - } - } - - return changed; - } - - @Override - public boolean equals(Object o) - { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - - RecordedAction that = (RecordedAction) o; - - if (_description != null ? !_description.equals(that._description) : that._description != null) return false; - if (_inputs != null ? !_inputs.equals(that._inputs) : that._inputs != null) return false; - if (_name != null ? !_name.equals(that._name) : that._name != null) return false; - if (_outputs != null ? !_outputs.equals(that._outputs) : that._outputs != null) return false; - return !(_params != null ? !_params.equals(that._params) : that._params != null); - } - - @Override - public int hashCode() - { - int result = _inputs != null ? _inputs.hashCode() : 0; - result = 31 * result + (_outputs != null ? _outputs.hashCode() : 0); - result = 31 * result + (_params != null ? _params.hashCode() : 0); - result = 31 * result + (_name != null ? _name.hashCode() : 0); - result = 31 * result + (_description != null ? _description.hashCode() : 0); - return result; - } -} +/* + * Copyright (c) 2008-2018 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.api.pipeline; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import org.labkey.api.exp.PropertyDescriptor; +import org.labkey.api.exp.PropertyType; +import org.labkey.api.util.FileUtil; +import org.labkey.api.util.Pair; +import org.labkey.vfs.FileLike; + +import java.io.File; +import java.io.Serializable; +import java.net.URI; +import java.util.Collections; +import java.util.Date; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +/** + * Used to record an action performed by the pipeline. Consumed by XarGeneratorTask, which will create a full + * experiment run to document the steps performed. + * User: jeckels + * Date: Jul 25, 2008 + */ +public class RecordedAction +{ + public static final ParameterType COMMAND_LINE_PARAM = new ParameterType("Command line", "terms.labkey.org#CommandLine", PropertyType.STRING); + + private final Set _inputs = new LinkedHashSet<>(); + private final Set _outputs = new LinkedHashSet<>(); + + @JsonSerialize(keyUsing = ObjectKeySerialization.Serializer.class) + @JsonDeserialize(keyUsing = ObjectKeySerialization.Deserializer.class) + private Map _params = new LinkedHashMap<>(); + @JsonSerialize(keyUsing = ObjectKeySerialization.Serializer.class) + @JsonDeserialize(keyUsing = ObjectKeySerialization.Deserializer.class) + private Map _outputParams = new LinkedHashMap<>(); + + @JsonSerialize(keyUsing = ObjectKeySerialization.Serializer.class) + @JsonDeserialize(keyUsing = ObjectKeySerialization.Deserializer.class) + private Map _props = new LinkedHashMap<>(); + private String _name; + private String _description; + private Date _activityDate; + private Date _startTime; + private Date _endTime; + private Integer _recordCount; + private String _runName; + private String _comments; + + // Provenance map (list of from and to lsid pairs) + private Set> _provenanceMap = new HashSet<>(); + // Set of lsids + private Set _materialInputs = new HashSet<>(); + private Set _materialOutputs = new HashSet<>(); + + // set of lsids + private Set _objectInputs = new HashSet<>(); + private Set _objectOutputs = new HashSet<>(); + + private boolean _isStart; + private boolean _isEnd; + + /** No-args constructor to support de-serialization in Java 7 and beyond */ + @SuppressWarnings({"UnusedDeclaration"}) + public RecordedAction() {} + + public RecordedAction(String name) + { + setName(name); + setDescription(name); + } + + public void addInput(File input, String role) + { + addInput(input.toURI(), role); + } + + public void addInput(FileLike input, String role) + { + addInput(input.toNioPathForRead().toFile(), role); + } + + private boolean uriExists(URI toTest, Set set) + { + for (DataFile df : set) + { + if (toTest.equals(df.getURI())) + { + return true; + } + } + + return false; + } + + public void addInput(URI input, String role) + { + addInput(input, role, true); + } + + public void addInputIfNotPresent(File input, String role) + { + addInput(input.toURI(), role, false); + } + + /** + * Exp.data has a constraint that will only allow a given file + * once per action, so by default this will throw an exception + * if the same file is added twice as an input. Alternately, + * addInputIfNotPresent() which will silently ignore duplicate files. + */ + private void addInput(URI input, String role, boolean throwIfExists) + { + if (!uriExists(input, _inputs)) + { + _inputs.add(new DataFile(input, role, false, false)); + } + else if (throwIfExists) + { + throw new IllegalArgumentException("Already has been added as an input for the action " + getName() + ":" + FileUtil.uriToString(input)); + } + } + + /** + * Exp.data has a constraint that will only allow a given file + * once per action, so by default this will throw an exception + * if the same file is added twice as an output. Alternately, + * addOutputIfNotPresent() which will silently ignore duplicate files. + */ + public void addOutput(File output, String role, boolean transientFile) + { + addOutput(output.toURI(), role, transientFile, false); + } + + public void addOutputIfNotPresent(File output, String role, boolean transientFile) + { + addOutput(output.toURI(), role, transientFile, false, false); + } + + public void addOutput(File output, String role, boolean transientFile, boolean generated) + { + addOutput(output.toURI(), role, transientFile, generated); + } + + public void addOutput(URI output, String role, boolean transientFile) + { + addOutput(output, role, transientFile, false); + } + + public void addOutput(URI output, String role, boolean transientFile, boolean generated) + { + addOutput(output, role, transientFile, generated, true); + } + + private void addOutput(URI output, String role, boolean transientFile, boolean generated, boolean throwIfExists) + { + if (!uriExists(output, _outputs)) + { + _outputs.add(new DataFile(output, role, transientFile, generated)); + } + else if (throwIfExists) + { + throw new IllegalArgumentException("Already has been added as an output for the action " + getName() + ":" + FileUtil.uriToString(output)); + } + } + + public Set getInputs() + { + return Collections.unmodifiableSet(_inputs); + } + + public Set getOutputs() + { + return Collections.unmodifiableSet(_outputs); + } + + public String getName() + { + return _name; + } + + public void setName(String name) + { + _name = name; + } + + public String getRunName() + { + return _runName; + } + + public void setRunName(String runName) + { + _runName = runName; + } + + public String getDescription() + { + return _description; + } + + public void setDescription(String description) + { + _description = description; + } + + public Date getActivityDate() + { + return _activityDate; + } + + public void setActivityDate(Date activityDate) + { + _activityDate = activityDate; + } + + public void setStartTime(Date startTime) + { + _startTime = startTime; + } + + public Date getStartTime() + { + return _startTime; + } + + public void setEndTime(Date endTime) + { + _endTime = endTime; + } + + public Date getEndTime() + { + return _endTime; + } + + public void setRecordCount(Integer recordCount) + { + _recordCount = recordCount; + } + + public Integer getRecordCount() + { + return _recordCount; + } + + public void addParameter(ParameterType type, Object value) + { + _params.put(type, value); + } + + public void addOutputParameter(ParameterType type, Object value) + { + _outputParams.put(type, value); + } + + public void addProperty(PropertyDescriptor pd, Object value ) + { + _props.put(pd,value); + } + + public Map getParams() + { + return Collections.unmodifiableMap(_params); + } + + public Map getOutputParams() + { + return Collections.unmodifiableMap(_outputParams); + } + + public Map getProps() + { + return Collections.unmodifiableMap(_props); + } + + public Set> getProvenanceMap() + { + return _provenanceMap; + } + + public void setProvenanceMap(Set> provenanceMap) + { + _provenanceMap = provenanceMap; + } + + public Set getMaterialInputs() + { + return _materialInputs; + } + + public void setMaterialInputs(Set materialInputs) + { + _materialInputs = materialInputs; + } + + public Set getMaterialOutputs() + { + return _materialOutputs; + } + + public void setMaterialOutputs(Set materialOutputs) + { + _materialOutputs = materialOutputs; + } + + public Set getObjectInputs() + { + return _objectInputs; + } + + public void setObjectInputs(Set objectInputs) + { + _objectInputs = objectInputs; + } + + public Set getObjectOutputs() + { + return _objectOutputs; + } + + public void setObjectOutputs(Set objectOutputs) + { + _objectOutputs = objectOutputs; + } + + public void setProps(Map props) + { + _props = props; + } + + public boolean isStart() + { + return _isStart; + } + + public void setStart(boolean start) + { + _isStart = start; + } + + public boolean isEnd() + { + return _isEnd; + } + + public void setEnd(boolean end) + { + _isEnd = end; + } + + public String getComments() + { + return _comments; + } + + public void setComments(String comments) + { + _comments = comments; + } + + public static class ParameterType implements Serializable + { + public static String createUri(String name) + { + return "terms.labkey.org#" + name.replaceAll("\\s",""); + } + + private String _uri; + private String _name; + private PropertyType _type; + + // No-args constructor to support de-serialization in Java 7 + @SuppressWarnings({"UnusedDeclaration"}) + public ParameterType() + { + } + + public ParameterType(String name, PropertyType type) + { + this(name, createUri(name), type); + } + + public ParameterType(String name, String uri, PropertyType type) + { + _name = name; + _uri = uri; + _type = type; + } + + public String getURI() + { + return _uri; + } + + public String getName() + { + return _name; + } + + public PropertyType getType() + { + return _type; + } + } + + public static class DataFile + { + private URI _uri; + private String _role; + private boolean _transient; + private boolean _generated; + + // No-args constructor to support de-serialization in Java 7 + @SuppressWarnings({"UnusedDeclaration"}) + public DataFile() + { + } + + public DataFile(URI uri, String role, boolean transientFile, boolean generated) + { + _uri = uri; + _role = role; + _transient = transientFile; + _generated = generated; + } + + public URI getURI() + { + return _uri; + } + + public String getRole() + { + return _role; + } + + public boolean isTransient() + { + return _transient; + } + + public boolean equals(Object o) + { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + DataFile that = (DataFile) o; + + if (_role != null ? !_role.equals(that._role) : that._role != null) return false; + return !(_uri != null ? !_uri.equals(that._uri) : that._uri != null); + } + + public int hashCode() + { + int result; + result = (_uri != null ? _uri.hashCode() : 0); + result = 31 * result + (_role != null ? _role.hashCode() : 0); + return result; + } + + public boolean isGenerated() + { + return _generated; + } + } + + public String toString() + { + return _description + " Inputs: " + _inputs + " Outputs: " + _outputs; + } + + public boolean updateForMovedFile(File original, File moved) + { + boolean changed = false; + + if (potentiallySwapFiles(original, moved, _inputs)) + { + changed = true; + } + + if (potentiallySwapFiles(original, moved, _outputs)) + { + changed = true; + } + + return changed; + } + + private boolean potentiallySwapFiles(File original, File moved, Set toInspect) + { + boolean changed = false; + for (DataFile df : toInspect) + { + if (original.toURI().equals(df.getURI())) + { + df._uri = moved.toURI(); + changed = true; + } + } + + return changed; + } + + @Override + public boolean equals(Object o) + { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + RecordedAction that = (RecordedAction) o; + + if (_description != null ? !_description.equals(that._description) : that._description != null) return false; + if (_inputs != null ? !_inputs.equals(that._inputs) : that._inputs != null) return false; + if (_name != null ? !_name.equals(that._name) : that._name != null) return false; + if (_outputs != null ? !_outputs.equals(that._outputs) : that._outputs != null) return false; + return !(_params != null ? !_params.equals(that._params) : that._params != null); + } + + @Override + public int hashCode() + { + int result = _inputs != null ? _inputs.hashCode() : 0; + result = 31 * result + (_outputs != null ? _outputs.hashCode() : 0); + result = 31 * result + (_params != null ? _params.hashCode() : 0); + result = 31 * result + (_name != null ? _name.hashCode() : 0); + result = 31 * result + (_description != null ? _description.hashCode() : 0); + return result; + } +} diff --git a/api/src/org/labkey/api/pipeline/RecordedActionSet.java b/api/src/org/labkey/api/pipeline/RecordedActionSet.java index 589f133922d..d0496a22f9e 100644 --- a/api/src/org/labkey/api/pipeline/RecordedActionSet.java +++ b/api/src/org/labkey/api/pipeline/RecordedActionSet.java @@ -1,102 +1,102 @@ -/* - * Copyright (c) 2008-2018 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.api.pipeline; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.fasterxml.jackson.databind.annotation.JsonSerialize; -import org.labkey.vfs.FileLike; - -import java.net.URI; -import java.util.Arrays; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.Map; -import java.util.Set; - -/** - * A collection of all the recorded actions performed in the context of a single pipeline job. - * User: jeckels - * Date: Aug 8, 2008 - */ -public class RecordedActionSet -{ - private final Set _actions; - @JsonSerialize(keyUsing = StringKeySerialization.Serializer.class) - @JsonDeserialize(keyUsing = StringKeySerialization.URIDeserializer.class) - private final Map _otherInputs; - - @JsonCreator - private RecordedActionSet(@JsonProperty("_actions") Set actions) - { - _actions = actions; - _otherInputs = new LinkedHashMap<>(); - } - - public RecordedActionSet() - { - this(Collections.emptyList()); - } - - public RecordedActionSet(RecordedAction... actions) - { - this(Arrays.asList(actions)); - } - - public RecordedActionSet(Iterable actions) - { - _actions = new LinkedHashSet<>(); - for (RecordedAction action : actions) - { - _actions.add(action); - } - _otherInputs = new LinkedHashMap<>(); - } - - public RecordedActionSet(RecordedActionSet actionSet) - { - this(); - add(actionSet); - } - - public Set getActions() - { - return _actions; - } - - public Map getOtherInputs() - { - return _otherInputs; - } - - public void add(FileLike inputFile, String inputRole) - { - _otherInputs.put(inputFile.toNioPathForRead().toUri(), inputRole); - } - - public void add(RecordedActionSet set) - { - _actions.addAll(set.getActions()); - _otherInputs.putAll(set.getOtherInputs()); - } - - public void add(RecordedAction action) - { - _actions.add(action); - } -} +/* + * Copyright (c) 2008-2018 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.api.pipeline; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import org.labkey.vfs.FileLike; + +import java.net.URI; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +/** + * A collection of all the recorded actions performed in the context of a single pipeline job. + * User: jeckels + * Date: Aug 8, 2008 + */ +public class RecordedActionSet +{ + private final Set _actions; + @JsonSerialize(keyUsing = StringKeySerialization.Serializer.class) + @JsonDeserialize(keyUsing = StringKeySerialization.URIDeserializer.class) + private final Map _otherInputs; + + @JsonCreator + private RecordedActionSet(@JsonProperty("_actions") Set actions) + { + _actions = actions; + _otherInputs = new LinkedHashMap<>(); + } + + public RecordedActionSet() + { + this(Collections.emptyList()); + } + + public RecordedActionSet(RecordedAction... actions) + { + this(Arrays.asList(actions)); + } + + public RecordedActionSet(Iterable actions) + { + _actions = new LinkedHashSet<>(); + for (RecordedAction action : actions) + { + _actions.add(action); + } + _otherInputs = new LinkedHashMap<>(); + } + + public RecordedActionSet(RecordedActionSet actionSet) + { + this(); + add(actionSet); + } + + public Set getActions() + { + return _actions; + } + + public Map getOtherInputs() + { + return _otherInputs; + } + + public void add(FileLike inputFile, String inputRole) + { + _otherInputs.put(inputFile.toNioPathForRead().toUri(), inputRole); + } + + public void add(RecordedActionSet set) + { + _actions.addAll(set.getActions()); + _otherInputs.putAll(set.getOtherInputs()); + } + + public void add(RecordedAction action) + { + _actions.add(action); + } +} diff --git a/api/src/org/labkey/api/pipeline/WorkDirectory.java b/api/src/org/labkey/api/pipeline/WorkDirectory.java index a9645975fe3..58b90294576 100644 --- a/api/src/org/labkey/api/pipeline/WorkDirectory.java +++ b/api/src/org/labkey/api/pipeline/WorkDirectory.java @@ -1,145 +1,145 @@ -/* - * Copyright (c) 2008-2015 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.api.pipeline; - -import org.labkey.api.pipeline.cmd.TaskPath; -import org.labkey.api.util.FileType; -import org.labkey.vfs.FileLike; - -import java.io.File; -import java.io.IOException; -import java.util.List; -import java.util.Map; - -/** - * Represents a working directory in which files are made available to pipeline tasks. Typically, output files - * are stored in this working directory and moved to their desired permanent location after the task has completed - * successfully. Additionally, file inputs may be copied to the directory to ensure they are local, providing better - * IO performance. - * - * @author brendanx - */ -public interface WorkDirectory -{ - enum Function { - /** File is an input into the job. */ - input, - /** File is an output of the job. */ - output, - /** File is a relative path from the root of the {@link TaskPipeline#getDeclaringModule()}. */ - module - } - - /** - * @return the directory where the input files live and where the output files will end up - */ - File getDir(); - - /** Informs the WorkDirectory that a new file is being created. It is treated as a Function.output */ - File newFile(String name); - - /** Informs the WorkDirectory that a new file is being created. */ - File newFile(Function f, String name); - - /** Informs the WorkDirectory that a new file is being created. It is treated as a Function.output */ - File newFile(FileType type); - - /** Informs the WorkDirectory that a new file is being created. */ - File newFile(Function f, FileType type); - - /** - * Indicates that a file is to be used as input. The implementation can choose whether it needs to be copied, unless - * forceCopy is true (in which case it will always be copied to the work directory - * @return the full path to the file where it is available for use - */ - File inputFile(File fileInput, boolean forceCopy) throws IOException; - - default File inputFile(FileLike fileInput, boolean forceCopy) throws IOException - { - return inputFile(fileInput.toNioPathForRead().toFile(), forceCopy); - } - - - /** - * Indicates that a file is to be used as input. The implementation can choose whether it needs to be copied, unless - * forceCopy is true (in which case it will always be copied to the work directory. This version of the method allows the caller - * to manually specify the destination file, which allows callers to place files into subdirectories of the work directory - * @return the full path to the file where it is available for use - */ - File inputFile(File fileInput, File fileWork, boolean forceCopy) throws IOException; - - /** @return the relative path of the file relative to the work directory itself. The file is presumed to be under the work directory. */ - String getRelativePath(File fileWork) throws IOException; - - /** - * @return the final location for file after it's copied out of the work directory - */ - File outputFile(File fileWork) throws IOException; - - /** - * @return the final location for file after it's copied out of the work directory - */ - File outputFile(File fileWork, String nameDest) throws IOException; - - /** - * @return copies the file to the specified location - */ - File outputFile(File fileWork, File dest) throws IOException; - - /** - * Delete a file from the working directory - */ - void discardFile(File fileWork) throws IOException; - - /** Deletes any inputs that were copied into this working directory */ - void discardCopiedInputs() throws IOException; - - /** - * Associates all of the output files now in the work directory (including those that were explicitly declared as - * expected outputs and any other files that might be present) with the RecordedAction - */ - void acceptFilesAsOutputs(Map expectedOutputs, RecordedAction action) throws IOException; - - /** - * Cleans up any lingering inputs and deletes the working directory - * @param success whether or not the task completed successfully. If so, it's fair to complain about unexpected - * files that are still left. If not, don't add additional errors to the log. - */ - void remove(boolean success) throws IOException; - - /** - * Pipeline inputs are copied to the working directory. If the passed file was already copied to the work directory, this will - * return the local copy. - */ - File getWorkingCopyForInput(File f); - - /** - * Ensures that we have a lock, if needed. The lock must be released by the caller. Locks can be configured so that - * we do not have too many separate network file operations in place across multiple machines. - */ - CopyingResource ensureCopyingLock() throws IOException; - - List getWorkFiles(Function f, TaskPath tp); - - File newWorkFile(Function output, TaskPath taskPath, String baseName); - - /** A lock for copying files over a network share, for convenient use with try-with-resources */ - interface CopyingResource extends AutoCloseable - { - @Override - void close(); - } -} +/* + * Copyright (c) 2008-2015 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.api.pipeline; + +import org.labkey.api.pipeline.cmd.TaskPath; +import org.labkey.api.util.FileType; +import org.labkey.vfs.FileLike; + +import java.io.File; +import java.io.IOException; +import java.util.List; +import java.util.Map; + +/** + * Represents a working directory in which files are made available to pipeline tasks. Typically, output files + * are stored in this working directory and moved to their desired permanent location after the task has completed + * successfully. Additionally, file inputs may be copied to the directory to ensure they are local, providing better + * IO performance. + * + * @author brendanx + */ +public interface WorkDirectory +{ + enum Function { + /** File is an input into the job. */ + input, + /** File is an output of the job. */ + output, + /** File is a relative path from the root of the {@link TaskPipeline#getDeclaringModule()}. */ + module + } + + /** + * @return the directory where the input files live and where the output files will end up + */ + File getDir(); + + /** Informs the WorkDirectory that a new file is being created. It is treated as a Function.output */ + File newFile(String name); + + /** Informs the WorkDirectory that a new file is being created. */ + File newFile(Function f, String name); + + /** Informs the WorkDirectory that a new file is being created. It is treated as a Function.output */ + File newFile(FileType type); + + /** Informs the WorkDirectory that a new file is being created. */ + File newFile(Function f, FileType type); + + /** + * Indicates that a file is to be used as input. The implementation can choose whether it needs to be copied, unless + * forceCopy is true (in which case it will always be copied to the work directory + * @return the full path to the file where it is available for use + */ + File inputFile(File fileInput, boolean forceCopy) throws IOException; + + default File inputFile(FileLike fileInput, boolean forceCopy) throws IOException + { + return inputFile(fileInput.toNioPathForRead().toFile(), forceCopy); + } + + + /** + * Indicates that a file is to be used as input. The implementation can choose whether it needs to be copied, unless + * forceCopy is true (in which case it will always be copied to the work directory. This version of the method allows the caller + * to manually specify the destination file, which allows callers to place files into subdirectories of the work directory + * @return the full path to the file where it is available for use + */ + File inputFile(File fileInput, File fileWork, boolean forceCopy) throws IOException; + + /** @return the relative path of the file relative to the work directory itself. The file is presumed to be under the work directory. */ + String getRelativePath(File fileWork) throws IOException; + + /** + * @return the final location for file after it's copied out of the work directory + */ + File outputFile(File fileWork) throws IOException; + + /** + * @return the final location for file after it's copied out of the work directory + */ + File outputFile(File fileWork, String nameDest) throws IOException; + + /** + * @return copies the file to the specified location + */ + File outputFile(File fileWork, File dest) throws IOException; + + /** + * Delete a file from the working directory + */ + void discardFile(File fileWork) throws IOException; + + /** Deletes any inputs that were copied into this working directory */ + void discardCopiedInputs() throws IOException; + + /** + * Associates all of the output files now in the work directory (including those that were explicitly declared as + * expected outputs and any other files that might be present) with the RecordedAction + */ + void acceptFilesAsOutputs(Map expectedOutputs, RecordedAction action) throws IOException; + + /** + * Cleans up any lingering inputs and deletes the working directory + * @param success whether or not the task completed successfully. If so, it's fair to complain about unexpected + * files that are still left. If not, don't add additional errors to the log. + */ + void remove(boolean success) throws IOException; + + /** + * Pipeline inputs are copied to the working directory. If the passed file was already copied to the work directory, this will + * return the local copy. + */ + File getWorkingCopyForInput(File f); + + /** + * Ensures that we have a lock, if needed. The lock must be released by the caller. Locks can be configured so that + * we do not have too many separate network file operations in place across multiple machines. + */ + CopyingResource ensureCopyingLock() throws IOException; + + List getWorkFiles(Function f, TaskPath tp); + + File newWorkFile(Function output, TaskPath taskPath, String baseName); + + /** A lock for copying files over a network share, for convenient use with try-with-resources */ + interface CopyingResource extends AutoCloseable + { + @Override + void close(); + } +} diff --git a/api/src/org/labkey/api/pipeline/browse/PipelinePathForm.java b/api/src/org/labkey/api/pipeline/browse/PipelinePathForm.java index e19ac52483a..60e98c6e642 100644 --- a/api/src/org/labkey/api/pipeline/browse/PipelinePathForm.java +++ b/api/src/org/labkey/api/pipeline/browse/PipelinePathForm.java @@ -1,193 +1,193 @@ -/* - * Copyright (c) 2008-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.api.pipeline.browse; - -import org.labkey.api.data.Container; -import org.labkey.api.exp.api.ExpData; -import org.labkey.api.exp.api.ExperimentService; -import org.labkey.api.pipeline.PipeRoot; -import org.labkey.api.pipeline.PipelineService; -import org.labkey.api.security.permissions.ReadPermission; -import org.labkey.api.util.FileUtil; -import org.labkey.api.util.NetworkDrive; -import org.labkey.api.view.NotFoundException; -import org.labkey.api.view.ViewForm; -import org.labkey.vfs.FileLike; - -import java.io.File; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; - -/** - * PipelinePathForm - * - * Form bean class for pipeline root navigation. - */ -public class PipelinePathForm extends ViewForm -{ - private String _path; - private String[] _file = new String[0]; - private int[] _fileIds = new int[0]; - - public String getPath() - { - return _path; - } - - public void setPath(String path) - { - _path = path; - } - - public String[] getFile() - { - return _file; - } - - public void setFile(String[] file) - { - if (null == file) - return; - for (String s : file) - { - if (s != null) - { - if (s.contains("..") || s.contains("/") || s.contains("\\")) - { - throw new IllegalArgumentException("File names should not include any path information"); - } - } - } - _file = file; - } - - public int[] getFileIds() - { - return _fileIds; - } - - public void setFileIds(int[] fileIds) - { - _fileIds = fileIds; - } - - /** - * For the string filesnames provided, ensures that the files are all in the same directory, which is under the container's pipeline root, - * and that they all exist on disk, though they could be directories, not files. - * For ExpData IDs provided, ensures the files exists and the user has read permission on the associated container. The files do not need to be located in the same directory. - * Throws NotFoundException if no files are specified, invalid files are specified, there's no pipeline root, etc. - */ - public List getValidatedFiles(Container c) - { - return getValidatedFiles(c, false); - } - - public List getValidatedFiles(Container c, boolean allowNonExistentFiles) - { - PipeRoot pr = getPipeRoot(c); - - FileLike dir = pr.resolvePathToFileLike(getPath()); - if (dir == null || !dir.exists()) - throw new NotFoundException("Could not find path " + getPath()); - - if ((getFile() == null || getFile().length == 0) && (getFileIds() == null || getFileIds().length == 0)) - { - throw new NotFoundException("No files specified"); - } - - List result = new ArrayList<>(); - for (String fileName : _file) - { - FileLike f = pr.resolvePathToFileLike(getPath() + "/" + fileName); - if (!allowNonExistentFiles && !NetworkDrive.exists(f)) - { - throw new NotFoundException("Could not find file '" + fileName + "' in '" + getPath() + "'"); - } - result.add(f); - } - - ExperimentService es = ExperimentService.get(); - if (_fileIds != null) - { - for (int fileId : _fileIds) - { - ExpData data = es.getExpData(fileId); - if(data == null) - { - throw new NotFoundException("Could not find file associated with Data Id: '" + fileId); - } - - if (!data.getContainer().hasPermission(getUser(), ReadPermission.class)) - { - throw new NotFoundException("Insufficient permissions for file '" + data.getFile()); - } - - FileLike file = data.getFileLike(); - if (!allowNonExistentFiles && !NetworkDrive.exists(file)) - { - throw new NotFoundException("Could not find file '" + file + "'"); - } - result.add(file); - } - } - - return result; - } - - public List getValidatedPaths(Container c, boolean allowNonExistentFiles) - { - List files = getValidatedFiles(c, allowNonExistentFiles); - List result = new ArrayList<>(); - for (FileLike file : files) - { - result.add(file.toNioPathForRead()); - } - return result; - } - - public PipeRoot getPipeRoot(Container c) - { - PipeRoot pr = PipelineService.get().findPipelineRoot(c); - if (pr == null) - throw new NotFoundException("Could not find a pipeline root for " + c.getPath()); - return pr; - } - - /** Verifies that only a single file was selected and returns it, throwing an exception if there isn't exactly one */ - public FileLike getValidatedSingleFile(Container c) - { - List files = getValidatedFiles(c); - if (files.size() != 1) - { - throw new IllegalArgumentException("Expected a single file but got " + files.size()); - } - return files.get(0); - } - - /** Verifies that only a single file was selected and returns it, throwing an exception if there isn't exactly one */ - @Deprecated // use the FileLike version - public Path getValidatedSinglePath(Container c) - { - List files = getValidatedPaths(c, false); - if (files.size() != 1) - { - throw new IllegalArgumentException("Expected a single file but got " + files.size()); - } - return files.get(0); - } -} +/* + * Copyright (c) 2008-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.api.pipeline.browse; + +import org.labkey.api.data.Container; +import org.labkey.api.exp.api.ExpData; +import org.labkey.api.exp.api.ExperimentService; +import org.labkey.api.pipeline.PipeRoot; +import org.labkey.api.pipeline.PipelineService; +import org.labkey.api.security.permissions.ReadPermission; +import org.labkey.api.util.FileUtil; +import org.labkey.api.util.NetworkDrive; +import org.labkey.api.view.NotFoundException; +import org.labkey.api.view.ViewForm; +import org.labkey.vfs.FileLike; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** + * PipelinePathForm + * + * Form bean class for pipeline root navigation. + */ +public class PipelinePathForm extends ViewForm +{ + private String _path; + private String[] _file = new String[0]; + private int[] _fileIds = new int[0]; + + public String getPath() + { + return _path; + } + + public void setPath(String path) + { + _path = path; + } + + public String[] getFile() + { + return _file; + } + + public void setFile(String[] file) + { + if (null == file) + return; + for (String s : file) + { + if (s != null) + { + if (s.contains("..") || s.contains("/") || s.contains("\\")) + { + throw new IllegalArgumentException("File names should not include any path information"); + } + } + } + _file = file; + } + + public int[] getFileIds() + { + return _fileIds; + } + + public void setFileIds(int[] fileIds) + { + _fileIds = fileIds; + } + + /** + * For the string filesnames provided, ensures that the files are all in the same directory, which is under the container's pipeline root, + * and that they all exist on disk, though they could be directories, not files. + * For ExpData IDs provided, ensures the files exists and the user has read permission on the associated container. The files do not need to be located in the same directory. + * Throws NotFoundException if no files are specified, invalid files are specified, there's no pipeline root, etc. + */ + public List getValidatedFiles(Container c) + { + return getValidatedFiles(c, false); + } + + public List getValidatedFiles(Container c, boolean allowNonExistentFiles) + { + PipeRoot pr = getPipeRoot(c); + + FileLike dir = pr.resolvePathToFileLike(getPath()); + if (dir == null || !dir.exists()) + throw new NotFoundException("Could not find path " + getPath()); + + if ((getFile() == null || getFile().length == 0) && (getFileIds() == null || getFileIds().length == 0)) + { + throw new NotFoundException("No files specified"); + } + + List result = new ArrayList<>(); + for (String fileName : _file) + { + FileLike f = pr.resolvePathToFileLike(getPath() + "/" + fileName); + if (!allowNonExistentFiles && !NetworkDrive.exists(f)) + { + throw new NotFoundException("Could not find file '" + fileName + "' in '" + getPath() + "'"); + } + result.add(f); + } + + ExperimentService es = ExperimentService.get(); + if (_fileIds != null) + { + for (int fileId : _fileIds) + { + ExpData data = es.getExpData(fileId); + if(data == null) + { + throw new NotFoundException("Could not find file associated with Data Id: '" + fileId); + } + + if (!data.getContainer().hasPermission(getUser(), ReadPermission.class)) + { + throw new NotFoundException("Insufficient permissions for file '" + data.getFile()); + } + + FileLike file = data.getFileLike(); + if (!allowNonExistentFiles && !NetworkDrive.exists(file)) + { + throw new NotFoundException("Could not find file '" + file + "'"); + } + result.add(file); + } + } + + return result; + } + + public List getValidatedPaths(Container c, boolean allowNonExistentFiles) + { + List files = getValidatedFiles(c, allowNonExistentFiles); + List result = new ArrayList<>(); + for (FileLike file : files) + { + result.add(file.toNioPathForRead()); + } + return result; + } + + public PipeRoot getPipeRoot(Container c) + { + PipeRoot pr = PipelineService.get().findPipelineRoot(c); + if (pr == null) + throw new NotFoundException("Could not find a pipeline root for " + c.getPath()); + return pr; + } + + /** Verifies that only a single file was selected and returns it, throwing an exception if there isn't exactly one */ + public FileLike getValidatedSingleFile(Container c) + { + List files = getValidatedFiles(c); + if (files.size() != 1) + { + throw new IllegalArgumentException("Expected a single file but got " + files.size()); + } + return files.get(0); + } + + /** Verifies that only a single file was selected and returns it, throwing an exception if there isn't exactly one */ + @Deprecated // use the FileLike version + public Path getValidatedSinglePath(Container c) + { + List files = getValidatedPaths(c, false); + if (files.size() != 1) + { + throw new IllegalArgumentException("Expected a single file but got " + files.size()); + } + return files.get(0); + } +} diff --git a/api/src/org/labkey/api/pipeline/file/AbstractFileAnalysisJob.java b/api/src/org/labkey/api/pipeline/file/AbstractFileAnalysisJob.java index 7b4b9d58ec7..95696e8dc7c 100644 --- a/api/src/org/labkey/api/pipeline/file/AbstractFileAnalysisJob.java +++ b/api/src/org/labkey/api/pipeline/file/AbstractFileAnalysisJob.java @@ -1,475 +1,475 @@ -/* - * Copyright (c) 2008-2018 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.api.pipeline.file; - -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.jetbrains.annotations.NotNull; -import org.labkey.api.exp.PropertyType; -import org.labkey.api.exp.api.ExpRun; -import org.labkey.api.exp.api.ExperimentService; -import org.labkey.api.exp.api.ExperimentUrls; -import org.labkey.api.pipeline.ParamParser; -import org.labkey.api.pipeline.PipeRoot; -import org.labkey.api.pipeline.PipelineJob; -import org.labkey.api.pipeline.PipelineJobService; -import org.labkey.api.pipeline.RecordedAction; -import org.labkey.api.pipeline.TaskId; -import org.labkey.api.pipeline.TaskPipeline; -import org.labkey.api.util.FileType; -import org.labkey.api.util.FileUtil; -import org.labkey.api.util.NetworkDrive; -import org.labkey.api.util.PageFlowUtil; -import org.labkey.api.view.ActionURL; -import org.labkey.api.view.ViewBackgroundInfo; -import org.labkey.vfs.FileLike; - -import java.io.File; -import java.io.IOException; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.TreeMap; -import java.util.stream.Collectors; - -/** - * AbstractFileAnalysisJob - */ -abstract public class AbstractFileAnalysisJob extends PipelineJob implements FileAnalysisJobSupport -{ - private static final Logger _log = LogManager.getLogger(AbstractFileAnalysisJob.class); - - protected Long _experimentRunRowId; - private String _protocolName; - private String _joinedBaseName; - private String _baseName; - private FileLike _dirData; - private FileLike _dirAnalysis; - private FileLike _fileParameters; - private List _filesInput; - private List _inputTypes; - private boolean _splittable = true; - - private Map _parametersDefaults; - private Map _parametersOverrides; - - public static final String ANALYSIS_PARAMETERS_ROLE_NAME = "AnalysisParameters"; - - // For serialization - protected AbstractFileAnalysisJob() {} - - public AbstractFileAnalysisJob(@NotNull AbstractFileAnalysisProtocol protocol, - String providerName, - ViewBackgroundInfo info, - PipeRoot root, - String protocolName, - FileLike fileParameters, - List filesInput, - boolean splittable) throws IOException - { - super(providerName, info, root); - - _filesInput = filesInput; - _inputTypes = FileType.findTypes(protocol.getInputTypes(), _filesInput); - _dirData = filesInput.get(0).getParent(); - _protocolName = protocolName; - - _fileParameters = fileParameters; - getActionSet().add(_fileParameters, ANALYSIS_PARAMETERS_ROLE_NAME); // input - _dirAnalysis = _fileParameters.getParent(); - - // Load parameter files - _parametersOverrides = getInputParameters().getInputParameters(); - - // Check for explicitly set default parameters. Otherwise use the default. - String paramDefaults = _parametersOverrides.get("list path, default parameters"); - FileLike fileDefaults; - if (paramDefaults != null) - fileDefaults = getPipeRoot().resolvePathToFileLike(paramDefaults); - else - fileDefaults = protocol.getFactory().getDefaultParametersFile(root); - - _parametersDefaults = fileDefaults != null && fileDefaults.exists() ? - getInputParameters(fileDefaults).getInputParameters() : - Collections.emptyMap(); - - if (_log.isDebugEnabled()) - { - logParameters("Defaults", fileDefaults, _parametersDefaults); - logParameters("Overrides", fileParameters, _parametersOverrides); - } - - _splittable = splittable; - _joinedBaseName = protocol.getJoinedBaseName(); - if (_filesInput.size() > 1) - { - _baseName = _joinedBaseName; - } - else - { - _baseName = protocol.getBaseName(_filesInput.get(0)); - } - - String logFile = protocol.timestampLog() ? FileUtil.makeFileNameWithTimestamp(_baseName) : _baseName; - setupLocalDirectoryAndJobLog(getPipeRoot(), "FileAnalysis", logFile); - } - - /** - * @return Path String for a local working directory, temporary if root is cloud based - */ - @Override - protected Path getWorkingDirectoryString() - { - return _dirAnalysis.toNioPathForWrite().toAbsolutePath(); - } - - public AbstractFileAnalysisJob(AbstractFileAnalysisJob job, FileLike fileInput) - { - this(job, Collections.singletonList(fileInput)); - } - - public AbstractFileAnalysisJob(AbstractFileAnalysisJob job, List filesInput) - { - super(job); - - // Copy some parameters from the parent job. - _experimentRunRowId = job._experimentRunRowId; - _protocolName = job._protocolName; - _dirData = job._dirData; - _dirAnalysis = job._dirAnalysis; - _fileParameters = job._fileParameters; - _parametersDefaults = job._parametersDefaults; - _parametersOverrides = job._parametersOverrides; - _splittable = job._splittable; - _joinedBaseName = job._joinedBaseName; - - // Change parameters which are specific to the fraction job. - _filesInput = new ArrayList<>(filesInput); - _inputTypes = FileType.findTypes(job._inputTypes, _filesInput); - _baseName = (_inputTypes.isEmpty() ? filesInput.get(0).getName() : _inputTypes.get(0).getBaseName(filesInput.get(0))); - - setupLocalDirectoryAndJobLog(getPipeRoot(), "FileAnalysis", _baseName); - } - - @Override - public void clearActionSet(ExpRun run) - { - super.clearActionSet(run); - getActionSet().add(_fileParameters, ANALYSIS_PARAMETERS_ROLE_NAME); - - _experimentRunRowId = run.getRowId(); - } - - public void setSplittable(boolean splittable) - { - _splittable = splittable; - } - - @Override - public boolean isSplittable() - { - return _splittable && getInputFilePaths().size() > 1; - } - - @Override - public List createSplitJobs() - { - if (getInputFiles().size() == 1) - return super.createSplitJobs(); - - ArrayList jobs = new ArrayList<>(); - for (FileLike file : _filesInput) - jobs.add(createSingleFileJob(file)); - return Collections.unmodifiableList(jobs); - } - - @Override - public TaskPipeline getTaskPipeline() - { - return PipelineJobService.get().getTaskPipeline(getTaskPipelineId()); - } - - abstract public TaskId getTaskPipelineId(); - - abstract public AbstractFileAnalysisJob createSingleFileJob(FileLike file); - - @Override - public String getProtocolName() - { - return _protocolName; - } - - @Override - public String getBaseName() - { - return _baseName; - } - - @Override - public String getJoinedBaseName() - { - return _joinedBaseName; - } - - @Override - public List getSplitBaseNames() - { - ArrayList baseNames = new ArrayList<>(); - for (FileLike fileInput : _filesInput) - { - for (FileType ft : _inputTypes) - { - if (ft.isType(fileInput)) - { - baseNames.add(ft.getBaseName(fileInput)); - break; - } - } - } - return baseNames; - } - - @Override - public String getBaseNameForFileType(FileType fileType) - { - if (fileType != null) - { - for (FileLike fileInput : _filesInput) - { - if (fileType.isType(fileInput)) - return fileType.getBaseName(fileInput); - } - } - - return getBaseName(); - } - - @Override - public File getDataDirectory() - { - return _dirData.toNioPathForRead().toFile(); - } - - @Override - public Path getDataDirectoryPath() - { - return _dirData.toNioPathForRead(); - } - - @Override - public File getAnalysisDirectory() - { - return _dirAnalysis.toNioPathForWrite().toFile(); - } - - @Override - public Path getAnalysisDirectoryPath() - { - return _dirAnalysis.toNioPathForWrite(); - } - - @Override - public File findOutputFile(@NotNull String outputDir, @NotNull String fileName) - { - return getOutputFile(outputDir, fileName, getPipeRoot(), getLogger(), getAnalysisDirectory()); - } - - public static File getOutputFile(@NotNull String outputDir, @NotNull String fileName, PipeRoot root, Logger log, File analysisDirectory) - { - File dir; - if (outputDir.startsWith("/")) - { - dir = root.resolvePath(outputDir); - if (dir == null) - throw new RuntimeException("Output directory not under pipeline root: " + outputDir); - - if (!NetworkDrive.exists(dir)) - { - log.info("Creating output directory under pipeline root: " + dir); - if (!dir.mkdirs()) - throw new RuntimeException("Failed to create output directory under pipeline root: " + outputDir); - } - } - else - { - dir = new File(analysisDirectory, outputDir); - if (!NetworkDrive.exists(dir)) - { - log.info("Creating output directory under pipeline analysis dir: " + dir); - if (!dir.mkdirs()) - throw new RuntimeException("Failed to create output directory under analysis dir: " + outputDir); - } - } - - return new File(dir, fileName); - } - - @Override - public List getInputFiles() - { - return getInputFilePaths().stream().map(Path::toFile).collect(Collectors.toList()); - } - - @Override - public List getInputFilePaths() - { - return _filesInput.stream().map(FileLike::toNioPathForRead).toList(); - } - - @Override - public File getParametersFile() - { - return _fileParameters.toNioPathForRead().toFile(); - } - - @Override - public Map getParameters() - { - HashMap params = new HashMap<>(_parametersDefaults); - params.putAll(_parametersOverrides); - - // Add previous output parameters to the current set - for (RecordedAction action : getActionSet().getActions()) - { - for (Map.Entry entry : action.getOutputParams().entrySet()) - { - RecordedAction.ParameterType p = entry.getKey(); - Object value = entry.getValue(); - if (p.getType() != PropertyType.ATTACHMENT) - params.put(p.getName(), Objects.toString(value, null)); - } - } - - return Collections.unmodifiableMap(params); - } - - public ParamParser getInputParameters() throws IOException - { - return getInputParameters(_fileParameters); - } - - public ParamParser getInputParameters(FileLike parametersFile) throws IOException - { - ParamParser parser = createParamParser(); - parser.parse(parametersFile.openInputStream()); - if (parser.getErrors() != null) - { - ParamParser.Error err = parser.getErrors()[0]; - if (err.getLine() == 0) - { - throw new IOException("Failed parsing input xml '" + parametersFile + "'.\n" + - err.getMessage()); - } - else - { - throw new IOException("Failed parsing input xml '" + parametersFile + "'.\n" + - "Line " + err.getLine() + ": " + err.getMessage()); - } - } - return parser; - } - - private void logParameters(String description, FileLike file, Map parameters) - { - _log.debug(description + " " + parameters.size() + " parameters (" + file + "):"); - for (Map.Entry entry : new TreeMap<>(parameters).entrySet()) - _log.debug(entry.getKey() + " = " + entry.getValue()); - _log.debug(""); - } - - @Override - public ParamParser createParamParser() - { - return PipelineJobService.get().createParamParser(); - } - - @Override - public String getDescription() - { - return getDataDescription(getDataDirectoryPath(), getBaseName(), getJoinedBaseName(), getProtocolName(), getInputFilePaths()); - } - - @Override - public ActionURL getStatusHref() - { - if (_experimentRunRowId != null) - { - ExpRun run = ExperimentService.get().getExpRun(_experimentRunRowId.intValue()); - if (run != null) - return PageFlowUtil.urlProvider(ExperimentUrls.class).getRunGraphURL(run); - } - return null; - } - - @Deprecated //prefer Path version - public static String getDataDescription(File dirData, String baseName, String joinedBaseName, String protocolName) - { - return getDataDescription(dirData.toPath(), baseName, joinedBaseName, protocolName, Collections.emptyList()); - } - - public static String getDataDescription(Path dirData, String baseName, String joinedBaseName, String protocolName, List inputFiles) - { - String dataName = ""; - if (dirData != null) - { - dataName = dirData.getFileName().toString(); - // Can't remember why we would ever need the "xml" check. We may get an extra "." in the path, - // so check for that and remove it. - if (".".equals(dataName) || "xml".equals(dataName)) - { - dirData = dirData.getParent(); - if (dirData != null) - dataName = dirData.getFileName().toString(); - } - } - - StringBuilder description = new StringBuilder(dataName); - if (baseName != null && !baseName.equals(dataName) && - !(AbstractFileAnalysisProtocol.LEGACY_JOINED_BASENAME.equals(baseName) || baseName.equals(joinedBaseName))) // For cluster - { - if (!description.isEmpty()) - description.append("/"); - description.append(baseName); - } - description.append(" (").append(protocolName).append(")"); - - // input files - if (!inputFiles.isEmpty()) - { - description.append(" ("); - //p.getFileName returns the full S3 path -- S3fs bug? - description.append(inputFiles.stream().map(FileUtil::getFileName).collect(Collectors.joining(","))); - description.append(")"); - } - return description.toString(); - } - - /** - * returns support level for .xml.gz handling - * we always read .xml.gz, but may also have a - * preference for producing it in the pipeline - */ - @Override - public FileType.gzSupportLevel getGZPreference() - { - String doGZ = getParameters().get("pipeline, gzip outputs"); - return "yes".equalsIgnoreCase(doGZ)?FileType.gzSupportLevel.PREFER_GZ:FileType.gzSupportLevel.SUPPORT_GZ; - } -} +/* + * Copyright (c) 2008-2018 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.api.pipeline.file; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.NotNull; +import org.labkey.api.exp.PropertyType; +import org.labkey.api.exp.api.ExpRun; +import org.labkey.api.exp.api.ExperimentService; +import org.labkey.api.exp.api.ExperimentUrls; +import org.labkey.api.pipeline.ParamParser; +import org.labkey.api.pipeline.PipeRoot; +import org.labkey.api.pipeline.PipelineJob; +import org.labkey.api.pipeline.PipelineJobService; +import org.labkey.api.pipeline.RecordedAction; +import org.labkey.api.pipeline.TaskId; +import org.labkey.api.pipeline.TaskPipeline; +import org.labkey.api.util.FileType; +import org.labkey.api.util.FileUtil; +import org.labkey.api.util.NetworkDrive; +import org.labkey.api.util.PageFlowUtil; +import org.labkey.api.view.ActionURL; +import org.labkey.api.view.ViewBackgroundInfo; +import org.labkey.vfs.FileLike; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; +import java.util.stream.Collectors; + +/** + * AbstractFileAnalysisJob + */ +abstract public class AbstractFileAnalysisJob extends PipelineJob implements FileAnalysisJobSupport +{ + private static final Logger _log = LogManager.getLogger(AbstractFileAnalysisJob.class); + + protected Long _experimentRunRowId; + private String _protocolName; + private String _joinedBaseName; + private String _baseName; + private FileLike _dirData; + private FileLike _dirAnalysis; + private FileLike _fileParameters; + private List _filesInput; + private List _inputTypes; + private boolean _splittable = true; + + private Map _parametersDefaults; + private Map _parametersOverrides; + + public static final String ANALYSIS_PARAMETERS_ROLE_NAME = "AnalysisParameters"; + + // For serialization + protected AbstractFileAnalysisJob() {} + + public AbstractFileAnalysisJob(@NotNull AbstractFileAnalysisProtocol protocol, + String providerName, + ViewBackgroundInfo info, + PipeRoot root, + String protocolName, + FileLike fileParameters, + List filesInput, + boolean splittable) throws IOException + { + super(providerName, info, root); + + _filesInput = filesInput; + _inputTypes = FileType.findTypes(protocol.getInputTypes(), _filesInput); + _dirData = filesInput.get(0).getParent(); + _protocolName = protocolName; + + _fileParameters = fileParameters; + getActionSet().add(_fileParameters, ANALYSIS_PARAMETERS_ROLE_NAME); // input + _dirAnalysis = _fileParameters.getParent(); + + // Load parameter files + _parametersOverrides = getInputParameters().getInputParameters(); + + // Check for explicitly set default parameters. Otherwise use the default. + String paramDefaults = _parametersOverrides.get("list path, default parameters"); + FileLike fileDefaults; + if (paramDefaults != null) + fileDefaults = getPipeRoot().resolvePathToFileLike(paramDefaults); + else + fileDefaults = protocol.getFactory().getDefaultParametersFile(root); + + _parametersDefaults = fileDefaults != null && fileDefaults.exists() ? + getInputParameters(fileDefaults).getInputParameters() : + Collections.emptyMap(); + + if (_log.isDebugEnabled()) + { + logParameters("Defaults", fileDefaults, _parametersDefaults); + logParameters("Overrides", fileParameters, _parametersOverrides); + } + + _splittable = splittable; + _joinedBaseName = protocol.getJoinedBaseName(); + if (_filesInput.size() > 1) + { + _baseName = _joinedBaseName; + } + else + { + _baseName = protocol.getBaseName(_filesInput.get(0)); + } + + String logFile = protocol.timestampLog() ? FileUtil.makeFileNameWithTimestamp(_baseName) : _baseName; + setupLocalDirectoryAndJobLog(getPipeRoot(), "FileAnalysis", logFile); + } + + /** + * @return Path String for a local working directory, temporary if root is cloud based + */ + @Override + protected Path getWorkingDirectoryString() + { + return _dirAnalysis.toNioPathForWrite().toAbsolutePath(); + } + + public AbstractFileAnalysisJob(AbstractFileAnalysisJob job, FileLike fileInput) + { + this(job, Collections.singletonList(fileInput)); + } + + public AbstractFileAnalysisJob(AbstractFileAnalysisJob job, List filesInput) + { + super(job); + + // Copy some parameters from the parent job. + _experimentRunRowId = job._experimentRunRowId; + _protocolName = job._protocolName; + _dirData = job._dirData; + _dirAnalysis = job._dirAnalysis; + _fileParameters = job._fileParameters; + _parametersDefaults = job._parametersDefaults; + _parametersOverrides = job._parametersOverrides; + _splittable = job._splittable; + _joinedBaseName = job._joinedBaseName; + + // Change parameters which are specific to the fraction job. + _filesInput = new ArrayList<>(filesInput); + _inputTypes = FileType.findTypes(job._inputTypes, _filesInput); + _baseName = (_inputTypes.isEmpty() ? filesInput.get(0).getName() : _inputTypes.get(0).getBaseName(filesInput.get(0))); + + setupLocalDirectoryAndJobLog(getPipeRoot(), "FileAnalysis", _baseName); + } + + @Override + public void clearActionSet(ExpRun run) + { + super.clearActionSet(run); + getActionSet().add(_fileParameters, ANALYSIS_PARAMETERS_ROLE_NAME); + + _experimentRunRowId = run.getRowId(); + } + + public void setSplittable(boolean splittable) + { + _splittable = splittable; + } + + @Override + public boolean isSplittable() + { + return _splittable && getInputFilePaths().size() > 1; + } + + @Override + public List createSplitJobs() + { + if (getInputFiles().size() == 1) + return super.createSplitJobs(); + + ArrayList jobs = new ArrayList<>(); + for (FileLike file : _filesInput) + jobs.add(createSingleFileJob(file)); + return Collections.unmodifiableList(jobs); + } + + @Override + public TaskPipeline getTaskPipeline() + { + return PipelineJobService.get().getTaskPipeline(getTaskPipelineId()); + } + + abstract public TaskId getTaskPipelineId(); + + abstract public AbstractFileAnalysisJob createSingleFileJob(FileLike file); + + @Override + public String getProtocolName() + { + return _protocolName; + } + + @Override + public String getBaseName() + { + return _baseName; + } + + @Override + public String getJoinedBaseName() + { + return _joinedBaseName; + } + + @Override + public List getSplitBaseNames() + { + ArrayList baseNames = new ArrayList<>(); + for (FileLike fileInput : _filesInput) + { + for (FileType ft : _inputTypes) + { + if (ft.isType(fileInput)) + { + baseNames.add(ft.getBaseName(fileInput)); + break; + } + } + } + return baseNames; + } + + @Override + public String getBaseNameForFileType(FileType fileType) + { + if (fileType != null) + { + for (FileLike fileInput : _filesInput) + { + if (fileType.isType(fileInput)) + return fileType.getBaseName(fileInput); + } + } + + return getBaseName(); + } + + @Override + public File getDataDirectory() + { + return _dirData.toNioPathForRead().toFile(); + } + + @Override + public Path getDataDirectoryPath() + { + return _dirData.toNioPathForRead(); + } + + @Override + public File getAnalysisDirectory() + { + return _dirAnalysis.toNioPathForWrite().toFile(); + } + + @Override + public Path getAnalysisDirectoryPath() + { + return _dirAnalysis.toNioPathForWrite(); + } + + @Override + public File findOutputFile(@NotNull String outputDir, @NotNull String fileName) + { + return getOutputFile(outputDir, fileName, getPipeRoot(), getLogger(), getAnalysisDirectory()); + } + + public static File getOutputFile(@NotNull String outputDir, @NotNull String fileName, PipeRoot root, Logger log, File analysisDirectory) + { + File dir; + if (outputDir.startsWith("/")) + { + dir = root.resolvePath(outputDir); + if (dir == null) + throw new RuntimeException("Output directory not under pipeline root: " + outputDir); + + if (!NetworkDrive.exists(dir)) + { + log.info("Creating output directory under pipeline root: " + dir); + if (!dir.mkdirs()) + throw new RuntimeException("Failed to create output directory under pipeline root: " + outputDir); + } + } + else + { + dir = new File(analysisDirectory, outputDir); + if (!NetworkDrive.exists(dir)) + { + log.info("Creating output directory under pipeline analysis dir: " + dir); + if (!dir.mkdirs()) + throw new RuntimeException("Failed to create output directory under analysis dir: " + outputDir); + } + } + + return new File(dir, fileName); + } + + @Override + public List getInputFiles() + { + return getInputFilePaths().stream().map(Path::toFile).collect(Collectors.toList()); + } + + @Override + public List getInputFilePaths() + { + return _filesInput.stream().map(FileLike::toNioPathForRead).toList(); + } + + @Override + public File getParametersFile() + { + return _fileParameters.toNioPathForRead().toFile(); + } + + @Override + public Map getParameters() + { + HashMap params = new HashMap<>(_parametersDefaults); + params.putAll(_parametersOverrides); + + // Add previous output parameters to the current set + for (RecordedAction action : getActionSet().getActions()) + { + for (Map.Entry entry : action.getOutputParams().entrySet()) + { + RecordedAction.ParameterType p = entry.getKey(); + Object value = entry.getValue(); + if (p.getType() != PropertyType.ATTACHMENT) + params.put(p.getName(), Objects.toString(value, null)); + } + } + + return Collections.unmodifiableMap(params); + } + + public ParamParser getInputParameters() throws IOException + { + return getInputParameters(_fileParameters); + } + + public ParamParser getInputParameters(FileLike parametersFile) throws IOException + { + ParamParser parser = createParamParser(); + parser.parse(parametersFile.openInputStream()); + if (parser.getErrors() != null) + { + ParamParser.Error err = parser.getErrors()[0]; + if (err.getLine() == 0) + { + throw new IOException("Failed parsing input xml '" + parametersFile + "'.\n" + + err.getMessage()); + } + else + { + throw new IOException("Failed parsing input xml '" + parametersFile + "'.\n" + + "Line " + err.getLine() + ": " + err.getMessage()); + } + } + return parser; + } + + private void logParameters(String description, FileLike file, Map parameters) + { + _log.debug(description + " " + parameters.size() + " parameters (" + file + "):"); + for (Map.Entry entry : new TreeMap<>(parameters).entrySet()) + _log.debug(entry.getKey() + " = " + entry.getValue()); + _log.debug(""); + } + + @Override + public ParamParser createParamParser() + { + return PipelineJobService.get().createParamParser(); + } + + @Override + public String getDescription() + { + return getDataDescription(getDataDirectoryPath(), getBaseName(), getJoinedBaseName(), getProtocolName(), getInputFilePaths()); + } + + @Override + public ActionURL getStatusHref() + { + if (_experimentRunRowId != null) + { + ExpRun run = ExperimentService.get().getExpRun(_experimentRunRowId.intValue()); + if (run != null) + return PageFlowUtil.urlProvider(ExperimentUrls.class).getRunGraphURL(run); + } + return null; + } + + @Deprecated //prefer Path version + public static String getDataDescription(File dirData, String baseName, String joinedBaseName, String protocolName) + { + return getDataDescription(dirData.toPath(), baseName, joinedBaseName, protocolName, Collections.emptyList()); + } + + public static String getDataDescription(Path dirData, String baseName, String joinedBaseName, String protocolName, List inputFiles) + { + String dataName = ""; + if (dirData != null) + { + dataName = dirData.getFileName().toString(); + // Can't remember why we would ever need the "xml" check. We may get an extra "." in the path, + // so check for that and remove it. + if (".".equals(dataName) || "xml".equals(dataName)) + { + dirData = dirData.getParent(); + if (dirData != null) + dataName = dirData.getFileName().toString(); + } + } + + StringBuilder description = new StringBuilder(dataName); + if (baseName != null && !baseName.equals(dataName) && + !(AbstractFileAnalysisProtocol.LEGACY_JOINED_BASENAME.equals(baseName) || baseName.equals(joinedBaseName))) // For cluster + { + if (!description.isEmpty()) + description.append("/"); + description.append(baseName); + } + description.append(" (").append(protocolName).append(")"); + + // input files + if (!inputFiles.isEmpty()) + { + description.append(" ("); + //p.getFileName returns the full S3 path -- S3fs bug? + description.append(inputFiles.stream().map(FileUtil::getFileName).collect(Collectors.joining(","))); + description.append(")"); + } + return description.toString(); + } + + /** + * returns support level for .xml.gz handling + * we always read .xml.gz, but may also have a + * preference for producing it in the pipeline + */ + @Override + public FileType.gzSupportLevel getGZPreference() + { + String doGZ = getParameters().get("pipeline, gzip outputs"); + return "yes".equalsIgnoreCase(doGZ)?FileType.gzSupportLevel.PREFER_GZ:FileType.gzSupportLevel.SUPPORT_GZ; + } +} diff --git a/api/src/org/labkey/api/pipeline/file/AbstractFileAnalysisProtocol.java b/api/src/org/labkey/api/pipeline/file/AbstractFileAnalysisProtocol.java index 07a591701f9..14c20e885f1 100644 --- a/api/src/org/labkey/api/pipeline/file/AbstractFileAnalysisProtocol.java +++ b/api/src/org/labkey/api/pipeline/file/AbstractFileAnalysisProtocol.java @@ -1,281 +1,281 @@ -/* - * Copyright (c) 2008-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.api.pipeline.file; - -import org.apache.commons.io.input.ReaderInputStream; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.labkey.api.action.ApiUsageException; -import org.labkey.api.data.Container; -import org.labkey.api.pipeline.ParamParser; -import org.labkey.api.pipeline.PipeRoot; -import org.labkey.api.pipeline.PipelineJob; -import org.labkey.api.pipeline.PipelineProtocol; -import org.labkey.api.util.FileType; -import org.labkey.api.util.FileUtil; -import org.labkey.api.util.UnexpectedException; -import org.labkey.api.util.XmlBeansUtil; -import org.labkey.api.view.ViewBackgroundInfo; -import org.labkey.api.writer.PrintWriters; -import org.labkey.vfs.FileLike; -import org.xml.sax.InputSource; - -import javax.xml.parsers.DocumentBuilder; -import javax.xml.transform.OutputKeys; -import javax.xml.transform.Transformer; -import javax.xml.transform.TransformerFactory; -import javax.xml.transform.dom.DOMSource; -import javax.xml.transform.stream.StreamResult; -import java.io.BufferedReader; -import java.io.File; -import java.io.IOException; -import java.io.PrintWriter; -import java.io.StringReader; -import java.io.StringWriter; -import java.nio.charset.Charset; -import java.nio.file.Path; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -/** - * AbstractFileAnalysisProtocol - */ -public abstract class AbstractFileAnalysisProtocol - extends PipelineProtocol -{ - private static final Logger _log = LogManager.getLogger(AbstractFileAnalysisProtocol.class); - - public static final String LEGACY_JOINED_BASENAME = "all"; - - protected String description; - protected String xml; - - protected String email; - protected boolean timestampLog; - - public AbstractFileAnalysisProtocol(String name, String description, String xml) - { - super(name); - - this.description = description; - setXml(xml); - } - - public String getDescription() - { - return description; - } - - public void setDescription(String description) - { - this.description = description; - } - - public String getXml() - { - return xml; - } - - /** - * The xml string has bad formatting and extra whitespace from having had some parameters stripped after being read from file. - * Fix it for redisplay. - * @param xml The raw xml read from file - */ - public void setXml(String xml) - { - try - { - BufferedReader reader = new BufferedReader(new StringReader(xml)); - StringBuilder stripped = new StringBuilder(); - String line; - while ((line = reader.readLine()) != null) - stripped.append(line.trim()); - DocumentBuilder db = XmlBeansUtil.DOCUMENT_BUILDER_FACTORY.newDocumentBuilder(); - DOMSource xmlInput = new DOMSource(db.parse(new InputSource(new StringReader(stripped.toString())))); - StreamResult xmlOutput = new StreamResult(new StringWriter()); - Transformer transformer = TransformerFactory.newInstance().newTransformer(); - transformer.setOutputProperty(OutputKeys.INDENT, "yes"); - transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "2"); - transformer.transform(xmlInput, xmlOutput); - this.xml = xmlOutput.getWriter().toString(); - } - catch (Exception e) - { - // This shouldn't happen; bad input xml would have been detected upstream of here. - throw new ApiUsageException("Invalid xml format", e); - } - } - - public String getEmail() - { - return email; - } - - public void setEmail(String email) - { - this.email = email; - } - - /** - * Get the base name used to construct files names for multi-file inputs and outputs. - * The default base name is the protocol's name except for AbstractMS2SearchProtocol which defaults to "all". - */ - public String getJoinedBaseName() - { - return getName(); - } - - public String getBaseName(FileLike file) - { - FileType ft = findInputType(file); - if (ft == null) - return file.getName(); - - return ft.getBaseName(file); - } - - public FileLike getAnalysisDir(FileLike dirData, PipeRoot root) - { - return getFactory().getAnalysisDir(dirData, getName(), root); - } - - public FileLike getParametersFile(FileLike dirData, PipeRoot root) - { - return getFactory().getParametersFile(dirData, getName(), root); - } - - @Override - public void saveDefinition(PipeRoot root) throws IOException - { - save(getFactory().getProtocolFile(root, getName(), false), null, null); - } - - public void saveInstance(FileLike file, Container c) throws IOException - { - Map addParams = new HashMap<>(); - addParams.put(PipelineJob.PIPELINE_EMAIL_ADDRESS_PARAM, email); - save(file, null, addParams); - } - - protected void save(FileLike file, Map addParams, Map instanceParams) throws IOException - { - if (xml == null || xml.isEmpty()) - { - xml = """ - - - """; - } - - ParamParser parser = parse(); - if (parser.getErrors() != null) - { - ParamParser.Error err = parser.getErrors()[0]; - if (err.getLine() == 0) - throw new IllegalArgumentException(err.getMessage()); - else - throw new IllegalArgumentException("Line " + err.getLine() + ": " + err.getMessage()); - } - - FileLike dir = file.getParent(); - if (!dir.exists()) - { - try - { - FileUtil.createDirectories(dir); - } - catch (IOException e) - { - throw new IOException("Failed to create directory '" + dir + "'."); - } - } - - parser.setInputParameter(PipelineJob.PIPELINE_PROTOCOL_NAME_PARAM, getName()); - parser.setInputParameter(PipelineJob.PIPELINE_PROTOCOL_DESCRIPTION_PARAM, getDescription()); - - if (addParams != null) - { - for (Map.Entry entry : addParams.entrySet()) - parser.setInputParameter(entry.getKey(), entry.getValue()); - } - if (instanceParams != null) - { - for (Map.Entry entry : instanceParams.entrySet()) - parser.setInputParameter(entry.getKey(), entry.getValue()); - } - - try (PrintWriter writer = PrintWriters.getPrintWriter(file.openOutputStream())) - { - xml = parser.getXML(); - if (xml == null) - throw new IOException("Error writing input XML."); - writer.write(xml, 0, xml.length()); - } - catch (IOException eio) - { - _log.error("Error writing input XML.", eio); - throw eio; - } - } - - @NotNull - protected ParamParser parse() - { - ParamParser parser = getFactory().createParamParser(); - try - { - parser.parse(new ReaderInputStream.Builder().setReader(new StringReader(xml)).setCharset(Charset.defaultCharset()).get()); - } - catch (IOException e) - { - // Shouldn't happen since we already had the content in-memory as a String - throw UnexpectedException.wrap(e); - } - return parser; - } - - public FileType findInputType(FileLike file) - { - for (FileType type : getInputTypes()) - { - if (type.isType(file)) - return type; - } - return null; - } - - public abstract List getInputTypes(); - - @Override - public abstract AbstractFileAnalysisProtocolFactory getFactory(); - - public abstract JOB createPipelineJob(ViewBackgroundInfo info, - PipeRoot root, List filesInput, - FileLike fileParameters, @Nullable Map variableMap) throws IOException; - - public boolean timestampLog() - { - return timestampLog; - } - - public void setTimestampLog(boolean timestampLog) - { - this.timestampLog = timestampLog; - } -} +/* + * Copyright (c) 2008-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.api.pipeline.file; + +import org.apache.commons.io.input.ReaderInputStream; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.labkey.api.action.ApiUsageException; +import org.labkey.api.data.Container; +import org.labkey.api.pipeline.ParamParser; +import org.labkey.api.pipeline.PipeRoot; +import org.labkey.api.pipeline.PipelineJob; +import org.labkey.api.pipeline.PipelineProtocol; +import org.labkey.api.util.FileType; +import org.labkey.api.util.FileUtil; +import org.labkey.api.util.UnexpectedException; +import org.labkey.api.util.XmlBeansUtil; +import org.labkey.api.view.ViewBackgroundInfo; +import org.labkey.api.writer.PrintWriters; +import org.labkey.vfs.FileLike; +import org.xml.sax.InputSource; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.transform.OutputKeys; +import javax.xml.transform.Transformer; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringReader; +import java.io.StringWriter; +import java.nio.charset.Charset; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * AbstractFileAnalysisProtocol + */ +public abstract class AbstractFileAnalysisProtocol + extends PipelineProtocol +{ + private static final Logger _log = LogManager.getLogger(AbstractFileAnalysisProtocol.class); + + public static final String LEGACY_JOINED_BASENAME = "all"; + + protected String description; + protected String xml; + + protected String email; + protected boolean timestampLog; + + public AbstractFileAnalysisProtocol(String name, String description, String xml) + { + super(name); + + this.description = description; + setXml(xml); + } + + public String getDescription() + { + return description; + } + + public void setDescription(String description) + { + this.description = description; + } + + public String getXml() + { + return xml; + } + + /** + * The xml string has bad formatting and extra whitespace from having had some parameters stripped after being read from file. + * Fix it for redisplay. + * @param xml The raw xml read from file + */ + public void setXml(String xml) + { + try + { + BufferedReader reader = new BufferedReader(new StringReader(xml)); + StringBuilder stripped = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) + stripped.append(line.trim()); + DocumentBuilder db = XmlBeansUtil.DOCUMENT_BUILDER_FACTORY.newDocumentBuilder(); + DOMSource xmlInput = new DOMSource(db.parse(new InputSource(new StringReader(stripped.toString())))); + StreamResult xmlOutput = new StreamResult(new StringWriter()); + Transformer transformer = TransformerFactory.newInstance().newTransformer(); + transformer.setOutputProperty(OutputKeys.INDENT, "yes"); + transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "2"); + transformer.transform(xmlInput, xmlOutput); + this.xml = xmlOutput.getWriter().toString(); + } + catch (Exception e) + { + // This shouldn't happen; bad input xml would have been detected upstream of here. + throw new ApiUsageException("Invalid xml format", e); + } + } + + public String getEmail() + { + return email; + } + + public void setEmail(String email) + { + this.email = email; + } + + /** + * Get the base name used to construct files names for multi-file inputs and outputs. + * The default base name is the protocol's name except for AbstractMS2SearchProtocol which defaults to "all". + */ + public String getJoinedBaseName() + { + return getName(); + } + + public String getBaseName(FileLike file) + { + FileType ft = findInputType(file); + if (ft == null) + return file.getName(); + + return ft.getBaseName(file); + } + + public FileLike getAnalysisDir(FileLike dirData, PipeRoot root) + { + return getFactory().getAnalysisDir(dirData, getName(), root); + } + + public FileLike getParametersFile(FileLike dirData, PipeRoot root) + { + return getFactory().getParametersFile(dirData, getName(), root); + } + + @Override + public void saveDefinition(PipeRoot root) throws IOException + { + save(getFactory().getProtocolFile(root, getName(), false), null, null); + } + + public void saveInstance(FileLike file, Container c) throws IOException + { + Map addParams = new HashMap<>(); + addParams.put(PipelineJob.PIPELINE_EMAIL_ADDRESS_PARAM, email); + save(file, null, addParams); + } + + protected void save(FileLike file, Map addParams, Map instanceParams) throws IOException + { + if (xml == null || xml.isEmpty()) + { + xml = """ + + + """; + } + + ParamParser parser = parse(); + if (parser.getErrors() != null) + { + ParamParser.Error err = parser.getErrors()[0]; + if (err.getLine() == 0) + throw new IllegalArgumentException(err.getMessage()); + else + throw new IllegalArgumentException("Line " + err.getLine() + ": " + err.getMessage()); + } + + FileLike dir = file.getParent(); + if (!dir.exists()) + { + try + { + FileUtil.createDirectories(dir); + } + catch (IOException e) + { + throw new IOException("Failed to create directory '" + dir + "'."); + } + } + + parser.setInputParameter(PipelineJob.PIPELINE_PROTOCOL_NAME_PARAM, getName()); + parser.setInputParameter(PipelineJob.PIPELINE_PROTOCOL_DESCRIPTION_PARAM, getDescription()); + + if (addParams != null) + { + for (Map.Entry entry : addParams.entrySet()) + parser.setInputParameter(entry.getKey(), entry.getValue()); + } + if (instanceParams != null) + { + for (Map.Entry entry : instanceParams.entrySet()) + parser.setInputParameter(entry.getKey(), entry.getValue()); + } + + try (PrintWriter writer = PrintWriters.getPrintWriter(file.openOutputStream())) + { + xml = parser.getXML(); + if (xml == null) + throw new IOException("Error writing input XML."); + writer.write(xml, 0, xml.length()); + } + catch (IOException eio) + { + _log.error("Error writing input XML.", eio); + throw eio; + } + } + + @NotNull + protected ParamParser parse() + { + ParamParser parser = getFactory().createParamParser(); + try + { + parser.parse(new ReaderInputStream.Builder().setReader(new StringReader(xml)).setCharset(Charset.defaultCharset()).get()); + } + catch (IOException e) + { + // Shouldn't happen since we already had the content in-memory as a String + throw UnexpectedException.wrap(e); + } + return parser; + } + + public FileType findInputType(FileLike file) + { + for (FileType type : getInputTypes()) + { + if (type.isType(file)) + return type; + } + return null; + } + + public abstract List getInputTypes(); + + @Override + public abstract AbstractFileAnalysisProtocolFactory getFactory(); + + public abstract JOB createPipelineJob(ViewBackgroundInfo info, + PipeRoot root, List filesInput, + FileLike fileParameters, @Nullable Map variableMap) throws IOException; + + public boolean timestampLog() + { + return timestampLog; + } + + public void setTimestampLog(boolean timestampLog) + { + this.timestampLog = timestampLog; + } +} diff --git a/api/src/org/labkey/api/pipeline/file/AbstractFileAnalysisProtocolFactory.java b/api/src/org/labkey/api/pipeline/file/AbstractFileAnalysisProtocolFactory.java index c5e2c640a14..3a6a2147689 100644 --- a/api/src/org/labkey/api/pipeline/file/AbstractFileAnalysisProtocolFactory.java +++ b/api/src/org/labkey/api/pipeline/file/AbstractFileAnalysisProtocolFactory.java @@ -1,374 +1,374 @@ -/* - * Copyright (c) 2008-2017 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.api.pipeline.file; - -import org.apache.commons.io.input.ReaderInputStream; -import org.apache.commons.lang3.ArrayUtils; -import org.apache.logging.log4j.Logger; -import org.jetbrains.annotations.Nullable; -import org.labkey.api.data.Container; -import org.labkey.api.pipeline.ParamParser; -import org.labkey.api.pipeline.PipeRoot; -import org.labkey.api.pipeline.PipelineJob; -import org.labkey.api.pipeline.PipelineJobService; -import org.labkey.api.pipeline.PipelineProtocolFactory; -import org.labkey.api.pipeline.PipelineProvider; -import org.labkey.api.pipeline.PipelineService; -import org.labkey.api.pipeline.TaskPipeline; -import org.labkey.api.reader.Readers; -import org.labkey.api.util.FileUtil; -import org.labkey.api.util.NetworkDrive; -import org.labkey.api.util.PageFlowUtil; -import org.labkey.api.util.logging.LogHelper; -import org.labkey.api.writer.PrintWriters; -import org.labkey.vfs.FileLike; - -import java.io.BufferedReader; -import java.io.File; -import java.io.FileNotFoundException; -import java.io.IOException; -import java.io.InputStream; -import java.io.PrintWriter; -import java.io.Reader; -import java.io.StringReader; -import java.nio.file.InvalidPathException; -import java.nio.file.Path; -import java.util.List; - -/** - * Base class for protocol factories that are primarily focused on analyzing data files (as opposed to other types of resources) - */ -abstract public class AbstractFileAnalysisProtocolFactory> extends PipelineProtocolFactory -{ - private static final Logger _log = LogHelper.getLogger(AbstractFileAnalysisProtocolFactory.class, "Pipeline protocol and parameter errors"); - - public static final String DEFAULT_PARAMETERS_NAME = "default"; - - /** - * Get the file name used for parameter files in analysis directories. - * - * @return file name - */ - public String getParametersFileName() - { - return getName() + ".xml"; - } - - /** - * Get the file name for the default parameters for all protocols of this type. - * - * @return file name - */ - public String getDefaultParametersFileName() - { - return DEFAULT_PARAMETERS_NAME + ".xml"; - } - - /** - * Get the file name for the old default parameters for all protocols of this type, - * back when these files were stored in the root. - * - * @return file name - */ - public String getLegacyDefaultParametersFileName() - { - return getName() + "_default_input.xml"; - } - - /** - * Get the analysis directory location, given a directory containing the mass spec data. - * - * @param dirData mass spec data directory - * @param protocolName name of protocol for analysis - * @param root pipeline root under which the files are stored - * @return analysis directory - */ - public FileLike getAnalysisDir(FileLike dirData, String protocolName, PipeRoot root) - { - FileLike defaultFile = dirData.resolveChild(getName()).resolveChild(protocolName); - // Check if the pipeline root wants us to write somewhere else, because the source file might be in a read-only - // pipeline location - String relativePath = root.relativePath(defaultFile); - return root.resolvePathToFileLike(relativePath); - } - - /** - * Returns true if the file uses the type of protocol created by this factory. - */ - public boolean isProtocolTypeFile(File file) - { - return NetworkDrive.exists(new File(file.getParent(), getParametersFileName())); - } - - /** - * Get the parameters file location, given a directory containing the mass spec data. - * - * @param dirData mass spec data directory - * @param protocolName name of protocol for analysis - * @param root pipeline root under which the files are stored - * @return parameters file - */ - @Nullable - public FileLike getParametersFile(@Nullable FileLike dirData, String protocolName, PipeRoot root) - { - if (dirData == null) - { - return null; - } - FileLike defaultFile = getAnalysisDir(dirData, protocolName, root).resolveChild(getParametersFileName()); - // Check if the pipeline root wants us to write somewhere else, because the source file might be in a read-only - // pipeline location - String relativePath = root.relativePath(defaultFile); - return root.resolvePathToFileLike(relativePath); - } - - /** - * Get the default parameters file, given the pipeline root directory. - * - * @param root pipeline root directory - * @return default parameters file - */ - public FileLike getDefaultParametersFile(PipeRoot root) - { - return getProtocolDir(root, false).resolveChild(getDefaultParametersFileName()); - } - - /** - * Make sure default parameters for this protocol type exist. - * - * @param root pipeline root - */ - public void ensureDefaultParameters(PipeRoot root) throws IOException - { - if (!NetworkDrive.exists(getDefaultParametersFile(root))) - setDefaultParametersXML(root, getDefaultParametersXML(root)); - } - - @Override - public String[] getProtocolNames(PipeRoot root, FileLike dirData, boolean archived) - { - String[] protocolNames = super.getProtocolNames(root, dirData, archived); - - // The default parameters file is not really a protocol so remove it from the list. - return ArrayUtils.removeElement(protocolNames, DEFAULT_PARAMETERS_NAME); - } - - public void initSystemDirectory(File rootDir, File systemDir) - { - // Make sure the root protocol directory is in the right place. - File protocolRootDir = locateProtocolRootDir(rootDir, systemDir); - - // Make sure the defaults for this particular protocol are in the right place. - File fileLegacyDefaults = FileUtil.appendName(rootDir, getLegacyDefaultParametersFileName()); - if (NetworkDrive.exists(fileLegacyDefaults)) - { - File protocolDir = FileUtil.appendName(protocolRootDir, getName()); - fileLegacyDefaults.renameTo(FileUtil.appendName(protocolDir, getDefaultParametersFileName())); - } - } - - /** - * Override to set a custom validator. - * - * @return a parser for working with a parameter stream - */ - public ParamParser createParamParser() - { - return PipelineJobService.get().createParamParser(); - } - - public abstract T createProtocolInstance(String name, String description, String xml, Container container); - - protected T createProtocolInstance(ParamParser parser, Container container) - { - // Remove the pipeline specific parameters. - String name = parser.removeInputParameter(PipelineJob.PIPELINE_PROTOCOL_NAME_PARAM); - String description = parser.removeInputParameter(PipelineJob.PIPELINE_PROTOCOL_DESCRIPTION_PARAM); - String folder = parser.removeInputParameter(PipelineJob.PIPELINE_LOAD_FOLDER_PARAM); - String email = parser.removeInputParameter(PipelineJob.PIPELINE_EMAIL_ADDRESS_PARAM); - - T instance = createProtocolInstance(name, description, parser.getXML(), container); - - instance.setEmail(email); - - return instance; - } - - @Override - public T load(PipeRoot root, String name, boolean archived) throws IOException - { - T instance = loadInstance(getProtocolFile(root, name, archived), root.getContainer()); - - // Don't allow the XML to override the name passed in. This - // can be extremely confusing. - instance.setName(name); - return instance; - } - - public T loadInstance(FileLike file, Container container) throws IOException - { - ParamParser parser = createParamParser(); - try (InputStream is = file.openInputStream()) - { - parser.parse(is); - if (parser.getErrors() != null) - { - ParamParser.Error err = parser.getErrors()[0]; - if (err.getLine() == 0) - { - throw new IOException("Failed parsing input parameters '" + file + "'.\n" + - err.getMessage()); - } - else - { - throw new IOException("Failed parsing input parameters '" + file + "'.\n" + - "Line " + err.getLine() + ": " + err.getMessage()); - } - } - - return createProtocolInstance(parser, container); - } - } - - public String getDefaultParametersXML(PipeRoot root) throws IOException - { - FileLike fileDefault = getDefaultParametersFile(root); - if (!fileDefault.exists()) - return null; - - return new FileDefaultsReader(fileDefault).readXML(); - } - - protected static class FileDefaultsReader extends DefaultsReader - { - private final FileLike _fileDefaults; - - public FileDefaultsReader(FileLike fileDefaults) - { - _fileDefaults = fileDefaults; - } - - @Override - public Reader createReader() throws IOException - { - return Readers.getReader(_fileDefaults.openInputStream()); - } - } - - abstract protected static class DefaultsReader - { - abstract public Reader createReader() throws IOException; - - public String readXML() throws IOException - { - try (BufferedReader reader = new BufferedReader(createReader())) - { - return PageFlowUtil.getReaderContentsAsString(reader); - } - catch (FileNotFoundException enf) - { - _log.error("Default parameters file missing. Check product setup.", enf); - throw enf; - } - catch (IOException eio) - { - _log.error("Error reading default parameters file.", eio); - throw eio; - } - } - } - - public void setDefaultParametersXML(PipeRoot root, String xml) throws IOException - { - if (xml == null || xml.isEmpty()) - throw new IllegalArgumentException("You must supply default parameters for " + getName() + "."); - - ParamParser parser = createParamParser(); - parser.parse(new ReaderInputStream(new StringReader(xml))); - if (parser.getErrors() != null) - { - ParamParser.Error err = parser.getErrors()[0]; - if (err.getLine() == 0) - throw new IllegalArgumentException(err.getMessage()); - else - throw new IllegalArgumentException("Line " + err.getLine() + ": " + err.getMessage()); - } - - FileLike fileDefault = getDefaultParametersFile(root); - FileUtil.createDirectories(fileDefault.getParent()); - - try (PrintWriter writer = PrintWriters.getPrintWriter(fileDefault.openOutputStream())) - { - writer.write(xml, 0, xml.length()); - } - catch (IOException eio) - { - _log.error("Error writing default parameters file.", eio); - throw eio; - } - } - - public static >, F extends AbstractFileAnalysisProtocolFactory> - F fromFile(Class clazz, File file) - { - List providers = PipelineService.get().getPipelineProviders(); - for (PipelineProvider provider : providers) - { - if (!(clazz.isInstance(provider))) - continue; - - T mprovider = (T) provider; - F factory = mprovider.getProtocolFactory(file); - if (factory != null) - return factory; - } - - // TODO: Return some default? - return null; - } - - @Nullable - public AbstractFileAnalysisProtocol getProtocol(PipeRoot root, FileLike dirData, String protocolName, boolean archived) - { - try - { - FileLike protocolFile = getParametersFile(dirData, protocolName, root); - AbstractFileAnalysisProtocol result; - if (NetworkDrive.exists(protocolFile)) - { - result = loadInstance(protocolFile, root.getContainer()); - - // Don't allow the instance file to override the protocol name. - result.setName(protocolName); - } - else - { - protocolFile = getProtocolFile(root, protocolName, archived); - if (protocolFile == null || !protocolFile.exists()) - return null; - - result = load(root, protocolName, archived); - } - return result; - } - catch (IOException|InvalidPathException e) - { - _log.warn("Error loading protocol file.", e); - return null; - } - } - -} +/* + * Copyright (c) 2008-2017 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.api.pipeline.file; + +import org.apache.commons.io.input.ReaderInputStream; +import org.apache.commons.lang3.ArrayUtils; +import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.Nullable; +import org.labkey.api.data.Container; +import org.labkey.api.pipeline.ParamParser; +import org.labkey.api.pipeline.PipeRoot; +import org.labkey.api.pipeline.PipelineJob; +import org.labkey.api.pipeline.PipelineJobService; +import org.labkey.api.pipeline.PipelineProtocolFactory; +import org.labkey.api.pipeline.PipelineProvider; +import org.labkey.api.pipeline.PipelineService; +import org.labkey.api.pipeline.TaskPipeline; +import org.labkey.api.reader.Readers; +import org.labkey.api.util.FileUtil; +import org.labkey.api.util.NetworkDrive; +import org.labkey.api.util.PageFlowUtil; +import org.labkey.api.util.logging.LogHelper; +import org.labkey.api.writer.PrintWriters; +import org.labkey.vfs.FileLike; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.PrintWriter; +import java.io.Reader; +import java.io.StringReader; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.util.List; + +/** + * Base class for protocol factories that are primarily focused on analyzing data files (as opposed to other types of resources) + */ +abstract public class AbstractFileAnalysisProtocolFactory> extends PipelineProtocolFactory +{ + private static final Logger _log = LogHelper.getLogger(AbstractFileAnalysisProtocolFactory.class, "Pipeline protocol and parameter errors"); + + public static final String DEFAULT_PARAMETERS_NAME = "default"; + + /** + * Get the file name used for parameter files in analysis directories. + * + * @return file name + */ + public String getParametersFileName() + { + return getName() + ".xml"; + } + + /** + * Get the file name for the default parameters for all protocols of this type. + * + * @return file name + */ + public String getDefaultParametersFileName() + { + return DEFAULT_PARAMETERS_NAME + ".xml"; + } + + /** + * Get the file name for the old default parameters for all protocols of this type, + * back when these files were stored in the root. + * + * @return file name + */ + public String getLegacyDefaultParametersFileName() + { + return getName() + "_default_input.xml"; + } + + /** + * Get the analysis directory location, given a directory containing the mass spec data. + * + * @param dirData mass spec data directory + * @param protocolName name of protocol for analysis + * @param root pipeline root under which the files are stored + * @return analysis directory + */ + public FileLike getAnalysisDir(FileLike dirData, String protocolName, PipeRoot root) + { + FileLike defaultFile = dirData.resolveChild(getName()).resolveChild(protocolName); + // Check if the pipeline root wants us to write somewhere else, because the source file might be in a read-only + // pipeline location + String relativePath = root.relativePath(defaultFile); + return root.resolvePathToFileLike(relativePath); + } + + /** + * Returns true if the file uses the type of protocol created by this factory. + */ + public boolean isProtocolTypeFile(File file) + { + return NetworkDrive.exists(new File(file.getParent(), getParametersFileName())); + } + + /** + * Get the parameters file location, given a directory containing the mass spec data. + * + * @param dirData mass spec data directory + * @param protocolName name of protocol for analysis + * @param root pipeline root under which the files are stored + * @return parameters file + */ + @Nullable + public FileLike getParametersFile(@Nullable FileLike dirData, String protocolName, PipeRoot root) + { + if (dirData == null) + { + return null; + } + FileLike defaultFile = getAnalysisDir(dirData, protocolName, root).resolveChild(getParametersFileName()); + // Check if the pipeline root wants us to write somewhere else, because the source file might be in a read-only + // pipeline location + String relativePath = root.relativePath(defaultFile); + return root.resolvePathToFileLike(relativePath); + } + + /** + * Get the default parameters file, given the pipeline root directory. + * + * @param root pipeline root directory + * @return default parameters file + */ + public FileLike getDefaultParametersFile(PipeRoot root) + { + return getProtocolDir(root, false).resolveChild(getDefaultParametersFileName()); + } + + /** + * Make sure default parameters for this protocol type exist. + * + * @param root pipeline root + */ + public void ensureDefaultParameters(PipeRoot root) throws IOException + { + if (!NetworkDrive.exists(getDefaultParametersFile(root))) + setDefaultParametersXML(root, getDefaultParametersXML(root)); + } + + @Override + public String[] getProtocolNames(PipeRoot root, FileLike dirData, boolean archived) + { + String[] protocolNames = super.getProtocolNames(root, dirData, archived); + + // The default parameters file is not really a protocol so remove it from the list. + return ArrayUtils.removeElement(protocolNames, DEFAULT_PARAMETERS_NAME); + } + + public void initSystemDirectory(File rootDir, File systemDir) + { + // Make sure the root protocol directory is in the right place. + File protocolRootDir = locateProtocolRootDir(rootDir, systemDir); + + // Make sure the defaults for this particular protocol are in the right place. + File fileLegacyDefaults = FileUtil.appendName(rootDir, getLegacyDefaultParametersFileName()); + if (NetworkDrive.exists(fileLegacyDefaults)) + { + File protocolDir = FileUtil.appendName(protocolRootDir, getName()); + fileLegacyDefaults.renameTo(FileUtil.appendName(protocolDir, getDefaultParametersFileName())); + } + } + + /** + * Override to set a custom validator. + * + * @return a parser for working with a parameter stream + */ + public ParamParser createParamParser() + { + return PipelineJobService.get().createParamParser(); + } + + public abstract T createProtocolInstance(String name, String description, String xml, Container container); + + protected T createProtocolInstance(ParamParser parser, Container container) + { + // Remove the pipeline specific parameters. + String name = parser.removeInputParameter(PipelineJob.PIPELINE_PROTOCOL_NAME_PARAM); + String description = parser.removeInputParameter(PipelineJob.PIPELINE_PROTOCOL_DESCRIPTION_PARAM); + String folder = parser.removeInputParameter(PipelineJob.PIPELINE_LOAD_FOLDER_PARAM); + String email = parser.removeInputParameter(PipelineJob.PIPELINE_EMAIL_ADDRESS_PARAM); + + T instance = createProtocolInstance(name, description, parser.getXML(), container); + + instance.setEmail(email); + + return instance; + } + + @Override + public T load(PipeRoot root, String name, boolean archived) throws IOException + { + T instance = loadInstance(getProtocolFile(root, name, archived), root.getContainer()); + + // Don't allow the XML to override the name passed in. This + // can be extremely confusing. + instance.setName(name); + return instance; + } + + public T loadInstance(FileLike file, Container container) throws IOException + { + ParamParser parser = createParamParser(); + try (InputStream is = file.openInputStream()) + { + parser.parse(is); + if (parser.getErrors() != null) + { + ParamParser.Error err = parser.getErrors()[0]; + if (err.getLine() == 0) + { + throw new IOException("Failed parsing input parameters '" + file + "'.\n" + + err.getMessage()); + } + else + { + throw new IOException("Failed parsing input parameters '" + file + "'.\n" + + "Line " + err.getLine() + ": " + err.getMessage()); + } + } + + return createProtocolInstance(parser, container); + } + } + + public String getDefaultParametersXML(PipeRoot root) throws IOException + { + FileLike fileDefault = getDefaultParametersFile(root); + if (!fileDefault.exists()) + return null; + + return new FileDefaultsReader(fileDefault).readXML(); + } + + protected static class FileDefaultsReader extends DefaultsReader + { + private final FileLike _fileDefaults; + + public FileDefaultsReader(FileLike fileDefaults) + { + _fileDefaults = fileDefaults; + } + + @Override + public Reader createReader() throws IOException + { + return Readers.getReader(_fileDefaults.openInputStream()); + } + } + + abstract protected static class DefaultsReader + { + abstract public Reader createReader() throws IOException; + + public String readXML() throws IOException + { + try (BufferedReader reader = new BufferedReader(createReader())) + { + return PageFlowUtil.getReaderContentsAsString(reader); + } + catch (FileNotFoundException enf) + { + _log.error("Default parameters file missing. Check product setup.", enf); + throw enf; + } + catch (IOException eio) + { + _log.error("Error reading default parameters file.", eio); + throw eio; + } + } + } + + public void setDefaultParametersXML(PipeRoot root, String xml) throws IOException + { + if (xml == null || xml.isEmpty()) + throw new IllegalArgumentException("You must supply default parameters for " + getName() + "."); + + ParamParser parser = createParamParser(); + parser.parse(new ReaderInputStream(new StringReader(xml))); + if (parser.getErrors() != null) + { + ParamParser.Error err = parser.getErrors()[0]; + if (err.getLine() == 0) + throw new IllegalArgumentException(err.getMessage()); + else + throw new IllegalArgumentException("Line " + err.getLine() + ": " + err.getMessage()); + } + + FileLike fileDefault = getDefaultParametersFile(root); + FileUtil.createDirectories(fileDefault.getParent()); + + try (PrintWriter writer = PrintWriters.getPrintWriter(fileDefault.openOutputStream())) + { + writer.write(xml, 0, xml.length()); + } + catch (IOException eio) + { + _log.error("Error writing default parameters file.", eio); + throw eio; + } + } + + public static >, F extends AbstractFileAnalysisProtocolFactory> + F fromFile(Class clazz, File file) + { + List providers = PipelineService.get().getPipelineProviders(); + for (PipelineProvider provider : providers) + { + if (!(clazz.isInstance(provider))) + continue; + + T mprovider = (T) provider; + F factory = mprovider.getProtocolFactory(file); + if (factory != null) + return factory; + } + + // TODO: Return some default? + return null; + } + + @Nullable + public AbstractFileAnalysisProtocol getProtocol(PipeRoot root, FileLike dirData, String protocolName, boolean archived) + { + try + { + FileLike protocolFile = getParametersFile(dirData, protocolName, root); + AbstractFileAnalysisProtocol result; + if (NetworkDrive.exists(protocolFile)) + { + result = loadInstance(protocolFile, root.getContainer()); + + // Don't allow the instance file to override the protocol name. + result.setName(protocolName); + } + else + { + protocolFile = getProtocolFile(root, protocolName, archived); + if (protocolFile == null || !protocolFile.exists()) + return null; + + result = load(root, protocolName, archived); + } + return result; + } + catch (IOException|InvalidPathException e) + { + _log.warn("Error loading protocol file.", e); + return null; + } + } + +} diff --git a/api/src/org/labkey/api/pipeline/file/FileAnalysisJobSupport.java b/api/src/org/labkey/api/pipeline/file/FileAnalysisJobSupport.java index 82d9d9bfddd..ff6f6ee0056 100644 --- a/api/src/org/labkey/api/pipeline/file/FileAnalysisJobSupport.java +++ b/api/src/org/labkey/api/pipeline/file/FileAnalysisJobSupport.java @@ -1,174 +1,174 @@ -/* - * Copyright (c) 2008-2017 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.api.pipeline.file; - -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.labkey.api.pipeline.ParamParser; -import org.labkey.api.util.FileType; -import org.labkey.vfs.FileLike; -import org.labkey.vfs.FileSystemLike; - -import java.io.File; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -/** - * FileAnalysisJobSupport - * - * @author brendanx - */ -public interface FileAnalysisJobSupport -{ - /** - * @return protocol name of the current protocol. - */ - String getProtocolName(); - - /** - * @return the base name for the full set of files. - */ - String getJoinedBaseName(); - - /** - * @return the base names for all the split input files, or just this - * job's single base name in an array, if this is a split job. - */ - List getSplitBaseNames(); - - /** - * @return base name of the original input file. - */ - String getBaseName(); - - /** - * @param fileType The file type to compare - * @return base name for the specified FileType - */ - String getBaseNameForFileType(FileType fileType); - - /** - * @return the directory in which the original input file resides. - */ - @Deprecated //Prefer the getDataDirectoryPath version as File return type doesn't support full URIs very well - File getDataDirectory(); - default Path getDataDirectoryPath() - { - // TODO This needs implementation in derived classes... - // This is typically safe but may cause an error if FileSystem provider isn't configured - return getDataDirectory().toPath(); - } - - /** - * @return the directory where the input files reside, and where the - * final analysis should end up. - */ - @Deprecated // Please use getAnalysisDirectoryPath instead, as File objects may have issues with full URIs - File getAnalysisDirectory(); - default Path getAnalysisDirectoryPath() - { - // TODO This needs implementation in derived classes... - // This is typically safe but may cause an error if FileSystem provider isn't configured - return getAnalysisDirectory().toPath(); - } - - default FileLike getAnalysisDirectoryFileLike() - { - // TODO This needs implementation in derived classes... - // This is typically safe but may cause an error if FileSystem provider isn't configured - return FileSystemLike.wrapFile(getAnalysisDirectory()); - } - - /** - * Returns a file for use as input in the pipeline, given its name. - * This allows the task definitions to name files they require as input, - * and the pipeline definition to specify where those files should come from. - */ - @Deprecated // Please use findInputPath instead, as File objects may have issues with full URIs - File findInputFile(String name); - default Path findInputPath(String filepath) - { - // TODO This needs implementation in derived classes... - // This is typically safe but may cause an error if FileSystem provider isn't configured - return findInputFile(filepath).toPath(); - } - - /** - * Returns a file for use as output in the pipeline, given its name. - * This allows the task definitions to name files they create as output, - * and the pipeline definition to specify where those files should end up. - */ - @Deprecated //Please switch to use findOutputPath - File findOutputFile(String name); //TODO update implementations to return nio.Path directly - default Path findOutputPath(String name) - { - //This is generally safe, but may fail if the appropriate filesystem providers are not registered. - return findOutputFile(name).toPath(); - } - - /** - * Returns a file for the output dir and file name. - * The output dir is a directory path relative to the analysis directory, - * or, if the path starts with "/", relative to the pipeline root. - */ - @Deprecated //Please switch to use findOutputPath - File findOutputFile(@NotNull String outputDir, @NotNull String fileName); - default Path findOutputPath(@NotNull String outputDir, @NotNull String filename) - { - //This is generally safe, but may fail if the appropriate filesystem providers are not registered. - return findOutputFile(outputDir, filename).toPath(); - } - - /** - * @return a parameter parser object for writing parameters to a file. - */ - ParamParser createParamParser(); - - /** - * @return name-value map of the BioML parameters. - */ - Map getParameters(); - - /** - * @return the parameters input file used to drive the pipeline. - */ - @Nullable - @Deprecated //Use Path based versions - File getParametersFile(); - - /** - * @return a list of all input files analyzed. - */ - @Deprecated - List getInputFiles(); - - default List getInputFilePaths() - { - //Implemented as such for backwards compatibility - return getInputFiles().stream().map(File::toPath).collect(Collectors.toList()); - } - - /** - * returns support level for .xml.gz handling: - * SUPPORT_GZ or PREFER_GZ - * we always read .xml.gz, but may also have a - * preference for producing it in the pipeline - */ - FileType.gzSupportLevel getGZPreference(); - -} +/* + * Copyright (c) 2008-2017 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.api.pipeline.file; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.labkey.api.pipeline.ParamParser; +import org.labkey.api.util.FileType; +import org.labkey.vfs.FileLike; +import org.labkey.vfs.FileSystemLike; + +import java.io.File; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * FileAnalysisJobSupport + * + * @author brendanx + */ +public interface FileAnalysisJobSupport +{ + /** + * @return protocol name of the current protocol. + */ + String getProtocolName(); + + /** + * @return the base name for the full set of files. + */ + String getJoinedBaseName(); + + /** + * @return the base names for all the split input files, or just this + * job's single base name in an array, if this is a split job. + */ + List getSplitBaseNames(); + + /** + * @return base name of the original input file. + */ + String getBaseName(); + + /** + * @param fileType The file type to compare + * @return base name for the specified FileType + */ + String getBaseNameForFileType(FileType fileType); + + /** + * @return the directory in which the original input file resides. + */ + @Deprecated //Prefer the getDataDirectoryPath version as File return type doesn't support full URIs very well + File getDataDirectory(); + default Path getDataDirectoryPath() + { + // TODO This needs implementation in derived classes... + // This is typically safe but may cause an error if FileSystem provider isn't configured + return getDataDirectory().toPath(); + } + + /** + * @return the directory where the input files reside, and where the + * final analysis should end up. + */ + @Deprecated // Please use getAnalysisDirectoryPath instead, as File objects may have issues with full URIs + File getAnalysisDirectory(); + default Path getAnalysisDirectoryPath() + { + // TODO This needs implementation in derived classes... + // This is typically safe but may cause an error if FileSystem provider isn't configured + return getAnalysisDirectory().toPath(); + } + + default FileLike getAnalysisDirectoryFileLike() + { + // TODO This needs implementation in derived classes... + // This is typically safe but may cause an error if FileSystem provider isn't configured + return FileSystemLike.wrapFile(getAnalysisDirectory()); + } + + /** + * Returns a file for use as input in the pipeline, given its name. + * This allows the task definitions to name files they require as input, + * and the pipeline definition to specify where those files should come from. + */ + @Deprecated // Please use findInputPath instead, as File objects may have issues with full URIs + File findInputFile(String name); + default Path findInputPath(String filepath) + { + // TODO This needs implementation in derived classes... + // This is typically safe but may cause an error if FileSystem provider isn't configured + return findInputFile(filepath).toPath(); + } + + /** + * Returns a file for use as output in the pipeline, given its name. + * This allows the task definitions to name files they create as output, + * and the pipeline definition to specify where those files should end up. + */ + @Deprecated //Please switch to use findOutputPath + File findOutputFile(String name); //TODO update implementations to return nio.Path directly + default Path findOutputPath(String name) + { + //This is generally safe, but may fail if the appropriate filesystem providers are not registered. + return findOutputFile(name).toPath(); + } + + /** + * Returns a file for the output dir and file name. + * The output dir is a directory path relative to the analysis directory, + * or, if the path starts with "/", relative to the pipeline root. + */ + @Deprecated //Please switch to use findOutputPath + File findOutputFile(@NotNull String outputDir, @NotNull String fileName); + default Path findOutputPath(@NotNull String outputDir, @NotNull String filename) + { + //This is generally safe, but may fail if the appropriate filesystem providers are not registered. + return findOutputFile(outputDir, filename).toPath(); + } + + /** + * @return a parameter parser object for writing parameters to a file. + */ + ParamParser createParamParser(); + + /** + * @return name-value map of the BioML parameters. + */ + Map getParameters(); + + /** + * @return the parameters input file used to drive the pipeline. + */ + @Nullable + @Deprecated //Use Path based versions + File getParametersFile(); + + /** + * @return a list of all input files analyzed. + */ + @Deprecated + List getInputFiles(); + + default List getInputFilePaths() + { + //Implemented as such for backwards compatibility + return getInputFiles().stream().map(File::toPath).collect(Collectors.toList()); + } + + /** + * returns support level for .xml.gz handling: + * SUPPORT_GZ or PREFER_GZ + * we always read .xml.gz, but may also have a + * preference for producing it in the pipeline + */ + FileType.gzSupportLevel getGZPreference(); + +} diff --git a/api/src/org/labkey/api/pipeline/file/FileAnalysisTaskPipeline.java b/api/src/org/labkey/api/pipeline/file/FileAnalysisTaskPipeline.java index 803d5eabea5..469338e5879 100644 --- a/api/src/org/labkey/api/pipeline/file/FileAnalysisTaskPipeline.java +++ b/api/src/org/labkey/api/pipeline/file/FileAnalysisTaskPipeline.java @@ -1,121 +1,121 @@ -/* - * Copyright (c) 2008-2018 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.api.pipeline.file; - -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.labkey.api.data.Container; -import org.labkey.api.formSchema.FormSchema; -import org.labkey.api.pipeline.PipelineActionConfig; -import org.labkey.api.pipeline.TaskPipeline; -import org.labkey.api.util.FileType; -import org.labkey.api.util.ReturnURLString; -import org.labkey.api.util.URLHelper; -import org.labkey.vfs.FileLike; - -import java.io.File; -import java.io.FileFilter; -import java.nio.file.DirectoryStream; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import java.util.function.Predicate; - -/** - * FileAnalysisTaskPipeline - * A filter interface that implements both the io.FileFilter and nio.DirectoryStream.Filter interfaces - */ -public interface FileAnalysisTaskPipeline extends TaskPipeline -{ - interface FilePathFilter extends FileFilter, DirectoryStream.Filter, Predicate - { - @Override - boolean accept(File file); - - @Override - boolean accept(Path path); - - @Override - default boolean test(FileLike fileLike) - { - return accept(fileLike.toNioPathForRead()); - } - } - - - /** - * Returns the name of the protocol factory for this pipeline, which - * will be used as the root directory name for all analyses of this type - * and the directory name of the saved system protocol XML files. - * - * @return the name of the protocol factory - */ - String getProtocolFactoryName(); - - /** - * Returns the full list of acceptable file types that can be used to - * start this pipeline. - * - * @return list containing acceptable initial file types - */ - @NotNull - List getInitialFileTypes(); - - /** - * Returns a FileFilter for use in creating an input file set. - * - * @return filter for input files - */ - @NotNull - FilePathFilter getInitialFileTypeFilter(); - - @NotNull - URLHelper getAnalyzeURL(Container c, String path, @Nullable ReturnURLString parsedReturnUrl); - - @NotNull - Map> getTypeHierarchy(); - - @Nullable - PipelineActionConfig.displayState getDefaultDisplayState(); - - boolean isAllowForTriggerConfiguration(); - - /** - * Write out the job info as a tsv file similar to the R transformation runProperties format. - * This is a info file for an entire job (or split job) that command line or script tasks may use - * to determine the inputs files and other job related metadata. - * - * @see org.labkey.api.pipeline.file.AbstractFileAnalysisJob#writeJobInfoTSV(java.io.File) - * @see org.labkey.api.qc.TsvDataExchangeHandler - * @link https://www.labkey.org/Documentation/wiki-page.view?name=runProperties - */ - boolean isWriteJobInfoFile(); - - /** - * Allow the job to be split if there are multiple file inputs. - */ - boolean isSplittable(); - - String getHelpText(); - - Boolean isMoveAvailable(); - - Boolean isInitialFileTypesRequired(); - - FormSchema getFormSchema(); - - FormSchema getCustomFieldsFormSchema(); -} +/* + * Copyright (c) 2008-2018 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.api.pipeline.file; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.labkey.api.data.Container; +import org.labkey.api.formSchema.FormSchema; +import org.labkey.api.pipeline.PipelineActionConfig; +import org.labkey.api.pipeline.TaskPipeline; +import org.labkey.api.util.FileType; +import org.labkey.api.util.ReturnURLString; +import org.labkey.api.util.URLHelper; +import org.labkey.vfs.FileLike; + +import java.io.File; +import java.io.FileFilter; +import java.nio.file.DirectoryStream; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.function.Predicate; + +/** + * FileAnalysisTaskPipeline + * A filter interface that implements both the io.FileFilter and nio.DirectoryStream.Filter interfaces + */ +public interface FileAnalysisTaskPipeline extends TaskPipeline +{ + interface FilePathFilter extends FileFilter, DirectoryStream.Filter, Predicate + { + @Override + boolean accept(File file); + + @Override + boolean accept(Path path); + + @Override + default boolean test(FileLike fileLike) + { + return accept(fileLike.toNioPathForRead()); + } + } + + + /** + * Returns the name of the protocol factory for this pipeline, which + * will be used as the root directory name for all analyses of this type + * and the directory name of the saved system protocol XML files. + * + * @return the name of the protocol factory + */ + String getProtocolFactoryName(); + + /** + * Returns the full list of acceptable file types that can be used to + * start this pipeline. + * + * @return list containing acceptable initial file types + */ + @NotNull + List getInitialFileTypes(); + + /** + * Returns a FileFilter for use in creating an input file set. + * + * @return filter for input files + */ + @NotNull + FilePathFilter getInitialFileTypeFilter(); + + @NotNull + URLHelper getAnalyzeURL(Container c, String path, @Nullable ReturnURLString parsedReturnUrl); + + @NotNull + Map> getTypeHierarchy(); + + @Nullable + PipelineActionConfig.displayState getDefaultDisplayState(); + + boolean isAllowForTriggerConfiguration(); + + /** + * Write out the job info as a tsv file similar to the R transformation runProperties format. + * This is a info file for an entire job (or split job) that command line or script tasks may use + * to determine the inputs files and other job related metadata. + * + * @see org.labkey.api.pipeline.file.AbstractFileAnalysisJob#writeJobInfoTSV(java.io.File) + * @see org.labkey.api.qc.TsvDataExchangeHandler + * @link https://www.labkey.org/Documentation/wiki-page.view?name=runProperties + */ + boolean isWriteJobInfoFile(); + + /** + * Allow the job to be split if there are multiple file inputs. + */ + boolean isSplittable(); + + String getHelpText(); + + Boolean isMoveAvailable(); + + Boolean isInitialFileTypesRequired(); + + FormSchema getFormSchema(); + + FormSchema getCustomFieldsFormSchema(); +} diff --git a/api/src/org/labkey/api/study/SpecimenTransform.java b/api/src/org/labkey/api/study/SpecimenTransform.java index 65531c925ae..e62f3af113e 100644 --- a/api/src/org/labkey/api/study/SpecimenTransform.java +++ b/api/src/org/labkey/api/study/SpecimenTransform.java @@ -1,87 +1,87 @@ -/* - * Copyright (c) 2013-2016 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.api.study; - -import org.jetbrains.annotations.Nullable; -import org.labkey.api.data.Container; -import org.labkey.api.pipeline.PipelineJob; -import org.labkey.api.pipeline.PipelineJobException; -import org.labkey.api.query.ValidationException; -import org.labkey.api.security.User; -import org.labkey.api.util.FileType; -import org.labkey.api.view.ActionURL; -import org.labkey.vfs.FileLike; - -import java.io.File; -import java.nio.file.Path; - -/** - * User: klum - * Date: 11/12/13 - */ -public interface SpecimenTransform -{ - /** - * Returns the descriptive name - */ - String getName(); - - /** - * Returns whether module containing transform is present for a container - */ - boolean isValid(Container container); - - /** - * Returns whether transform is active for a container - */ - boolean isActive(Container container); - - /** - * Returns the file type that this transform can accept - */ - FileType getFileType(); - - void transform(@Nullable PipelineJob job, Path input, Path outputArchive) throws PipelineJobException; - - /** - * An optional post transform step. - */ - void postTransform(@Nullable PipelineJob job, File input, File outputArchive) throws PipelineJobException; - - @Nullable - ActionURL getManageAction(Container c, User user); - - /** - * Returns and saved configuration information - */ - ExternalImportConfig getExternalImportConfig(Container c, User user) throws ValidationException; - - /** - * An optional capability to import from an external (API) source, data that can be transformed into - * a LabKey compatible specimen archive - * - * @param importConfig configuration object - * @param inputArchive the file to write the externally sourced data into - */ - void importFromExternalSource(@Nullable PipelineJob job, ExternalImportConfig importConfig, FileLike inputArchive) throws PipelineJobException; - - interface ExternalImportConfig - { - String getBaseServerUrl(); - String getUsername(); - String getPassword(); - } -} +/* + * Copyright (c) 2013-2016 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.api.study; + +import org.jetbrains.annotations.Nullable; +import org.labkey.api.data.Container; +import org.labkey.api.pipeline.PipelineJob; +import org.labkey.api.pipeline.PipelineJobException; +import org.labkey.api.query.ValidationException; +import org.labkey.api.security.User; +import org.labkey.api.util.FileType; +import org.labkey.api.view.ActionURL; +import org.labkey.vfs.FileLike; + +import java.io.File; +import java.nio.file.Path; + +/** + * User: klum + * Date: 11/12/13 + */ +public interface SpecimenTransform +{ + /** + * Returns the descriptive name + */ + String getName(); + + /** + * Returns whether module containing transform is present for a container + */ + boolean isValid(Container container); + + /** + * Returns whether transform is active for a container + */ + boolean isActive(Container container); + + /** + * Returns the file type that this transform can accept + */ + FileType getFileType(); + + void transform(@Nullable PipelineJob job, Path input, Path outputArchive) throws PipelineJobException; + + /** + * An optional post transform step. + */ + void postTransform(@Nullable PipelineJob job, File input, File outputArchive) throws PipelineJobException; + + @Nullable + ActionURL getManageAction(Container c, User user); + + /** + * Returns and saved configuration information + */ + ExternalImportConfig getExternalImportConfig(Container c, User user) throws ValidationException; + + /** + * An optional capability to import from an external (API) source, data that can be transformed into + * a LabKey compatible specimen archive + * + * @param importConfig configuration object + * @param inputArchive the file to write the externally sourced data into + */ + void importFromExternalSource(@Nullable PipelineJob job, ExternalImportConfig importConfig, FileLike inputArchive) throws PipelineJobException; + + interface ExternalImportConfig + { + String getBaseServerUrl(); + String getUsername(); + String getPassword(); + } +} diff --git a/api/src/org/labkey/api/util/FileType.java b/api/src/org/labkey/api/util/FileType.java index 584a9deb082..d874803027c 100644 --- a/api/src/org/labkey/api/util/FileType.java +++ b/api/src/org/labkey/api/util/FileType.java @@ -1,801 +1,801 @@ -/* - * Copyright (c) 2008-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.api.util; - -import org.apache.commons.io.IOCase; -import org.apache.tika.detect.DefaultDetector; -import org.apache.tika.detect.Detector; -import org.apache.tika.io.TikaInputStream; -import org.apache.tika.metadata.Metadata; -import org.apache.tika.mime.MediaType; -import org.apache.tika.mime.MimeTypes; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.junit.Assert; -import org.junit.Test; -import org.labkey.api.pipeline.file.FileAnalysisJobSupport; -import org.labkey.vfs.FileLike; - -import java.io.File; -import java.io.IOException; -import java.io.Serializable; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; - -/** - * FileType - * - * @author brendanx - */ -public class FileType implements Serializable -{ - private static final Detector DETECTOR = new DefaultDetector(MimeTypes.getDefaultMimeTypes()); - - // For serialization - protected FileType() {} - - public File findInputFile(FileAnalysisJobSupport support, String baseName) - { - if (_suffixes.size() > 1) - { - for (String suffix : _suffixes) - { - File f = support.findInputFile(baseName + suffix); - if (f != null && NetworkDrive.exists(f)) - { - return f; - } - } - } - - return support.findInputFile(getDefaultName(baseName)); - } - - /** handle TPP's native use of .xml.gz **/ - public enum gzSupportLevel - { - NO_GZ, // we don't support gzip for this filetype - SUPPORT_GZ, // we support gzip for this filetype, but it's not the norm - PREFER_GZ // we support gzip for this filetype, and it's the default for new files - } - - /** A list of possible suffixes in priority order. Later suffixes may also match earlier suffixes */ - private List _suffixes; - /** a list of filetypes to reject - handles the scenario where old pepxml files are "foo.xml" and - * we have to avoid grabbing "foo.pep-prot.xml" - */ - private List _antiTypes; - /** The canonical suffix, will be used when creating new files from scratch */ - private String _defaultSuffix; - - /** Mime content type. */ - private List _contentTypes; - - private Boolean _dir; - /** If _preferGZ is true, assume suffix.gz for new files to support TPP's transparent .xml.gz useage. - * When dealing with existing files, non-gz version is still assumed to be the target if found **/ - private Boolean _preferGZ; - /** If _supportGZ is true, accept .suffix.gz as the equivalent of .suffix **/ - private Boolean _supportGZ; - private boolean _caseSensitiveOnCaseSensitiveFileSystems = false; - - /** - * true if the different file extensions are just transformed versions of the same data (such as .raw and .mzXML) - * and therefore if multiple are present only the first should be considered for actions in the UI. - * false if they are independent and should all be considered actionable - */ - private boolean _extensionsMutuallyExclusive = true; - - /** - * Constructor to use when type is assumed to be a file, but a call to isDirectory() - * is not necessary. - * - * @param supportGZ for handling of TPP's transparent use of .xml.gz - * @param suffix usually the file extension, but may be some other suffix to - * uniquely identify a file type - * - */ - public FileType(String suffix, gzSupportLevel supportGZ) - { - this(Arrays.asList(suffix), suffix, supportGZ); - } - - /** - * Constructor to use when type is assumed to be a file, but a call to isDirectory() - * is not necessary. - * - * @param suffix usually the file extension, but may be some other suffix to - * uniquely identify a file type - * - */ - public FileType(String suffix) - { - this(Arrays.asList(suffix), suffix); - } - - /** - * Constructor to use when a call to isDirectory() is necessary to differentiate this - * file type. - * - * @param suffix usually the file extension, but may be some other suffix to - * uniquely identify a file type - * @param dir true when the type must be a directory - */ - public FileType(String suffix, boolean dir) - { - this(Arrays.asList(suffix), suffix, dir, gzSupportLevel.NO_GZ); - } - - /** - * @param suffixes list of what are usually the file extensions (but may be some other suffix to - * uniquely identify a file type), in priority order. The first suffix that matches a file will be used - * and files that match the rest of the suffixes will be ignored - * @param defaultSuffix the canonical suffix, will be used when creating new files from scratch - */ - public FileType(List suffixes, String defaultSuffix) - { - this(suffixes, defaultSuffix, false, gzSupportLevel.NO_GZ); - } - - /** - * @param suffixes list of what are usually the file extensions (but may be some other suffix to - * uniquely identify a file type), in priority order. The first suffix that matches a file will be used - * and files that match the rest of the suffixes will be ignored - * @param defaultSuffix the canonical suffix, will be used when creating new files from scratch - * @param contentTypes Content types for this file type. If null, a content type will be guessed based on the extension. - */ - public FileType(List suffixes, String defaultSuffix, List contentTypes) - { - this(suffixes, defaultSuffix, false, gzSupportLevel.NO_GZ, contentTypes); - } - - /** - * @param suffixes list of what are usually the file extensions (but may be some other suffix to - * uniquely identify a file type), in priority order. The first suffix that matches a file will be used - * and files that match the rest of the suffixes will be ignored - * @param defaultSuffix the canonical suffix, will be used when creating new files from scratch - * @param dir true when the type must be a directory - */ - public FileType(List suffixes, String defaultSuffix, boolean dir) - { - this(suffixes, defaultSuffix, dir, gzSupportLevel.NO_GZ); - } - - /** - * @param suffixes list of what are usually the file extensions (but may be some other suffix to - * uniquely identify a file type), in priority order. The first suffix that matches a file will be used - * and files that match the rest of the suffixes will be ignored - * @param defaultSuffix the canonical suffix, will be used when creating new files from scratch - * @param dir true when the type must be a directory - * @param supportGZ for handling TPP's transparent use of .xml.gz - */ - public FileType(List suffixes, String defaultSuffix, boolean dir, gzSupportLevel supportGZ) - { - this(suffixes, defaultSuffix, dir, supportGZ, null); - } - - - /** - * @param suffixes list of what are usually the file extensions (but may be some other suffix to - * uniquely identify a file type), in priority order. The first suffix that matches a file will be used - * and files that match the rest of the suffixes will be ignored - * @param defaultSuffix the canonical suffix, will be used when creating new files from scratch - * @param doSupportGZ for handling TPP's transparent use of .xml.gz - */ - public FileType(List suffixes, String defaultSuffix, gzSupportLevel doSupportGZ) - { - this(suffixes, defaultSuffix, false, doSupportGZ, null); - } - - /** - * @param suffixes list of what are usually the file extensions (but may be some other suffix to - * uniquely identify a file type), in priority order. The first suffix that matches a file will be used - * and files that match the rest of the suffixes will be ignored - * @param defaultSuffix the canonical suffix, will be used when creating new files from scratch - * @param doSupportGZ for handling TPP's transparent use of .xml.gz - * @param contentTypes Content types for this file type. If null, a content type will be guessed based on the extension. - */ - public FileType(List suffixes, String defaultSuffix, boolean dir, gzSupportLevel doSupportGZ, List contentTypes) - { - _suffixes = suffixes; - supportGZ(doSupportGZ); - _defaultSuffix = defaultSuffix; - _dir = Boolean.valueOf(dir); - _antiTypes = new ArrayList<>(0); - if (!suffixes.contains(defaultSuffix)) - { - throw new IllegalArgumentException("List of suffixes " + _suffixes + " does not contain the preferred suffix:" + _defaultSuffix); - } - - if (contentTypes == null) - { - MimeMap mm = new MimeMap(); - String contentType = mm.getContentType(defaultSuffix); - if (contentType != null) - _contentTypes = Collections.singletonList(contentType); - else - _contentTypes = Collections.emptyList(); - } - else - { - _contentTypes = Collections.unmodifiableList(new ArrayList<>(contentTypes)); - } - } - - /** helper for supporting TPP's use of .xml.gz */ - private String tryName(Path parentDir, String name) - { - if (_supportGZ.booleanValue()) // TPP treats xml.gz as a native format - { - FileUtil.legalPathPartThrow(name); - // in the case of existing files, non-gz copy wins if present - Path f = parentDir!=null ? FileUtil.appendName(parentDir, name) : Path.of(name); - if (!NetworkDrive.exists(f)) - { // non-gz copy doesn't exist - how about .gz version? - String gzname = name + ".gz"; - if (_preferGZ.booleanValue()) - { // we like .gz for new filenames, so don't care if exists - return gzname; - } - f = parentDir!=null ? FileUtil.appendName(parentDir, gzname) : Path.of(gzname); - if (NetworkDrive.exists(f)) - { // we don't prefer .gz, but we support it if it exists - return gzname; - } - } - } - return name; - } - - /** Uses the preferred suffix, useful when there's not a directory of existing files to reference */ - /** if _preferGZ is set, will use preferred suffix.gz since TPP treats .gz as native format, - * unless non-gz file exists */ - public String getDefaultName(String basename) - { - return tryName(null, basename + _defaultSuffix); - } - - /** - * turn support for gzipped files on and off - */ - public boolean supportGZ(gzSupportLevel doSupportGZ) - { - _supportGZ = Boolean.valueOf(doSupportGZ != gzSupportLevel.NO_GZ); - _preferGZ = Boolean.valueOf(doSupportGZ == gzSupportLevel.PREFER_GZ); - return _supportGZ.booleanValue(); - } - - /** - * add a new supported suffix, return new list length - */ - public int addSuffix(String newsuffix) - { - List s = new ArrayList<>(_suffixes.size()+1); - for (String suffix : _suffixes) - { - s.add(suffix); - } - s.add(newsuffix); - _suffixes = s; - return _suffixes.size(); - } - - /** - * add a new filetype to reject, return new list length - */ - public int addAntiFileType(FileType anti) - { - List s = new ArrayList<>(_antiTypes.size()+1); - for (FileType a : _antiTypes) - { - s.add(a); - } - s.add(anti); - _antiTypes = s; - return _antiTypes.size(); - } - - // used to avoid, for example, mistaking protxml ".pep-prot.xml" for pepxml ".xml" file - private boolean isAntiFileType(String name, byte[] header) - { - for (FileType a : _antiTypes) - { - if (a.isType(name)) - { - return true; - } - } - return false; - } - - /** - * Looks for a file in the parentDir that matches, in priority order. If one is found, returns its file name. - * If nothing matches, uses the defaultSuffix to build a file name. - */ - public String getName(File parentDir, String basename) - { - return getName(parentDir.toPath(), basename); - } - - public String getName(FileLike parentDir, String basename) - { - return getName(parentDir.toNioPathForRead(), basename); - } - - public String getName(Path parentDir, String basename) - { - if (_suffixes.size() > 1) - { - // Only bother checking if we have more than one possible suffix - for (String suffix : _suffixes) - { - String name = tryName(parentDir, basename + suffix); - Path f = FileUtil.appendName(parentDir, name); - if (NetworkDrive.exists(f)) - { - // avoid, for example, mistaking protxml ".pep-prot.xml" for pepxml ".xml" file - if (!isAntiFileType(name, null)) - { - return name; - } - } - } - } - return tryName(parentDir, basename + _defaultSuffix); - } - - public String getName(String parentDirName, String basename) - { - File parentDir = new File(parentDirName); - return getName(parentDir,basename); - } - - /** - * Looks for a file in the parentDir that matches, in priority order. If one is found, returns its file name. - * If nothing matches, uses the defaultSuffix to build a file name. - */ - @Deprecated //please switch to using the nio.Path version as the File class can have issues using full URIs - public File getFile(File parentDir, String basename) - { - return new File(parentDir, getName(parentDir, basename)); - } - - public Path getPath(Path parentDir, String basename) - { - return FileUtil.appendName(parentDir, getName(parentDir, basename)); - } - - /** - * @return the index of the first suffix that matches. Useful when looking through a directory of files and - * determining which is the preferred file for this FileType. - */ - public int getIndexMatch(Path file) - { - return getIndexMatch(file.getFileName().toString(), file.toString()); - } - - private int getIndexMatch(String filename, String filePath) - { - if (!isAntiFileType(filename, null)) // avoid, for example, mistaking .pep-prot.xml for .xml - { - for (int i = 0; i < _suffixes.size(); i++) - { - String s = toLowerIfCaseInsensitive(_suffixes.get(i)); - if (toLowerIfCaseInsensitive(filename).endsWith(s)) - { - return i; - } - // TPP treats .xml.gz as a native format - if (_supportGZ.booleanValue() && toLowerIfCaseInsensitive(filename).endsWith(s + ".gz")) - { - return i; - } - } - } - - throw new IllegalArgumentException("No match found for " + filePath + " with " + this); - } - - private String toLowerIfCaseInsensitive(String s) - { - if (s == null) - { - return null; - } - if (_caseSensitiveOnCaseSensitiveFileSystems && IOCase.SYSTEM.isCaseSensitive()) - { - return s; - } - return s.toLowerCase(); - } - - /** - * Finds the best suffix based on priority order, strips it off, and returns the remainder. If there is no matching - * suffix, returns the original file name. - */ - public String getBaseName(File file) - { - return getBaseName(file.toPath()); - } - - public String getBaseName(FileLike file) - { - return getBaseName(file.toNioPathForRead()); - } - - public String getBaseName(@NotNull java.nio.file.Path file) - { - String fileName = file.getFileName().toString(); - if (isAntiFileType(fileName, null) || !isType(file)) - return fileName; - - String suffix = null; - for (String s : _suffixes) - { - // run the entire list in order to assure strongest match - // consider .msprefix.mzxml vs .mzxml for example - if (toLowerIfCaseInsensitive(fileName).endsWith(toLowerIfCaseInsensitive(s))) - { - if ((null==suffix) || (s.length()>suffix.length())) - { - suffix = s; - } - } - else if (_supportGZ.booleanValue()) // TPP treats .xml.gz as a native read format - { - String sgz = s+".gz"; - if (fileName.endsWith(sgz)) - { - if ((null==suffix) || (sgz.length()>suffix.length())) - { - suffix = sgz; - } - } - } - } - assert suffix != null : "Could not find matching suffix even though types match"; - return fileName.substring(0, fileName.length() - suffix.length()); - } - - public File newFile(File parent, String basename) - { - return FileUtil.appendName(parent, getName(parent, basename)); - } - - public FileLike newFile(FileLike parent, String basename) - { - return parent.resolveChild(getName(parent, basename)); - } - - public Path newFile(Path parent, String basename) - { - return FileUtil.appendName(parent, getName(parent, basename)); - } - - public boolean isType(File file) - { - return isType(file, null, null); - } - - public boolean isType(FileLike file) - { - return isType(file.toNioPathForRead(), null, null); - } - - public boolean isType(java.nio.file.Path path) - { - return isType(path, null, null); - } - - public boolean isType(java.nio.file.Path path, String contentType, byte[] header) - { - if ((path == null) || (_dir != null && _dir.booleanValue() != Files.isDirectory(path))) - return false; - - return isType(path.getFileName().toString(), contentType, header); - } - - - public boolean isType(File file, String contentType, byte[] header) - { - if ((file == null) || (_dir != null && _dir.booleanValue() != file.isDirectory())) - return false; - - return isType(file.getName(), contentType, header); - } - - /** - * Checks if the path matches any of the suffixes - */ - public boolean isType(String filePath) - { - return isType(filePath, null, null); - } - - /** - * Checks if the path matches any of the suffixes and the file header if provided. - */ - public boolean isType(@Nullable String filePath, @Nullable String contentType, @Nullable byte[] header) - { - String providedContentType = contentType; // Save it for later - - // avoid, for example, mistaking protxml ".pep-prot.xml" for pepxml ".xml" - if (isAntiFileType(filePath, header)) - { - return false; - } - - // Attempt to match by content type. - if (_contentTypes != null) - { - // Use Tika to determine the content type - if (contentType == null && header != null) - contentType = detectContentType(filePath, header); - - if (contentType != null) - { - contentType = contentType.toLowerCase().trim(); - if (_contentTypes.contains(contentType)) - return true; - } - } - - // Attempt to match by suffix and header. - if (filePath != null) - { - filePath = toLowerIfCaseInsensitive(filePath); - for (String suffix : _suffixes) - { - suffix = toLowerIfCaseInsensitive(suffix); - if (filePath.endsWith(suffix)) - { - if (header == null || isHeaderMatch(header)) - return true; - } - // TPP treats .xml.gz as a native format - if (_supportGZ.booleanValue() && filePath.endsWith(suffix + ".gz")) - { - if (header == null || isHeaderMatch(header)) - return true; - } - } - } - - // Attempt to match using just the header, but only if the original content type was null, Issue 47814 - return null == providedContentType && header != null && isHeaderMatch(header); - } - - protected static String detectContentType(String fileName, byte[] header) - { - final Metadata metadata = new Metadata(); - metadata.set("resourceName", fileName); - try (TikaInputStream is = TikaInputStream.get(header, metadata)) - { - MediaType mediaType = DETECTOR.detect(is, metadata); - if (mediaType != null) - return mediaType.toString(); - - return null; - } - catch (IOException e) - { - throw new RuntimeException(e); - } - } - - public boolean isMatch(String name, String basename) - { - for (String suffix : _suffixes) - { - if (name.equalsIgnoreCase(basename + suffix)) - { - return true; - } - // TPP treats .xml.gz as a native format - if (_supportGZ.booleanValue() && name.equals(basename + suffix+".gz")) - { - return true; - } - } - return false; - } - - /** - * Checks if the file header matches. This is useful for FileTypes that share an - * extension, e.g. "txt" or "xml", or when the filename or extension isn't available. - * - * @param header First few K of the file. - * @return True if the header matches, false otherwise. - */ - public boolean isHeaderMatch(@NotNull byte[] header) - { - return false; - } - - public boolean equals(Object o) - { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - - FileType fileType = (FileType) o; - - if (_supportGZ != null ? !_supportGZ.equals(fileType._supportGZ) : fileType._supportGZ != null) return false; - if (_preferGZ != null ? !_preferGZ.equals(fileType._preferGZ) : fileType._preferGZ != null) return false; - if (_dir != null ? !_dir.equals(fileType._dir) : fileType._dir != null) return false; - if (_defaultSuffix != null ? !_defaultSuffix.equals(fileType._defaultSuffix) : fileType._defaultSuffix != null) - return false; - if (_antiTypes != null ? !_antiTypes.equals(fileType._antiTypes) : fileType._antiTypes != null) return false; - return !(_suffixes != null ? !_suffixes.equals(fileType._suffixes) : fileType._suffixes != null); - } - - public String getDefaultSuffix() - { - return _defaultSuffix; - } - - public List getSuffixes() - { - return Collections.unmodifiableList(_suffixes); - } - - public int hashCode() - { - int result; - result = (_suffixes != null ? _suffixes.hashCode() : 0); - result = 31 * result + (_defaultSuffix != null ? _defaultSuffix.hashCode() : 0); - result = 31 * result + (_dir != null ? _dir.hashCode() : 0); - result = 31 * result + (_supportGZ != null ? _supportGZ.hashCode() : 0); - result = 31 * result + (_preferGZ != null ? _preferGZ.hashCode() : 0); - return result; - } - - public String toString() - { - return (_dir == null || !_dir.booleanValue() ? _suffixes.toString() : _suffixes + "/"); - } - - @NotNull - public static List findTypes(@NotNull List types, @NotNull List files) - { - ArrayList foundTypes = new ArrayList<>(); - // This O(n*m), but these are usually very short lists. - for (FileType type : types) - { - for (FileLike file : files) - { - if (type.isType(file.getName())) - { - foundTypes.add(type); - break; - } - } - } - return foundTypes; - } - - /** - * true if the different file extensions are just transformed versions of the same data (such as .raw and .mzXML) - * and therefore if multiple are present only the first should be considered for actions in the UI. - * false if they are independent and should all be considered actionable - */ - public boolean isExtensionsMutuallyExclusive() - { - return _extensionsMutuallyExclusive; - } - - /** - * @param extensionsMutuallyExclusive true if the different file extensions are just transformed versions of the - * same data (such as .raw and .mzXML) and therefore if multiple are present only the first should be - * considered for actions in the UI. - * false if they are independent and should all be considered actionable - */ - public void setExtensionsMutuallyExclusive(boolean extensionsMutuallyExclusive) - { - _extensionsMutuallyExclusive = extensionsMutuallyExclusive; - } - - /** - * @return a FileType that will only match on the default suffix for this FileType - */ - public FileType getDefaultFileType() - { - if (!_suffixes.isEmpty()) - { - FileType ft = new FileType(_defaultSuffix); - ft._dir = _dir; - ft._supportGZ = _supportGZ.booleanValue(); - ft._preferGZ = _preferGZ.booleanValue(); - return ft; - } - else - { - return this; - } - } - - public String getDefaultRole() - { - if (_defaultSuffix.contains(".")) - { - return _defaultSuffix.substring(_defaultSuffix.indexOf(".") + 1); - } - return _defaultSuffix; - } - - public boolean isCaseSensitiveOnCaseSensitiveFileSystems() - { - return _caseSensitiveOnCaseSensitiveFileSystems; - } - - public void setCaseSensitiveOnCaseSensitiveFileSystems(boolean caseSensitiveOnCaseSensitiveFileSystems) - { - _caseSensitiveOnCaseSensitiveFileSystems = caseSensitiveOnCaseSensitiveFileSystems; - } - - public List getContentTypes() - { - return _contentTypes; - } - - public static class TestCase extends Assert - { - @Test - public void test() - { - // simple case - FileType ft = new FileType(".foo"); - assertTrue(ft.isType("test.foo")); - assertTrue(!ft.isType("test.foo.gz")); - assertEquals("test.foo",ft.getDefaultName("test")); - - // support for .gz - FileType ftgz = new FileType(".foo",gzSupportLevel.SUPPORT_GZ); - assertTrue(ftgz.isType("test.foo")); - assertTrue(ftgz.isType("test.foo.gz")); - assertEquals("test.foo",ftgz.getDefaultName("test")); - - // preference for .gz - FileType ftgzgz = new FileType(".foo",gzSupportLevel.PREFER_GZ); - assertTrue(ftgzgz.isType("test.foo")); - assertTrue(ftgzgz.isType("test.foo.gz")); - assertEquals("test.foo.gz",ftgzgz.getDefaultName("test")); - - // multiple extensions - ArrayList foobar = new ArrayList<>(); - foobar.add(".foo"); - foobar.add(".bar"); - FileType ftt = new FileType(foobar,".foo",false,gzSupportLevel.SUPPORT_GZ); - assertTrue(ftt.isType("test.foo")); - assertTrue(ftt.isType("test.bar")); - assertTrue(ftt.isType("test.foo.gz")); - assertTrue(ftt.isType("test.bar.gz")); - assertTrue(ftt.isType("test.bAr.gZ")); // extensions are case insensitive - assertEquals("test.foo",ftt.getDefaultName("test")); - - // antitypes - for example avoid mistaking protxml ".pep-prot.xml" for pepxml ".xml" - assertTrue(ftt.isType("test.foo.bar")); - ftt.addAntiFileType(new FileType(".foo.bar")); - assertTrue(!ftt.isType("test.foo.bar")); - assertTrue(ftt.isType("test.foo")); - assertTrue(ftt.isType("test.bar")); - - } - } -} +/* + * Copyright (c) 2008-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.api.util; + +import org.apache.commons.io.IOCase; +import org.apache.tika.detect.DefaultDetector; +import org.apache.tika.detect.Detector; +import org.apache.tika.io.TikaInputStream; +import org.apache.tika.metadata.Metadata; +import org.apache.tika.mime.MediaType; +import org.apache.tika.mime.MimeTypes; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.junit.Assert; +import org.junit.Test; +import org.labkey.api.pipeline.file.FileAnalysisJobSupport; +import org.labkey.vfs.FileLike; + +import java.io.File; +import java.io.IOException; +import java.io.Serializable; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * FileType + * + * @author brendanx + */ +public class FileType implements Serializable +{ + private static final Detector DETECTOR = new DefaultDetector(MimeTypes.getDefaultMimeTypes()); + + // For serialization + protected FileType() {} + + public File findInputFile(FileAnalysisJobSupport support, String baseName) + { + if (_suffixes.size() > 1) + { + for (String suffix : _suffixes) + { + File f = support.findInputFile(baseName + suffix); + if (f != null && NetworkDrive.exists(f)) + { + return f; + } + } + } + + return support.findInputFile(getDefaultName(baseName)); + } + + /** handle TPP's native use of .xml.gz **/ + public enum gzSupportLevel + { + NO_GZ, // we don't support gzip for this filetype + SUPPORT_GZ, // we support gzip for this filetype, but it's not the norm + PREFER_GZ // we support gzip for this filetype, and it's the default for new files + } + + /** A list of possible suffixes in priority order. Later suffixes may also match earlier suffixes */ + private List _suffixes; + /** a list of filetypes to reject - handles the scenario where old pepxml files are "foo.xml" and + * we have to avoid grabbing "foo.pep-prot.xml" + */ + private List _antiTypes; + /** The canonical suffix, will be used when creating new files from scratch */ + private String _defaultSuffix; + + /** Mime content type. */ + private List _contentTypes; + + private Boolean _dir; + /** If _preferGZ is true, assume suffix.gz for new files to support TPP's transparent .xml.gz useage. + * When dealing with existing files, non-gz version is still assumed to be the target if found **/ + private Boolean _preferGZ; + /** If _supportGZ is true, accept .suffix.gz as the equivalent of .suffix **/ + private Boolean _supportGZ; + private boolean _caseSensitiveOnCaseSensitiveFileSystems = false; + + /** + * true if the different file extensions are just transformed versions of the same data (such as .raw and .mzXML) + * and therefore if multiple are present only the first should be considered for actions in the UI. + * false if they are independent and should all be considered actionable + */ + private boolean _extensionsMutuallyExclusive = true; + + /** + * Constructor to use when type is assumed to be a file, but a call to isDirectory() + * is not necessary. + * + * @param supportGZ for handling of TPP's transparent use of .xml.gz + * @param suffix usually the file extension, but may be some other suffix to + * uniquely identify a file type + * + */ + public FileType(String suffix, gzSupportLevel supportGZ) + { + this(Arrays.asList(suffix), suffix, supportGZ); + } + + /** + * Constructor to use when type is assumed to be a file, but a call to isDirectory() + * is not necessary. + * + * @param suffix usually the file extension, but may be some other suffix to + * uniquely identify a file type + * + */ + public FileType(String suffix) + { + this(Arrays.asList(suffix), suffix); + } + + /** + * Constructor to use when a call to isDirectory() is necessary to differentiate this + * file type. + * + * @param suffix usually the file extension, but may be some other suffix to + * uniquely identify a file type + * @param dir true when the type must be a directory + */ + public FileType(String suffix, boolean dir) + { + this(Arrays.asList(suffix), suffix, dir, gzSupportLevel.NO_GZ); + } + + /** + * @param suffixes list of what are usually the file extensions (but may be some other suffix to + * uniquely identify a file type), in priority order. The first suffix that matches a file will be used + * and files that match the rest of the suffixes will be ignored + * @param defaultSuffix the canonical suffix, will be used when creating new files from scratch + */ + public FileType(List suffixes, String defaultSuffix) + { + this(suffixes, defaultSuffix, false, gzSupportLevel.NO_GZ); + } + + /** + * @param suffixes list of what are usually the file extensions (but may be some other suffix to + * uniquely identify a file type), in priority order. The first suffix that matches a file will be used + * and files that match the rest of the suffixes will be ignored + * @param defaultSuffix the canonical suffix, will be used when creating new files from scratch + * @param contentTypes Content types for this file type. If null, a content type will be guessed based on the extension. + */ + public FileType(List suffixes, String defaultSuffix, List contentTypes) + { + this(suffixes, defaultSuffix, false, gzSupportLevel.NO_GZ, contentTypes); + } + + /** + * @param suffixes list of what are usually the file extensions (but may be some other suffix to + * uniquely identify a file type), in priority order. The first suffix that matches a file will be used + * and files that match the rest of the suffixes will be ignored + * @param defaultSuffix the canonical suffix, will be used when creating new files from scratch + * @param dir true when the type must be a directory + */ + public FileType(List suffixes, String defaultSuffix, boolean dir) + { + this(suffixes, defaultSuffix, dir, gzSupportLevel.NO_GZ); + } + + /** + * @param suffixes list of what are usually the file extensions (but may be some other suffix to + * uniquely identify a file type), in priority order. The first suffix that matches a file will be used + * and files that match the rest of the suffixes will be ignored + * @param defaultSuffix the canonical suffix, will be used when creating new files from scratch + * @param dir true when the type must be a directory + * @param supportGZ for handling TPP's transparent use of .xml.gz + */ + public FileType(List suffixes, String defaultSuffix, boolean dir, gzSupportLevel supportGZ) + { + this(suffixes, defaultSuffix, dir, supportGZ, null); + } + + + /** + * @param suffixes list of what are usually the file extensions (but may be some other suffix to + * uniquely identify a file type), in priority order. The first suffix that matches a file will be used + * and files that match the rest of the suffixes will be ignored + * @param defaultSuffix the canonical suffix, will be used when creating new files from scratch + * @param doSupportGZ for handling TPP's transparent use of .xml.gz + */ + public FileType(List suffixes, String defaultSuffix, gzSupportLevel doSupportGZ) + { + this(suffixes, defaultSuffix, false, doSupportGZ, null); + } + + /** + * @param suffixes list of what are usually the file extensions (but may be some other suffix to + * uniquely identify a file type), in priority order. The first suffix that matches a file will be used + * and files that match the rest of the suffixes will be ignored + * @param defaultSuffix the canonical suffix, will be used when creating new files from scratch + * @param doSupportGZ for handling TPP's transparent use of .xml.gz + * @param contentTypes Content types for this file type. If null, a content type will be guessed based on the extension. + */ + public FileType(List suffixes, String defaultSuffix, boolean dir, gzSupportLevel doSupportGZ, List contentTypes) + { + _suffixes = suffixes; + supportGZ(doSupportGZ); + _defaultSuffix = defaultSuffix; + _dir = Boolean.valueOf(dir); + _antiTypes = new ArrayList<>(0); + if (!suffixes.contains(defaultSuffix)) + { + throw new IllegalArgumentException("List of suffixes " + _suffixes + " does not contain the preferred suffix:" + _defaultSuffix); + } + + if (contentTypes == null) + { + MimeMap mm = new MimeMap(); + String contentType = mm.getContentType(defaultSuffix); + if (contentType != null) + _contentTypes = Collections.singletonList(contentType); + else + _contentTypes = Collections.emptyList(); + } + else + { + _contentTypes = Collections.unmodifiableList(new ArrayList<>(contentTypes)); + } + } + + /** helper for supporting TPP's use of .xml.gz */ + private String tryName(Path parentDir, String name) + { + if (_supportGZ.booleanValue()) // TPP treats xml.gz as a native format + { + FileUtil.legalPathPartThrow(name); + // in the case of existing files, non-gz copy wins if present + Path f = parentDir!=null ? FileUtil.appendName(parentDir, name) : Path.of(name); + if (!NetworkDrive.exists(f)) + { // non-gz copy doesn't exist - how about .gz version? + String gzname = name + ".gz"; + if (_preferGZ.booleanValue()) + { // we like .gz for new filenames, so don't care if exists + return gzname; + } + f = parentDir!=null ? FileUtil.appendName(parentDir, gzname) : Path.of(gzname); + if (NetworkDrive.exists(f)) + { // we don't prefer .gz, but we support it if it exists + return gzname; + } + } + } + return name; + } + + /** Uses the preferred suffix, useful when there's not a directory of existing files to reference */ + /** if _preferGZ is set, will use preferred suffix.gz since TPP treats .gz as native format, + * unless non-gz file exists */ + public String getDefaultName(String basename) + { + return tryName(null, basename + _defaultSuffix); + } + + /** + * turn support for gzipped files on and off + */ + public boolean supportGZ(gzSupportLevel doSupportGZ) + { + _supportGZ = Boolean.valueOf(doSupportGZ != gzSupportLevel.NO_GZ); + _preferGZ = Boolean.valueOf(doSupportGZ == gzSupportLevel.PREFER_GZ); + return _supportGZ.booleanValue(); + } + + /** + * add a new supported suffix, return new list length + */ + public int addSuffix(String newsuffix) + { + List s = new ArrayList<>(_suffixes.size()+1); + for (String suffix : _suffixes) + { + s.add(suffix); + } + s.add(newsuffix); + _suffixes = s; + return _suffixes.size(); + } + + /** + * add a new filetype to reject, return new list length + */ + public int addAntiFileType(FileType anti) + { + List s = new ArrayList<>(_antiTypes.size()+1); + for (FileType a : _antiTypes) + { + s.add(a); + } + s.add(anti); + _antiTypes = s; + return _antiTypes.size(); + } + + // used to avoid, for example, mistaking protxml ".pep-prot.xml" for pepxml ".xml" file + private boolean isAntiFileType(String name, byte[] header) + { + for (FileType a : _antiTypes) + { + if (a.isType(name)) + { + return true; + } + } + return false; + } + + /** + * Looks for a file in the parentDir that matches, in priority order. If one is found, returns its file name. + * If nothing matches, uses the defaultSuffix to build a file name. + */ + public String getName(File parentDir, String basename) + { + return getName(parentDir.toPath(), basename); + } + + public String getName(FileLike parentDir, String basename) + { + return getName(parentDir.toNioPathForRead(), basename); + } + + public String getName(Path parentDir, String basename) + { + if (_suffixes.size() > 1) + { + // Only bother checking if we have more than one possible suffix + for (String suffix : _suffixes) + { + String name = tryName(parentDir, basename + suffix); + Path f = FileUtil.appendName(parentDir, name); + if (NetworkDrive.exists(f)) + { + // avoid, for example, mistaking protxml ".pep-prot.xml" for pepxml ".xml" file + if (!isAntiFileType(name, null)) + { + return name; + } + } + } + } + return tryName(parentDir, basename + _defaultSuffix); + } + + public String getName(String parentDirName, String basename) + { + File parentDir = new File(parentDirName); + return getName(parentDir,basename); + } + + /** + * Looks for a file in the parentDir that matches, in priority order. If one is found, returns its file name. + * If nothing matches, uses the defaultSuffix to build a file name. + */ + @Deprecated //please switch to using the nio.Path version as the File class can have issues using full URIs + public File getFile(File parentDir, String basename) + { + return new File(parentDir, getName(parentDir, basename)); + } + + public Path getPath(Path parentDir, String basename) + { + return FileUtil.appendName(parentDir, getName(parentDir, basename)); + } + + /** + * @return the index of the first suffix that matches. Useful when looking through a directory of files and + * determining which is the preferred file for this FileType. + */ + public int getIndexMatch(Path file) + { + return getIndexMatch(file.getFileName().toString(), file.toString()); + } + + private int getIndexMatch(String filename, String filePath) + { + if (!isAntiFileType(filename, null)) // avoid, for example, mistaking .pep-prot.xml for .xml + { + for (int i = 0; i < _suffixes.size(); i++) + { + String s = toLowerIfCaseInsensitive(_suffixes.get(i)); + if (toLowerIfCaseInsensitive(filename).endsWith(s)) + { + return i; + } + // TPP treats .xml.gz as a native format + if (_supportGZ.booleanValue() && toLowerIfCaseInsensitive(filename).endsWith(s + ".gz")) + { + return i; + } + } + } + + throw new IllegalArgumentException("No match found for " + filePath + " with " + this); + } + + private String toLowerIfCaseInsensitive(String s) + { + if (s == null) + { + return null; + } + if (_caseSensitiveOnCaseSensitiveFileSystems && IOCase.SYSTEM.isCaseSensitive()) + { + return s; + } + return s.toLowerCase(); + } + + /** + * Finds the best suffix based on priority order, strips it off, and returns the remainder. If there is no matching + * suffix, returns the original file name. + */ + public String getBaseName(File file) + { + return getBaseName(file.toPath()); + } + + public String getBaseName(FileLike file) + { + return getBaseName(file.toNioPathForRead()); + } + + public String getBaseName(@NotNull java.nio.file.Path file) + { + String fileName = file.getFileName().toString(); + if (isAntiFileType(fileName, null) || !isType(file)) + return fileName; + + String suffix = null; + for (String s : _suffixes) + { + // run the entire list in order to assure strongest match + // consider .msprefix.mzxml vs .mzxml for example + if (toLowerIfCaseInsensitive(fileName).endsWith(toLowerIfCaseInsensitive(s))) + { + if ((null==suffix) || (s.length()>suffix.length())) + { + suffix = s; + } + } + else if (_supportGZ.booleanValue()) // TPP treats .xml.gz as a native read format + { + String sgz = s+".gz"; + if (fileName.endsWith(sgz)) + { + if ((null==suffix) || (sgz.length()>suffix.length())) + { + suffix = sgz; + } + } + } + } + assert suffix != null : "Could not find matching suffix even though types match"; + return fileName.substring(0, fileName.length() - suffix.length()); + } + + public File newFile(File parent, String basename) + { + return FileUtil.appendName(parent, getName(parent, basename)); + } + + public FileLike newFile(FileLike parent, String basename) + { + return parent.resolveChild(getName(parent, basename)); + } + + public Path newFile(Path parent, String basename) + { + return FileUtil.appendName(parent, getName(parent, basename)); + } + + public boolean isType(File file) + { + return isType(file, null, null); + } + + public boolean isType(FileLike file) + { + return isType(file.toNioPathForRead(), null, null); + } + + public boolean isType(java.nio.file.Path path) + { + return isType(path, null, null); + } + + public boolean isType(java.nio.file.Path path, String contentType, byte[] header) + { + if ((path == null) || (_dir != null && _dir.booleanValue() != Files.isDirectory(path))) + return false; + + return isType(path.getFileName().toString(), contentType, header); + } + + + public boolean isType(File file, String contentType, byte[] header) + { + if ((file == null) || (_dir != null && _dir.booleanValue() != file.isDirectory())) + return false; + + return isType(file.getName(), contentType, header); + } + + /** + * Checks if the path matches any of the suffixes + */ + public boolean isType(String filePath) + { + return isType(filePath, null, null); + } + + /** + * Checks if the path matches any of the suffixes and the file header if provided. + */ + public boolean isType(@Nullable String filePath, @Nullable String contentType, @Nullable byte[] header) + { + String providedContentType = contentType; // Save it for later + + // avoid, for example, mistaking protxml ".pep-prot.xml" for pepxml ".xml" + if (isAntiFileType(filePath, header)) + { + return false; + } + + // Attempt to match by content type. + if (_contentTypes != null) + { + // Use Tika to determine the content type + if (contentType == null && header != null) + contentType = detectContentType(filePath, header); + + if (contentType != null) + { + contentType = contentType.toLowerCase().trim(); + if (_contentTypes.contains(contentType)) + return true; + } + } + + // Attempt to match by suffix and header. + if (filePath != null) + { + filePath = toLowerIfCaseInsensitive(filePath); + for (String suffix : _suffixes) + { + suffix = toLowerIfCaseInsensitive(suffix); + if (filePath.endsWith(suffix)) + { + if (header == null || isHeaderMatch(header)) + return true; + } + // TPP treats .xml.gz as a native format + if (_supportGZ.booleanValue() && filePath.endsWith(suffix + ".gz")) + { + if (header == null || isHeaderMatch(header)) + return true; + } + } + } + + // Attempt to match using just the header, but only if the original content type was null, Issue 47814 + return null == providedContentType && header != null && isHeaderMatch(header); + } + + protected static String detectContentType(String fileName, byte[] header) + { + final Metadata metadata = new Metadata(); + metadata.set("resourceName", fileName); + try (TikaInputStream is = TikaInputStream.get(header, metadata)) + { + MediaType mediaType = DETECTOR.detect(is, metadata); + if (mediaType != null) + return mediaType.toString(); + + return null; + } + catch (IOException e) + { + throw new RuntimeException(e); + } + } + + public boolean isMatch(String name, String basename) + { + for (String suffix : _suffixes) + { + if (name.equalsIgnoreCase(basename + suffix)) + { + return true; + } + // TPP treats .xml.gz as a native format + if (_supportGZ.booleanValue() && name.equals(basename + suffix+".gz")) + { + return true; + } + } + return false; + } + + /** + * Checks if the file header matches. This is useful for FileTypes that share an + * extension, e.g. "txt" or "xml", or when the filename or extension isn't available. + * + * @param header First few K of the file. + * @return True if the header matches, false otherwise. + */ + public boolean isHeaderMatch(@NotNull byte[] header) + { + return false; + } + + public boolean equals(Object o) + { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + FileType fileType = (FileType) o; + + if (_supportGZ != null ? !_supportGZ.equals(fileType._supportGZ) : fileType._supportGZ != null) return false; + if (_preferGZ != null ? !_preferGZ.equals(fileType._preferGZ) : fileType._preferGZ != null) return false; + if (_dir != null ? !_dir.equals(fileType._dir) : fileType._dir != null) return false; + if (_defaultSuffix != null ? !_defaultSuffix.equals(fileType._defaultSuffix) : fileType._defaultSuffix != null) + return false; + if (_antiTypes != null ? !_antiTypes.equals(fileType._antiTypes) : fileType._antiTypes != null) return false; + return !(_suffixes != null ? !_suffixes.equals(fileType._suffixes) : fileType._suffixes != null); + } + + public String getDefaultSuffix() + { + return _defaultSuffix; + } + + public List getSuffixes() + { + return Collections.unmodifiableList(_suffixes); + } + + public int hashCode() + { + int result; + result = (_suffixes != null ? _suffixes.hashCode() : 0); + result = 31 * result + (_defaultSuffix != null ? _defaultSuffix.hashCode() : 0); + result = 31 * result + (_dir != null ? _dir.hashCode() : 0); + result = 31 * result + (_supportGZ != null ? _supportGZ.hashCode() : 0); + result = 31 * result + (_preferGZ != null ? _preferGZ.hashCode() : 0); + return result; + } + + public String toString() + { + return (_dir == null || !_dir.booleanValue() ? _suffixes.toString() : _suffixes + "/"); + } + + @NotNull + public static List findTypes(@NotNull List types, @NotNull List files) + { + ArrayList foundTypes = new ArrayList<>(); + // This O(n*m), but these are usually very short lists. + for (FileType type : types) + { + for (FileLike file : files) + { + if (type.isType(file.getName())) + { + foundTypes.add(type); + break; + } + } + } + return foundTypes; + } + + /** + * true if the different file extensions are just transformed versions of the same data (such as .raw and .mzXML) + * and therefore if multiple are present only the first should be considered for actions in the UI. + * false if they are independent and should all be considered actionable + */ + public boolean isExtensionsMutuallyExclusive() + { + return _extensionsMutuallyExclusive; + } + + /** + * @param extensionsMutuallyExclusive true if the different file extensions are just transformed versions of the + * same data (such as .raw and .mzXML) and therefore if multiple are present only the first should be + * considered for actions in the UI. + * false if they are independent and should all be considered actionable + */ + public void setExtensionsMutuallyExclusive(boolean extensionsMutuallyExclusive) + { + _extensionsMutuallyExclusive = extensionsMutuallyExclusive; + } + + /** + * @return a FileType that will only match on the default suffix for this FileType + */ + public FileType getDefaultFileType() + { + if (!_suffixes.isEmpty()) + { + FileType ft = new FileType(_defaultSuffix); + ft._dir = _dir; + ft._supportGZ = _supportGZ.booleanValue(); + ft._preferGZ = _preferGZ.booleanValue(); + return ft; + } + else + { + return this; + } + } + + public String getDefaultRole() + { + if (_defaultSuffix.contains(".")) + { + return _defaultSuffix.substring(_defaultSuffix.indexOf(".") + 1); + } + return _defaultSuffix; + } + + public boolean isCaseSensitiveOnCaseSensitiveFileSystems() + { + return _caseSensitiveOnCaseSensitiveFileSystems; + } + + public void setCaseSensitiveOnCaseSensitiveFileSystems(boolean caseSensitiveOnCaseSensitiveFileSystems) + { + _caseSensitiveOnCaseSensitiveFileSystems = caseSensitiveOnCaseSensitiveFileSystems; + } + + public List getContentTypes() + { + return _contentTypes; + } + + public static class TestCase extends Assert + { + @Test + public void test() + { + // simple case + FileType ft = new FileType(".foo"); + assertTrue(ft.isType("test.foo")); + assertTrue(!ft.isType("test.foo.gz")); + assertEquals("test.foo",ft.getDefaultName("test")); + + // support for .gz + FileType ftgz = new FileType(".foo",gzSupportLevel.SUPPORT_GZ); + assertTrue(ftgz.isType("test.foo")); + assertTrue(ftgz.isType("test.foo.gz")); + assertEquals("test.foo",ftgz.getDefaultName("test")); + + // preference for .gz + FileType ftgzgz = new FileType(".foo",gzSupportLevel.PREFER_GZ); + assertTrue(ftgzgz.isType("test.foo")); + assertTrue(ftgzgz.isType("test.foo.gz")); + assertEquals("test.foo.gz",ftgzgz.getDefaultName("test")); + + // multiple extensions + ArrayList foobar = new ArrayList<>(); + foobar.add(".foo"); + foobar.add(".bar"); + FileType ftt = new FileType(foobar,".foo",false,gzSupportLevel.SUPPORT_GZ); + assertTrue(ftt.isType("test.foo")); + assertTrue(ftt.isType("test.bar")); + assertTrue(ftt.isType("test.foo.gz")); + assertTrue(ftt.isType("test.bar.gz")); + assertTrue(ftt.isType("test.bAr.gZ")); // extensions are case insensitive + assertEquals("test.foo",ftt.getDefaultName("test")); + + // antitypes - for example avoid mistaking protxml ".pep-prot.xml" for pepxml ".xml" + assertTrue(ftt.isType("test.foo.bar")); + ftt.addAntiFileType(new FileType(".foo.bar")); + assertTrue(!ftt.isType("test.foo.bar")); + assertTrue(ftt.isType("test.foo")); + assertTrue(ftt.isType("test.bar")); + + } + } +} diff --git a/api/src/org/labkey/api/util/FileUtil.java b/api/src/org/labkey/api/util/FileUtil.java index 4b473470677..eba329b567c 100644 --- a/api/src/org/labkey/api/util/FileUtil.java +++ b/api/src/org/labkey/api/util/FileUtil.java @@ -1,2435 +1,2435 @@ -/* - * Copyright (c) 2008-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.labkey.api.util; - -import org.apache.commons.io.FileUtils; -import org.apache.commons.io.FilenameUtils; -import org.apache.commons.io.IOUtils; -import org.apache.commons.io.file.SimplePathVisitor; -import org.apache.commons.io.input.LabKeyByteBufferCleaner; -import org.apache.commons.lang3.StringUtils; -import org.apache.logging.log4j.Logger; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.jmock.Expectations; -import org.jmock.Mockery; -import org.jmock.lib.legacy.ClassImposteriser; -import org.junit.Assert; -import org.junit.Test; -import org.labkey.api.cloud.CloudStoreService; -import org.labkey.api.data.Container; -import org.labkey.api.files.FileContentService; -import org.labkey.api.security.Crypt; -import org.labkey.api.settings.AppProps; -import org.labkey.api.util.logging.LogHelper; -import org.labkey.api.view.UnauthorizedException; -import org.labkey.vfs.FileLike; -import org.labkey.vfs.FileSystemLike; - -import java.io.BufferedInputStream; -import java.io.BufferedReader; -import java.io.ByteArrayInputStream; -import java.io.Closeable; -import java.io.DataOutput; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.FileReader; -import java.io.FileWriter; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.io.Reader; -import java.io.Writer; -import java.net.URI; -import java.net.URISyntaxException; -import java.nio.ByteBuffer; -import java.nio.CharBuffer; -import java.nio.channels.FileChannel; -import java.nio.channels.FileLock; -import java.nio.channels.ReadableByteChannel; -import java.nio.file.FileSystems; -import java.nio.file.FileVisitResult; -import java.nio.file.Files; -import java.nio.file.InvalidPathException; -import java.nio.file.Path; -import java.nio.file.StandardCopyOption; -import java.nio.file.attribute.BasicFileAttributes; -import java.nio.file.attribute.FileAttribute; -import java.nio.file.attribute.PosixFilePermission; -import java.nio.file.attribute.PosixFilePermissions; -import java.security.DigestInputStream; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.Comparator; -import java.util.HashSet; -import java.util.LinkedList; -import java.util.List; -import java.util.Objects; -import java.util.Set; -import java.util.regex.Pattern; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -public class FileUtil -{ - public static final String FILE_SCHEME = "file"; // url scheme for local file system - - private static final Logger LOG = LogHelper.getLogger(FileUtil.class, "FileUtil.java logger"); - - private static File _tempDir = null; - private static FileLike _tempDirFileLike = null; - - static private final String windowsRestricted = "\\/:*?\"<>|`"; - // and ` seems like a bad idea for linux? - static private final String linuxRestricted = "`"; - static private final String restrictedPrintable = windowsRestricted + linuxRestricted; - - private static final ThreadLocal> tempPaths = ThreadLocal.withInitial(HashSet::new); - - private static Pattern extensionChecker; - - public static void startRequest() - { - tempPaths.get().clear(); - } - - @SuppressWarnings("RedundantOperationOnEmptyContainer") - public static void stopRequest() - { - var paths = tempPaths.get(); - assert paths.isEmpty(); - for (Path p : paths) - { - try - { - Files.deleteIfExists(p); - } - catch (IOException x) - { - p.toFile().deleteOnExit(); - } - } - paths.clear(); - } - - - @Deprecated - public static boolean deleteDirectoryContents(File dir) - { - try - { - return deleteDirectoryContents(dir.toPath()); - } - catch (IOException e) - { - return false; // could there be more done here to log the error? - } - } - - - public static boolean deleteDirectoryContents(Path dir) throws IOException - { - return deleteDirectoryContents(dir, null); - } - - - public static boolean deleteDirectoryContents(FileLike dir) throws IOException - { - if (!dir.getFileSystem().canWriteFiles()) - throw new UnauthorizedException(); - return deleteDirectoryContents(toFileForWrite(dir).toPath(), null); - } - - - public static boolean deleteDirectoryContents(Path dir, @Nullable Logger log) throws IOException - { - if (Files.isDirectory(dir)) - { - File dirFile = dir.toFile(); //TODO this method should be converted to use Path and Files.walkFileTree - String[] children = dirFile.list(); - - if (null == children) // 17562 - return true; - - for (String aChildren : children) - { - boolean success = deleteDir(FileUtil.appendName(dirFile, aChildren), log); - if (!success) - { - return false; - } - } - } - return true; - } - - - public static boolean deleteSubDirs(File dir) - { - if (dir.isDirectory()) - { - File[] children = dir.listFiles(); - if (null != children) - { - for (File child : children) - { - boolean success = true; - if (child.isDirectory()) - success = deleteDir(child); - if (!success) - { - return false; - } - } - } - } - return true; - } - - - /** File.delete() will only delete a directory if it's empty, but this will - * delete all the contents and the directory */ - public static boolean deleteDir(File dir) - { - return deleteDir(dir, null); - } - - public static boolean deleteDir(FileLike dir) - { - return deleteDir(dir.toNioPathForWrite(), null); - } - - @Deprecated - public static boolean deleteDir(@NotNull File dir, Logger log) - { - return deleteDir(dir.toPath(), log); - } - - - public static boolean deleteDir(Path dir, Logger log) - { - //TODO seems like this could be reworked to use Files.walkFileTree - log = log == null ? LOG : log; - - // Issue 22336: See note in FileUtils.isSymLink() about windows-specific bugs for symlinks: - // http://commons.apache.org/proper/commons-io/apidocs/org/apache/commons/io/FileUtils.html - if (!Files.isSymbolicLink(dir)) - { - try - { - // this returns true if !dir.isDirectory() - boolean success = deleteDirectoryContents(dir, log); - if (!success) - return false; - } - catch (IOException e) - { - log.debug(String.format("Unable to clean dir [%1$s]", dir), e); - return false; - } - } - - IOException lastException = null; - - // The directory is now either a sym-link or empty, so delete it - for (int i = 0; i < 5 ; i++) - { - try - { - Files.deleteIfExists(dir); - return true; - } - catch (IOException e) - { - lastException = e; - // Issue 39579: Folder import sometimes fails to delete temp directory - // wait a little then try again - log.warn("Failed to delete file. Sleep and try to delete again. " + e.getMessage()); - try {Thread.sleep(1000);} catch (InterruptedException x) {/* pass */} - } - } - log.warn("Failed to delete file after 5 attempts: " + FileUtil.getAbsoluteCaseSensitiveFile(dir.toFile()), lastException); - return false; - } - - - public static boolean deleteDir(@NotNull Path dir) throws IOException - { - if (Files.exists(dir)) - { - if (hasCloudScheme(dir)) - { - // TODO: On Windows, collect is yielding AccessDenied Exception, so only do this for cloud - try (Stream paths = Files.walk(dir)) - { - boolean success = true; - for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) - { - success = Files.deleteIfExists(path) && success; - } - return success; - } - } - else - { - return deleteDir(dir.toFile()); // Note: we maintain existing behavior from before Path work, which is to ignore any error - } - } - - return true; - } - - - public static void copyDirectory(Path srcPath, Path destPath) throws IOException - { - // Will replace existing files - if (!Files.exists(destPath)) - FileUtil.createDirectory(destPath); - try (Stream list = Files.list(srcPath)) - { - for (Path srcChild : list.toList()) - { - Path destChild = destPath.resolve(getFileName(srcChild)); - if (Files.isDirectory(srcChild)) - copyDirectory(srcChild, destChild); - else - Files.copy(srcChild, destChild, StandardCopyOption.REPLACE_EXISTING); - } - } - } - - public static String isAllowedFileName(String s, boolean checkFileExtension) - { - return isAllowedFileName(s, checkFileExtension, AppProps.getInstance()); - } - - static String isAllowedFileName(String s, boolean checkFileExtension, AppProps appProps) - { - if (appProps.isInvalidFilenameBlocked()) - { - String msg = validateFileName(s); - if (msg != null) - return msg; - } - - if (checkFileExtension) - { - String badExtension = checkExtension(s, AppProps.getInstance()); - if (badExtension != null) - return "This file type [" + badExtension + "] is not allowed. Accepted file extensions: " + AppProps.getInstance().getAllowedExtensions(); - } - return null; - } - - public static @Nullable String validateFileName(String s) - { - return StringUtilsLabKey.validateLegalNames(s, restrictedPrintable, "Filename"); - } - - private static String checkExtension(String filename, AppProps appProps) - { - // If the allow list is empty, allow any extension - if (appProps.getAllowedExtensions().isEmpty()) - return null; - - if (extensionChecker == null) - setExtensionChecker(appProps); - - String extension = FilenameUtils.getExtension(filename); - return extensionChecker.matcher(filename).matches() ? null : extension; - } - - private static void setExtensionChecker(AppProps appProps) - { - // Regex encode the allowed extensions (escape periods and add '|' optional matcher) - String allowedExtensions = appProps.getAllowedExtensions().stream().map(Pattern::quote).collect(Collectors.joining("|")); - // Allow any extension in the list unless it is preceded by a '.' which we use as a proxy for double/multi extensions - extensionChecker = Pattern.compile(String.format("^[^\\.]*(%1$s)$", allowedExtensions), Pattern.CASE_INSENSITIVE); - } - - public static void clearExtensionChecker() - { - extensionChecker = null; - } - - public static void checkAllowedFileName(String s, boolean checkFileExtension) throws IOException - { - String msg = isAllowedFileName(s, checkFileExtension); - if (null == msg) - return; - throw new IOException(s + ": " + msg); - } - - public static boolean mkdir(File file) throws IOException - { - return mkdir(file, AppProps.getInstance().isInvalidFilenameBlocked()); - } - - public static File toFileForRead(FileLike file) - { - if (null == file) - return null; - return file.toNioPathForRead().toFile(); - } - - public static File toFileForWrite(FileLike file) - { - if (null == file) - return null; - return file.toNioPathForWrite().toFile(); - } - - public static boolean mkdir(FileLike file) throws IOException - { - return mkdir(toFileForWrite(file), AppProps.getInstance().isInvalidFilenameBlocked()); - } - - public static boolean mkdir(File file, boolean checkFileName) throws IOException - { - if (checkFileName) - checkAllowedFileName(file.getName(), false); - //noinspection SSBasedInspection - return file.mkdir(); - } - - - public static boolean mkdirs(File file) throws IOException - { - return mkdirs(file, AppProps.getInstance().isInvalidFilenameBlocked()); - } - - public static boolean mkdirs(FileLike file) throws IOException - { - if (!file.getFileSystem().canWriteFiles()) - throw new UnauthorizedException(); - var ret = mkdirs(toFileForWrite(file), AppProps.getInstance().isInvalidFilenameBlocked()); - file.refresh(); - return ret; - } - - public static boolean mkdirs(File file, boolean checkFileName) throws IOException - { - File parent = file; - while (!Files.exists(parent.toPath())) - { - if (checkFileName) - checkAllowedFileName(parent.getName(), false); - parent = parent.getParentFile(); - } - //noinspection SSBasedInspection - return file.mkdirs(); - } - - public static boolean mkdirs(FileLike file, boolean checkFileName) throws IOException - { - FileLike parent = file; - var ret = false; - while (!Files.exists(parent.toNioPathForWrite())) - { - ret = true; - if (checkFileName) - checkAllowedFileName(parent.getName(), false); - parent = parent.getParent(); - } - file.mkdirs(); - return ret; - } - - - public static FileLike createDirectory(FileLike path) throws IOException - { - createDirectory(path.toNioPathForWrite(), AppProps.getInstance().isInvalidFilenameBlocked()); - return path; - } - - public static Path createDirectory(Path path) throws IOException - { - return createDirectory(path, AppProps.getInstance().isInvalidFilenameBlocked()); - } - - - public static Path createDirectory(Path path, boolean checkFileName) throws IOException - { - if (checkFileName) - checkAllowedFileName(getFileName(path), false); - if (!Files.exists(path)) - //noinspection SSBasedInspection - return Files.createDirectory(path); - return path; - } - - - public static Path createDirectories(Path path) throws IOException - { - return createDirectories(path, AppProps.getInstance().isInvalidFilenameBlocked()); - } - - - public static void createDirectories(FileLike file) throws IOException - { - if (!file.getFileSystem().canWriteFiles()) - throw new UnauthorizedException(); - File target = toFileForWrite(file); - createDirectories(target.toPath(), AppProps.getInstance().isInvalidFilenameBlocked()); - } - - - public static Path createDirectories(Path path, boolean checkFileName) throws IOException - { - Path parent = path; - while (!Files.exists(parent)) - { - if (checkFileName) - checkAllowedFileName(getFileName(parent), false); - parent = parent.getParent(); - } - //noinspection SSBasedInspection - return Files.createDirectories(path); - } - - - public static boolean renameTo(FileLike from, FileLike to) - { - // TODO FileLike.renameTo() - return toFileForRead(from).renameTo(toFileForWrite(to)); - } - - - public static boolean createNewFile(File file) throws IOException - { - return createNewFile(file, AppProps.getInstance().isInvalidFilenameBlocked()); - } - - - public static boolean createNewFile(File file, boolean checkFileName) throws IOException - { - if (checkFileName) - checkAllowedFileName(file.getName(), true); - //noinspection SSBasedInspection - return file.createNewFile(); - } - - - public static boolean createNewFile(FileLike file, boolean checkFileName) throws IOException - { - if (checkFileName) - checkAllowedFileName(file.getName(), true); - var ret = !file.exists(); - file.createFile(); - return ret; - } - - - public static Path createFile(Path path, FileAttribute... attrs) throws IOException - { - return createFile(path, AppProps.getInstance().isInvalidFilenameBlocked(), attrs); - } - - - public static Path createFile(Path path, boolean checkFileName, FileAttribute... attrs) throws IOException - { - if (checkFileName) - checkAllowedFileName(getFileName(path), true); - return Files.createFile(path, attrs); - } - - - // return true if file exists and is not a directory - public static boolean isFileAndExists(@Nullable Path path) - { - try - { - // One call to cloud rather than two (exists && !isDirectory) - return (null != path && !Files.readAttributes(path, BasicFileAttributes.class).isDirectory()); - } - catch (IOException e) - { - return false; - } - } - - - /** - * Remove text right of a specific number of periods, including the periods, from a file's name. - *
    - *
  • C:\dir\name.ext, 1 => name
  • - *
  • C:\dir\name.ext1.ext2, 2 => name
  • - *
  • C:\dir\name.ext1.ext2, 1 => name.ext1
  • - *
- * - * @param fileName name of the file - * @param dots number of dots to remove - * @return base name - */ - public static String getBaseName(String fileName, int dots) - { - String baseName = fileName; - while (dots-- > 0 && baseName.indexOf('.') != -1) - baseName = baseName.substring(0, baseName.lastIndexOf('.')); - return baseName; - } - - - /** - * Remove text right of and including the last period in a file's name. - * @param fileName name of the file - * @return base name - */ - public static String getBaseName(String fileName) - { - return getBaseName(fileName, 1); - } - - - /** - * Remove text right of a specific number of periods, including the periods, from a file's name. - *
    - *
  • C:\dir\name.ext, 1 => name
  • - *
  • C:\dir\name.ext1.ext2, 2 => name
  • - *
  • C:\dir\name.ext1.ext2, 1 => name.ext1
  • - *
- * - * @param file file from which to get the name - * @param dots number of dots to remove - * @return base name - */ - public static String getBaseName(File file, int dots) - { - return getBaseName(file.getName(), dots); - } - - public static String getBaseName(FileLike file, int dots) - { - return getBaseName(file.toNioPathForRead().toFile(), dots); - } - - - /** - * Remove text right of and including the last period in a file's name. - * @param file file from which to get the name - * @return base name - */ - public static String getBaseName(File file) - { - return getBaseName(file, 1); - } - - public static String getBaseName(FileLike file) - { - return getBaseName(file, 1); - } - - - /** - * Returns the file name extension without the dot, null if there - * isn't one. - */ - @Nullable - public static String getExtension(File file) - { - return getExtension(file.getName()); - } - - - /** - * Returns the file name extension without the dot, null if there - * isn't one. - */ - @Nullable - public static String getExtension(String name) - { - if (name != null && name.lastIndexOf('.') != -1) - { - return name.substring(name.lastIndexOf('.') + 1); - } - return null; - } - - - public static boolean hasCloudScheme(Path path) - { - try - { - return hasCloudScheme(path.toUri()); - } - catch (Exception e) - { - return false; - } - } - - - public static boolean hasCloudScheme(URI uri) - { - return "s3".equalsIgnoreCase(uri.getScheme()); - } - - - public static boolean hasCloudScheme(String url) - { - return url.toLowerCase().startsWith("s3://"); - } - - - public static boolean hasCloudScheme(FileLike filelike) - { - return "s3".equals(filelike.getFileSystem().getScheme()); - } - - - public static String getAbsolutePath(Path path) - { - if (!FileUtil.hasCloudScheme(path)) - return path.toFile().getAbsolutePath(); - else - return getPathStringWithoutAccessId(path.toAbsolutePath().toUri()); - - } - - - @Nullable - public static String getAbsolutePath(Container container, Path path) - { // Returned string is NOT necessarily a URI (i.e. it is not encoded) - return getAbsolutePath(container, path.toUri()); - } - - - @Nullable - public static String getAbsolutePath(Container container, URI uri) - { - if (!uri.isAbsolute()) - return null; - else if (!FileUtil.hasCloudScheme(uri)) - return new File(uri).getAbsolutePath(); - else - return getAbsolutePathWithoutAccessIdFromCloudUrl(container, uri); - } - - - @Nullable - public static String getAbsoluteCaseSensitivePathString(Container container, URI uri) - { - if (!uri.isAbsolute()) - return null; - else if (!FileUtil.hasCloudScheme(uri)) - return getAbsoluteCaseSensitiveFile(new File(uri)).toPath().toUri().toString(); // Was: return getAbsoluteCaseSensitiveFile(new File(uri)).toURI().toString(); // #36352 - else - return getAbsolutePathWithoutAccessIdFromCloudUrl(container, uri); - } - - - @Nullable - public static Path getAbsoluteCaseSensitivePath(Container container, URI uri) - { - if (!uri.isAbsolute()) - return null; - else if (!FileUtil.hasCloudScheme(uri)) - return getAbsoluteCaseSensitiveFile(new File(uri)).toPath(); - else - return getAbsolutePathFromCloudUrl(container, uri); - } - - - @Nullable - private static String getAbsolutePathWithoutAccessIdFromCloudUrl(Container container, URI uri) - { - Path path = getAbsolutePathFromCloudUrl(container, uri); - return null != path ? getPathStringWithoutAccessId(path.toAbsolutePath().toUri()) : null; - } - - - @Nullable - private static Path getAbsolutePathFromCloudUrl(Container container, URI uri) - { - Path path = Objects.requireNonNull(CloudStoreService.get()).getPathFromUrl(container, uri.toString()); - return null != path ? path.toAbsolutePath() : null; - } - - - public static Path getAbsoluteCaseSensitivePath(Container container, Path path) - { - if (!FileUtil.hasCloudScheme(path)) - return getAbsoluteCaseSensitiveFile(path.toFile()).toPath(); - else - return path.toAbsolutePath(); - } - - - @Nullable - public static Path getPath(Container container, URI uri) - { - if (!uri.isAbsolute()) - return null; - else if (!FileUtil.hasCloudScheme(uri)) - return new File(uri).toPath(); - else - return Objects.requireNonNull(CloudStoreService.get()).getPathFromUrl(container, uri.toString()); - } - - - public static URI createUri(String str) - { - return createUri(str, true); - } - - - public static URI createUri(String str, boolean isEncoded) - { - str = str.replace("\\", "/"); - // Assume that Windows-style drive-letter paths like c:/myfile.txt should be treated as file:/ URIs - if (str.matches("^[A-Za-z]:/.*")) - return new File(str).toURI(); - - String str2 = str; - if (str2.startsWith("/")) - str2 = "file://" + str; - - // Creating stack traces is expensive so only bother if we're really going to log it - if (LOG.isDebugEnabled()) - { - LOG.debug("CreateUri from: " + str + " [" + Thread.currentThread().getStackTrace()[2].toString() + "]"); - } - if (isEncoded) - str2 = str2.replace(" ", "%20"); // Spaces in paths make URI unhappy - else - str2 = encodeForURL(str2); - try - { - return new URI(str2); - } - catch (URISyntaxException e) - { - // We're handling encoded and unencoded, so this can fail because of certain reserved chars; - if (str.startsWith("/")) - return new File(str).toPath().toUri(); - throw new IllegalArgumentException(e); - } - } - - - @NotNull - public static String getFileName(Path fullPath) - { - // We want unencoded fileName - if (hasCloudScheme(fullPath)) - { - Path path = fullPath.getFileName(); - return path == null ? "" : path.toUri().getPath(); - } - else - { - return fullPath.getFileName().toString(); - } - } - - - /** Only returns a child path */ - public static File appendPath(File dir, org.labkey.api.util.Path originalPath) - { - org.labkey.api.util.Path path = originalPath.normalize(); - if (path == null || (!path.isEmpty() && "..".equals(path.get(0)))) - throw new InvalidPathException(originalPath.toString(), "Path to parent not allowed"); - @SuppressWarnings("SSBasedInspection") - var ret = new File(dir, path.toString()); - if (!ret.toPath().normalize().startsWith(dir.toPath().normalize())) - throw new InvalidPathException(originalPath.toString(), "Path to parent not allowed"); - return ret; - } - - - /** Only returns a child path */ - public static FileLike appendPath(FileLike dir, org.labkey.api.util.Path path) - { - path = path.normalize(); - if (!path.isEmpty() && "..".equals(path.get(0))) - throw new InvalidPathException(path.toString(), "Path to parent not allowed"); - return dir.resolveFile(path); - } - - - /** Resolve a relative path, may not be a descendant. */ - public static FileLike resolveFile(FileLike dir, org.labkey.api.util.Path path) - { - return dir.resolveFile(path); - } - - - /* Only returns an immediate child */ - public static File appendName(File dir, org.labkey.api.util.Path.Part part) - { - return appendName(dir, part.toString()); - } - - - /* Only returns an immediate child */ - public static File appendName(File dir, String name) - { - if (!dir.isAbsolute()) - { - dir = dir.getAbsoluteFile(); - } - legalPathPartThrow(name); - @SuppressWarnings("SSBasedInspection") - var ret = new File(dir, name); - - if (!ret.toPath().normalize().startsWith(dir.toPath().normalize())) - throw new InvalidPathException(name, "Path to parent not allowed"); - return ret; - } - - /* Only returns an immediate child */ - public static Path appendName(Path dir, String name) - { - legalPathPartThrow(name); - var ret = dir.resolve(name); - - if (!ret.normalize().startsWith(dir.normalize())) - throw new InvalidPathException(name, "Path to parent not allowed"); - return ret; - } - - - // narrower check than isLegalName() or isAllowedFileName() - // this check that a name is a valid path part (e.g. filename) and is not path like. - public static void legalPathPartThrow(String name) - { - int invalidCharacterIndex = StringUtils.indexOfAny(name, '/', File.separatorChar); - if (invalidCharacterIndex >= 0) - throw new InvalidPathException(name, "Invalid file or directory name", invalidCharacterIndex); - if (".".equals(name) || "..".equals(name)) - throw new InvalidPathException(name, "Invalid file or directory name"); - } - - - public static String decodeSpaces(@NotNull String str) - { - return str.replace("%20", " "); - } - - - public static String pathToString(Path path) - { // Returns a URI string (encoded) - return getPathStringWithoutAccessId(path.toUri()); - } - - - public static String uriToString(URI uri) - { - return getPathStringWithoutAccessId(uri); - } - - - public static Path stringToPath(Container container, String str) - { - return stringToPath(container, str, true); - } - - - public static Path stringToPath(Container container, String str, boolean isEncoded) - { - if (!FileUtil.hasCloudScheme(str)) - return new File(createUri(str, isEncoded)).toPath(); - else - return Objects.requireNonNull(CloudStoreService.get()).getPathFromUrl(container, PageFlowUtil.decode(str)/*decode everything not just the space*/); - } - - - public static String getCloudRootPathString(String cloudName) - { - return FileContentService.CLOUD_ROOT_PREFIX + "/" + cloudName; - } - - - @Nullable - private static String getPathStringWithoutAccessId(URI uri) - { - if (null != uri) - if (hasCloudScheme(uri)) - return uri.toString().replaceFirst("/\\w+@s3", "/s3"); // Remove accessId portion if exists - else - { - try - { - return Objects.requireNonNull(URIUtil.normalizeUri(uri)).toString(); - } - catch (URISyntaxException e) - { - LOG.debug("Error attempting to conform uri: " + e.getMessage()); - return uri.toString(); - } - } - else - return null; - } - - - /** - * Get relative path of File 'file' with respect to 'home' directory - *

-     * example : home = /a/b/c
-     *           file    = /a/d/e/x.txt
-     *           return = ../../d/e/x.txt
-     * 

- * The path returned has system specific directory separators. - *

- * It is equivalent to:
- *

home.toURI().relativize(f.toURI).toString().replace('/', File.separatorChar)
- * - * @param home base path, should be a directory, not a file, or it doesn't make sense - * @param file file to generate path for - * @param canonicalize whether or not the paths need to be canonicalized - * @return path from home to file as a string - */ - public static String relativize(File home, File file, boolean canonicalize) throws IOException - { - if (canonicalize) - { - home = FileUtil.getAbsoluteCaseSensitiveFile(home); - file = FileUtil.getAbsoluteCaseSensitiveFile(file); - } - else - { - home = resolveFile(home); - file = resolveFile(file); - } - return matchPathLists(getPathList(home), getPathList(file)); - } - - - /** - * Get a relative path of File 'file' with respect to 'home' directory, - * forcing Unix (i.e. URI) forward slashes for directory separators. - *

- * This is a lot like URIUtil.relativize() without requiring - * that the file be a descendant of the base. - *

- * It is equivalent to:
- *

home.toURI().relativize(f.toURI).toString()
- */ - public static String relativizeUnix(File home, File f, boolean canonicalize) throws IOException - { - return relativize(home, f, canonicalize).replace('\\', '/'); - } - - - public static String relativizeUnix(Path home, Path f, boolean canonicalize) throws IOException - { - if (!hasCloudScheme(home) && !hasCloudScheme(f)) - return relativizeUnix(home.toFile(), f.toFile(), canonicalize); - return getPathStringWithoutAccessId(home.toUri().relativize(f.toUri())); - } - - - /** - * Break a path down into individual elements and add to a list. - *

- * example : if a path is /a/b/c/d.txt, the breakdown will be [d.txt,c,b,a] - * - * @param file input file - * @return a List collection with the individual elements of the path in reverse order - */ - private static List getPathList(File file) - { - List parts = new ArrayList<>(); - while (file != null) - { - parts.add(file.getName()); - file = file.getParentFile(); - } - - return parts; - } - - - /** - * Figure out a string representing the relative path of - * 'file' with respect to 'home' - * - * @param home home path - * @param file path of file - * @return relative path from home to file - */ - public static String matchPathLists(List home, List file) - { - // start at the beginning of the lists - // iterate while both lists are equal - StringBuilder path = new StringBuilder(); - int i = home.size() - 1; - int j = file.size() - 1; - - // first eliminate common root - while ((i >= 0) && (j >= 0) && (home.get(i).equals(file.get(j)))) - { - i--; - j--; - } - - // for each remaining level in the home path, add a .. - for (; i >= 0; i--) - path.append("..").append(File.separator); - - // for each level in the file path, add the path - for (; j >= 1; j--) - path.append(file.get(j)).append(File.separator); - - // if nothing left of the file, then it was a directory - // of which home is a subdirectory. - if (j < 0) - { - if (path.isEmpty()) - path.append("."); - else - path.delete(path.length() - 1, path.length()); // remove trailing sep - } - else - path.append(file.get(j)); // add file name - - return path.toString(); - } - - public static void copyFile(FileLike src, FileLike dst) throws IOException - { - try (InputStream in = src.openInputStream(); - OutputStream out = dst.openOutputStream()) - { - copyData(in, out); - } - } - - - public static void copyFile(File src, File dst) throws IOException - { - try (FileInputStream is = new FileInputStream(src); - FileChannel in = is.getChannel(); - FileLock lockIn = in.lock(0L, Long.MAX_VALUE, true)) - { - copyFile(in, in.size(), dst); - dst.setLastModified(src.lastModified()); - } - } - - - // FileUtil.copyFile() does not use transferTo() or sync() - public static void copyFile(ReadableByteChannel in, long expected, File dst) throws IOException - { - createNewFile(dst); - - boolean success = false; - long actual = 0; - long bytesCopied; - - LOG.debug("Starting to transfer to " + dst + ", expecting " + (expected == -1 ? "an unknown number" : Long.toString(expected)) + " bytes"); - - try (FileOutputStream os = new FileOutputStream(dst); - FileChannel out = os.getChannel(); - FileLock lockOut = out.lock()) - { - do - { - bytesCopied = out.transferFrom(in, actual, Long.MAX_VALUE); - actual += bytesCopied; - if (actual != expected && bytesCopied != 0) - { - LOG.debug("Still transferring to " + dst + ", " + actual + " bytes transferred so far"); - } - } - while (bytesCopied != 0); - success = actual == expected; - os.getFD().sync(); - } - finally - { - if (success) - { - LOG.debug("Finished transferring " + actual + " bytes to " + dst); - } - else - { - LOG.debug("Failed during transfer, but successfully copied at least " + actual + " bytes to " + dst); - } - } - } - - - /** - * Copies an entire file system branch to another location, including the root directory itself - * @param src The source file root - * @param dest The destination file root - * @throws IOException thrown from IO functions - */ - public static void copyBranch(File src, File dest) throws IOException - { - copyBranch(src, dest, false); - } - - - /** - * Copies an entire file system branch to another location - * - * @param src The source file root - * @param dest The destination file root - * @param contentsOnly Pass false to copy the root directory as well as the files within; true to just copy the contents - * @throws IOException Thrown if there's an IO exception - */ - public static void copyBranch(File src, File dest, boolean contentsOnly) throws IOException - { - //if src is just a file, copy it and return - if (src.isFile()) - { - File destFile = FileUtil.appendName(dest, src.getName()); - copyFile(src, destFile); - return; - } - - //if copying the src root directory as well, make that - //within the dest and re-assign dest to the new directory - if (!contentsOnly) - { - dest = FileUtil.appendName(dest, src.getName()); - mkdirs(dest); - if(!dest.isDirectory()) - throw new IOException("Unable to create the directory " + dest + "!"); - } - - File[] children = src.listFiles(); - if (children == null) - { - throw new IOException("Unable to get file listing for directory: " + src); - } - for (File file : children) - { - copyBranch(file, dest, false); - } - } - - - /** - * always returns path starting with /. Tries to leave trailing '/' as is - * (unless ends with /. or /..) - * - * @param path path to normalize - * @return cleaned path or null if path goes outside of 'root' - */ - @Deprecated // use java.util.Path - public static String normalize(String path) - { - if (path == null || equals(path,'/')) - return path; - - String str = path; - if (str.indexOf('\\') >= 0) - str = str.replace('\\', '/'); - if (!startsWith(str,'/')) - str = "/" + str; - int len = str.length(); - - // quick scan, look for /. or // -quickScan: - { - for (int i=0 ; i list = normalizeSplit(str); - if (null == list) - return null; - if (list.isEmpty()) - return "/"; - StringBuilder sb = new StringBuilder(str.length()+2); - for (String name : list) - { - sb.append('/'); - sb.append(name); - } - return sb.toString(); - } - - - @Deprecated // use java.util.Path - public static ArrayList normalizeSplit(String str) - { - int len = str.length(); - ArrayList list = new ArrayList<>(); - int start = 0; - for (int i=0 ; i<=len ; i++) - { - if (i==len || str.charAt(i) == '/') - { - if (start < i) - { - String part = str.substring(start, i); - if (part.isEmpty() || equals(part,'.')) - { - } - else if (part.equals("..")) - { - if (list.isEmpty()) - return null; - list.remove(list.size()-1); - } - else - { - list.add(part); - } - } - start=i+1; - } - } - return list; - } - - public static String encodeForURL(String str) - { - return encodeForURL(str, false); - } - - public static String encodeForURL(String str, boolean checkEncoded) - { - if (checkEncoded && isUrlEncoded(str)) - return str; - - // str is unencoded; we need certain special chars encoded for it to become a URL - // % & # @ ~ {} [] - return StringUtils.replaceEach(str, DECODED, ENCODED); - } - - private static final String[] ENCODED = {"%25", "%23", "%26", "%40", "%7E", "%7B", "%7D", "%5B", "%5D", "%2B", "%20"}; - private static final String[] DECODED = {"%", "#", "&", "@", "~", "{", "}", "[", "]", "+", " "}; - - static public String decodeURL(String str) - { - return StringUtils.replaceEach(str, ENCODED, DECODED); - } - - public static boolean isUrlEncoded(String str) - { - return StringUtils.indexOfAny(str, ENCODED) > -1; - } - - static boolean startsWith(String s, char ch) - { - return !s.isEmpty() && s.charAt(0) == ch; - } - - - static boolean equals(String s, char ch) - { - return s.length() == 1 && s.charAt(0) == ch; - } - - - public static String relativePath(String dir, String filePath) - { - dir = normalize(dir); - filePath = normalize(filePath); - if (dir.endsWith("/")) - dir = dir.substring(0,dir.length()-1); - if (!filePath.toLowerCase().startsWith(dir.toLowerCase())) - return null; - String relPath = filePath.substring(dir.length()); - if (relPath.isEmpty()) - return relPath; - if (relPath.startsWith("/")) - return relPath.substring(1); - return null; - } - - - private static String digest(MessageDigest md, InputStream is) throws IOException - { - try (DigestInputStream dis = new DigestInputStream(is, md)) - { - byte[] buf = new byte[8 * 1024]; - while (-1 != (dis.read(buf))) - { - /* */ - } - return Crypt.encodeHex(md.digest()); - } - } - - - public static String sha1sum(InputStream is) throws IOException - { - try - { - return digest(MessageDigest.getInstance("SHA1"), is); - } - catch (NoSuchAlgorithmException e) - { - LOG.error("unexpected error", e); - return null; - } - finally - { - IOUtils.closeQuietly(is); - } - } - - - public static String sha1sum(byte[] bytes) throws IOException - { - return sha1sum(new ByteArrayInputStream(bytes)); - } - - - public static String md5sum(InputStream is) throws IOException - { - try - { - return digest(MessageDigest.getInstance("MD5"), is); - } - catch (NoSuchAlgorithmException e) - { - LOG.error("unexpected error", e); - return null; - } - finally - { - IOUtils.closeQuietly(is); - } - } - - - public static String md5sum(byte[] bytes) throws IOException - { - return md5sum(new ByteArrayInputStream(bytes)); - } - - - public static byte[] readHeader(@NotNull File f, int len) throws IOException - { - try (InputStream is = new BufferedInputStream(new FileInputStream(f))) - { - return FileUtil.readHeader(is, len); - } - } - - - public static byte[] readHeader(@NotNull InputStream is, int len) throws IOException - { - assert is.markSupported(); - is.mark(len); - try - { - byte[] buf = new byte[len]; - while (0 < len) - { - int r = is.read(buf, buf.length-len, len); - if (r == -1) - { - byte[] ret = new byte[buf.length-len]; - System.arraycopy(buf, 0, ret, 0, buf.length-len); - return ret; - } - len -= r; - } - return buf; - } - finally - { - is.reset(); - } - } - - - // - // NOTE: IOUtil uses fairly small buffers for copy - // - - final static int BUFFERSIZE = 32*1024; - - // Closes input stream - public static long copyData(InputStream is, File file) throws IOException - { - try (InputStream input = is; FileOutputStream fos = new FileOutputStream(file)) - { - return copyData(input, fos); - } - } - - /** Does not close input or output stream */ - public static long copyData(InputStream is, OutputStream os) throws IOException - { - byte[] buf = new byte[BUFFERSIZE]; - long total = 0; - int r; - while (0 <= (r = is.read(buf))) - { - os.write(buf,0,r); - total += r; - } - return total; - } - - - /** Does not close input or output stream */ - public static void copyData(InputStream is, DataOutput os, long len) throws IOException - { - byte[] buf = new byte[BUFFERSIZE]; - long remaining = len; - do - { - int r = (int)Math.min(buf.length, remaining); - r = is.read(buf, 0, r); - os.write(buf,0,r); - remaining -= r; - } while (0 < remaining); - } - - - /** Does not close input or output stream */ - public static void copyData(InputStream is, DataOutput os) throws IOException - { - byte[] buf = new byte[BUFFERSIZE]; - int r; - while (0 < (r = is.read(buf))) - os.write(buf,0,r); - } - - // NOTE: Keep in sync with the copied constants in TestFileUtils - private static final char[] ILLEGAL_CHARS = {'/','\\',':','?','<','>','*','|','"','^', '\n', '\r', '\''}; - public static final String ILLEGAL_CHARS_STRING = new String(ILLEGAL_CHARS); - - public static boolean isLegalName(String name) - { - if (name == null || name.trim().isEmpty()) - return false; - - if (name.length() > 255) - return false; - - return !StringUtils.containsAny(name, ILLEGAL_CHARS); - } - - // NOTE: Keep in sync with the copied implementation in TestFileUtils.makeLegalFileName() - public static String makeLegalName(String name) - { - if (name == null) - { - return "__null__"; - } - - if (name.isEmpty()) - { - return "__empty__"; - } - - //limit to 255 chars (FAT and OS X) - //replace illegal chars - char[] ret = new char[Math.min(255, name.length())]; - for(int idx = 0; idx < ret.length; ++idx) - { - char ch = name.charAt(idx); - // Reject characters that are illegal anywhere - if (StringUtils.contains(ILLEGAL_CHARS_STRING, ch) || - // Or characters that are illegal starts to a file name - (idx == 0 && (ch == '-' || ch == '$'))) - { - ch = '_'; - } - else if (ch == '-' && - idx > 0 && - name.charAt(idx - 1) == ' ') - { - int i = idx + 1; - // Skip through as many consecutive '-' as there might be - while (i < name.length() && name.charAt(i) == '-') - { - i++; - } - // If the next character after the '-' isn't a space, transform the leading '-' in the sequence - if (i < name.length() && name.charAt(i) != ' ') - { - ch = '_'; - } - } - - ret[idx] = ch; - } - - //can't end with space (windows) - //can't end with period (windows) - int lastIndex = ret.length - 1; - char ch = ret[lastIndex]; - if (ch == ' ' || ch == '.') - ret[lastIndex] = '_'; - - return new String(ret); - } - - - /** - * Returns the absolute path to a file. On Windows and Mac, corrects casing in file paths to match the - * canonical path. - */ - @NotNull - public static FileLike getAbsoluteCaseSensitiveFile(@NotNull FileLike file) - { - return FileSystemLike.wrapFile(getAbsoluteCaseSensitiveFile(file.toNioPathForRead().toFile())); - } - - @NotNull - public static File getAbsoluteCaseSensitiveFile(@NotNull File file) - { - file = resolveFile(file.getAbsoluteFile()); - if (isCaseInsensitiveFileSystem()) - { - try - { - @SuppressWarnings("SSBasedInspection") - File canonicalFile = file.getCanonicalFile(); - - if (canonicalFile.getAbsolutePath().equalsIgnoreCase(file.getAbsolutePath())) - { - return canonicalFile; - } - } - catch (IOException e) - { - // Ignore and just use the absolute file - } - } - return file.getAbsoluteFile(); - } - - - public static boolean isCaseInsensitiveFileSystem() - { - // FileSystem case sensitivity cannot be inferred from OS, for example mac os defaults to case-insensitive but can be configured to be case-sensitive - // Additionally, file root can be mounted to location on a different OS, or it can use S3 - String osName = System.getProperty("os.name").toLowerCase(); - return (osName.startsWith("windows") || osName.startsWith("mac os")); - } - - - /** - * Strips out ".." and "." from the path - */ - public static File resolveFile(File file) - { - File parent = file.getParentFile(); - if (parent == null) - { - return file; - } - if (".".equals(file.getName())) - { - return resolveFile(parent); - } - int dotDotCount = 0; - while ("..".equals(file.getName()) || dotDotCount > 0) - { - if ("..".equals(file.getName())) - { - dotDotCount++; - } - else if (!".".equals(file.getName())) - { - dotDotCount--; - } - if (parent.getParentFile() == null) - { - return parent; - } - file = file.getParentFile(); - parent = file.getParentFile(); - } - // we don't need to use FileUtil.appendName() here - //noinspection SSBasedInspection - return new File(resolveFile(parent), file.getName()); - } - - - // use FileLike createTempDirectoryFileLike() - @Deprecated - public static Path createTempDirectory(@Nullable String prefix) throws IOException - { - if (null != prefix) - legalPathPartThrow(prefix); - return Files.createTempDirectory(prefix).toAbsolutePath(); - } - - - public static FileLike createTempDirectoryFileLike(@Nullable String prefix) throws IOException - { - if (null != prefix) - legalPathPartThrow(prefix); - return new FileSystemLike.Builder(Files.createTempDirectory(prefix).toAbsolutePath()).readwrite().root(); - } - - - public static boolean deleteTempDirectoryFileLike(@NotNull FileLike file) throws IOException - { - if (!file.getPath().isEmpty()) - throw new IllegalArgumentException("Method expects a file returned by createTempDirectoryFileObject"); - if (!file.getFileSystem().canWriteFiles()) - throw new UnauthorizedException(); - return FileUtil.deleteDirectoryContents(file); - } - - - // Under Catalina, it seems to pick \tomcat\temp - // On the web server under Tomcat, it seems to pick c:\Documents and Settings\ITOMCAT_EDI\Local Settings\Temp - public static File getTempDirectory() - { - if (null == _tempDir) - { - try - { - File temp = createTempFile("deleteme", null); - _tempDir = temp.getParentFile().getAbsoluteFile(); - temp.delete(); - } - catch (IOException e) - { - throw new ConfigurationException("The temporary directory (likely " + System.getProperty("java.io.tmpdir") + ") on this server is inaccessible. There may be a file permission issue, or the directory may not exist.", e); - } - } - - return _tempDir; - } - - - public static FileLike getTempDirectoryFileLike() - { - if (null == _tempDirFileLike) - { - _tempDirFileLike = new FileSystemLike.Builder(getTempDirectory()).readwrite().noMemCheck().root(); - } - return _tempDirFileLike; - } - - - // Use this instead of File.createTempFile() (see Issue #46794) - public static File createTempFile(@Nullable String prefix, @Nullable String suffix, File directory) throws IOException - { - if (null != prefix) - legalPathPartThrow(prefix); - if (null != suffix) - legalPathPartThrow(suffix); - return Files.createTempFile(directory.toPath(), prefix, suffix).toFile(); - } - - // Use this instead of File.createTempFile() (see Issue #46794) - public static FileLike createTempFile(@Nullable String prefix, @Nullable String suffix, FileLike directory) throws IOException - { - if (null != prefix) - legalPathPartThrow(prefix); - if (null != suffix) - legalPathPartThrow(suffix); - var path = Files.createTempFile(directory.toNioPathForWrite(), prefix, suffix); - return directory.resolveChild(path.getFileName().toString()); - } - - // Use this instead of File.createTempFile() (see Issue #46794) - public static File createTempFile(@Nullable String prefix, @Nullable String suffix) throws IOException - { - return createTempFile(prefix, suffix, false); - } - - // Use this instead of File.createTempFile() (see Issue #46794) - public static FileLike createTempFileLike(@Nullable String prefix, @Nullable String suffix) throws IOException - { - return FileSystemLike.wrapFile(createTempFile(prefix, suffix, false)); - } - - public static File createTempFile(@Nullable String prefix, @Nullable String suffix, boolean threadLocal) throws IOException - { - if (null != prefix) - legalPathPartThrow(prefix); - if (null != suffix) - legalPathPartThrow(suffix); - var path = Files.createTempFile(prefix, suffix).toAbsolutePath(); - if (threadLocal) - tempPaths.get().add(path); - return path.toFile(); - } - - - private static final boolean isPosix = - FileSystems.getDefault().supportedFileAttributeViews().contains("posix"); - final static private FileAttribute[] tempFileAttributes = new FileAttribute[] { PosixFilePermissions.asFileAttribute(Set.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE)) }; - - public static boolean createTempFile(File file) throws IOException - { - if (file.exists()) - return false; - mkdirs(file.getParentFile()); - if (isPosix) - createFile(file.toPath(), tempFileAttributes); - else - createFile(file.toPath()); - return true; - } - - - public static void deleteTempFile(File f) - { - if (null != f && f.isFile()) - { - if(f.delete()) - tempPaths.get().remove(f.toPath()); - } - } - - - // Converts a document name into keywords appropriate for indexing. We want to retrieve a document named "labkey.txt" - // when the user searches for "labkey.txt", "labkey" or "txt". Lucene analyzers tokenize on whitespace, so this method - // returns the original document name plus the document name with common symbols replaced with spaces. - public static String getSearchKeywords(String documentName) - { - return documentName + " " + documentName.replaceAll("[._-]", " "); - } - - - /** - * Creates a legal, cross-platform file name from the component parts (replacing special characters like colons, semi-colons, slashes, etc - * @param prefix the start of the file name to generate, to be appended with a timestamp suffix - * @param extension the extension (not including the dot) for the desired file name - */ - public static String makeFileNameWithTimestamp(String prefix, @Nullable String extension) - { - return makeLegalName(prefix + "_" + getTimestamp() + (extension == null ? "" : ("." + extension))); - } - - - public static String makeFileNameWithTimestamp(String prefix) - { - return makeLegalName(prefix + "_" + getTimestamp()); - } - - - private static long lastTime = 0; - private static final Object timeLock = new Object(); - - // return a unique time, rounded to the nearest second - private static long currentSeconds() - { - synchronized(timeLock) - { - long sec = HeartBeat.currentTimeMillis(); - sec -= sec % 1000; - lastTime = Math.max(sec, lastTime + 1000); - return lastTime; - } - } - - - public static String getTimestamp() - { - String time = DateUtil.toISO(currentSeconds(), false); - time = time.replace(":", "-"); - time = time.replace(" ", "_"); - - return time; - } - - - private static String indent(LinkedList hasMoreFlags) - { - StringBuilder sb = new StringBuilder(); - for (int i = 0, len = hasMoreFlags.size(); i < len; i++) - { - Boolean hasMore = hasMoreFlags.get(i); - if (i == len-1) - sb.append(hasMore ? "├── " : "└── "); - else - sb.append(hasMore ? "│  " : " "); - } - - return sb.toString(); - } - - - private static void printTree(StringBuilder sb, Path node, LinkedList hasMoreFlags) throws IOException - { - Files.walkFileTree(node, new SimplePathVisitor() - { - @Override - public @NotNull FileVisitResult preVisitDirectory(@NotNull Path dir, @NotNull BasicFileAttributes attrs) throws IOException - { - hasMoreFlags.add(true); - return super.preVisitDirectory(dir, attrs); - } - - @Override - public @NotNull FileVisitResult visitFile(@NotNull Path file, @NotNull BasicFileAttributes attrs) throws IOException - { - appendFileLogEntry(sb, file, hasMoreFlags); - return super.visitFile(file, attrs); - } - - - @Override - public @NotNull FileVisitResult postVisitDirectory(@NotNull Path dir, IOException exc) throws IOException - { - hasMoreFlags.removeLast(); - return super.postVisitDirectory(dir, exc); - } - }); - } - - - private static void appendFileLogEntry(StringBuilder sb, Path node, LinkedList hasMoreFlags) throws IOException - { - if (hasMoreFlags.isEmpty()) - sb.append(node.toAbsolutePath()); - else - sb.append(indent(hasMoreFlags)).append(node.getFileName()); - - if (Files.isDirectory(node)) - sb.append("/"); - else - sb.append(" (").append(FileUtils.byteCountToDisplaySize(Files.size(node))).append(")"); - sb.append("\n"); - } - - - public static String printTree(Path root) throws IOException - { - StringBuilder sb = new StringBuilder(); - printTree(sb, root, new LinkedList<>()); - return sb.toString(); - } - - - public static String getUnencodedAbsolutePath(Container container, Path path) - { - if (!path.isAbsolute()) - return null; - else if (!FileUtil.hasCloudScheme(path)) - return path.toFile().getAbsolutePath(); - else - { - return PageFlowUtil.decode( //URI conversion encodes - getPathStringWithoutAccessId( - CloudStoreService.get().getPathFromUrl(container, path.toString()).toUri() - ) - ); - } - } - - public static File findUniqueFileName(String originalFilename, File dir) - { - if (originalFilename == null || originalFilename.isEmpty()) - { - originalFilename = "[unnamed]"; - } - File file; - int uniquifier = 0; - do - { - String fullName = getAppendedFileName(originalFilename, uniquifier); - file = appendName(dir, fullName); - uniquifier++; - } - while (file.exists()); - return file; - } - - public static FileLike findUniqueFileName(String originalFilename, FileLike dir) - { - if (originalFilename == null || originalFilename.isEmpty()) - { - originalFilename = "[unnamed]"; - } - FileLike file; - int uniquifier = 0; - do - { - String fullName = getAppendedFileName(originalFilename, uniquifier); - file = dir.resolveChild(fullName); - uniquifier++; - } - while (file.exists()); - return file; - } - - public static String getAppendedFileName(String originalFilename, int uniquifier) - { - String prefix = originalFilename; - String suffix = ""; - - int index = originalFilename.indexOf('.'); - if (index != -1) - { - prefix = originalFilename.substring(0, index); - suffix = originalFilename.substring(index); - } - - return prefix + (uniquifier == 0 ? "" : "-" + uniquifier) + suffix; - } - - - /* If you have a write once, read once text file/stream, you can use this class. - * It wraps the calls to create and delete a temp file, and also will use - * direct to cache the first portion of the file to avoid hitting the - * file system if the file is smaller. - * - * The caller needs to call close() on this object or the Reader returned - * by getReader(). Calling close on both is OK. - */ - public static class TempTextFileWrapper implements Closeable - { - final int characterLimitInMemory; - final ByteBuffer _byteBuffer; - final CharBuffer _charBuffer; - FileWriter _fileWriter = null; - FileReader _fileReader = null; - File _tmpFile = null; - boolean closed = false; // so we can ignore multiple calls to close - - Writer _writer = null; - Reader _reader = null; - - public TempTextFileWrapper(int characterLimitInMemory) - { - this.characterLimitInMemory = characterLimitInMemory; - this._byteBuffer = ByteBuffer.allocate(characterLimitInMemory * 2); - this._charBuffer = _byteBuffer.asCharBuffer(); - } - - public TempTextFileWrapper(CharBuffer charBuffer) - { - this.characterLimitInMemory = charBuffer.capacity(); - this._byteBuffer = null; - this._charBuffer = charBuffer; - } - - - public Writer getWriter() - { - if (null != _writer || closed) - throw new IllegalStateException(closed ? "TempTextFileWrapper is closed" : "getWriter() called twice"); - - // CONSIDER ByteBuffer.allocateDirect(), for now caller can pass in a direct buffer if desired - _writer = new Writer() - { - boolean closed = false; - - @Override - public void write(char @NotNull [] cbuf, int off, int len) throws IOException - { - if (closed) - throw new IOException("Writer is closed"); - if (_charBuffer.remaining() > 0) - { - var l = Math.min(_charBuffer.remaining(), len); - _charBuffer.put(cbuf, off, l); - if (l == len) - return; - off += l; - len -= l; - } - if (null == _fileWriter) - { - assert null == _tmpFile; - _tmpFile = FileUtil.createTempFile("tika", ".tmp.txt"); - _fileWriter = new FileWriter(_tmpFile, StringUtilsLabKey.DEFAULT_CHARSET); - } - _fileWriter.write(cbuf, off, len); - } - - @Override - public void flush() throws IOException - { - if (null != _fileWriter) - _fileWriter.flush(); - } - - @Override - public void close() throws IOException - { - if (null != _fileWriter) - { - _fileWriter.flush(); - _fileWriter.close(); - } - _fileWriter = null; - closed = true; - } - }; - return _writer; - } - - private void _prepareToRead() - { - if (null != _writer) - { - IOUtils.closeQuietly(_writer); - _writer = null; - _charBuffer.flip(); - } - } - - public Reader getReader() - { - if (null != _reader || closed) - throw new IllegalStateException(closed ? "TempTextFileWrapper is closed" : "getReader() called twice"); - - _reader = new Reader() - { - @Override - public int read(char @NotNull [] cbuf, int off, int len) throws IOException - { - _prepareToRead(); - - if (0 < _charBuffer.remaining()) - { - var l = Math.min(len, _charBuffer.remaining()); - _charBuffer.get(cbuf, off, l); - return l; - } - if (null == _fileReader && null != _tmpFile) - _fileReader = new FileReader(_tmpFile, StringUtilsLabKey.DEFAULT_CHARSET); - if (null == _fileReader) - return -1; - return _fileReader.read(cbuf, off, len); - } - - @Override - public void close() throws IOException - { - TempTextFileWrapper.this.close(); - } - }; - return _reader; - } - - public String getSummary(int length) - { - _prepareToRead(); - var l = Math.min(_charBuffer.limit(), length); - return _charBuffer.slice(0,l).toString(); - } - - @Override - public void close() throws IOException - { - if (!closed) - { - closed = true; - if (null != _fileReader) - IOUtils.closeQuietly(_fileReader); - _fileReader = null; - if (null != _fileWriter) - IOUtils.closeQuietly(_fileWriter); - _fileWriter = null; - if (null != _tmpFile) - FileUtil.deleteTempFile(_tmpFile); - _tmpFile = null; - if (null != _byteBuffer && _byteBuffer.isDirect()) - LabKeyByteBufferCleaner.clean(_byteBuffer); - } - } - } - - - @SuppressWarnings("SSBasedInspection") - public static class TestCase extends Assert - { - private static final File ROOT; - - static - { - File f = new File(".").getAbsoluteFile(); - while (f.getParentFile() != null) - { - f = f.getParentFile(); - } - ROOT = f; - } - - @Test - public void testStandardResolve() - { - assertEquals(new File(ROOT, "test/path/sub"), resolveFile(new File(ROOT, "test/path/sub"))); - assertEquals(new File(ROOT, "test"), resolveFile(new File(ROOT, "test"))); - assertEquals(new File(ROOT, "test/path/file.ext"), resolveFile(new File(ROOT, "test/path/file.ext"))); - } - - @Test - public void testDotResolve() - { - assertEquals(new File(ROOT, "test/path/sub"), resolveFile(new File(ROOT, "test/path/./sub"))); - assertEquals(new File(ROOT, "test"), resolveFile(new File(ROOT, "./test"))); - assertEquals(new File(ROOT, "test/path/file.ext"), resolveFile(new File(ROOT, "test/path/file.ext/."))); - } - - @Test - public void testDotDotResolve() - { - assertEquals(ROOT, resolveFile(new File(ROOT, ".."))); - assertEquals(new File(ROOT, "test/sub"), resolveFile(new File(ROOT, "test/path/../sub"))); - assertEquals(new File(ROOT, "test/sub2"), resolveFile(new File(ROOT, "test/path/../sub/../sub2"))); - assertEquals(new File(ROOT, "test"), resolveFile(new File(ROOT, "test/path/sub/../.."))); - assertEquals(new File(ROOT, "sub"), resolveFile(new File(ROOT, "test/path/../../sub"))); - assertEquals(new File(ROOT, "sub2"), resolveFile(new File(ROOT, "test/path/../../sub/../sub2"))); - assertEquals(new File(ROOT, "sub2"), resolveFile(new File(ROOT, "test/path/.././../sub/../sub2"))); - assertEquals(new File(ROOT, "sub2"), resolveFile(new File(ROOT, "test/path/.././../sub/../../sub2"))); - assertEquals(new File(ROOT, "sub2"), resolveFile(new File(ROOT, "a/test/path/.././../sub/../../sub2"))); - assertEquals(new File(ROOT, "b/sub2"), resolveFile(new File(ROOT, "b/a/test/path/.././../sub/../../sub2"))); - assertEquals(ROOT, resolveFile(new File(ROOT, "test/path/../../../.."))); - assertEquals(new File(ROOT, "test/sub"), resolveFile(new File(ROOT, "../../../../test/sub"))); - assertEquals(new File(ROOT, "test"), resolveFile(new File(ROOT, "../test"))); - assertEquals(new File(ROOT, "test/path"), resolveFile(new File(ROOT, "test/path/file.ext/.."))); - assertEquals(new File(ROOT, "folder"), resolveFile(new File(ROOT, ".././../folder"))); - assertEquals(new File(ROOT, "b"), resolveFile(new File(ROOT, "folder/a/.././../b"))); - } - - @Test - public void testUriToString() - { - assertEquals("converted file:/// URI does not match expected string", "file:///data/myfile.txt", uriToString(URI.create("file:///data/myfile.txt"))); - assertEquals("converted file:/ URI does not match expected string", "file:///data/myfile.txt", uriToString(URI.create("file:/data/myfile.txt"))); - } - - @Test - public void testNormalizeURI() - { - assertEquals("file:/// uri not as expected","file:///my/triple/file/path", uriToString(URI.create("file:///my/triple/file/path"))); - assertEquals("file:/// uri with drive letter not as expected","file:///C:/my/triple/file/path", uriToString(URI.create("file:///C:/my/triple/file/path"))); - assertEquals("file:/ uri not conformed to file:///","file:///my/single/file/path", uriToString(URI.create("file:/my/single/file/path"))); - assertEquals("file:/ with drive letter not conformed to file:///","file:///C:/my/single/file/path", uriToString(URI.create("file:/C:/my/single/file/path"))); - assertEquals("File uri with host not as expected", "file://localhost:8080/my/host/file/path", uriToString(URI.create("file://localhost:8080/my/host/file/path"))); - assertEquals("Schemed URI not as expected","http://localhost:8080/my/triple/file/path?query=abcd#anchor", uriToString(URI.create("http://localhost:8080/my/triple/file/path?query=abcd#anchor"))); - } - - @Test - public void testTempFileWrapper() throws IOException - { - try - { - FileUtil.startRequest(); - var sonnet = """ - From fairest creatures we desire increase, - That thereby beauty's rose might never die, - But as the riper should by time decease, - His tender heir might bear his memory: - But thou contracted to thine own bright eyes, - Feed'st thy light's flame with self-substantial fuel, - Making a famine where abundance lies, - Thy self thy foe, to thy sweet self too cruel: - Thou that art now the world's fresh ornament, - And only herald to the gaudy spring, - Within thine own bud buriest thy content, - And tender churl mak'st waste in niggarding: - Pity the world, or else this glutton be, - To eat the world's due, by the grave and thee. - """; - try (var tf = new TempTextFileWrapper(64)) - { - var w = tf.getWriter(); - for (var l : StringUtils.split(sonnet, '\n')) - w.write(l + "\n"); - var r = new BufferedReader(tf.getReader()); - String l, lines = ""; - while (null != (l = r.readLine())) - lines = lines + l + "\n"; - assertEquals(sonnet.trim(), lines.trim()); - assertEquals(sonnet.substring(0, 64), tf.getSummary(100)); - } - try (var tf = new TempTextFileWrapper(900)) - { - var w = tf.getWriter(); - for (var l : StringUtils.split(sonnet, '\n')) - w.write(l + "\n"); - var r = new BufferedReader(tf.getReader()); - String l, lines = ""; - while (null != (l = r.readLine())) - lines = lines + l + "\n"; - assertEquals(sonnet.trim(), lines.trim()); - assertEquals(sonnet.substring(0, 100), tf.getSummary(100)); - } - } - finally - { - // make sure we did not leave any temp files lying around - FileUtil.stopRequest(); - } - } - - @Test - public void testMakeLegalName() - { - assertEquals("__null__", makeLegalName(null)); - assertEquals("__empty__", makeLegalName("")); - assertEquals("_", makeLegalName(" ")); - assertEquals(" _", makeLegalName(" ")); - assertEquals("_", makeLegalName(".")); - assertEquals("._", makeLegalName("..")); - assertEquals("foo", makeLegalName("foo")); - assertEquals("foo_", makeLegalName("foo ")); - assertEquals("foo_", makeLegalName("foo.")); - assertEquals("foo -", makeLegalName("foo -")); - assertEquals("foo _arg", makeLegalName("foo -arg")); - assertEquals("foo _arg-arg", makeLegalName("foo -arg-arg")); - assertEquals("foo _arg _arg2", makeLegalName("foo -arg -arg2")); - - // These are allowed. Verify they don't get changed - assertEquals("a", makeLegalName("a")); - assertEquals("a-b", makeLegalName("a-b")); - assertEquals("a - b", makeLegalName("a - b")); - assertEquals("a- b", makeLegalName("a- b")); - assertEquals("a--b", makeLegalName("a--b")); - assertEquals("a -- b", makeLegalName("a -- b")); - assertEquals("a-- b", makeLegalName("a-- b")); - - // These aren't allowed. Make sure they get changed - assertEquals("_a", makeLegalName("-a")); - assertEquals(" _a", makeLegalName(" -a")); - assertEquals("a _b", makeLegalName("a -b")); - assertEquals("_-a", makeLegalName("--a")); - assertEquals(" _-a", makeLegalName(" --a")); - assertEquals("a _-b", makeLegalName("a --b")); - assertEquals("a _--b", makeLegalName("a ---b")); - - assertEquals(StringUtils.repeat('_', ILLEGAL_CHARS.length), makeLegalName(new String(ILLEGAL_CHARS))); - assertEquals(StringUtils.repeat('_', 255), makeLegalName(StringUtils.repeat(new String(ILLEGAL_CHARS), 50))); - assertEquals(StringUtils.repeat('.', 254) + "_", makeLegalName(StringUtils.repeat('.', 500))); - assertEquals(StringUtils.repeat(' ', 254) + "_", makeLegalName(StringUtils.repeat(' ', 500))); - } - - @Test - public void testAllowedFileName() - { - //Test Setup - Mockery _context = new Mockery(); - _context.setImposteriser(ClassImposteriser.INSTANCE); - AppProps mockProps = _context.mock(AppProps.class); - _context.checking(new Expectations(){{ - allowing(mockProps).isInvalidFilenameBlocked(); - will(returnValue(true)); - }}); - - assertNull(isAllowedFileName("a", false, mockProps)); - assertNull(isAllowedFileName("a-b", false, mockProps)); - assertNull(isAllowedFileName("a - b", false, mockProps)); - assertNull(isAllowedFileName("a- b", false, mockProps)); - assertNull(isAllowedFileName("a--b", false, mockProps)); - assertNull(isAllowedFileName("a -- b", false, mockProps)); - assertNull(isAllowedFileName("a-- b", false, mockProps)); - assertNull(isAllowedFileName("a b", false, mockProps)); - assertNull(isAllowedFileName("a%b", false, mockProps)); - assertNull(isAllowedFileName("a$b", false, mockProps)); - assertNull(isAllowedFileName("%ab", false, mockProps)); - - assertNotNull(isAllowedFileName(null, false, mockProps)); - assertNotNull(isAllowedFileName("", false, mockProps)); - assertNotNull(isAllowedFileName(" ", false, mockProps)); - assertNotNull(isAllowedFileName("a\tb", false, mockProps)); - assertNotNull(isAllowedFileName("-a", false, mockProps)); - assertNotNull(isAllowedFileName(" -a", false, mockProps)); - assertNotNull(isAllowedFileName("a -b", false, mockProps)); - assertNotNull(isAllowedFileName("--a", false, mockProps)); - assertNotNull(isAllowedFileName(" --a", false, mockProps)); - assertNotNull(isAllowedFileName("a --b", false, mockProps)); - assertNotNull(isAllowedFileName("a ---b", false, mockProps)); - assertNotNull(isAllowedFileName("a/b", false, mockProps)); - assertNotNull(isAllowedFileName("a\b", false, mockProps)); - assertNotNull(isAllowedFileName("a:b", false, mockProps)); - assertNotNull(isAllowedFileName("a*b", false, mockProps)); - assertNotNull(isAllowedFileName("a?b", false, mockProps)); - assertNotNull(isAllowedFileName("ab", false, mockProps)); - assertNotNull(isAllowedFileName("a\"b", false, mockProps)); - assertNotNull(isAllowedFileName("a|b", false, mockProps)); - assertNotNull(isAllowedFileName("a`b", false, mockProps)); - assertNotNull(isAllowedFileName("$ab", false, mockProps)); - assertNotNull(isAllowedFileName("-ab", false, mockProps)); - assertNotNull(isAllowedFileName("a`b", false, mockProps)); - } - - @Test - public void testAcceptableExtensions() - { - List allowedExtensions = Arrays.asList( - ".1", - ".txt", - ".tar", - ".tar.gz", - ".a_v", - ".xlsx", - ".l-()[]{}1☃"); - - //Test Setup - Mockery _context = new Mockery(); - _context.setImposteriser(ClassImposteriser.INSTANCE); - AppProps mockProps = _context.mock(AppProps.class); - _context.checking(new Expectations(){{ - allowing(mockProps).getAllowedExtensions(); - will(returnValue(allowedExtensions)); - }}); - - - assertNull("Extension should be allowed", checkExtension("test.txt", mockProps)); - assertNull("Multiple extension should be allowed", checkExtension("archive.tar.gz", mockProps)); - assertNull("Case-insensitive extension should be allowed", checkExtension("archive.TaR.Gz", mockProps)); - assertNull("Special characters aren't escaped properly", checkExtension("my test.l-()[]{}1☃", mockProps)); - assertNull("Numeric extension should be allowed", checkExtension("test.1", mockProps)); - assertNotNull("Multiple extension matched when it shouldn't", checkExtension("tar.gz", mockProps)); - assertNotNull("Matched unlist extension", checkExtension("my test.notListed", mockProps)); - assertNotNull("Combined multiple extension matched incorrectly", checkExtension("multi.a_v.tar", mockProps)); - assertNotNull("Multi-multi extension matched unexpectedly", checkExtension("multi.not.tar.gz", mockProps)); - assertNotNull("No extension matched unexpectedly", checkExtension("No extension", mockProps)); - } - - @Test - public void testNoAcceptableExtensions() - { - List allowedExtensions = Collections.emptyList(); - - //Test Setup - Mockery _context; - _context = new Mockery(); - _context.setImposteriser(ClassImposteriser.INSTANCE); - AppProps mockProps = _context.mock(AppProps.class); - _context.checking(new Expectations(){{ - allowing(mockProps).getAllowedExtensions(); - will(returnValue(allowedExtensions)); - }}); - - assertNull("Special characters aren't escaped properly", checkExtension("my test.l-()[]{}1☃", mockProps)); - assertNull("Unlisted extension should be allowed, but wasn't", checkExtension("my test.notListed", mockProps)); - assertNull("Combined extension should be allowed, but wasn't", checkExtension("multi.tar.a_v", mockProps)); - assertNull("No extension should be allowed, but wasn't", checkExtension("No extension", mockProps)); - assertNull("Numeric extension should be allowed", checkExtension("test.1", mockProps)); - } - - @Test - public void testGetAppendedFileName() - { - String originalFilename = "test.txt"; - assertEquals("test.txt", getAppendedFileName(originalFilename, 0)); - assertEquals("test-1.txt", getAppendedFileName(originalFilename, 1)); - assertEquals("test-2.txt", getAppendedFileName(originalFilename, 2)); - } - } -} +/* + * Copyright (c) 2008-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.labkey.api.util; + +import org.apache.commons.io.FileUtils; +import org.apache.commons.io.FilenameUtils; +import org.apache.commons.io.IOUtils; +import org.apache.commons.io.file.SimplePathVisitor; +import org.apache.commons.io.input.LabKeyByteBufferCleaner; +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jmock.Expectations; +import org.jmock.Mockery; +import org.jmock.lib.legacy.ClassImposteriser; +import org.junit.Assert; +import org.junit.Test; +import org.labkey.api.cloud.CloudStoreService; +import org.labkey.api.data.Container; +import org.labkey.api.files.FileContentService; +import org.labkey.api.security.Crypt; +import org.labkey.api.settings.AppProps; +import org.labkey.api.util.logging.LogHelper; +import org.labkey.api.view.UnauthorizedException; +import org.labkey.vfs.FileLike; +import org.labkey.vfs.FileSystemLike; + +import java.io.BufferedInputStream; +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.Closeable; +import java.io.DataOutput; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.Reader; +import java.io.Writer; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; +import java.nio.channels.ReadableByteChannel; +import java.nio.file.FileSystems; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.attribute.FileAttribute; +import java.nio.file.attribute.PosixFilePermission; +import java.nio.file.attribute.PosixFilePermissions; +import java.security.DigestInputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public class FileUtil +{ + public static final String FILE_SCHEME = "file"; // url scheme for local file system + + private static final Logger LOG = LogHelper.getLogger(FileUtil.class, "FileUtil.java logger"); + + private static File _tempDir = null; + private static FileLike _tempDirFileLike = null; + + static private final String windowsRestricted = "\\/:*?\"<>|`"; + // and ` seems like a bad idea for linux? + static private final String linuxRestricted = "`"; + static private final String restrictedPrintable = windowsRestricted + linuxRestricted; + + private static final ThreadLocal> tempPaths = ThreadLocal.withInitial(HashSet::new); + + private static Pattern extensionChecker; + + public static void startRequest() + { + tempPaths.get().clear(); + } + + @SuppressWarnings("RedundantOperationOnEmptyContainer") + public static void stopRequest() + { + var paths = tempPaths.get(); + assert paths.isEmpty(); + for (Path p : paths) + { + try + { + Files.deleteIfExists(p); + } + catch (IOException x) + { + p.toFile().deleteOnExit(); + } + } + paths.clear(); + } + + + @Deprecated + public static boolean deleteDirectoryContents(File dir) + { + try + { + return deleteDirectoryContents(dir.toPath()); + } + catch (IOException e) + { + return false; // could there be more done here to log the error? + } + } + + + public static boolean deleteDirectoryContents(Path dir) throws IOException + { + return deleteDirectoryContents(dir, null); + } + + + public static boolean deleteDirectoryContents(FileLike dir) throws IOException + { + if (!dir.getFileSystem().canWriteFiles()) + throw new UnauthorizedException(); + return deleteDirectoryContents(toFileForWrite(dir).toPath(), null); + } + + + public static boolean deleteDirectoryContents(Path dir, @Nullable Logger log) throws IOException + { + if (Files.isDirectory(dir)) + { + File dirFile = dir.toFile(); //TODO this method should be converted to use Path and Files.walkFileTree + String[] children = dirFile.list(); + + if (null == children) // 17562 + return true; + + for (String aChildren : children) + { + boolean success = deleteDir(FileUtil.appendName(dirFile, aChildren), log); + if (!success) + { + return false; + } + } + } + return true; + } + + + public static boolean deleteSubDirs(File dir) + { + if (dir.isDirectory()) + { + File[] children = dir.listFiles(); + if (null != children) + { + for (File child : children) + { + boolean success = true; + if (child.isDirectory()) + success = deleteDir(child); + if (!success) + { + return false; + } + } + } + } + return true; + } + + + /** File.delete() will only delete a directory if it's empty, but this will + * delete all the contents and the directory */ + public static boolean deleteDir(File dir) + { + return deleteDir(dir, null); + } + + public static boolean deleteDir(FileLike dir) + { + return deleteDir(dir.toNioPathForWrite(), null); + } + + @Deprecated + public static boolean deleteDir(@NotNull File dir, Logger log) + { + return deleteDir(dir.toPath(), log); + } + + + public static boolean deleteDir(Path dir, Logger log) + { + //TODO seems like this could be reworked to use Files.walkFileTree + log = log == null ? LOG : log; + + // Issue 22336: See note in FileUtils.isSymLink() about windows-specific bugs for symlinks: + // http://commons.apache.org/proper/commons-io/apidocs/org/apache/commons/io/FileUtils.html + if (!Files.isSymbolicLink(dir)) + { + try + { + // this returns true if !dir.isDirectory() + boolean success = deleteDirectoryContents(dir, log); + if (!success) + return false; + } + catch (IOException e) + { + log.debug(String.format("Unable to clean dir [%1$s]", dir), e); + return false; + } + } + + IOException lastException = null; + + // The directory is now either a sym-link or empty, so delete it + for (int i = 0; i < 5 ; i++) + { + try + { + Files.deleteIfExists(dir); + return true; + } + catch (IOException e) + { + lastException = e; + // Issue 39579: Folder import sometimes fails to delete temp directory + // wait a little then try again + log.warn("Failed to delete file. Sleep and try to delete again. " + e.getMessage()); + try {Thread.sleep(1000);} catch (InterruptedException x) {/* pass */} + } + } + log.warn("Failed to delete file after 5 attempts: " + FileUtil.getAbsoluteCaseSensitiveFile(dir.toFile()), lastException); + return false; + } + + + public static boolean deleteDir(@NotNull Path dir) throws IOException + { + if (Files.exists(dir)) + { + if (hasCloudScheme(dir)) + { + // TODO: On Windows, collect is yielding AccessDenied Exception, so only do this for cloud + try (Stream paths = Files.walk(dir)) + { + boolean success = true; + for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) + { + success = Files.deleteIfExists(path) && success; + } + return success; + } + } + else + { + return deleteDir(dir.toFile()); // Note: we maintain existing behavior from before Path work, which is to ignore any error + } + } + + return true; + } + + + public static void copyDirectory(Path srcPath, Path destPath) throws IOException + { + // Will replace existing files + if (!Files.exists(destPath)) + FileUtil.createDirectory(destPath); + try (Stream list = Files.list(srcPath)) + { + for (Path srcChild : list.toList()) + { + Path destChild = destPath.resolve(getFileName(srcChild)); + if (Files.isDirectory(srcChild)) + copyDirectory(srcChild, destChild); + else + Files.copy(srcChild, destChild, StandardCopyOption.REPLACE_EXISTING); + } + } + } + + public static String isAllowedFileName(String s, boolean checkFileExtension) + { + return isAllowedFileName(s, checkFileExtension, AppProps.getInstance()); + } + + static String isAllowedFileName(String s, boolean checkFileExtension, AppProps appProps) + { + if (appProps.isInvalidFilenameBlocked()) + { + String msg = validateFileName(s); + if (msg != null) + return msg; + } + + if (checkFileExtension) + { + String badExtension = checkExtension(s, AppProps.getInstance()); + if (badExtension != null) + return "This file type [" + badExtension + "] is not allowed. Accepted file extensions: " + AppProps.getInstance().getAllowedExtensions(); + } + return null; + } + + public static @Nullable String validateFileName(String s) + { + return StringUtilsLabKey.validateLegalNames(s, restrictedPrintable, "Filename"); + } + + private static String checkExtension(String filename, AppProps appProps) + { + // If the allow list is empty, allow any extension + if (appProps.getAllowedExtensions().isEmpty()) + return null; + + if (extensionChecker == null) + setExtensionChecker(appProps); + + String extension = FilenameUtils.getExtension(filename); + return extensionChecker.matcher(filename).matches() ? null : extension; + } + + private static void setExtensionChecker(AppProps appProps) + { + // Regex encode the allowed extensions (escape periods and add '|' optional matcher) + String allowedExtensions = appProps.getAllowedExtensions().stream().map(Pattern::quote).collect(Collectors.joining("|")); + // Allow any extension in the list unless it is preceded by a '.' which we use as a proxy for double/multi extensions + extensionChecker = Pattern.compile(String.format("^[^\\.]*(%1$s)$", allowedExtensions), Pattern.CASE_INSENSITIVE); + } + + public static void clearExtensionChecker() + { + extensionChecker = null; + } + + public static void checkAllowedFileName(String s, boolean checkFileExtension) throws IOException + { + String msg = isAllowedFileName(s, checkFileExtension); + if (null == msg) + return; + throw new IOException(s + ": " + msg); + } + + public static boolean mkdir(File file) throws IOException + { + return mkdir(file, AppProps.getInstance().isInvalidFilenameBlocked()); + } + + public static File toFileForRead(FileLike file) + { + if (null == file) + return null; + return file.toNioPathForRead().toFile(); + } + + public static File toFileForWrite(FileLike file) + { + if (null == file) + return null; + return file.toNioPathForWrite().toFile(); + } + + public static boolean mkdir(FileLike file) throws IOException + { + return mkdir(toFileForWrite(file), AppProps.getInstance().isInvalidFilenameBlocked()); + } + + public static boolean mkdir(File file, boolean checkFileName) throws IOException + { + if (checkFileName) + checkAllowedFileName(file.getName(), false); + //noinspection SSBasedInspection + return file.mkdir(); + } + + + public static boolean mkdirs(File file) throws IOException + { + return mkdirs(file, AppProps.getInstance().isInvalidFilenameBlocked()); + } + + public static boolean mkdirs(FileLike file) throws IOException + { + if (!file.getFileSystem().canWriteFiles()) + throw new UnauthorizedException(); + var ret = mkdirs(toFileForWrite(file), AppProps.getInstance().isInvalidFilenameBlocked()); + file.refresh(); + return ret; + } + + public static boolean mkdirs(File file, boolean checkFileName) throws IOException + { + File parent = file; + while (!Files.exists(parent.toPath())) + { + if (checkFileName) + checkAllowedFileName(parent.getName(), false); + parent = parent.getParentFile(); + } + //noinspection SSBasedInspection + return file.mkdirs(); + } + + public static boolean mkdirs(FileLike file, boolean checkFileName) throws IOException + { + FileLike parent = file; + var ret = false; + while (!Files.exists(parent.toNioPathForWrite())) + { + ret = true; + if (checkFileName) + checkAllowedFileName(parent.getName(), false); + parent = parent.getParent(); + } + file.mkdirs(); + return ret; + } + + + public static FileLike createDirectory(FileLike path) throws IOException + { + createDirectory(path.toNioPathForWrite(), AppProps.getInstance().isInvalidFilenameBlocked()); + return path; + } + + public static Path createDirectory(Path path) throws IOException + { + return createDirectory(path, AppProps.getInstance().isInvalidFilenameBlocked()); + } + + + public static Path createDirectory(Path path, boolean checkFileName) throws IOException + { + if (checkFileName) + checkAllowedFileName(getFileName(path), false); + if (!Files.exists(path)) + //noinspection SSBasedInspection + return Files.createDirectory(path); + return path; + } + + + public static Path createDirectories(Path path) throws IOException + { + return createDirectories(path, AppProps.getInstance().isInvalidFilenameBlocked()); + } + + + public static void createDirectories(FileLike file) throws IOException + { + if (!file.getFileSystem().canWriteFiles()) + throw new UnauthorizedException(); + File target = toFileForWrite(file); + createDirectories(target.toPath(), AppProps.getInstance().isInvalidFilenameBlocked()); + } + + + public static Path createDirectories(Path path, boolean checkFileName) throws IOException + { + Path parent = path; + while (!Files.exists(parent)) + { + if (checkFileName) + checkAllowedFileName(getFileName(parent), false); + parent = parent.getParent(); + } + //noinspection SSBasedInspection + return Files.createDirectories(path); + } + + + public static boolean renameTo(FileLike from, FileLike to) + { + // TODO FileLike.renameTo() + return toFileForRead(from).renameTo(toFileForWrite(to)); + } + + + public static boolean createNewFile(File file) throws IOException + { + return createNewFile(file, AppProps.getInstance().isInvalidFilenameBlocked()); + } + + + public static boolean createNewFile(File file, boolean checkFileName) throws IOException + { + if (checkFileName) + checkAllowedFileName(file.getName(), true); + //noinspection SSBasedInspection + return file.createNewFile(); + } + + + public static boolean createNewFile(FileLike file, boolean checkFileName) throws IOException + { + if (checkFileName) + checkAllowedFileName(file.getName(), true); + var ret = !file.exists(); + file.createFile(); + return ret; + } + + + public static Path createFile(Path path, FileAttribute... attrs) throws IOException + { + return createFile(path, AppProps.getInstance().isInvalidFilenameBlocked(), attrs); + } + + + public static Path createFile(Path path, boolean checkFileName, FileAttribute... attrs) throws IOException + { + if (checkFileName) + checkAllowedFileName(getFileName(path), true); + return Files.createFile(path, attrs); + } + + + // return true if file exists and is not a directory + public static boolean isFileAndExists(@Nullable Path path) + { + try + { + // One call to cloud rather than two (exists && !isDirectory) + return (null != path && !Files.readAttributes(path, BasicFileAttributes.class).isDirectory()); + } + catch (IOException e) + { + return false; + } + } + + + /** + * Remove text right of a specific number of periods, including the periods, from a file's name. + *

    + *
  • C:\dir\name.ext, 1 => name
  • + *
  • C:\dir\name.ext1.ext2, 2 => name
  • + *
  • C:\dir\name.ext1.ext2, 1 => name.ext1
  • + *
+ * + * @param fileName name of the file + * @param dots number of dots to remove + * @return base name + */ + public static String getBaseName(String fileName, int dots) + { + String baseName = fileName; + while (dots-- > 0 && baseName.indexOf('.') != -1) + baseName = baseName.substring(0, baseName.lastIndexOf('.')); + return baseName; + } + + + /** + * Remove text right of and including the last period in a file's name. + * @param fileName name of the file + * @return base name + */ + public static String getBaseName(String fileName) + { + return getBaseName(fileName, 1); + } + + + /** + * Remove text right of a specific number of periods, including the periods, from a file's name. + *
    + *
  • C:\dir\name.ext, 1 => name
  • + *
  • C:\dir\name.ext1.ext2, 2 => name
  • + *
  • C:\dir\name.ext1.ext2, 1 => name.ext1
  • + *
+ * + * @param file file from which to get the name + * @param dots number of dots to remove + * @return base name + */ + public static String getBaseName(File file, int dots) + { + return getBaseName(file.getName(), dots); + } + + public static String getBaseName(FileLike file, int dots) + { + return getBaseName(file.toNioPathForRead().toFile(), dots); + } + + + /** + * Remove text right of and including the last period in a file's name. + * @param file file from which to get the name + * @return base name + */ + public static String getBaseName(File file) + { + return getBaseName(file, 1); + } + + public static String getBaseName(FileLike file) + { + return getBaseName(file, 1); + } + + + /** + * Returns the file name extension without the dot, null if there + * isn't one. + */ + @Nullable + public static String getExtension(File file) + { + return getExtension(file.getName()); + } + + + /** + * Returns the file name extension without the dot, null if there + * isn't one. + */ + @Nullable + public static String getExtension(String name) + { + if (name != null && name.lastIndexOf('.') != -1) + { + return name.substring(name.lastIndexOf('.') + 1); + } + return null; + } + + + public static boolean hasCloudScheme(Path path) + { + try + { + return hasCloudScheme(path.toUri()); + } + catch (Exception e) + { + return false; + } + } + + + public static boolean hasCloudScheme(URI uri) + { + return "s3".equalsIgnoreCase(uri.getScheme()); + } + + + public static boolean hasCloudScheme(String url) + { + return url.toLowerCase().startsWith("s3://"); + } + + + public static boolean hasCloudScheme(FileLike filelike) + { + return "s3".equals(filelike.getFileSystem().getScheme()); + } + + + public static String getAbsolutePath(Path path) + { + if (!FileUtil.hasCloudScheme(path)) + return path.toFile().getAbsolutePath(); + else + return getPathStringWithoutAccessId(path.toAbsolutePath().toUri()); + + } + + + @Nullable + public static String getAbsolutePath(Container container, Path path) + { // Returned string is NOT necessarily a URI (i.e. it is not encoded) + return getAbsolutePath(container, path.toUri()); + } + + + @Nullable + public static String getAbsolutePath(Container container, URI uri) + { + if (!uri.isAbsolute()) + return null; + else if (!FileUtil.hasCloudScheme(uri)) + return new File(uri).getAbsolutePath(); + else + return getAbsolutePathWithoutAccessIdFromCloudUrl(container, uri); + } + + + @Nullable + public static String getAbsoluteCaseSensitivePathString(Container container, URI uri) + { + if (!uri.isAbsolute()) + return null; + else if (!FileUtil.hasCloudScheme(uri)) + return getAbsoluteCaseSensitiveFile(new File(uri)).toPath().toUri().toString(); // Was: return getAbsoluteCaseSensitiveFile(new File(uri)).toURI().toString(); // #36352 + else + return getAbsolutePathWithoutAccessIdFromCloudUrl(container, uri); + } + + + @Nullable + public static Path getAbsoluteCaseSensitivePath(Container container, URI uri) + { + if (!uri.isAbsolute()) + return null; + else if (!FileUtil.hasCloudScheme(uri)) + return getAbsoluteCaseSensitiveFile(new File(uri)).toPath(); + else + return getAbsolutePathFromCloudUrl(container, uri); + } + + + @Nullable + private static String getAbsolutePathWithoutAccessIdFromCloudUrl(Container container, URI uri) + { + Path path = getAbsolutePathFromCloudUrl(container, uri); + return null != path ? getPathStringWithoutAccessId(path.toAbsolutePath().toUri()) : null; + } + + + @Nullable + private static Path getAbsolutePathFromCloudUrl(Container container, URI uri) + { + Path path = Objects.requireNonNull(CloudStoreService.get()).getPathFromUrl(container, uri.toString()); + return null != path ? path.toAbsolutePath() : null; + } + + + public static Path getAbsoluteCaseSensitivePath(Container container, Path path) + { + if (!FileUtil.hasCloudScheme(path)) + return getAbsoluteCaseSensitiveFile(path.toFile()).toPath(); + else + return path.toAbsolutePath(); + } + + + @Nullable + public static Path getPath(Container container, URI uri) + { + if (!uri.isAbsolute()) + return null; + else if (!FileUtil.hasCloudScheme(uri)) + return new File(uri).toPath(); + else + return Objects.requireNonNull(CloudStoreService.get()).getPathFromUrl(container, uri.toString()); + } + + + public static URI createUri(String str) + { + return createUri(str, true); + } + + + public static URI createUri(String str, boolean isEncoded) + { + str = str.replace("\\", "/"); + // Assume that Windows-style drive-letter paths like c:/myfile.txt should be treated as file:/ URIs + if (str.matches("^[A-Za-z]:/.*")) + return new File(str).toURI(); + + String str2 = str; + if (str2.startsWith("/")) + str2 = "file://" + str; + + // Creating stack traces is expensive so only bother if we're really going to log it + if (LOG.isDebugEnabled()) + { + LOG.debug("CreateUri from: " + str + " [" + Thread.currentThread().getStackTrace()[2].toString() + "]"); + } + if (isEncoded) + str2 = str2.replace(" ", "%20"); // Spaces in paths make URI unhappy + else + str2 = encodeForURL(str2); + try + { + return new URI(str2); + } + catch (URISyntaxException e) + { + // We're handling encoded and unencoded, so this can fail because of certain reserved chars; + if (str.startsWith("/")) + return new File(str).toPath().toUri(); + throw new IllegalArgumentException(e); + } + } + + + @NotNull + public static String getFileName(Path fullPath) + { + // We want unencoded fileName + if (hasCloudScheme(fullPath)) + { + Path path = fullPath.getFileName(); + return path == null ? "" : path.toUri().getPath(); + } + else + { + return fullPath.getFileName().toString(); + } + } + + + /** Only returns a child path */ + public static File appendPath(File dir, org.labkey.api.util.Path originalPath) + { + org.labkey.api.util.Path path = originalPath.normalize(); + if (path == null || (!path.isEmpty() && "..".equals(path.get(0)))) + throw new InvalidPathException(originalPath.toString(), "Path to parent not allowed"); + @SuppressWarnings("SSBasedInspection") + var ret = new File(dir, path.toString()); + if (!ret.toPath().normalize().startsWith(dir.toPath().normalize())) + throw new InvalidPathException(originalPath.toString(), "Path to parent not allowed"); + return ret; + } + + + /** Only returns a child path */ + public static FileLike appendPath(FileLike dir, org.labkey.api.util.Path path) + { + path = path.normalize(); + if (!path.isEmpty() && "..".equals(path.get(0))) + throw new InvalidPathException(path.toString(), "Path to parent not allowed"); + return dir.resolveFile(path); + } + + + /** Resolve a relative path, may not be a descendant. */ + public static FileLike resolveFile(FileLike dir, org.labkey.api.util.Path path) + { + return dir.resolveFile(path); + } + + + /* Only returns an immediate child */ + public static File appendName(File dir, org.labkey.api.util.Path.Part part) + { + return appendName(dir, part.toString()); + } + + + /* Only returns an immediate child */ + public static File appendName(File dir, String name) + { + if (!dir.isAbsolute()) + { + dir = dir.getAbsoluteFile(); + } + legalPathPartThrow(name); + @SuppressWarnings("SSBasedInspection") + var ret = new File(dir, name); + + if (!ret.toPath().normalize().startsWith(dir.toPath().normalize())) + throw new InvalidPathException(name, "Path to parent not allowed"); + return ret; + } + + /* Only returns an immediate child */ + public static Path appendName(Path dir, String name) + { + legalPathPartThrow(name); + var ret = dir.resolve(name); + + if (!ret.normalize().startsWith(dir.normalize())) + throw new InvalidPathException(name, "Path to parent not allowed"); + return ret; + } + + + // narrower check than isLegalName() or isAllowedFileName() + // this check that a name is a valid path part (e.g. filename) and is not path like. + public static void legalPathPartThrow(String name) + { + int invalidCharacterIndex = StringUtils.indexOfAny(name, '/', File.separatorChar); + if (invalidCharacterIndex >= 0) + throw new InvalidPathException(name, "Invalid file or directory name", invalidCharacterIndex); + if (".".equals(name) || "..".equals(name)) + throw new InvalidPathException(name, "Invalid file or directory name"); + } + + + public static String decodeSpaces(@NotNull String str) + { + return str.replace("%20", " "); + } + + + public static String pathToString(Path path) + { // Returns a URI string (encoded) + return getPathStringWithoutAccessId(path.toUri()); + } + + + public static String uriToString(URI uri) + { + return getPathStringWithoutAccessId(uri); + } + + + public static Path stringToPath(Container container, String str) + { + return stringToPath(container, str, true); + } + + + public static Path stringToPath(Container container, String str, boolean isEncoded) + { + if (!FileUtil.hasCloudScheme(str)) + return new File(createUri(str, isEncoded)).toPath(); + else + return Objects.requireNonNull(CloudStoreService.get()).getPathFromUrl(container, PageFlowUtil.decode(str)/*decode everything not just the space*/); + } + + + public static String getCloudRootPathString(String cloudName) + { + return FileContentService.CLOUD_ROOT_PREFIX + "/" + cloudName; + } + + + @Nullable + private static String getPathStringWithoutAccessId(URI uri) + { + if (null != uri) + if (hasCloudScheme(uri)) + return uri.toString().replaceFirst("/\\w+@s3", "/s3"); // Remove accessId portion if exists + else + { + try + { + return Objects.requireNonNull(URIUtil.normalizeUri(uri)).toString(); + } + catch (URISyntaxException e) + { + LOG.debug("Error attempting to conform uri: " + e.getMessage()); + return uri.toString(); + } + } + else + return null; + } + + + /** + * Get relative path of File 'file' with respect to 'home' directory + *

+     * example : home = /a/b/c
+     *           file    = /a/d/e/x.txt
+     *           return = ../../d/e/x.txt
+     * 

+ * The path returned has system specific directory separators. + *

+ * It is equivalent to:
+ *

home.toURI().relativize(f.toURI).toString().replace('/', File.separatorChar)
+ * + * @param home base path, should be a directory, not a file, or it doesn't make sense + * @param file file to generate path for + * @param canonicalize whether or not the paths need to be canonicalized + * @return path from home to file as a string + */ + public static String relativize(File home, File file, boolean canonicalize) throws IOException + { + if (canonicalize) + { + home = FileUtil.getAbsoluteCaseSensitiveFile(home); + file = FileUtil.getAbsoluteCaseSensitiveFile(file); + } + else + { + home = resolveFile(home); + file = resolveFile(file); + } + return matchPathLists(getPathList(home), getPathList(file)); + } + + + /** + * Get a relative path of File 'file' with respect to 'home' directory, + * forcing Unix (i.e. URI) forward slashes for directory separators. + *

+ * This is a lot like URIUtil.relativize() without requiring + * that the file be a descendant of the base. + *

+ * It is equivalent to:
+ *

home.toURI().relativize(f.toURI).toString()
+ */ + public static String relativizeUnix(File home, File f, boolean canonicalize) throws IOException + { + return relativize(home, f, canonicalize).replace('\\', '/'); + } + + + public static String relativizeUnix(Path home, Path f, boolean canonicalize) throws IOException + { + if (!hasCloudScheme(home) && !hasCloudScheme(f)) + return relativizeUnix(home.toFile(), f.toFile(), canonicalize); + return getPathStringWithoutAccessId(home.toUri().relativize(f.toUri())); + } + + + /** + * Break a path down into individual elements and add to a list. + *

+ * example : if a path is /a/b/c/d.txt, the breakdown will be [d.txt,c,b,a] + * + * @param file input file + * @return a List collection with the individual elements of the path in reverse order + */ + private static List getPathList(File file) + { + List parts = new ArrayList<>(); + while (file != null) + { + parts.add(file.getName()); + file = file.getParentFile(); + } + + return parts; + } + + + /** + * Figure out a string representing the relative path of + * 'file' with respect to 'home' + * + * @param home home path + * @param file path of file + * @return relative path from home to file + */ + public static String matchPathLists(List home, List file) + { + // start at the beginning of the lists + // iterate while both lists are equal + StringBuilder path = new StringBuilder(); + int i = home.size() - 1; + int j = file.size() - 1; + + // first eliminate common root + while ((i >= 0) && (j >= 0) && (home.get(i).equals(file.get(j)))) + { + i--; + j--; + } + + // for each remaining level in the home path, add a .. + for (; i >= 0; i--) + path.append("..").append(File.separator); + + // for each level in the file path, add the path + for (; j >= 1; j--) + path.append(file.get(j)).append(File.separator); + + // if nothing left of the file, then it was a directory + // of which home is a subdirectory. + if (j < 0) + { + if (path.isEmpty()) + path.append("."); + else + path.delete(path.length() - 1, path.length()); // remove trailing sep + } + else + path.append(file.get(j)); // add file name + + return path.toString(); + } + + public static void copyFile(FileLike src, FileLike dst) throws IOException + { + try (InputStream in = src.openInputStream(); + OutputStream out = dst.openOutputStream()) + { + copyData(in, out); + } + } + + + public static void copyFile(File src, File dst) throws IOException + { + try (FileInputStream is = new FileInputStream(src); + FileChannel in = is.getChannel(); + FileLock lockIn = in.lock(0L, Long.MAX_VALUE, true)) + { + copyFile(in, in.size(), dst); + dst.setLastModified(src.lastModified()); + } + } + + + // FileUtil.copyFile() does not use transferTo() or sync() + public static void copyFile(ReadableByteChannel in, long expected, File dst) throws IOException + { + createNewFile(dst); + + boolean success = false; + long actual = 0; + long bytesCopied; + + LOG.debug("Starting to transfer to " + dst + ", expecting " + (expected == -1 ? "an unknown number" : Long.toString(expected)) + " bytes"); + + try (FileOutputStream os = new FileOutputStream(dst); + FileChannel out = os.getChannel(); + FileLock lockOut = out.lock()) + { + do + { + bytesCopied = out.transferFrom(in, actual, Long.MAX_VALUE); + actual += bytesCopied; + if (actual != expected && bytesCopied != 0) + { + LOG.debug("Still transferring to " + dst + ", " + actual + " bytes transferred so far"); + } + } + while (bytesCopied != 0); + success = actual == expected; + os.getFD().sync(); + } + finally + { + if (success) + { + LOG.debug("Finished transferring " + actual + " bytes to " + dst); + } + else + { + LOG.debug("Failed during transfer, but successfully copied at least " + actual + " bytes to " + dst); + } + } + } + + + /** + * Copies an entire file system branch to another location, including the root directory itself + * @param src The source file root + * @param dest The destination file root + * @throws IOException thrown from IO functions + */ + public static void copyBranch(File src, File dest) throws IOException + { + copyBranch(src, dest, false); + } + + + /** + * Copies an entire file system branch to another location + * + * @param src The source file root + * @param dest The destination file root + * @param contentsOnly Pass false to copy the root directory as well as the files within; true to just copy the contents + * @throws IOException Thrown if there's an IO exception + */ + public static void copyBranch(File src, File dest, boolean contentsOnly) throws IOException + { + //if src is just a file, copy it and return + if (src.isFile()) + { + File destFile = FileUtil.appendName(dest, src.getName()); + copyFile(src, destFile); + return; + } + + //if copying the src root directory as well, make that + //within the dest and re-assign dest to the new directory + if (!contentsOnly) + { + dest = FileUtil.appendName(dest, src.getName()); + mkdirs(dest); + if(!dest.isDirectory()) + throw new IOException("Unable to create the directory " + dest + "!"); + } + + File[] children = src.listFiles(); + if (children == null) + { + throw new IOException("Unable to get file listing for directory: " + src); + } + for (File file : children) + { + copyBranch(file, dest, false); + } + } + + + /** + * always returns path starting with /. Tries to leave trailing '/' as is + * (unless ends with /. or /..) + * + * @param path path to normalize + * @return cleaned path or null if path goes outside of 'root' + */ + @Deprecated // use java.util.Path + public static String normalize(String path) + { + if (path == null || equals(path,'/')) + return path; + + String str = path; + if (str.indexOf('\\') >= 0) + str = str.replace('\\', '/'); + if (!startsWith(str,'/')) + str = "/" + str; + int len = str.length(); + + // quick scan, look for /. or // +quickScan: + { + for (int i=0 ; i list = normalizeSplit(str); + if (null == list) + return null; + if (list.isEmpty()) + return "/"; + StringBuilder sb = new StringBuilder(str.length()+2); + for (String name : list) + { + sb.append('/'); + sb.append(name); + } + return sb.toString(); + } + + + @Deprecated // use java.util.Path + public static ArrayList normalizeSplit(String str) + { + int len = str.length(); + ArrayList list = new ArrayList<>(); + int start = 0; + for (int i=0 ; i<=len ; i++) + { + if (i==len || str.charAt(i) == '/') + { + if (start < i) + { + String part = str.substring(start, i); + if (part.isEmpty() || equals(part,'.')) + { + } + else if (part.equals("..")) + { + if (list.isEmpty()) + return null; + list.remove(list.size()-1); + } + else + { + list.add(part); + } + } + start=i+1; + } + } + return list; + } + + public static String encodeForURL(String str) + { + return encodeForURL(str, false); + } + + public static String encodeForURL(String str, boolean checkEncoded) + { + if (checkEncoded && isUrlEncoded(str)) + return str; + + // str is unencoded; we need certain special chars encoded for it to become a URL + // % & # @ ~ {} [] + return StringUtils.replaceEach(str, DECODED, ENCODED); + } + + private static final String[] ENCODED = {"%25", "%23", "%26", "%40", "%7E", "%7B", "%7D", "%5B", "%5D", "%2B", "%20"}; + private static final String[] DECODED = {"%", "#", "&", "@", "~", "{", "}", "[", "]", "+", " "}; + + static public String decodeURL(String str) + { + return StringUtils.replaceEach(str, ENCODED, DECODED); + } + + public static boolean isUrlEncoded(String str) + { + return StringUtils.indexOfAny(str, ENCODED) > -1; + } + + static boolean startsWith(String s, char ch) + { + return !s.isEmpty() && s.charAt(0) == ch; + } + + + static boolean equals(String s, char ch) + { + return s.length() == 1 && s.charAt(0) == ch; + } + + + public static String relativePath(String dir, String filePath) + { + dir = normalize(dir); + filePath = normalize(filePath); + if (dir.endsWith("/")) + dir = dir.substring(0,dir.length()-1); + if (!filePath.toLowerCase().startsWith(dir.toLowerCase())) + return null; + String relPath = filePath.substring(dir.length()); + if (relPath.isEmpty()) + return relPath; + if (relPath.startsWith("/")) + return relPath.substring(1); + return null; + } + + + private static String digest(MessageDigest md, InputStream is) throws IOException + { + try (DigestInputStream dis = new DigestInputStream(is, md)) + { + byte[] buf = new byte[8 * 1024]; + while (-1 != (dis.read(buf))) + { + /* */ + } + return Crypt.encodeHex(md.digest()); + } + } + + + public static String sha1sum(InputStream is) throws IOException + { + try + { + return digest(MessageDigest.getInstance("SHA1"), is); + } + catch (NoSuchAlgorithmException e) + { + LOG.error("unexpected error", e); + return null; + } + finally + { + IOUtils.closeQuietly(is); + } + } + + + public static String sha1sum(byte[] bytes) throws IOException + { + return sha1sum(new ByteArrayInputStream(bytes)); + } + + + public static String md5sum(InputStream is) throws IOException + { + try + { + return digest(MessageDigest.getInstance("MD5"), is); + } + catch (NoSuchAlgorithmException e) + { + LOG.error("unexpected error", e); + return null; + } + finally + { + IOUtils.closeQuietly(is); + } + } + + + public static String md5sum(byte[] bytes) throws IOException + { + return md5sum(new ByteArrayInputStream(bytes)); + } + + + public static byte[] readHeader(@NotNull File f, int len) throws IOException + { + try (InputStream is = new BufferedInputStream(new FileInputStream(f))) + { + return FileUtil.readHeader(is, len); + } + } + + + public static byte[] readHeader(@NotNull InputStream is, int len) throws IOException + { + assert is.markSupported(); + is.mark(len); + try + { + byte[] buf = new byte[len]; + while (0 < len) + { + int r = is.read(buf, buf.length-len, len); + if (r == -1) + { + byte[] ret = new byte[buf.length-len]; + System.arraycopy(buf, 0, ret, 0, buf.length-len); + return ret; + } + len -= r; + } + return buf; + } + finally + { + is.reset(); + } + } + + + // + // NOTE: IOUtil uses fairly small buffers for copy + // + + final static int BUFFERSIZE = 32*1024; + + // Closes input stream + public static long copyData(InputStream is, File file) throws IOException + { + try (InputStream input = is; FileOutputStream fos = new FileOutputStream(file)) + { + return copyData(input, fos); + } + } + + /** Does not close input or output stream */ + public static long copyData(InputStream is, OutputStream os) throws IOException + { + byte[] buf = new byte[BUFFERSIZE]; + long total = 0; + int r; + while (0 <= (r = is.read(buf))) + { + os.write(buf,0,r); + total += r; + } + return total; + } + + + /** Does not close input or output stream */ + public static void copyData(InputStream is, DataOutput os, long len) throws IOException + { + byte[] buf = new byte[BUFFERSIZE]; + long remaining = len; + do + { + int r = (int)Math.min(buf.length, remaining); + r = is.read(buf, 0, r); + os.write(buf,0,r); + remaining -= r; + } while (0 < remaining); + } + + + /** Does not close input or output stream */ + public static void copyData(InputStream is, DataOutput os) throws IOException + { + byte[] buf = new byte[BUFFERSIZE]; + int r; + while (0 < (r = is.read(buf))) + os.write(buf,0,r); + } + + // NOTE: Keep in sync with the copied constants in TestFileUtils + private static final char[] ILLEGAL_CHARS = {'/','\\',':','?','<','>','*','|','"','^', '\n', '\r', '\''}; + public static final String ILLEGAL_CHARS_STRING = new String(ILLEGAL_CHARS); + + public static boolean isLegalName(String name) + { + if (name == null || name.trim().isEmpty()) + return false; + + if (name.length() > 255) + return false; + + return !StringUtils.containsAny(name, ILLEGAL_CHARS); + } + + // NOTE: Keep in sync with the copied implementation in TestFileUtils.makeLegalFileName() + public static String makeLegalName(String name) + { + if (name == null) + { + return "__null__"; + } + + if (name.isEmpty()) + { + return "__empty__"; + } + + //limit to 255 chars (FAT and OS X) + //replace illegal chars + char[] ret = new char[Math.min(255, name.length())]; + for(int idx = 0; idx < ret.length; ++idx) + { + char ch = name.charAt(idx); + // Reject characters that are illegal anywhere + if (StringUtils.contains(ILLEGAL_CHARS_STRING, ch) || + // Or characters that are illegal starts to a file name + (idx == 0 && (ch == '-' || ch == '$'))) + { + ch = '_'; + } + else if (ch == '-' && + idx > 0 && + name.charAt(idx - 1) == ' ') + { + int i = idx + 1; + // Skip through as many consecutive '-' as there might be + while (i < name.length() && name.charAt(i) == '-') + { + i++; + } + // If the next character after the '-' isn't a space, transform the leading '-' in the sequence + if (i < name.length() && name.charAt(i) != ' ') + { + ch = '_'; + } + } + + ret[idx] = ch; + } + + //can't end with space (windows) + //can't end with period (windows) + int lastIndex = ret.length - 1; + char ch = ret[lastIndex]; + if (ch == ' ' || ch == '.') + ret[lastIndex] = '_'; + + return new String(ret); + } + + + /** + * Returns the absolute path to a file. On Windows and Mac, corrects casing in file paths to match the + * canonical path. + */ + @NotNull + public static FileLike getAbsoluteCaseSensitiveFile(@NotNull FileLike file) + { + return FileSystemLike.wrapFile(getAbsoluteCaseSensitiveFile(file.toNioPathForRead().toFile())); + } + + @NotNull + public static File getAbsoluteCaseSensitiveFile(@NotNull File file) + { + file = resolveFile(file.getAbsoluteFile()); + if (isCaseInsensitiveFileSystem()) + { + try + { + @SuppressWarnings("SSBasedInspection") + File canonicalFile = file.getCanonicalFile(); + + if (canonicalFile.getAbsolutePath().equalsIgnoreCase(file.getAbsolutePath())) + { + return canonicalFile; + } + } + catch (IOException e) + { + // Ignore and just use the absolute file + } + } + return file.getAbsoluteFile(); + } + + + public static boolean isCaseInsensitiveFileSystem() + { + // FileSystem case sensitivity cannot be inferred from OS, for example mac os defaults to case-insensitive but can be configured to be case-sensitive + // Additionally, file root can be mounted to location on a different OS, or it can use S3 + String osName = System.getProperty("os.name").toLowerCase(); + return (osName.startsWith("windows") || osName.startsWith("mac os")); + } + + + /** + * Strips out ".." and "." from the path + */ + public static File resolveFile(File file) + { + File parent = file.getParentFile(); + if (parent == null) + { + return file; + } + if (".".equals(file.getName())) + { + return resolveFile(parent); + } + int dotDotCount = 0; + while ("..".equals(file.getName()) || dotDotCount > 0) + { + if ("..".equals(file.getName())) + { + dotDotCount++; + } + else if (!".".equals(file.getName())) + { + dotDotCount--; + } + if (parent.getParentFile() == null) + { + return parent; + } + file = file.getParentFile(); + parent = file.getParentFile(); + } + // we don't need to use FileUtil.appendName() here + //noinspection SSBasedInspection + return new File(resolveFile(parent), file.getName()); + } + + + // use FileLike createTempDirectoryFileLike() + @Deprecated + public static Path createTempDirectory(@Nullable String prefix) throws IOException + { + if (null != prefix) + legalPathPartThrow(prefix); + return Files.createTempDirectory(prefix).toAbsolutePath(); + } + + + public static FileLike createTempDirectoryFileLike(@Nullable String prefix) throws IOException + { + if (null != prefix) + legalPathPartThrow(prefix); + return new FileSystemLike.Builder(Files.createTempDirectory(prefix).toAbsolutePath()).readwrite().root(); + } + + + public static boolean deleteTempDirectoryFileLike(@NotNull FileLike file) throws IOException + { + if (!file.getPath().isEmpty()) + throw new IllegalArgumentException("Method expects a file returned by createTempDirectoryFileObject"); + if (!file.getFileSystem().canWriteFiles()) + throw new UnauthorizedException(); + return FileUtil.deleteDirectoryContents(file); + } + + + // Under Catalina, it seems to pick \tomcat\temp + // On the web server under Tomcat, it seems to pick c:\Documents and Settings\ITOMCAT_EDI\Local Settings\Temp + public static File getTempDirectory() + { + if (null == _tempDir) + { + try + { + File temp = createTempFile("deleteme", null); + _tempDir = temp.getParentFile().getAbsoluteFile(); + temp.delete(); + } + catch (IOException e) + { + throw new ConfigurationException("The temporary directory (likely " + System.getProperty("java.io.tmpdir") + ") on this server is inaccessible. There may be a file permission issue, or the directory may not exist.", e); + } + } + + return _tempDir; + } + + + public static FileLike getTempDirectoryFileLike() + { + if (null == _tempDirFileLike) + { + _tempDirFileLike = new FileSystemLike.Builder(getTempDirectory()).readwrite().noMemCheck().root(); + } + return _tempDirFileLike; + } + + + // Use this instead of File.createTempFile() (see Issue #46794) + public static File createTempFile(@Nullable String prefix, @Nullable String suffix, File directory) throws IOException + { + if (null != prefix) + legalPathPartThrow(prefix); + if (null != suffix) + legalPathPartThrow(suffix); + return Files.createTempFile(directory.toPath(), prefix, suffix).toFile(); + } + + // Use this instead of File.createTempFile() (see Issue #46794) + public static FileLike createTempFile(@Nullable String prefix, @Nullable String suffix, FileLike directory) throws IOException + { + if (null != prefix) + legalPathPartThrow(prefix); + if (null != suffix) + legalPathPartThrow(suffix); + var path = Files.createTempFile(directory.toNioPathForWrite(), prefix, suffix); + return directory.resolveChild(path.getFileName().toString()); + } + + // Use this instead of File.createTempFile() (see Issue #46794) + public static File createTempFile(@Nullable String prefix, @Nullable String suffix) throws IOException + { + return createTempFile(prefix, suffix, false); + } + + // Use this instead of File.createTempFile() (see Issue #46794) + public static FileLike createTempFileLike(@Nullable String prefix, @Nullable String suffix) throws IOException + { + return FileSystemLike.wrapFile(createTempFile(prefix, suffix, false)); + } + + public static File createTempFile(@Nullable String prefix, @Nullable String suffix, boolean threadLocal) throws IOException + { + if (null != prefix) + legalPathPartThrow(prefix); + if (null != suffix) + legalPathPartThrow(suffix); + var path = Files.createTempFile(prefix, suffix).toAbsolutePath(); + if (threadLocal) + tempPaths.get().add(path); + return path.toFile(); + } + + + private static final boolean isPosix = + FileSystems.getDefault().supportedFileAttributeViews().contains("posix"); + final static private FileAttribute[] tempFileAttributes = new FileAttribute[] { PosixFilePermissions.asFileAttribute(Set.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE)) }; + + public static boolean createTempFile(File file) throws IOException + { + if (file.exists()) + return false; + mkdirs(file.getParentFile()); + if (isPosix) + createFile(file.toPath(), tempFileAttributes); + else + createFile(file.toPath()); + return true; + } + + + public static void deleteTempFile(File f) + { + if (null != f && f.isFile()) + { + if(f.delete()) + tempPaths.get().remove(f.toPath()); + } + } + + + // Converts a document name into keywords appropriate for indexing. We want to retrieve a document named "labkey.txt" + // when the user searches for "labkey.txt", "labkey" or "txt". Lucene analyzers tokenize on whitespace, so this method + // returns the original document name plus the document name with common symbols replaced with spaces. + public static String getSearchKeywords(String documentName) + { + return documentName + " " + documentName.replaceAll("[._-]", " "); + } + + + /** + * Creates a legal, cross-platform file name from the component parts (replacing special characters like colons, semi-colons, slashes, etc + * @param prefix the start of the file name to generate, to be appended with a timestamp suffix + * @param extension the extension (not including the dot) for the desired file name + */ + public static String makeFileNameWithTimestamp(String prefix, @Nullable String extension) + { + return makeLegalName(prefix + "_" + getTimestamp() + (extension == null ? "" : ("." + extension))); + } + + + public static String makeFileNameWithTimestamp(String prefix) + { + return makeLegalName(prefix + "_" + getTimestamp()); + } + + + private static long lastTime = 0; + private static final Object timeLock = new Object(); + + // return a unique time, rounded to the nearest second + private static long currentSeconds() + { + synchronized(timeLock) + { + long sec = HeartBeat.currentTimeMillis(); + sec -= sec % 1000; + lastTime = Math.max(sec, lastTime + 1000); + return lastTime; + } + } + + + public static String getTimestamp() + { + String time = DateUtil.toISO(currentSeconds(), false); + time = time.replace(":", "-"); + time = time.replace(" ", "_"); + + return time; + } + + + private static String indent(LinkedList hasMoreFlags) + { + StringBuilder sb = new StringBuilder(); + for (int i = 0, len = hasMoreFlags.size(); i < len; i++) + { + Boolean hasMore = hasMoreFlags.get(i); + if (i == len-1) + sb.append(hasMore ? "├── " : "└── "); + else + sb.append(hasMore ? "│  " : " "); + } + + return sb.toString(); + } + + + private static void printTree(StringBuilder sb, Path node, LinkedList hasMoreFlags) throws IOException + { + Files.walkFileTree(node, new SimplePathVisitor() + { + @Override + public @NotNull FileVisitResult preVisitDirectory(@NotNull Path dir, @NotNull BasicFileAttributes attrs) throws IOException + { + hasMoreFlags.add(true); + return super.preVisitDirectory(dir, attrs); + } + + @Override + public @NotNull FileVisitResult visitFile(@NotNull Path file, @NotNull BasicFileAttributes attrs) throws IOException + { + appendFileLogEntry(sb, file, hasMoreFlags); + return super.visitFile(file, attrs); + } + + + @Override + public @NotNull FileVisitResult postVisitDirectory(@NotNull Path dir, IOException exc) throws IOException + { + hasMoreFlags.removeLast(); + return super.postVisitDirectory(dir, exc); + } + }); + } + + + private static void appendFileLogEntry(StringBuilder sb, Path node, LinkedList hasMoreFlags) throws IOException + { + if (hasMoreFlags.isEmpty()) + sb.append(node.toAbsolutePath()); + else + sb.append(indent(hasMoreFlags)).append(node.getFileName()); + + if (Files.isDirectory(node)) + sb.append("/"); + else + sb.append(" (").append(FileUtils.byteCountToDisplaySize(Files.size(node))).append(")"); + sb.append("\n"); + } + + + public static String printTree(Path root) throws IOException + { + StringBuilder sb = new StringBuilder(); + printTree(sb, root, new LinkedList<>()); + return sb.toString(); + } + + + public static String getUnencodedAbsolutePath(Container container, Path path) + { + if (!path.isAbsolute()) + return null; + else if (!FileUtil.hasCloudScheme(path)) + return path.toFile().getAbsolutePath(); + else + { + return PageFlowUtil.decode( //URI conversion encodes + getPathStringWithoutAccessId( + CloudStoreService.get().getPathFromUrl(container, path.toString()).toUri() + ) + ); + } + } + + public static File findUniqueFileName(String originalFilename, File dir) + { + if (originalFilename == null || originalFilename.isEmpty()) + { + originalFilename = "[unnamed]"; + } + File file; + int uniquifier = 0; + do + { + String fullName = getAppendedFileName(originalFilename, uniquifier); + file = appendName(dir, fullName); + uniquifier++; + } + while (file.exists()); + return file; + } + + public static FileLike findUniqueFileName(String originalFilename, FileLike dir) + { + if (originalFilename == null || originalFilename.isEmpty()) + { + originalFilename = "[unnamed]"; + } + FileLike file; + int uniquifier = 0; + do + { + String fullName = getAppendedFileName(originalFilename, uniquifier); + file = dir.resolveChild(fullName); + uniquifier++; + } + while (file.exists()); + return file; + } + + public static String getAppendedFileName(String originalFilename, int uniquifier) + { + String prefix = originalFilename; + String suffix = ""; + + int index = originalFilename.indexOf('.'); + if (index != -1) + { + prefix = originalFilename.substring(0, index); + suffix = originalFilename.substring(index); + } + + return prefix + (uniquifier == 0 ? "" : "-" + uniquifier) + suffix; + } + + + /* If you have a write once, read once text file/stream, you can use this class. + * It wraps the calls to create and delete a temp file, and also will use + * direct to cache the first portion of the file to avoid hitting the + * file system if the file is smaller. + * + * The caller needs to call close() on this object or the Reader returned + * by getReader(). Calling close on both is OK. + */ + public static class TempTextFileWrapper implements Closeable + { + final int characterLimitInMemory; + final ByteBuffer _byteBuffer; + final CharBuffer _charBuffer; + FileWriter _fileWriter = null; + FileReader _fileReader = null; + File _tmpFile = null; + boolean closed = false; // so we can ignore multiple calls to close + + Writer _writer = null; + Reader _reader = null; + + public TempTextFileWrapper(int characterLimitInMemory) + { + this.characterLimitInMemory = characterLimitInMemory; + this._byteBuffer = ByteBuffer.allocate(characterLimitInMemory * 2); + this._charBuffer = _byteBuffer.asCharBuffer(); + } + + public TempTextFileWrapper(CharBuffer charBuffer) + { + this.characterLimitInMemory = charBuffer.capacity(); + this._byteBuffer = null; + this._charBuffer = charBuffer; + } + + + public Writer getWriter() + { + if (null != _writer || closed) + throw new IllegalStateException(closed ? "TempTextFileWrapper is closed" : "getWriter() called twice"); + + // CONSIDER ByteBuffer.allocateDirect(), for now caller can pass in a direct buffer if desired + _writer = new Writer() + { + boolean closed = false; + + @Override + public void write(char @NotNull [] cbuf, int off, int len) throws IOException + { + if (closed) + throw new IOException("Writer is closed"); + if (_charBuffer.remaining() > 0) + { + var l = Math.min(_charBuffer.remaining(), len); + _charBuffer.put(cbuf, off, l); + if (l == len) + return; + off += l; + len -= l; + } + if (null == _fileWriter) + { + assert null == _tmpFile; + _tmpFile = FileUtil.createTempFile("tika", ".tmp.txt"); + _fileWriter = new FileWriter(_tmpFile, StringUtilsLabKey.DEFAULT_CHARSET); + } + _fileWriter.write(cbuf, off, len); + } + + @Override + public void flush() throws IOException + { + if (null != _fileWriter) + _fileWriter.flush(); + } + + @Override + public void close() throws IOException + { + if (null != _fileWriter) + { + _fileWriter.flush(); + _fileWriter.close(); + } + _fileWriter = null; + closed = true; + } + }; + return _writer; + } + + private void _prepareToRead() + { + if (null != _writer) + { + IOUtils.closeQuietly(_writer); + _writer = null; + _charBuffer.flip(); + } + } + + public Reader getReader() + { + if (null != _reader || closed) + throw new IllegalStateException(closed ? "TempTextFileWrapper is closed" : "getReader() called twice"); + + _reader = new Reader() + { + @Override + public int read(char @NotNull [] cbuf, int off, int len) throws IOException + { + _prepareToRead(); + + if (0 < _charBuffer.remaining()) + { + var l = Math.min(len, _charBuffer.remaining()); + _charBuffer.get(cbuf, off, l); + return l; + } + if (null == _fileReader && null != _tmpFile) + _fileReader = new FileReader(_tmpFile, StringUtilsLabKey.DEFAULT_CHARSET); + if (null == _fileReader) + return -1; + return _fileReader.read(cbuf, off, len); + } + + @Override + public void close() throws IOException + { + TempTextFileWrapper.this.close(); + } + }; + return _reader; + } + + public String getSummary(int length) + { + _prepareToRead(); + var l = Math.min(_charBuffer.limit(), length); + return _charBuffer.slice(0,l).toString(); + } + + @Override + public void close() throws IOException + { + if (!closed) + { + closed = true; + if (null != _fileReader) + IOUtils.closeQuietly(_fileReader); + _fileReader = null; + if (null != _fileWriter) + IOUtils.closeQuietly(_fileWriter); + _fileWriter = null; + if (null != _tmpFile) + FileUtil.deleteTempFile(_tmpFile); + _tmpFile = null; + if (null != _byteBuffer && _byteBuffer.isDirect()) + LabKeyByteBufferCleaner.clean(_byteBuffer); + } + } + } + + + @SuppressWarnings("SSBasedInspection") + public static class TestCase extends Assert + { + private static final File ROOT; + + static + { + File f = new File(".").getAbsoluteFile(); + while (f.getParentFile() != null) + { + f = f.getParentFile(); + } + ROOT = f; + } + + @Test + public void testStandardResolve() + { + assertEquals(new File(ROOT, "test/path/sub"), resolveFile(new File(ROOT, "test/path/sub"))); + assertEquals(new File(ROOT, "test"), resolveFile(new File(ROOT, "test"))); + assertEquals(new File(ROOT, "test/path/file.ext"), resolveFile(new File(ROOT, "test/path/file.ext"))); + } + + @Test + public void testDotResolve() + { + assertEquals(new File(ROOT, "test/path/sub"), resolveFile(new File(ROOT, "test/path/./sub"))); + assertEquals(new File(ROOT, "test"), resolveFile(new File(ROOT, "./test"))); + assertEquals(new File(ROOT, "test/path/file.ext"), resolveFile(new File(ROOT, "test/path/file.ext/."))); + } + + @Test + public void testDotDotResolve() + { + assertEquals(ROOT, resolveFile(new File(ROOT, ".."))); + assertEquals(new File(ROOT, "test/sub"), resolveFile(new File(ROOT, "test/path/../sub"))); + assertEquals(new File(ROOT, "test/sub2"), resolveFile(new File(ROOT, "test/path/../sub/../sub2"))); + assertEquals(new File(ROOT, "test"), resolveFile(new File(ROOT, "test/path/sub/../.."))); + assertEquals(new File(ROOT, "sub"), resolveFile(new File(ROOT, "test/path/../../sub"))); + assertEquals(new File(ROOT, "sub2"), resolveFile(new File(ROOT, "test/path/../../sub/../sub2"))); + assertEquals(new File(ROOT, "sub2"), resolveFile(new File(ROOT, "test/path/.././../sub/../sub2"))); + assertEquals(new File(ROOT, "sub2"), resolveFile(new File(ROOT, "test/path/.././../sub/../../sub2"))); + assertEquals(new File(ROOT, "sub2"), resolveFile(new File(ROOT, "a/test/path/.././../sub/../../sub2"))); + assertEquals(new File(ROOT, "b/sub2"), resolveFile(new File(ROOT, "b/a/test/path/.././../sub/../../sub2"))); + assertEquals(ROOT, resolveFile(new File(ROOT, "test/path/../../../.."))); + assertEquals(new File(ROOT, "test/sub"), resolveFile(new File(ROOT, "../../../../test/sub"))); + assertEquals(new File(ROOT, "test"), resolveFile(new File(ROOT, "../test"))); + assertEquals(new File(ROOT, "test/path"), resolveFile(new File(ROOT, "test/path/file.ext/.."))); + assertEquals(new File(ROOT, "folder"), resolveFile(new File(ROOT, ".././../folder"))); + assertEquals(new File(ROOT, "b"), resolveFile(new File(ROOT, "folder/a/.././../b"))); + } + + @Test + public void testUriToString() + { + assertEquals("converted file:/// URI does not match expected string", "file:///data/myfile.txt", uriToString(URI.create("file:///data/myfile.txt"))); + assertEquals("converted file:/ URI does not match expected string", "file:///data/myfile.txt", uriToString(URI.create("file:/data/myfile.txt"))); + } + + @Test + public void testNormalizeURI() + { + assertEquals("file:/// uri not as expected","file:///my/triple/file/path", uriToString(URI.create("file:///my/triple/file/path"))); + assertEquals("file:/// uri with drive letter not as expected","file:///C:/my/triple/file/path", uriToString(URI.create("file:///C:/my/triple/file/path"))); + assertEquals("file:/ uri not conformed to file:///","file:///my/single/file/path", uriToString(URI.create("file:/my/single/file/path"))); + assertEquals("file:/ with drive letter not conformed to file:///","file:///C:/my/single/file/path", uriToString(URI.create("file:/C:/my/single/file/path"))); + assertEquals("File uri with host not as expected", "file://localhost:8080/my/host/file/path", uriToString(URI.create("file://localhost:8080/my/host/file/path"))); + assertEquals("Schemed URI not as expected","http://localhost:8080/my/triple/file/path?query=abcd#anchor", uriToString(URI.create("http://localhost:8080/my/triple/file/path?query=abcd#anchor"))); + } + + @Test + public void testTempFileWrapper() throws IOException + { + try + { + FileUtil.startRequest(); + var sonnet = """ + From fairest creatures we desire increase, + That thereby beauty's rose might never die, + But as the riper should by time decease, + His tender heir might bear his memory: + But thou contracted to thine own bright eyes, + Feed'st thy light's flame with self-substantial fuel, + Making a famine where abundance lies, + Thy self thy foe, to thy sweet self too cruel: + Thou that art now the world's fresh ornament, + And only herald to the gaudy spring, + Within thine own bud buriest thy content, + And tender churl mak'st waste in niggarding: + Pity the world, or else this glutton be, + To eat the world's due, by the grave and thee. + """; + try (var tf = new TempTextFileWrapper(64)) + { + var w = tf.getWriter(); + for (var l : StringUtils.split(sonnet, '\n')) + w.write(l + "\n"); + var r = new BufferedReader(tf.getReader()); + String l, lines = ""; + while (null != (l = r.readLine())) + lines = lines + l + "\n"; + assertEquals(sonnet.trim(), lines.trim()); + assertEquals(sonnet.substring(0, 64), tf.getSummary(100)); + } + try (var tf = new TempTextFileWrapper(900)) + { + var w = tf.getWriter(); + for (var l : StringUtils.split(sonnet, '\n')) + w.write(l + "\n"); + var r = new BufferedReader(tf.getReader()); + String l, lines = ""; + while (null != (l = r.readLine())) + lines = lines + l + "\n"; + assertEquals(sonnet.trim(), lines.trim()); + assertEquals(sonnet.substring(0, 100), tf.getSummary(100)); + } + } + finally + { + // make sure we did not leave any temp files lying around + FileUtil.stopRequest(); + } + } + + @Test + public void testMakeLegalName() + { + assertEquals("__null__", makeLegalName(null)); + assertEquals("__empty__", makeLegalName("")); + assertEquals("_", makeLegalName(" ")); + assertEquals(" _", makeLegalName(" ")); + assertEquals("_", makeLegalName(".")); + assertEquals("._", makeLegalName("..")); + assertEquals("foo", makeLegalName("foo")); + assertEquals("foo_", makeLegalName("foo ")); + assertEquals("foo_", makeLegalName("foo.")); + assertEquals("foo -", makeLegalName("foo -")); + assertEquals("foo _arg", makeLegalName("foo -arg")); + assertEquals("foo _arg-arg", makeLegalName("foo -arg-arg")); + assertEquals("foo _arg _arg2", makeLegalName("foo -arg -arg2")); + + // These are allowed. Verify they don't get changed + assertEquals("a", makeLegalName("a")); + assertEquals("a-b", makeLegalName("a-b")); + assertEquals("a - b", makeLegalName("a - b")); + assertEquals("a- b", makeLegalName("a- b")); + assertEquals("a--b", makeLegalName("a--b")); + assertEquals("a -- b", makeLegalName("a -- b")); + assertEquals("a-- b", makeLegalName("a-- b")); + + // These aren't allowed. Make sure they get changed + assertEquals("_a", makeLegalName("-a")); + assertEquals(" _a", makeLegalName(" -a")); + assertEquals("a _b", makeLegalName("a -b")); + assertEquals("_-a", makeLegalName("--a")); + assertEquals(" _-a", makeLegalName(" --a")); + assertEquals("a _-b", makeLegalName("a --b")); + assertEquals("a _--b", makeLegalName("a ---b")); + + assertEquals(StringUtils.repeat('_', ILLEGAL_CHARS.length), makeLegalName(new String(ILLEGAL_CHARS))); + assertEquals(StringUtils.repeat('_', 255), makeLegalName(StringUtils.repeat(new String(ILLEGAL_CHARS), 50))); + assertEquals(StringUtils.repeat('.', 254) + "_", makeLegalName(StringUtils.repeat('.', 500))); + assertEquals(StringUtils.repeat(' ', 254) + "_", makeLegalName(StringUtils.repeat(' ', 500))); + } + + @Test + public void testAllowedFileName() + { + //Test Setup + Mockery _context = new Mockery(); + _context.setImposteriser(ClassImposteriser.INSTANCE); + AppProps mockProps = _context.mock(AppProps.class); + _context.checking(new Expectations(){{ + allowing(mockProps).isInvalidFilenameBlocked(); + will(returnValue(true)); + }}); + + assertNull(isAllowedFileName("a", false, mockProps)); + assertNull(isAllowedFileName("a-b", false, mockProps)); + assertNull(isAllowedFileName("a - b", false, mockProps)); + assertNull(isAllowedFileName("a- b", false, mockProps)); + assertNull(isAllowedFileName("a--b", false, mockProps)); + assertNull(isAllowedFileName("a -- b", false, mockProps)); + assertNull(isAllowedFileName("a-- b", false, mockProps)); + assertNull(isAllowedFileName("a b", false, mockProps)); + assertNull(isAllowedFileName("a%b", false, mockProps)); + assertNull(isAllowedFileName("a$b", false, mockProps)); + assertNull(isAllowedFileName("%ab", false, mockProps)); + + assertNotNull(isAllowedFileName(null, false, mockProps)); + assertNotNull(isAllowedFileName("", false, mockProps)); + assertNotNull(isAllowedFileName(" ", false, mockProps)); + assertNotNull(isAllowedFileName("a\tb", false, mockProps)); + assertNotNull(isAllowedFileName("-a", false, mockProps)); + assertNotNull(isAllowedFileName(" -a", false, mockProps)); + assertNotNull(isAllowedFileName("a -b", false, mockProps)); + assertNotNull(isAllowedFileName("--a", false, mockProps)); + assertNotNull(isAllowedFileName(" --a", false, mockProps)); + assertNotNull(isAllowedFileName("a --b", false, mockProps)); + assertNotNull(isAllowedFileName("a ---b", false, mockProps)); + assertNotNull(isAllowedFileName("a/b", false, mockProps)); + assertNotNull(isAllowedFileName("a\b", false, mockProps)); + assertNotNull(isAllowedFileName("a:b", false, mockProps)); + assertNotNull(isAllowedFileName("a*b", false, mockProps)); + assertNotNull(isAllowedFileName("a?b", false, mockProps)); + assertNotNull(isAllowedFileName("ab", false, mockProps)); + assertNotNull(isAllowedFileName("a\"b", false, mockProps)); + assertNotNull(isAllowedFileName("a|b", false, mockProps)); + assertNotNull(isAllowedFileName("a`b", false, mockProps)); + assertNotNull(isAllowedFileName("$ab", false, mockProps)); + assertNotNull(isAllowedFileName("-ab", false, mockProps)); + assertNotNull(isAllowedFileName("a`b", false, mockProps)); + } + + @Test + public void testAcceptableExtensions() + { + List allowedExtensions = Arrays.asList( + ".1", + ".txt", + ".tar", + ".tar.gz", + ".a_v", + ".xlsx", + ".l-()[]{}1☃"); + + //Test Setup + Mockery _context = new Mockery(); + _context.setImposteriser(ClassImposteriser.INSTANCE); + AppProps mockProps = _context.mock(AppProps.class); + _context.checking(new Expectations(){{ + allowing(mockProps).getAllowedExtensions(); + will(returnValue(allowedExtensions)); + }}); + + + assertNull("Extension should be allowed", checkExtension("test.txt", mockProps)); + assertNull("Multiple extension should be allowed", checkExtension("archive.tar.gz", mockProps)); + assertNull("Case-insensitive extension should be allowed", checkExtension("archive.TaR.Gz", mockProps)); + assertNull("Special characters aren't escaped properly", checkExtension("my test.l-()[]{}1☃", mockProps)); + assertNull("Numeric extension should be allowed", checkExtension("test.1", mockProps)); + assertNotNull("Multiple extension matched when it shouldn't", checkExtension("tar.gz", mockProps)); + assertNotNull("Matched unlist extension", checkExtension("my test.notListed", mockProps)); + assertNotNull("Combined multiple extension matched incorrectly", checkExtension("multi.a_v.tar", mockProps)); + assertNotNull("Multi-multi extension matched unexpectedly", checkExtension("multi.not.tar.gz", mockProps)); + assertNotNull("No extension matched unexpectedly", checkExtension("No extension", mockProps)); + } + + @Test + public void testNoAcceptableExtensions() + { + List allowedExtensions = Collections.emptyList(); + + //Test Setup + Mockery _context; + _context = new Mockery(); + _context.setImposteriser(ClassImposteriser.INSTANCE); + AppProps mockProps = _context.mock(AppProps.class); + _context.checking(new Expectations(){{ + allowing(mockProps).getAllowedExtensions(); + will(returnValue(allowedExtensions)); + }}); + + assertNull("Special characters aren't escaped properly", checkExtension("my test.l-()[]{}1☃", mockProps)); + assertNull("Unlisted extension should be allowed, but wasn't", checkExtension("my test.notListed", mockProps)); + assertNull("Combined extension should be allowed, but wasn't", checkExtension("multi.tar.a_v", mockProps)); + assertNull("No extension should be allowed, but wasn't", checkExtension("No extension", mockProps)); + assertNull("Numeric extension should be allowed", checkExtension("test.1", mockProps)); + } + + @Test + public void testGetAppendedFileName() + { + String originalFilename = "test.txt"; + assertEquals("test.txt", getAppendedFileName(originalFilename, 0)); + assertEquals("test-1.txt", getAppendedFileName(originalFilename, 1)); + assertEquals("test-2.txt", getAppendedFileName(originalFilename, 2)); + } + } +} diff --git a/api/src/org/labkey/api/util/PossiblyGZIPpedFileInputStreamFactory.java b/api/src/org/labkey/api/util/PossiblyGZIPpedFileInputStreamFactory.java index cf292b3d998..ec063643fc1 100644 --- a/api/src/org/labkey/api/util/PossiblyGZIPpedFileInputStreamFactory.java +++ b/api/src/org/labkey/api/util/PossiblyGZIPpedFileInputStreamFactory.java @@ -1,60 +1,60 @@ -/* - * Copyright (c) 2010-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.api.util; - -import org.labkey.vfs.FileLike; - -import java.io.IOException; -import java.io.InputStream; -import java.util.zip.GZIPInputStream; - - -/** - * examine a file and access as a gzip file if appropriate - * - * note this would be tidier as "public class PossiblyGZIPpedFileInputStream extends InputStream" but - * I worry about performance since the read() call gets hit a lot and would rather not insert - * another deeply loop nested function call PossiblyGZIPpedFileInputStream.read->_iStream.read() - * - * bpratt, Insilicos - * - */ -abstract public class PossiblyGZIPpedFileInputStreamFactory -{ - private static final int STREAM_BUFFER_SIZE = 128 * 1024; - - static public InputStream getStream(FileLike f) throws IOException - { - InputStream fis = f.openInputStream(); - try - { - return new GZIPInputStream(fis, STREAM_BUFFER_SIZE); - } - catch (java.io.IOException e) - { - // not a gzip file - reopen since we ate a couple of bytes - try - { - fis.close(); - } - catch (java.io.IOException ee) - { - // seems unlikely at this point - } - return f.openInputStream(); - } - } -} +/* + * Copyright (c) 2010-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.api.util; + +import org.labkey.vfs.FileLike; + +import java.io.IOException; +import java.io.InputStream; +import java.util.zip.GZIPInputStream; + + +/** + * examine a file and access as a gzip file if appropriate + * + * note this would be tidier as "public class PossiblyGZIPpedFileInputStream extends InputStream" but + * I worry about performance since the read() call gets hit a lot and would rather not insert + * another deeply loop nested function call PossiblyGZIPpedFileInputStream.read->_iStream.read() + * + * bpratt, Insilicos + * + */ +abstract public class PossiblyGZIPpedFileInputStreamFactory +{ + private static final int STREAM_BUFFER_SIZE = 128 * 1024; + + static public InputStream getStream(FileLike f) throws IOException + { + InputStream fis = f.openInputStream(); + try + { + return new GZIPInputStream(fis, STREAM_BUFFER_SIZE); + } + catch (java.io.IOException e) + { + // not a gzip file - reopen since we ate a couple of bytes + try + { + fis.close(); + } + catch (java.io.IOException ee) + { + // seems unlikely at this point + } + return f.openInputStream(); + } + } +} diff --git a/api/src/org/labkey/api/writer/ZipUtil.java b/api/src/org/labkey/api/writer/ZipUtil.java index 9af28cd2610..5bafbf1c8c4 100644 --- a/api/src/org/labkey/api/writer/ZipUtil.java +++ b/api/src/org/labkey/api/writer/ZipUtil.java @@ -1,204 +1,204 @@ -/* - * Copyright (c) 2009-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.api.writer; - -import org.apache.logging.log4j.Logger; -import org.jetbrains.annotations.Nullable; -import org.labkey.api.util.CheckedInputStream; -import org.labkey.api.util.FileUtil; -import org.labkey.api.util.PageFlowUtil; -import org.labkey.api.util.ResponseHelper; - -import jakarta.servlet.http.HttpServletResponse; -import org.labkey.vfs.FileLike; -import org.labkey.vfs.FileSystemLike; - -import java.io.BufferedInputStream; -import java.io.BufferedOutputStream; -import java.io.File; -import java.io.FileInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.nio.file.FileAlreadyExistsException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; -import java.util.stream.Collectors; -import java.util.zip.ZipEntry; -import java.util.zip.ZipInputStream; -import java.util.zip.ZipOutputStream; - -/** - * User: adam - * Date: Apr 28, 2009 - * Time: 2:00:23 PM - */ -public class ZipUtil -{ - public static List unzipToDirectory(Path zipFile, Path unzipDir) throws IOException - { - return unzipToDirectory(zipFile, unzipDir, null); - } - - public static List unzipToDirectory(FileLike zipFile, FileLike unzipDir) throws IOException - { - List paths = unzipToDirectory(zipFile.toNioPathForRead(), unzipDir.toNioPathForWrite(), null); - File rootFile = unzipDir.toNioPathForRead().toFile(); - List result = new ArrayList<>(); - for (Path path : paths) - { - result.add(FileSystemLike.wrapFile(rootFile, path.toFile())); - } - return result; - } - - @Deprecated - public static List unzipToDirectory(File zipFile, File unzipDir, @Nullable Logger log) throws IOException - { - return unzipToDirectory(zipFile.toPath(), unzipDir.toPath(), log).stream().map(Path::toFile).collect(Collectors.toList()); - } - - public static List unzipToDirectory(Path zipFile, Path unzipDir, @Nullable Logger log) throws IOException - { - return unzipToDirectory(zipFile, unzipDir, log, false); - } - - // Unzip an archive to the specified directory; log each file if Logger is non-null - public static List unzipToDirectory(Path zipFile, Path unzipDir, @Nullable Logger log, boolean includeFolder) throws IOException - { - try (InputStream is = Files.newInputStream(zipFile)) - { - return unzipToDirectory(is, unzipDir, log, includeFolder); - } - } - - // Unzip a zipped input stream to the specified directory - public static List unzipToDirectory(InputStream is, Path unzipDir) throws IOException - { - return unzipToDirectory(is, unzipDir, null); - } - - public static List unzipToDirectory(InputStream is, Path unzipDir, @Nullable Logger log) throws IOException - { - return unzipToDirectory(is, unzipDir, log, false); - } - - // Unzips an input stream to the specified directory; logs each file if Logger is non-null. - public static List unzipToDirectory(InputStream is, Path unzipDir, @Nullable Logger log, boolean includeFolder) throws IOException - { - List files = new ArrayList<>(); - - // ZipInputStream.close() should close InputStream is. Use a CheckedInputStream to be sure. - try (ZipInputStream zis = new ZipInputStream(new CheckedInputStream(is))) - { - ZipEntry entry; - - while (null != (entry = zis.getNextEntry())) - { - Path destFile = unzipDir.resolve(entry.getName()); - - //Verify that the entry target doesn't attempt to push data outside the unzipDir by resolving '..' - if (!destFile.toAbsolutePath().normalize().startsWith(unzipDir.toAbsolutePath().normalize().toString())) { - throw new IOException("Zip entry is outside of the target dir. \nDest file: " + destFile + " \nUnzip dir: " + unzipDir + " \nZip entry: " + entry.getName()); - } - - if (entry.isDirectory()) - { - FileUtil.createDirectories(destFile); - if (!Files.isDirectory(destFile)) - { - throw new IOException("Failed to create directory: " + destFile.getFileName().toString()); - } - if (includeFolder) - files.add(destFile); - continue; - } - - if (null != log) - log.info("Expanding " + entry.getName()); - - FileUtil.createDirectories(destFile.getParent()); - if (Files.exists(destFile)) - { - throw new IOException("File already exists: " + destFile.getFileName().toString()); - } - - try - { - FileUtil.createFile(destFile); - } - catch (FileAlreadyExistsException e) - { - throw new IOException("Failed to extract file: " + destFile.getFileName(), e); - } - - // We can't close() this, otherwise zis will get closed - BufferedInputStream bis = new BufferedInputStream(zis); - - try (BufferedOutputStream os = new BufferedOutputStream(Files.newOutputStream(destFile))) - { - FileUtil.copyData(bis, os); - } - - files.add(destFile); - zis.closeEntry(); - } - } - - return files; - } - - - public static void zipToStream(HttpServletResponse response, File file, boolean preZipped) throws IOException - { - response.setContentType("application/zip"); - ResponseHelper.setContentDisposition(response, ResponseHelper.ContentDispositionType.attachment, file.getName() + (preZipped ? "" : ".zip")); - - if (preZipped) - { - PageFlowUtil.streamFile(response, file, true); - return; - } - - try (ZipOutputStream zos = new ZipOutputStream(response.getOutputStream())) - { - addResource(file, zos); - } - } - - - private static void addResource(File file, ZipOutputStream out) throws IOException - { - if (file.listFiles() != null) - { - for (File f : file.listFiles()) - { - addResource(f, out); - } - } - else - { - ZipEntry entry = new ZipEntry(file.getName()); - out.putNextEntry(entry); - - try (InputStream in = new FileInputStream(file)) - { - FileUtil.copyData(in, out); - } - } - } -} +/* + * Copyright (c) 2009-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.api.writer; + +import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.Nullable; +import org.labkey.api.util.CheckedInputStream; +import org.labkey.api.util.FileUtil; +import org.labkey.api.util.PageFlowUtil; +import org.labkey.api.util.ResponseHelper; + +import jakarta.servlet.http.HttpServletResponse; +import org.labkey.vfs.FileLike; +import org.labkey.vfs.FileSystemLike; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; +import java.util.zip.ZipOutputStream; + +/** + * User: adam + * Date: Apr 28, 2009 + * Time: 2:00:23 PM + */ +public class ZipUtil +{ + public static List unzipToDirectory(Path zipFile, Path unzipDir) throws IOException + { + return unzipToDirectory(zipFile, unzipDir, null); + } + + public static List unzipToDirectory(FileLike zipFile, FileLike unzipDir) throws IOException + { + List paths = unzipToDirectory(zipFile.toNioPathForRead(), unzipDir.toNioPathForWrite(), null); + File rootFile = unzipDir.toNioPathForRead().toFile(); + List result = new ArrayList<>(); + for (Path path : paths) + { + result.add(FileSystemLike.wrapFile(rootFile, path.toFile())); + } + return result; + } + + @Deprecated + public static List unzipToDirectory(File zipFile, File unzipDir, @Nullable Logger log) throws IOException + { + return unzipToDirectory(zipFile.toPath(), unzipDir.toPath(), log).stream().map(Path::toFile).collect(Collectors.toList()); + } + + public static List unzipToDirectory(Path zipFile, Path unzipDir, @Nullable Logger log) throws IOException + { + return unzipToDirectory(zipFile, unzipDir, log, false); + } + + // Unzip an archive to the specified directory; log each file if Logger is non-null + public static List unzipToDirectory(Path zipFile, Path unzipDir, @Nullable Logger log, boolean includeFolder) throws IOException + { + try (InputStream is = Files.newInputStream(zipFile)) + { + return unzipToDirectory(is, unzipDir, log, includeFolder); + } + } + + // Unzip a zipped input stream to the specified directory + public static List unzipToDirectory(InputStream is, Path unzipDir) throws IOException + { + return unzipToDirectory(is, unzipDir, null); + } + + public static List unzipToDirectory(InputStream is, Path unzipDir, @Nullable Logger log) throws IOException + { + return unzipToDirectory(is, unzipDir, log, false); + } + + // Unzips an input stream to the specified directory; logs each file if Logger is non-null. + public static List unzipToDirectory(InputStream is, Path unzipDir, @Nullable Logger log, boolean includeFolder) throws IOException + { + List files = new ArrayList<>(); + + // ZipInputStream.close() should close InputStream is. Use a CheckedInputStream to be sure. + try (ZipInputStream zis = new ZipInputStream(new CheckedInputStream(is))) + { + ZipEntry entry; + + while (null != (entry = zis.getNextEntry())) + { + Path destFile = unzipDir.resolve(entry.getName()); + + //Verify that the entry target doesn't attempt to push data outside the unzipDir by resolving '..' + if (!destFile.toAbsolutePath().normalize().startsWith(unzipDir.toAbsolutePath().normalize().toString())) { + throw new IOException("Zip entry is outside of the target dir. \nDest file: " + destFile + " \nUnzip dir: " + unzipDir + " \nZip entry: " + entry.getName()); + } + + if (entry.isDirectory()) + { + FileUtil.createDirectories(destFile); + if (!Files.isDirectory(destFile)) + { + throw new IOException("Failed to create directory: " + destFile.getFileName().toString()); + } + if (includeFolder) + files.add(destFile); + continue; + } + + if (null != log) + log.info("Expanding " + entry.getName()); + + FileUtil.createDirectories(destFile.getParent()); + if (Files.exists(destFile)) + { + throw new IOException("File already exists: " + destFile.getFileName().toString()); + } + + try + { + FileUtil.createFile(destFile); + } + catch (FileAlreadyExistsException e) + { + throw new IOException("Failed to extract file: " + destFile.getFileName(), e); + } + + // We can't close() this, otherwise zis will get closed + BufferedInputStream bis = new BufferedInputStream(zis); + + try (BufferedOutputStream os = new BufferedOutputStream(Files.newOutputStream(destFile))) + { + FileUtil.copyData(bis, os); + } + + files.add(destFile); + zis.closeEntry(); + } + } + + return files; + } + + + public static void zipToStream(HttpServletResponse response, File file, boolean preZipped) throws IOException + { + response.setContentType("application/zip"); + ResponseHelper.setContentDisposition(response, ResponseHelper.ContentDispositionType.attachment, file.getName() + (preZipped ? "" : ".zip")); + + if (preZipped) + { + PageFlowUtil.streamFile(response, file, true); + return; + } + + try (ZipOutputStream zos = new ZipOutputStream(response.getOutputStream())) + { + addResource(file, zos); + } + } + + + private static void addResource(File file, ZipOutputStream out) throws IOException + { + if (file.listFiles() != null) + { + for (File f : file.listFiles()) + { + addResource(f, out); + } + } + else + { + ZipEntry entry = new ZipEntry(file.getName()); + out.putNextEntry(entry); + + try (InputStream in = new FileInputStream(file)) + { + FileUtil.copyData(in, out); + } + } + } +} diff --git a/experiment/src/org/labkey/experiment/controllers/exp/ExperimentController.java b/experiment/src/org/labkey/experiment/controllers/exp/ExperimentController.java index b318b2f99c9..74d36678560 100644 --- a/experiment/src/org/labkey/experiment/controllers/exp/ExperimentController.java +++ b/experiment/src/org/labkey/experiment/controllers/exp/ExperimentController.java @@ -1,8371 +1,8371 @@ -/* - * Copyright (c) 2008-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.labkey.experiment.controllers.exp; - -import au.com.bytecode.opencsv.CSVWriter; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import jakarta.servlet.http.HttpSession; -import org.apache.commons.collections4.MapUtils; -import org.apache.commons.lang3.StringUtils; -import org.apache.commons.lang3.Strings; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.apache.poi.ss.usermodel.Workbook; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; -import org.labkey.api.action.ApiJsonWriter; -import org.labkey.api.action.ApiResponse; -import org.labkey.api.action.ApiSimpleResponse; -import org.labkey.api.action.ApiUsageException; -import org.labkey.api.action.ExportAction; -import org.labkey.api.action.FormHandlerAction; -import org.labkey.api.action.FormViewAction; -import org.labkey.api.action.HasViewContext; -import org.labkey.api.action.Marshal; -import org.labkey.api.action.Marshaller; -import org.labkey.api.action.MutatingApiAction; -import org.labkey.api.action.QueryViewAction; -import org.labkey.api.action.ReadOnlyApiAction; -import org.labkey.api.action.ReturnUrlForm; -import org.labkey.api.action.SimpleApiJsonForm; -import org.labkey.api.action.SimpleErrorView; -import org.labkey.api.action.SimpleResponse; -import org.labkey.api.action.SimpleViewAction; -import org.labkey.api.action.SpringActionController; -import org.labkey.api.assay.AssayFileWriter; -import org.labkey.api.assay.AssayProtocolSchema; -import org.labkey.api.assay.AssayProvider; -import org.labkey.api.assay.AssayService; -import org.labkey.api.assay.actions.UploadWizardAction; -import org.labkey.api.assay.security.DesignAssayPermission; -import org.labkey.api.attachments.AttachmentParent; -import org.labkey.api.attachments.AttachmentService; -import org.labkey.api.attachments.BaseDownloadAction; -import org.labkey.api.audit.AbstractAuditTypeProvider; -import org.labkey.api.audit.AuditLogService; -import org.labkey.api.audit.DetailedAuditTypeEvent; -import org.labkey.api.audit.SampleTimelineAuditEvent; -import org.labkey.api.audit.TransactionAuditProvider; -import org.labkey.api.collections.CaseInsensitiveHashMap; -import org.labkey.api.collections.CaseInsensitiveHashSet; -import org.labkey.api.collections.LongHashMap; -import org.labkey.api.data.ActionButton; -import org.labkey.api.data.BaseColumnInfo; -import org.labkey.api.data.ButtonBar; -import org.labkey.api.data.ColumnInfo; -import org.labkey.api.data.CompareType; -import org.labkey.api.data.Container; -import org.labkey.api.data.ContainerFilter; -import org.labkey.api.data.ContainerManager; -import org.labkey.api.data.DataRegion; -import org.labkey.api.data.DataRegionSelection; -import org.labkey.api.data.DbSchema; -import org.labkey.api.data.DbScope; -import org.labkey.api.data.DisplayColumn; -import org.labkey.api.data.ExcelWriter; -import org.labkey.api.data.MenuButton; -import org.labkey.api.data.NameGenerator; -import org.labkey.api.data.SQLFragment; -import org.labkey.api.data.ShowRows; -import org.labkey.api.data.SimpleDisplayColumn; -import org.labkey.api.data.SimpleFilter; -import org.labkey.api.data.Sort; -import org.labkey.api.data.SqlSelector; -import org.labkey.api.data.TSVWriter; -import org.labkey.api.data.TableInfo; -import org.labkey.api.data.TableSelector; -import org.labkey.api.dataiterator.DataIteratorContext; -import org.labkey.api.exp.AbstractParameter; -import org.labkey.api.exp.DeleteForm; -import org.labkey.api.exp.DuplicateMaterialException; -import org.labkey.api.exp.ExperimentDataHandler; -import org.labkey.api.exp.ExperimentException; -import org.labkey.api.exp.ExperimentRunForm; -import org.labkey.api.exp.ExperimentRunListView; -import org.labkey.api.exp.ExperimentRunType; -import org.labkey.api.exp.Identifiable; -import org.labkey.api.exp.Lsid; -import org.labkey.api.exp.LsidManager; -import org.labkey.api.exp.LsidType; -import org.labkey.api.exp.ObjectProperty; -import org.labkey.api.exp.OntologyManager; -import org.labkey.api.exp.ProtocolApplicationParameter; -import org.labkey.api.exp.XarContext; -import org.labkey.api.exp.api.DataClassDomainKindProperties; -import org.labkey.api.exp.api.ExpData; -import org.labkey.api.exp.api.ExpDataClass; -import org.labkey.api.exp.api.ExpExperiment; -import org.labkey.api.exp.api.ExpLineageOptions; -import org.labkey.api.exp.api.ExpMaterial; -import org.labkey.api.exp.api.ExpMaterialRunInput; -import org.labkey.api.exp.api.ExpObject; -import org.labkey.api.exp.api.ExpProtocol; -import org.labkey.api.exp.api.ExpProtocolApplication; -import org.labkey.api.exp.api.ExpRun; -import org.labkey.api.exp.api.ExpRunAttachmentParent; -import org.labkey.api.exp.api.ExpRunEditor; -import org.labkey.api.exp.api.ExpRunItem; -import org.labkey.api.exp.api.ExpSampleType; -import org.labkey.api.exp.api.ExperimentJSONConverter; -import org.labkey.api.exp.api.ExperimentService; -import org.labkey.api.exp.api.ExperimentUrls; -import org.labkey.api.exp.api.NameExpressionOptionService; -import org.labkey.api.exp.api.ResolveLsidsForm; -import org.labkey.api.exp.api.SampleTypeDomainKind; -import org.labkey.api.exp.api.SampleTypeService; -import org.labkey.api.exp.list.ListService; -import org.labkey.api.exp.property.Domain; -import org.labkey.api.exp.property.DomainAuditProvider; -import org.labkey.api.exp.property.DomainKind; -import org.labkey.api.exp.property.DomainProperty; -import org.labkey.api.exp.property.DomainTemplate; -import org.labkey.api.exp.property.DomainTemplateGroup; -import org.labkey.api.exp.property.DomainUtil; -import org.labkey.api.exp.property.PropertyService; -import org.labkey.api.exp.query.ExpDataProtocolInputTable; -import org.labkey.api.exp.query.ExpInputTable; -import org.labkey.api.exp.query.ExpMaterialProtocolInputTable; -import org.labkey.api.exp.query.ExpSchema; -import org.labkey.api.exp.query.SamplesSchema; -import org.labkey.api.exp.xar.LSIDRelativizer; -import org.labkey.api.exp.xar.LsidUtils; -import org.labkey.api.files.FileContentService; -import org.labkey.api.gwt.client.AuditBehaviorType; -import org.labkey.api.inventory.InventoryService; -import org.labkey.api.module.ModuleHtmlView; -import org.labkey.api.module.ModuleLoader; -import org.labkey.api.pipeline.PipeRoot; -import org.labkey.api.pipeline.PipelineService; -import org.labkey.api.pipeline.PipelineStatusFile; -import org.labkey.api.pipeline.PipelineUrls; -import org.labkey.api.pipeline.PipelineValidationException; -import org.labkey.api.qc.SampleStatusService; -import org.labkey.api.query.AbstractQueryImportAction; -import org.labkey.api.query.BatchValidationException; -import org.labkey.api.query.DetailsURL; -import org.labkey.api.query.DuplicateKeyException; -import org.labkey.api.query.FieldKey; -import org.labkey.api.query.QueryAction; -import org.labkey.api.query.QueryDefinition; -import org.labkey.api.query.QueryException; -import org.labkey.api.query.QueryForm; -import org.labkey.api.query.QueryParam; -import org.labkey.api.query.QueryService; -import org.labkey.api.query.QuerySettings; -import org.labkey.api.query.QueryUpdateForm; -import org.labkey.api.query.QueryUpdateService; -import org.labkey.api.query.QueryUpdateServiceException; -import org.labkey.api.query.QueryView; -import org.labkey.api.query.SchemaKey; -import org.labkey.api.query.UserSchema; -import org.labkey.api.query.UserSchemaAction; -import org.labkey.api.reader.ColumnDescriptor; -import org.labkey.api.reader.DataLoader; -import org.labkey.api.reader.DataLoaderFactory; -import org.labkey.api.reader.ExcelFactory; -import org.labkey.api.search.SearchService; -import org.labkey.api.security.ActionNames; -import org.labkey.api.security.RequiresAnyOf; -import org.labkey.api.security.RequiresNoPermission; -import org.labkey.api.security.RequiresPermission; -import org.labkey.api.security.SecurableResource; -import org.labkey.api.security.User; -import org.labkey.api.security.permissions.AdminPermission; -import org.labkey.api.security.permissions.DeletePermission; -import org.labkey.api.security.permissions.DesignDataClassPermission; -import org.labkey.api.security.permissions.DesignSampleTypePermission; -import org.labkey.api.security.permissions.InsertPermission; -import org.labkey.api.security.permissions.Permission; -import org.labkey.api.security.permissions.ReadPermission; -import org.labkey.api.security.permissions.SampleWorkflowDeletePermission; -import org.labkey.api.security.permissions.SiteAdminPermission; -import org.labkey.api.security.permissions.TroubleshooterPermission; -import org.labkey.api.security.permissions.UpdatePermission; -import org.labkey.api.settings.AppProps; -import org.labkey.api.settings.ConceptURIProperties; -import org.labkey.api.sql.LabKeySql; -import org.labkey.api.study.Dataset; -import org.labkey.api.study.StudyService; -import org.labkey.api.study.StudyUrls; -import org.labkey.api.study.publish.StudyPublishService; -import org.labkey.api.usageMetrics.SimpleMetricsService; -import org.labkey.api.util.DOM; -import org.labkey.api.util.DOM.LK; -import org.labkey.api.util.ErrorRenderer; -import org.labkey.api.util.ExceptionUtil; -import org.labkey.api.util.FileStream; -import org.labkey.api.util.FileUtil; -import org.labkey.api.util.HtmlString; -import org.labkey.api.util.HtmlStringBuilder; -import org.labkey.api.util.ImageUtil; -import org.labkey.api.util.JSoupUtil; -import org.labkey.api.util.LinkBuilder; -import org.labkey.api.util.NetworkDrive; -import org.labkey.api.util.PageFlowUtil; -import org.labkey.api.util.Pair; -import org.labkey.api.util.ResponseHelper; -import org.labkey.api.util.SafeToRender; -import org.labkey.api.util.SessionHelper; -import org.labkey.api.util.StringExpression; -import org.labkey.api.util.URLHelper; -import org.labkey.api.util.UniqueID; -import org.labkey.api.util.CsrfInput; -import org.labkey.api.view.ActionURL; -import org.labkey.api.view.BadRequestException; -import org.labkey.api.view.DataView; -import org.labkey.api.view.DataViewSnapshotSelectionForm; -import org.labkey.api.view.DetailsView; -import org.labkey.api.view.HBox; -import org.labkey.api.view.HtmlView; -import org.labkey.api.view.HttpView; -import org.labkey.api.view.InsertView; -import org.labkey.api.view.JspView; -import org.labkey.api.view.NavTree; -import org.labkey.api.view.NotFoundException; -import org.labkey.api.view.RedirectException; -import org.labkey.api.view.UnauthorizedException; -import org.labkey.api.view.UpdateView; -import org.labkey.api.view.VBox; -import org.labkey.api.view.ViewBackgroundInfo; -import org.labkey.api.view.ViewContext; -import org.labkey.api.view.ViewServlet; -import org.labkey.api.view.WebPartView; -import org.labkey.api.view.template.ClientDependency; -import org.labkey.api.view.template.PageConfig; -import org.labkey.experiment.ChooseExperimentTypeBean; -import org.labkey.experiment.ConfirmDeleteView; -import org.labkey.experiment.CustomPropertiesView; -import org.labkey.experiment.DataClassWebPart; -import org.labkey.experiment.DerivedSamplePropertyHelper; -import org.labkey.experiment.DotGraph; -import org.labkey.experiment.ExpDataFileListener; -import org.labkey.experiment.ExperimentRunDisplayColumn; -import org.labkey.experiment.ExperimentRunGraph; -import org.labkey.experiment.LineageGraphDisplayColumn; -import org.labkey.experiment.MissingFilesCheckInfo; -import org.labkey.experiment.MoveRunsBean; -import org.labkey.experiment.ParentChildView; -import org.labkey.experiment.ProtocolApplicationDisplayColumn; -import org.labkey.experiment.ProtocolDisplayColumn; -import org.labkey.experiment.ProtocolWebPart; -import org.labkey.experiment.RunGroupWebPart; -import org.labkey.experiment.SampleTypeDisplayColumn; -import org.labkey.experiment.SampleTypeWebPart; -import org.labkey.experiment.StandardAndCustomPropertiesView; -import org.labkey.experiment.XarExportPipelineJob; -import org.labkey.experiment.XarExportType; -import org.labkey.experiment.XarExporter; -import org.labkey.experiment.api.ClosureQueryHelper; -import org.labkey.experiment.api.DataClass; -import org.labkey.experiment.api.DataClassDomainKind; -import org.labkey.experiment.api.ExpDataClassAttachmentParent; -import org.labkey.experiment.api.ExpDataClassImpl; -import org.labkey.experiment.api.ExpDataImpl; -import org.labkey.experiment.api.ExpExperimentImpl; -import org.labkey.experiment.api.ExpMaterialImpl; -import org.labkey.experiment.api.ExpProtocolApplicationImpl; -import org.labkey.experiment.api.ExpProtocolImpl; -import org.labkey.experiment.api.ExpRunImpl; -import org.labkey.experiment.api.ExpSampleTypeImpl; -import org.labkey.experiment.api.Experiment; -import org.labkey.experiment.api.ExperimentServiceImpl; -import org.labkey.experiment.api.GraphAlgorithms; -import org.labkey.experiment.api.ProtocolActionStepDetail; -import org.labkey.experiment.api.SampleTypeServiceImpl; -import org.labkey.experiment.api.SampleTypeUpdateServiceDI; -import org.labkey.experiment.controllers.property.PropertyController; -import org.labkey.experiment.lineage.ExpLineageServiceImpl; -import org.labkey.experiment.pipeline.ExperimentPipelineJob; -import org.labkey.experiment.types.TypesController; -import org.labkey.experiment.xar.XarExportSelection; -import org.labkey.vfs.FileLike; -import org.labkey.vfs.FileSystemLike; -import org.springframework.beans.PropertyValue; -import org.springframework.beans.PropertyValues; -import org.springframework.validation.BindException; -import org.springframework.validation.Errors; -import org.springframework.validation.ObjectError; -import org.springframework.web.multipart.MultipartFile; -import org.springframework.web.multipart.MultipartHttpServletRequest; -import org.springframework.web.servlet.ModelAndView; - -import javax.imageio.ImageIO; -import java.awt.*; -import java.awt.image.BufferedImage; -import java.io.BufferedOutputStream; -import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.FileNotFoundException; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.net.URI; -import java.nio.file.Files; -import java.nio.file.Path; -import java.sql.SQLException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.Comparator; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Iterator; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.Set; -import java.util.TreeSet; -import java.util.function.Function; -import java.util.stream.Collectors; - -import static java.util.stream.Collectors.toList; -import static org.labkey.api.data.DbScope.CommitTaskOption.POSTCOMMIT; -import static org.labkey.api.exp.query.ExpSchema.TableType.DataInputs; -import static org.labkey.api.query.QueryImportPipelineJob.QUERY_IMPORT_NOTIFICATION_PROVIDER_PARAM; -import static org.labkey.api.query.QueryImportPipelineJob.QUERY_IMPORT_PIPELINE_DESCRIPTION_PARAM; -import static org.labkey.api.query.QueryImportPipelineJob.QUERY_IMPORT_PIPELINE_PROVIDER_PARAM; -import static org.labkey.api.util.DOM.A; -import static org.labkey.api.util.DOM.Attribute.action; -import static org.labkey.api.util.DOM.Attribute.href; -import static org.labkey.api.util.DOM.Attribute.id; -import static org.labkey.api.util.DOM.Attribute.method; -import static org.labkey.api.util.DOM.Attribute.name; -import static org.labkey.api.util.DOM.Attribute.size; -import static org.labkey.api.util.DOM.Attribute.src; -import static org.labkey.api.util.DOM.Attribute.target; -import static org.labkey.api.util.DOM.Attribute.type; -import static org.labkey.api.util.DOM.Attribute.value; -import static org.labkey.api.util.DOM.Attribute.width; -import static org.labkey.api.util.DOM.DIV; -import static org.labkey.api.util.DOM.IMG; -import static org.labkey.api.util.DOM.INPUT; -import static org.labkey.api.util.DOM.LI; -import static org.labkey.api.util.DOM.TABLE; -import static org.labkey.api.util.DOM.TD; -import static org.labkey.api.util.DOM.TR; -import static org.labkey.api.util.DOM.UL; -import static org.labkey.api.util.DOM.at; -import static org.labkey.api.util.DOM.cl; -import static org.labkey.experiment.ExpDataIterators.setContainerFilterForImport; -import static org.labkey.experiment.api.SampleTypeServiceImpl.SampleChangeType.update; - -public class ExperimentController extends SpringActionController -{ - private static final Logger _log = LogManager.getLogger(ExperimentController.class); - private static final DefaultActionResolver _actionResolver = new DefaultActionResolver( - ExperimentController.class - ); - private static final String GUEST_DIRECTORY_NAME = "guest"; - - public ExperimentController() - { - setActionResolver(_actionResolver); - } - - public static void ensureCorrectContainer(Container requestContainer, ExpObject object, ViewContext viewContext) - { - Container objectContainer = object.getContainer(); - if (!requestContainer.equals(objectContainer)) - { - ActionURL url = viewContext.cloneActionURL(); - url.setContainer(objectContainer); - throw new RedirectException(url); - } - } - - // Complete no-op, but leave in place in case we decide to adjust the base nav trail - private void addRootNavTrail(NavTree root) - { - // Intentionally don't add an "Experiment" node to the list because it's too overloaded. All content on the - // default action can be added to a portal page if desired. - } - - @Override - public PageConfig defaultPageConfig() - { - // set default help topic for controller - PageConfig config = super.defaultPageConfig(); - config.setHelpTopic("experiment"); - return config; - } - - @ActionNames("begin,gridView") - @RequiresPermission(ReadPermission.class) - public class BeginAction extends SimpleViewAction - { - @Override - public VBox getView(Object o, BindException errors) - { - VBox result = new VBox(); - - VBox runListView = createRunListView(20); - result.addView(runListView); - - RunGroupWebPart runGroups = new RunGroupWebPart(getViewContext(), false); - runGroups.showHeader(); - result.addView(runGroups); - - result.addView(new ProtocolWebPart(false, getViewContext())); - result.addView(new SampleTypeWebPart(false, getViewContext())); - result.addView(new DataClassWebPart(false, getViewContext(), null)); - - return result; - } - - @Override - public void addNavTrail(NavTree root) - { - addRootNavTrail(root); - root.addChild("Experiment"); - } - } - - @RequiresPermission(ReadPermission.class) - public class ShowRunsAction extends SimpleViewAction - { - @Override - public VBox getView(Object o, BindException errors) - { - return createRunListView(100); - } - - @Override - public void addNavTrail(NavTree root) - { - addRootNavTrail(root); - root.addChild("Experiment Runs"); - } - } - - private VBox createRunListView(int defaultMaxRows) - { - Set types = ExperimentService.get().getExperimentRunTypes(getContainer()); - ChooseExperimentTypeBean bean = new ChooseExperimentTypeBean(types, ExperimentRunType.getSelectedFilter(types, getViewContext().getRequest().getParameter("experimentRunFilter")), getViewContext().getActionURL().clone(), Collections.emptyList()); - JspView chooserView = new JspView<>("/org/labkey/experiment/experimentRunQueryHeader.jsp", bean); - - ExperimentRunListView view = ExperimentService.get().createExperimentRunWebPart(getViewContext(), bean.getSelectedFilter()); - view.setFrame(WebPartView.FrameType.NONE); - - // When paginated and the user hasn't explicitly set a maxRows, use the default maxRows size. - QuerySettings settings = view.getSettings(); - if (!settings.isMaxRowsSet() && settings.getShowRows() == ShowRows.PAGINATED) - { - settings.setMaxRows(defaultMaxRows); - } - - VBox result = new VBox(chooserView, view); - result.setFrame(WebPartView.FrameType.PORTAL); - return result; - } - - @RequiresPermission(ReadPermission.class) - @ActionNames("showRunGroups, showExperiments") - public class ShowRunGroupsAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object o, BindException errors) - { - RunGroupWebPart webPart = new RunGroupWebPart(getViewContext(), false); - webPart.setFrame(WebPartView.FrameType.NONE); - return webPart; - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("runGroups"); - addRootNavTrail(root); - root.addChild("Run Groups"); - } - } - - public record Field(String domainURI, String domainName, String name, Container container) {} - public record MiniExpObject(Object rowId, String name) {} - public record TimelineSummary(MiniExpObject miniExpObject, String mostRecentValue) {} - public record ProblemType(String tableName, String fieldName, String pkName) { - public Object toHtml(List summaries) - { - return DOM.DIV( - DOM.H4(tableName), - DOM.TABLE(at(cl("table-condensed", "labkey-data-region", "table-bordered")), - DOM.THEAD(DOM.TH(pkName), DOM.TH(fieldName)), - summaries.stream().map(summary -> - DOM.TR(DOM.TD(summary.miniExpObject.name), DOM.TD(summary.mostRecentValue))) - )); - } - } - - @RequiresPermission(SiteAdminPermission.class) - public static class ReportLostFieldValuesAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object o, BindException errors) - { - // Find all the fields that could have lost data due to issue 52666 - TableInfo t = new ExpSchema(getUser(), ContainerManager.getRoot()).getTable(ExpSchema.TableType.Fields.name(), ContainerFilter.getUnsafeEverythingFilter()); - List fields = new TableSelector(t, - new SimpleFilter(FieldKey.fromParts("StorageColumnNameMatch"), false). - addCondition(FieldKey.fromParts("DomainURI"), ":AssayDomain-Data.", CompareType.DOES_NOT_CONTAIN), - null). - getArrayList(Field.class); - - // Prep audit table for querying - UserSchema auditSchema = AuditLogService.get().createSchema(getUser(), ContainerManager.getRoot()); - - Map> sampleTypeSummaries = new HashMap<>(); - Map> dataClassSummaries = new HashMap<>(); - Map> listSummaries = new HashMap<>(); - - Map> problematicFields = new LinkedHashMap<>(); - - for (Field field : fields) - { - String domainURI = field.domainURI; - String fieldName = field.name; - Container container = field.container; - Domain domain = PropertyService.get().getDomain(container, domainURI); - if (domain != null && domain.getDomainKind() != null) - { - TableInfo table = domain.getDomainKind().getTableInfo(getUser(), container, domain, ContainerFilter.getUnsafeEverythingFilter()); - - if (table != null) - { - // Drill into sample types - if (domain.getDomainKind().getClass().equals(SampleTypeDomainKind.class)) - { - // rows that currently have no value for the field with potential for data loss - List rowsWithNull = new TableSelector(table, - new HashSet<>(List.of("RowId", "Name")), - new SimpleFilter(new CompareType.CompareClause(FieldKey.fromParts(fieldName), CompareType.ISBLANK, null)), - null). - getArrayList(MiniExpObject.class); - - List fixupsNeeded = checkData( - rowsWithNull, - fieldName, - obj -> new SimpleFilter(FieldKey.fromParts("SampleId"), obj.rowId), - auditSchema.getTable(SampleTimelineAuditEvent.EVENT_TYPE, ContainerFilter.getUnsafeEverythingFilter())); - if (!fixupsNeeded.isEmpty()) - { - sampleTypeSummaries.put(new ProblemType(table.getName(), fieldName, "SampleID"), fixupsNeeded); - } - } - // and data classes/sample sources - if (domain.getDomainKind().getClass().equals(DataClassDomainKind.class)) - { - // rows samples that current have no value for the field with potential for data loss - List rowsWithNull = new TableSelector(table, - new HashSet<>(List.of("RowId", "Name")), - new SimpleFilter(new CompareType.CompareClause(FieldKey.fromParts(fieldName), CompareType.ISBLANK, null)), - null). - getArrayList(MiniExpObject.class); - - List fixupsNeeded = checkData( - rowsWithNull, - fieldName, - obj -> new SimpleFilter(FieldKey.fromParts("RowPk"), Objects.toString(obj.rowId)). - addCondition(FieldKey.fromParts("SchemaName"), "exp.data"). - addCondition(FieldKey.fromParts("QueryName"), domain.getName()), - auditSchema.getTable("QueryUpdateAuditEvent", ContainerFilter.getUnsafeEverythingFilter())); - - if (!fixupsNeeded.isEmpty()) - { - dataClassSummaries.put(new ProblemType(table.getName(), fieldName, "SourceID"), fixupsNeeded); - } - } - // and lists - if ("lists".equals(table.getUserSchema().getName())) - { - // rows samples that current have no value for the field with potential for data loss - List rowsWithNull = new ArrayList<>(); - - ColumnInfo entityIdCol = table.getColumn("EntityId"); - ColumnInfo pkCol = table.getPkColumns().get(0); - - new TableSelector(table, - List.of(entityIdCol, pkCol), - new SimpleFilter(new CompareType.CompareClause(FieldKey.fromParts(fieldName), CompareType.ISBLANK, null)), - null). - forEachResults(r -> - { - Object entityId = entityIdCol.getValue(r); - Object pk = pkCol.getValue(r); - rowsWithNull.add(new MiniExpObject(entityId, pk.toString())); - }); - - - List fixupsNeeded = checkData( - rowsWithNull, - fieldName, - obj -> new SimpleFilter(FieldKey.fromParts("ListItemEntityId"), obj.rowId), - auditSchema.getTable("ListAuditEvent", ContainerFilter.getUnsafeEverythingFilter())); - - if (!fixupsNeeded.isEmpty()) - { - listSummaries.put(new ProblemType(table.getName(), fieldName, table.getPkColumnNames().get(0)), fixupsNeeded); - } - } - - long totalRows = new TableSelector(table).getRowCount(); - long emptyRows = new TableSelector(table, new SimpleFilter(new CompareType.CompareClause(FieldKey.fromParts(fieldName), CompareType.ISBLANK, null)), null).getRowCount(); - problematicFields.put(field, Pair.of(totalRows, emptyRows)); - } - else - { - problematicFields.put(field, Pair.of(null, null)); - } - } - } - - return new HtmlView("Fixups Needed", - DOM.createHtmlFragment( - DOM.H2("Potentially Problematic Fields"), - problematicFields.isEmpty() ? "No problematic fields detected!" : - DOM.TABLE(at(cl("table-condensed", "labkey-data-region", "table-bordered")), - DOM.THEAD(DOM.TH("Domain Name"), DOM.TH("Domain URI"), DOM.TH("Field Name"), DOM.TH("Container"), DOM.TH("Total Rows"), DOM.TH("Rows with Nulls")), - problematicFields.entrySet().stream().map(e -> { - Field f = e.getKey(); - Pair counts = e.getValue(); - return DOM.TR( - DOM.TD(f.domainName), - DOM.TD(f.domainURI), - DOM.TD(f.name), - DOM.TD(f.container.getPath()), - DOM.TD(counts.first), - DOM.TD(counts.second) - ); - } - )), - - DOM.H2("Sample Types"), - sampleTypeSummaries.isEmpty() ? "No problems detected!" : - sampleTypeSummaries.entrySet().stream().map(e -> - e.getKey().toHtml(e.getValue())), - - DOM.H2("Data Classes"), - dataClassSummaries.isEmpty() ? "No problems detected!" : - dataClassSummaries.entrySet().stream().map(e -> - e.getKey().toHtml(e.getValue())), - - DOM.H2("Lists"), - listSummaries.isEmpty() ? "No problems detected!" : - listSummaries.entrySet().stream().map(e -> - e.getKey().toHtml(e.getValue())) - )); - } - - @NotNull - private List checkData(List rowsWithNull, String fieldName, Function filterGenerator, TableInfo auditTable) - { - List fixupsNeeded = new ArrayList<>(); - - // For each sample without a value today, check the audit history - for (MiniExpObject row : rowsWithNull) - { - // Order by RowId to get them in the sequence they happened in - var events = new TableSelector(auditTable, filterGenerator.apply(row), new Sort("RowId")).getArrayList(DetailedAuditTypeEvent.class); - // Remember the most recently set value - String mostRecentValue = null; - for (DetailedAuditTypeEvent event : events) - { - Map newValues = new CaseInsensitiveHashMap<>(AbstractAuditTypeProvider.decodeFromDataMap(event.getNewRecordMap())); - if (newValues.containsKey(fieldName)) - { - // Will be the empty string if the value was intentionally set to blank - mostRecentValue = newValues.get(fieldName); - } - } - // If the value had been set before, and its most recent insert/update wasn't setting it blank, - // it's most likely a lost value - if (mostRecentValue != null && !mostRecentValue.isEmpty()) - { - fixupsNeeded.add(new TimelineSummary(row, mostRecentValue)); - } - } - return fixupsNeeded; - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild("Accidentally Nulled Field Report"); - } - } - - @RequiresPermission(ReadPermission.class) - public static class CreateHiddenRunGroupAction extends MutatingApiAction - { - @Override - public ApiResponse execute(SimpleApiJsonForm form, BindException errors) throws Exception - { - JSONObject json = form.getJsonObject(); - String selectionKey = json.optString("selectionKey", null); - List runs = new ArrayList<>(); - - // Accept either an explicit list of run IDs - if (json.has("runIds")) - { - JSONArray runIds = json.getJSONArray("runIds"); - for (int i = 0; i < runIds.length(); i++) - { - ExpRunImpl run = ExperimentServiceImpl.get().getExpRun(runIds.getInt(i)); - if (run != null) - { - runs.add(run); - } - } - } - // Or a reference to a DataRegion selection key - else if (selectionKey != null) - { - Set ids = DataRegionSelection.getSelectedIntegers(getViewContext(), selectionKey, false); - for (Long id : ids) - { - ExpRunImpl run = ExperimentServiceImpl.get().getExpRun(id); - if (run != null) - { - runs.add(run); - } - } - } - if (runs.isEmpty()) - { - throw new NotFoundException(); - } - - ExpExperiment group = ExperimentService.get().createHiddenRunGroup(getContainer(), getUser(), runs.toArray(new ExpRun[0])); - if (selectionKey != null) - DataRegionSelection.clearAll(getViewContext(), selectionKey); - - ApiSimpleResponse response = new ApiSimpleResponse(); - response.putBean(group, "rowId", "LSID", "name", "hidden"); - return response; - } - } - - - @RequiresPermission(ReadPermission.class) - public class DetailsAction extends QueryViewAction - { - private ExpExperimentImpl _experiment; - - public DetailsAction() - { - super(ExpObjectForm.class); - } - - private Pair> createViews(ExpObjectForm form, BindException errors) - { - _experiment = ExperimentServiceImpl.get().getExpExperiment(form.getRowId()); - if (_experiment == null) - { - throw new NotFoundException("Could not find an experiment with RowId " + form.getRowId()); - } - - if (!_experiment.getContainer().equals(getContainer())) - { - throw new RedirectException(getViewContext().cloneActionURL().setContainer(_experiment.getContainer())); - } - - List protocols = _experiment.getAllProtocols(); - - Set types = new TreeSet<>(ExperimentService.get().getExperimentRunTypes(getContainer())); - ExperimentRunType selectedType = ExperimentRunType.getSelectedFilter(types, getViewContext().getRequest().getParameter("experimentRunFilter")); - - ChooseExperimentTypeBean bean = new ChooseExperimentTypeBean(types, selectedType, getViewContext().getActionURL().clone(), protocols); - JspView chooserView = new JspView<>("/org/labkey/experiment/experimentRunQueryHeader.jsp", bean, errors); - - ExperimentRunListView runListView = ExperimentRunListView.createView(getViewContext(), bean.getSelectedFilter(), true); - runListView.getRunTable().setExperiment(_experiment); - runListView.setShowRemoveFromExperimentButton(true); - runListView.setShowDeleteButton(true); - runListView.setShowAddToRunGroupButton(true); - runListView.setShowExportButtons(true); - runListView.setShowMoveRunsButton(true); - return new Pair<>(runListView, chooserView); - } - - @Override - protected ModelAndView getHtmlView(ExpObjectForm form, BindException errors) throws Exception - { - Pair> views = createViews(form, errors); - - CustomPropertiesView customPropertiesView = new CustomPropertiesView(_experiment.getLSID(), getContainer()); - - TableInfo runGroupsTable = new ExpSchema(getUser(), getContainer()).getTable(ExpSchema.TableType.RunGroups); - - DetailsView detailsView = new DetailsView(new DataRegion(), _experiment.getRowId()); - detailsView.getDataRegion().setTable(runGroupsTable); - detailsView.getDataRegion().addColumns(runGroupsTable, "RowId,Name,Created,Modified,Contact,ExperimentDescriptionURL,Hypothesis,Comments"); - detailsView.getDataRegion().getDisplayColumn(0).setVisible(false); - detailsView.getDataRegion().getDisplayColumn(2).setWidth("60%"); - - ButtonBar bb = new ButtonBar(); - bb.setStyle(ButtonBar.Style.separateButtons); - ActionButton b = new ActionButton(ExperimentUrlsImpl.get().getShowUpdateURL(_experiment), "Edit"); - b.setDisplayPermission(UpdatePermission.class); - bb.add(b); - detailsView.getDataRegion().setButtonBar(bb); - if (_experiment.getBatchProtocol() != null) - { - detailsView.setTitle("Batch Details"); - detailsView.getDataRegion().addColumns(runGroupsTable, "BatchProtocolId"); - } - else - { - detailsView.setTitle("Run Group Details"); - } - - VBox runsVBox = new VBox(views.second, createInitializedQueryView(form, errors, false, null)); - runsVBox.setTitle("Experiment Runs"); - runsVBox.setFrame(WebPartView.FrameType.PORTAL); - - return new VBox(new StandardAndCustomPropertiesView(detailsView, customPropertiesView), runsVBox); - } - - @Override - protected ExperimentRunListView createQueryView(ExpObjectForm form, BindException errors, boolean forExport, String dataRegion) - { - return createViews(form, errors).first; - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("runGroups"); - addRootNavTrail(root); - root.addChild("Run Groups", ExperimentUrlsImpl.get().getShowExperimentsURL(getContainer())); - root.addChild(_experiment.getName()); - } - } - - @RequiresPermission(ReadPermission.class) - public class ListSampleTypesAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object o, BindException errors) - { - SampleTypeWebPart view = new SampleTypeWebPart(false, getViewContext()); - view.setFrame(WebPartView.FrameType.NONE); - view.setErrorMessage(getViewContext().getRequest().getParameter("errorMessage")); - - return view; - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("sampleSets"); - addRootNavTrail(root); - root.addChild("Sample Types"); - } - } - - @RequiresPermission(ReadPermission.class) - public class ShowSampleTypeAction extends SimpleViewAction - { - private ExpSampleTypeImpl _sampleType; - - @Override - public ModelAndView getView(ExpObjectForm form, BindException errors) - { - _sampleType = SampleTypeServiceImpl.get().getSampleType(getContainer(), getUser(), form.getRowId()); - if (_sampleType == null && form.getLsid() != null) - { - if (form.getLsid().equalsIgnoreCase("Material") || form.getLsid().equalsIgnoreCase("Sample")) - { - // Not a real sample type - just show all the materials instead - throw new RedirectException(new ActionURL(ShowAllMaterialsAction.class, getContainer())); - } - // Check if the URL specifies the LSID, and stick the bean back into the form - _sampleType = SampleTypeServiceImpl.get().getSampleType(form.getLsid()); - } - - if (_sampleType == null) - { - throw new NotFoundException("No matching sample type found"); - } - - List allScopedSampleTypes = (List) SampleTypeService.get().getSampleTypes(getContainer(), getUser(), true); - if (!allScopedSampleTypes.contains(_sampleType)) - { - ensureCorrectContainer(getContainer(), _sampleType, getViewContext()); - } - - SamplesSchema schema = new SamplesSchema(getUser(), getContainer()); - QuerySettings settings = schema.getSettings(getViewContext(), "Material", _sampleType.getName()); - QueryView queryView = new SampleTypeContentsView(_sampleType, schema, settings, errors); - - DetailsView detailsView = new DetailsView(getSampleTypeRegion(getViewContext()), _sampleType.getRowId()); - detailsView.getDataRegion().getDisplayColumn("Name").setURL((ActionURL)null); - detailsView.getDataRegion().getDisplayColumn("LSID").setVisible(false); - detailsView.getDataRegion().getDisplayColumn("MaterialLSIDPrefix").setVisible(false); - detailsView.getDataRegion().getDisplayColumn("LabelColor").setVisible(false); - detailsView.getDataRegion().getDisplayColumn("MetricUnit").setVisible(false); - detailsView.getDataRegion().getDisplayColumn("Category").setVisible(false); - - detailsView.setTitle("Sample Type Properties"); - detailsView.getDataRegion().getButtonBar(DataRegion.MODE_DETAILS).setStyle(ButtonBar.Style.separateButtons); - - Container autoLinkContainer = _sampleType.getAutoLinkTargetContainer(); - if (null != autoLinkContainer) - { - DisplayColumn autoLinkTargetColumn = detailsView.getDataRegion().getDisplayColumn("autoLinkTargetContainer"); - autoLinkTargetColumn.setVisible(false); - - SimpleDisplayColumn displayAutoLinkTargetColumn = new SimpleDisplayColumn(); - displayAutoLinkTargetColumn.setCaption("Auto Link Target Container:"); - String path = autoLinkContainer.getPath(); - displayAutoLinkTargetColumn.setDisplayHtml(path.equals("/") ? "" : path); - detailsView.getDataRegion().addDisplayColumn(displayAutoLinkTargetColumn); - } - - DisplayColumn autoLinkCategoryColumn = detailsView.getDataRegion().getDisplayColumn("autoLinkCategory"); - autoLinkCategoryColumn.setVisible(false); - SimpleDisplayColumn displayAutoLinkCategoryColumn = new SimpleDisplayColumn(); - displayAutoLinkCategoryColumn.setCaption("Auto Link Category:"); - displayAutoLinkCategoryColumn.setDisplayHtml(_sampleType.getAutoLinkCategory()); - detailsView.getDataRegion().addDisplayColumn(displayAutoLinkCategoryColumn); - - if (_sampleType.hasNameAsIdCol()) - { - SimpleDisplayColumn nameIdCol = new SimpleDisplayColumn(); - nameIdCol.setCaption("Has Name Id Column:"); - nameIdCol.setDisplayHtml("true"); - detailsView.getDataRegion().addDisplayColumn(nameIdCol); - } - - if (_sampleType.hasIdColumns()) - { - SimpleDisplayColumn idCols = new SimpleDisplayColumn(); - idCols.setCaption("Id Column(s):"); - String names = _sampleType.getIdCols().stream() - .filter(Objects::nonNull) - .map(DomainProperty::getName) - .collect(Collectors.joining(", ")); - if (!names.isEmpty()) - { - idCols.setDisplayHtml(PageFlowUtil.filter(names)); - detailsView.getDataRegion().addDisplayColumn(idCols); - } - } - - if (_sampleType.getParentCol() != null) - { - SimpleDisplayColumn parentCol = new SimpleDisplayColumn(PageFlowUtil.filter(_sampleType.getParentCol().getName())); - parentCol.setCaption("Parent Column:"); - detailsView.getDataRegion().addDisplayColumn(parentCol); - } - - try - { - SimpleDisplayColumn importAliasCol = new SimpleDisplayColumn(); - importAliasCol.setCaption("Parent Import Alias(es):"); - if (!_sampleType.getImportAliases().isEmpty()) - importAliasCol.setDisplayHtml(PageFlowUtil.filter(StringUtils.join(_sampleType.getImportAliases().keySet(), ", "))); - detailsView.getDataRegion().addDisplayColumn(importAliasCol); - } - catch (IOException e) - { - // unable to parse import alias map from JSON - } - - if (!getContainer().equals(_sampleType.getContainer())) - { - ActionURL definitionURL = urlProvider(ExperimentUrls.class).getShowSampleTypeURL(_sampleType); - SimpleDisplayColumn definedInCol = new SimpleDisplayColumn("" + - PageFlowUtil.filter(_sampleType.getContainer().getPath()) + - ""); - definedInCol.setCaption("Defined In:"); - detailsView.getDataRegion().addDisplayColumn(definedInCol); - } - - // Not all sample types can be edited - DomainKind domainKind = _sampleType.getDomain().getDomainKind(); - if (domainKind != null && domainKind.canEditDefinition(getUser(), _sampleType.getDomain())) - { - if (domainKind instanceof SampleTypeDomainKind) - { - ActionURL updateURL = new ActionURL(EditSampleTypeAction.class, _sampleType.getContainer()); - updateURL.addParameter("RowId", _sampleType.getRowId()); - updateURL.addReturnUrl(getViewContext().getActionURL()); - - if (!getContainer().equals(_sampleType.getContainer())) - { - String editLink = updateURL.toString(); - ActionButton updateButton = new ActionButton("Edit Type"); - updateButton.setActionType(ActionButton.Action.SCRIPT); - updateButton.setScript("if (window.confirm('This sample type is defined in the " + _sampleType.getContainer().getPath() + " folder. Would you still like to edit it?')) { window.location = '" + editLink + "' }"); - detailsView.getDataRegion().getButtonBar(DataRegion.MODE_DETAILS).add(updateButton); - } - else - { - ActionButton updateButton = new ActionButton(updateURL, "Edit Type", ActionButton.Action.LINK); - updateButton.setDisplayPermission(DesignSampleTypePermission.class); - updateButton.setPrimary(true); - detailsView.getDataRegion().getButtonBar(DataRegion.MODE_DETAILS).add(updateButton); - } - - ActionURL deleteURL = new ActionURL(DeleteSampleTypesAction.class, _sampleType.getContainer()); - deleteURL.addParameter("singleObjectRowId", _sampleType.getRowId()); - deleteURL.addReturnUrl(ExperimentUrlsImpl.get().getShowSampleTypeListURL(getContainer())); - ActionButton deleteButton = new ActionButton(deleteURL, "Delete Type", ActionButton.Action.LINK); - deleteButton.setDisplayPermission(DesignSampleTypePermission.class); - detailsView.getDataRegion().getButtonBar(DataRegion.MODE_DETAILS).add(deleteButton); - } - else - { - ActionURL editURL = domainKind.urlEditDefinition(_sampleType.getDomain(), new ViewBackgroundInfo(_sampleType.getContainer(), getUser(), getViewContext().getActionURL())); - if (editURL != null) - { - editURL.addReturnUrl(getViewContext().getActionURL()); - ActionButton editTypeButton = new ActionButton(editURL, "Edit Fields"); - editTypeButton.setDisplayPermission(UpdatePermission.class); - detailsView.getDataRegion().getButtonBar(DataRegion.MODE_DETAILS).add(editTypeButton); - } - } - } - - if (_sampleType.canImportMoreSamples()) - { - TableInfo table = queryView.getTable(); - if (table != null) - { - ActionURL importURL = table.getImportDataURL(getContainer()); - if (importURL != null) - { - importURL = importURL.clone(); - importURL.addReturnUrl(getViewContext().getActionURL()); - ActionButton uploadButton = new ActionButton(importURL, "Import More Samples", ActionButton.Action.LINK); - uploadButton.setDisplayPermission(UpdatePermission.class); - detailsView.getDataRegion().getButtonBar(DataRegion.MODE_DETAILS).add(uploadButton); - } - } - } - - var publish = StudyPublishService.get(); - if (AuditLogService.get().isViewable() && publish != null) - { - ContainerFilter cf = ContainerFilter.Type.CurrentAndSubfolders.create(getContainer(), getUser()); - ActionURL linkToStudyHistoryURL = publish.getPublishHistory(getContainer(), Dataset.PublishSource.SampleType, _sampleType.getRowId(), cf); - ActionButton linkToStudyHistoryButton = new ActionButton(linkToStudyHistoryURL, "Link to Study History", ActionButton.Action.LINK); - linkToStudyHistoryButton.setDisplayPermission(InsertPermission.class); - detailsView.getDataRegion().getButtonBar(DataRegion.MODE_DETAILS).add(linkToStudyHistoryButton); - } - - return new VBox(detailsView, queryView); - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("sampleSets"); - ActionURL url = ExperimentUrls.get().getShowSampleTypeListURL(getContainer()); - addRootNavTrail(root); - root.addChild("Sample Types", url); - root.addChild("Sample Type " + _sampleType.getName()); - } - } - - @RequiresPermission(ReadPermission.class) - public class ShowAllMaterialsAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object o, BindException errors) - { - ExpSchema schema = new ExpSchema(getUser(), getContainer()); - QuerySettings settings = schema.getSettings(getViewContext(), "Materials", ExpSchema.TableType.Materials.toString()); - QueryView view = new QueryView(schema, settings, errors) - { - @Override - protected void populateButtonBar(DataView view, ButtonBar bar) - { - super.populateButtonBar(view, bar); - bar.add(SampleTypeContentsView.getDeriveSamplesButton(getContainer(),null)); - } - }; - view.setShowDetailsColumn(false); - return view; - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("sampleSets"); - addRootNavTrail(root); - root.addChild("All Materials"); - } - } - - /** - * Only shows standard and custom properties, not parent and child samples. Used for indexing - */ - @RequiresPermission(ReadPermission.class) - public class ShowMaterialSimpleAction extends SimpleViewAction - { - protected ExpMaterialImpl _material; - - @Override - public VBox getView(ExpObjectForm form, BindException errors) throws Exception - { - Container c = getContainer(); - _material = ExperimentServiceImpl.get().getExpMaterial(form.getRowId()); - if (_material == null && form.getLsid() != null) - { - _material = ExperimentServiceImpl.get().getExpMaterial(form.getLsid()); - } - if (_material == null) - { - throw new NotFoundException("Could not find a material with RowId " + form.getRowId()); - } - - ensureCorrectContainer(getContainer(), _material, getViewContext()); - - ExpRunImpl run = _material.getRun(); - ExpProtocol sourceProtocol = _material.getSourceProtocol(); - ExpProtocolApplication sourceProtocolApplication = _material.getSourceApplication(); - - DataRegion dr = new DataRegion(); - dr.addColumns(ExperimentServiceImpl.get().getTinfoMaterial().getUserEditableColumns()); - dr.removeColumns("RowId", "RunId", "LastIndexed", "LSID", "SourceApplicationId", "CpasType"); - - //dr.addColumns(extraProps); - dr.addDisplayColumn(new ExperimentRunDisplayColumn(run, "Source Experiment Run")); - dr.addDisplayColumn(new ProtocolDisplayColumn(sourceProtocol, "Source Protocol")); - dr.addDisplayColumn(new ProtocolApplicationDisplayColumn(sourceProtocolApplication, "Source Protocol Application")); - dr.addDisplayColumn(new LineageGraphDisplayColumn(_material, run)); - dr.addDisplayColumn(new SampleTypeDisplayColumn(_material)); - - //TODO: Can't yet edit materials uploaded from a material source - dr.setButtonBar(new ButtonBar()); - DetailsView detailsView = new DetailsView(dr, _material.getRowId()); - detailsView.setTitle("Standard Properties"); - detailsView.setFrame(WebPartView.FrameType.PORTAL); - - CustomPropertiesView cpv = new CustomPropertiesView(_material, c, getUser()); - - return new VBox(new StandardAndCustomPropertiesView(detailsView, cpv)); - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("sampleSets"); - addRootNavTrail(root); - root.addChild("Sample Types", ExperimentUrlsImpl.get().getShowSampleTypeListURL(getContainer())); - ExpSampleType sampleType = _material.getSampleType(); - if (sampleType != null) - { - root.addChild(sampleType.getName(), ExperimentUrlsImpl.get().getShowSampleTypeURL(sampleType)); - } - root.addChild("Sample " + _material.getName()); - } - } - - @RequiresPermission(ReadPermission.class) - public class ShowMaterialAction extends ShowMaterialSimpleAction - { - @Override - public VBox getView(ExpObjectForm form, BindException errors) throws Exception - { - VBox vbox = super.getView(form, errors); - - List materialsToInvestigate = new ArrayList<>(); - final Set successorRuns = new HashSet<>(); - materialsToInvestigate.add(_material); - Set investigatedMaterials = new HashSet<>(); - do - { - // Query for all the next tier of materials at once - issue 45402 - List followupRuns = ExperimentService.get().getRunsUsingMaterials(materialsToInvestigate); - - // Mark this set as investigated and reset for the next cycle - investigatedMaterials.addAll(materialsToInvestigate); - materialsToInvestigate = new ArrayList<>(); - - for (ExpRun r : followupRuns) - { - // Only expand the material outputs of the run if it's our first time visiting it - if (successorRuns.add(r)) - { - materialsToInvestigate.addAll(r.getMaterialOutputs()); - } - } - - if (successorRuns.size() > 1000) - { - // Give up - there may be a cycle or other problematic data - break; - } - - // Cull the ones we've already looked up - materialsToInvestigate.removeAll(investigatedMaterials); - } - while (!materialsToInvestigate.isEmpty()); - - HtmlStringBuilder updateLinks = HtmlStringBuilder.of(); - ExpSampleType st = _material.getSampleType(); - if (st != null && st.getContainer() != null && st.getContainer().hasPermission(getUser(), UpdatePermission.class)) - { - // XXX: ridiculous amount of work to get a update url expression for the sample type's table. - UserSchema samplesSchema = QueryService.get().getUserSchema(getUser(), st.getContainer(), "Samples"); - QueryDefinition queryDef = samplesSchema.getQueryDefForTable(st.getName()); - StringExpression expr = queryDef.urlExpr(QueryAction.updateQueryRow, null); - if (expr != null) - { - // Since we're building a detailsURL outside the context of a "row" need to set the correct - // container context on the generated expr. - ((DetailsURL) expr).setContainerContext(st.getContainer()); - String url = expr.eval(Collections.singletonMap(new FieldKey(null, "RowId"), _material.getRowId())); - updateLinks.append(LinkBuilder.labkeyLink("edit", url)).append(" "); - } - } - - if (getContainer().hasPermission(getUser(), InsertPermission.class)) - { - ActionURL deriveURL = new ActionURL(DeriveSamplesChooseTargetAction.class, getContainer()); - deriveURL.addParameter("rowIds", _material.getRowId()); - if (st != null) - deriveURL.addParameter("targetSampleTypeId", st.getRowId()); - - updateLinks.append(LinkBuilder.labkeyLink("derive samples from this sample", deriveURL)).append(" "); - } - - vbox.addView(new HtmlView(updateLinks)); - - ExperimentRunListView runListView = ExperimentRunListView.createView(getViewContext(), ExperimentRunType.ALL_RUNS_TYPE, true); - runListView.setShowRecordSelectors(false); - runListView.getRunTable().setRuns(successorRuns); - runListView.getRunTable().setContainerFilter(new ContainerFilter.AllFolders(getUser())); - runListView.setAllowableContainerFilterTypes(ContainerFilter.Type.Current, ContainerFilter.Type.CurrentAndSubfolders, ContainerFilter.Type.AllFolders); - runListView.setTitle("Runs associated with this material or a derived material"); - - ParentChildView pv = new ParentChildView(_material, getViewContext()); - vbox.addView(pv); - vbox.addView(runListView); - - return vbox; - } - } - - - // - // DataClass - // - - @RequiresPermission(ReadPermission.class) - public class ListDataClassAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object o, BindException errors) - { - DataClassWebPart view = new DataClassWebPart(false, getViewContext(), null); - view.setFrame(WebPartView.FrameType.NONE); - view.setErrorMessage(getViewContext().getRequest().getParameter("errorMessage")); - - return view; - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("dataClass"); - addRootNavTrail(root); - root.addChild("Data Classes"); - } - } - - public static class DataClassForm extends ExpObjectForm - { - private String _name; - - public String getName() - { - return _name; - } - - public void setName(String name) - { - _name = name; - } - - public ExpDataClassImpl getDataClass(@Nullable Container container) - { - ExpDataClassImpl dataClass = null; - - if (getName() != null) - { - dataClass = ExperimentServiceImpl.get().getDataClass(getContainer(), getUser(), getName()); - if (dataClass == null) - throw new NotFoundException("No data class found for name '" + getName() + "'."); - } - else if (getRowId() > 0) - { - dataClass = ExperimentServiceImpl.get().getDataClass(getContainer(), getUser(), getRowId()); - } - - if (dataClass == null) - throw new NotFoundException("No data class found."); - else if (container != null && !container.equals(dataClass.getContainer())) - throw new NotFoundException("Data class is not defined in the given container."); - - return dataClass; - } - } - - @RequiresPermission(ReadPermission.class) - public class ShowDataClassAction extends SimpleViewAction - { - private ExpDataClassImpl _dataClass; - - @Override - public ModelAndView getView(DataClassForm form, BindException errors) - { - _dataClass = form.getDataClass(null); - return new VBox(getDataClassPropertiesView(), getDataClassContentsView(errors)); - } - - private DetailsView getDataClassPropertiesView() - { - ExpSchema expSchema = new ExpSchema(getUser(), _dataClass.getContainer()); - - TableInfo table = ExpSchema.TableType.DataClasses.createTable(expSchema, null, null); - QueryUpdateForm tvf = new QueryUpdateForm(table, getViewContext(), null); - tvf.setPkVal(_dataClass.getRowId()); - DetailsView detailsView = new DetailsView(tvf); - detailsView.setTitle("Data Class Properties"); - - ButtonBar bb = new ButtonBar(); - bb.setStyle(ButtonBar.Style.separateButtons); - boolean inDefinitionContainer = getContainer().equals(_dataClass.getContainer()); - - DomainKind domainKind = _dataClass.getDomain().getDomainKind(); - if (domainKind != null && domainKind.canEditDefinition(getUser(), _dataClass.getDomain())) - { - ActionURL updateURL = new ActionURL(EditDataClassAction.class, _dataClass.getContainer()); - updateURL.addParameter("rowId", _dataClass.getRowId()); - updateURL.addReturnUrl(urlProvider(ExperimentUrls.class).getShowDataClassURL(_dataClass.getContainer(), _dataClass.getRowId())); - - if (inDefinitionContainer) - { - ActionButton updateButton = new ActionButton(updateURL, "Edit Data Class", ActionButton.Action.LINK); - updateButton.setDisplayPermission(DesignDataClassPermission.class); - updateButton.setPrimary(true); - bb.add(updateButton); - } - else if (_dataClass.getContainer().hasPermission(getUser(), DesignDataClassPermission.class)) - { - ActionButton updateButton = new ActionButton("Edit Data Class"); - updateButton.setActionType(ActionButton.Action.SCRIPT); - updateButton.setScript("if (window.confirm('This data class is defined in the " + _dataClass.getContainer().getPath() + " folder. Would you still like to edit it?')) { window.location = '" + updateURL + "' }"); - updateButton.setPrimary(true); - bb.add(updateButton); - } - - ActionURL deleteURL = new ActionURL(DeleteDataClassAction.class, _dataClass.getContainer()); - deleteURL.addParameter("singleObjectRowId", _dataClass.getRowId()); - deleteURL.addReturnUrl(ExperimentUrlsImpl.get().getDataClassListURL(getContainer())); - ActionButton deleteButton = new ActionButton(deleteURL, "Delete Data Class", ActionButton.Action.LINK); - - if (inDefinitionContainer) - { - deleteButton.setDisplayPermission(DesignDataClassPermission.class); - bb.add(deleteButton); - } - else if (_dataClass.getContainer().hasPermission(getUser(), DesignDataClassPermission.class)) - { - bb.add(deleteButton); - } - } - detailsView.getDataRegion().setButtonBar(bb); - - if (!inDefinitionContainer) - { - ActionURL definitionURL = urlProvider(ExperimentUrls.class).getShowDataClassURL(_dataClass.getContainer(), _dataClass.getRowId()); - LinkBuilder link = LinkBuilder.simpleLink(_dataClass.getContainer().getPath(), definitionURL); - SimpleDisplayColumn definedInCol = new SimpleDisplayColumn(link.toString()); - definedInCol.setCaption("Defined In:"); - detailsView.getDataRegion().addDisplayColumn(definedInCol); - } - - return detailsView; - } - - private QueryView getDataClassContentsView(BindException errors) - { - UserSchema dataClassSchema = QueryService.get().getUserSchema(getUser(), getContainer(), ExpSchema.SCHEMA_EXP_DATA); - QuerySettings settings = dataClassSchema.getSettings(getViewContext(), QueryView.DATAREGIONNAME_DEFAULT, _dataClass.getName()); - - return new QueryView(dataClassSchema, settings, errors) - { - @Override - public @NotNull LinkedHashSet getClientDependencies() - { - LinkedHashSet resources = super.getClientDependencies(); - resources.add(ClientDependency.fromPath("Ext4")); - resources.add(ClientDependency.fromPath("dataregion/confirmDelete.js")); - return resources; - } - - @Override - public ActionButton createDeleteButton() - { - ActionButton button = super.createDeleteButton(); - if (button != null) - { - String dependencyText = ExperimentService.get() - .getObjectReferencers() - .stream() - .map(referencer -> referencer.getObjectReferenceDescription(ExpData.class)) - .collect(Collectors.joining(" or ")); - - button.setScript("LABKEY.dataregion.confirmDelete(" + - PageFlowUtil.jsString(getDataRegionName()) + ", " + - PageFlowUtil.jsString(ExpSchema.SCHEMA_EXP_DATA.toString()) + ", " + - PageFlowUtil.jsString(getQueryDef().getName()) + ", " + - "'experiment', 'getDataOperationConfirmationData.api', " + - PageFlowUtil.jsString(getSelectionKey()) + ", " + - "'data object', 'data objects', '" + dependencyText + "', {dataOperation: 'Delete'})"); - button.setRequiresSelection(true); - } - return button; - } - }; - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("dataClass"); - addRootNavTrail(root); - root.addChild("Data Classes", ExperimentUrlsImpl.get().getDataClassListURL(getContainer())); - root.addChild(_dataClass.getName()); - } - } - - @RequiresPermission(DesignDataClassPermission.class) - public class DeleteDataClassAction extends AbstractDeleteAction - { - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("dataClass"); - super.addNavTrail(root); - } - - @Override - protected void deleteObjects(DeleteForm deleteForm) - { - List dataClasses = getDataClasses(deleteForm); - if (!ensureCorrectContainer(dataClasses)) - { - throw new UnauthorizedException(); - } - for (ExpRun run : getRuns(dataClasses)) - { - if (!run.getContainer().hasPermission(getUser(), DeletePermission.class)) - { - throw new UnauthorizedException(); - } - } - for (ExpDataClass dataClass : dataClasses) - { - dataClass.delete(getUser(), deleteForm.getUserComment()); - } - } - - @Override - public ModelAndView getView(DeleteForm deleteForm, boolean reshow, BindException errors) - { - List dataClasses = getDataClasses(deleteForm); - - if (!ensureCorrectContainer(dataClasses)) - { - throw new RedirectException(ExperimentUrlsImpl.get().getDataClassListURL(getContainer(), "To delete a data class, you must be in its folder or project.")); - } - - return new ConfirmDeleteView("Data Class", ShowDataClassAction.class, dataClasses, deleteForm, getRuns(dataClasses)); - } - - private List getDataClasses(DeleteForm deleteForm) - { - List dataClasses = new ArrayList<>(); - for (long rowId : deleteForm.getIds(false)) - { - ExpDataClass dataClass = ExperimentServiceImpl.get().getDataClass(getContainer(), getUser(), rowId); - if (dataClass != null) - { - dataClasses.add(dataClass); - } - } - return dataClasses; - } - - private boolean ensureCorrectContainer(List dataClasses) - { - for (ExpDataClass dataClass : dataClasses) - { - Container sourceContainer = dataClass.getContainer(); - if (!sourceContainer.equals(getContainer())) - { - return false; - } - } - return true; - } - - private List getRuns(List dataClasses) - { - if (!dataClasses.isEmpty()) - { - List runArray = ExperimentService.get().getRunsUsingDataClasses(dataClasses); - return ExperimentService.get().runsDeletedWithInput(runArray); - } - else - { - return Collections.emptyList(); - } - } - } - - @Marshal(Marshaller.Jackson) - @RequiresPermission(ReadPermission.class) - public static class GetDataClassPropertiesAction extends ReadOnlyApiAction - { - @Override - public Object execute(DataClassForm form, BindException errors) throws Exception - { - ExpDataClass dataClass = form.getDataClass(getContainer()); - if (dataClass != null) - return new DataClassDomainKindProperties(dataClass); - else - throw new NotFoundException("Data class does not exist in this container for rowId " + form.getRowId() + "."); - } - } - - @RequiresPermission(DesignDataClassPermission.class) - public static class EditDataClassAction extends SimpleViewAction - { - private ExpDataClassImpl _dataClass; - - @Override - public ModelAndView getView(DataClassForm form, BindException errors) - { - boolean create = form.getLSID() == null && form.getRowId() == 0 && form.getName() == null; - if (!create) - _dataClass = form.getDataClass(getContainer()); - - return ModuleHtmlView.get(ModuleLoader.getInstance().getModule("core"), ModuleHtmlView.getGeneratedViewPath("dataClassDesigner")); - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("dataClass"); - - root.addChild("Data Classes", ExperimentUrlsImpl.get().getDataClassListURL(getContainer())); - if (_dataClass == null) - { - root.addChild("Create Data Class"); - } - else - { - root.addChild(_dataClass.getName(), ExperimentUrlsImpl.get().getShowDataClassURL(getContainer(), _dataClass.getRowId())); - root.addChild("Update Data Class"); - } - } - } - - @RequiresPermission(DesignDataClassPermission.class) - public static class CreateDataClassFromTemplateAction extends FormViewAction - { - private ActionURL _successUrl; - private Map _domainTemplates; - - @Override - public void validateCommand(CreateDataClassFromTemplateForm form, Errors errors) - { - String name = null; - _domainTemplates = DomainTemplateGroup.getAllTemplates(getContainer()); - - if (!_domainTemplates.containsKey(form.getDomainTemplate())) - { - errors.reject(ERROR_MSG, "Unknown template selected: " + form.getDomainTemplate()); - } - else - { - DomainTemplate template = _domainTemplates.get(form.getDomainTemplate()); - name = template.getTemplateName(); - - // Issue 40230: if template includes sample type option, verify that it exists - if (template.getOptions().containsKey("sampleSet")) - { - String sampleTypeName = template.getOptions().get("sampleSet").toString(); - ExpSampleType sampleType = SampleTypeServiceImpl.get().getSampleType(getContainer(), getUser(), sampleTypeName); - if (sampleType == null) - errors.reject(ERROR_MSG, "Unable to find a sample type in this container with name: " + sampleTypeName + "."); - } - } - - if (StringUtils.isBlank(name)) - errors.reject(ERROR_MSG, "DataClass template selection is required."); - else if (ExperimentService.get().getDataClass(getContainer(), getUser(), name) != null) - errors.reject(ERROR_MSG, "DataClass '" + name + "' already exists."); - - } - - @Override - public ModelAndView getView(CreateDataClassFromTemplateForm form, boolean reshow, BindException errors) - { - Set templates = DomainTemplateGroup.getTemplatesForDomainKind(getContainer(), DataClassDomainKind.NAME); - form.setAvailableDomainTemplateNames(templates); - - Set messages = new HashSet<>(); - Map groups = DomainTemplateGroup.getAllGroups(getContainer()); - for (DomainTemplateGroup g : groups.values()) - messages.addAll(g.getErrors()); - form.setXmlParseErrors(messages); - - return new JspView<>("/org/labkey/experiment/createDataClassFromTemplate.jsp", form, errors); - } - - @Override - public boolean handlePost(CreateDataClassFromTemplateForm form, BindException errors) throws Exception - { - DomainTemplate template = _domainTemplates.get(form.getDomainTemplate()); - Domain domain = DomainUtil.createDomain(template, getContainer(), getUser(), form.getName()); - - _successUrl = domain.getDomainKind().urlEditDefinition(domain, getViewContext()); - return true; - } - - @Override - public URLHelper getSuccessURL(CreateDataClassFromTemplateForm form) - { - return _successUrl; - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("dataClass"); - root.addChild("Create Data Class from Template"); - } - } - - public static class CreateDataClassFromTemplateForm extends DataClass - { - private String _domainTemplate; - private Set _availableDomainTemplateNames; - private Set _xmlParseErrors; - private final ReturnUrlForm _returnUrlForm = new ReturnUrlForm(); - - public String getDomainTemplate() - { - return _domainTemplate; - } - - public void setDomainTemplate(String domainTemplate) - { - _domainTemplate = domainTemplate; - } - - public Set getAvailableDomainTemplateNames() - { - return _availableDomainTemplateNames; - } - - public void setAvailableDomainTemplateNames(Set availableDomainTemplateNames) - { - _availableDomainTemplateNames = availableDomainTemplateNames; - } - - public Set getXmlParseErrors() - { - return _xmlParseErrors; - } - - public void setXmlParseErrors(Set xmlParseErrors) - { - _xmlParseErrors = xmlParseErrors; - } - - @Nullable - public String getReturnUrl() - { - return _returnUrlForm.getReturnUrl(); - } - - public void setReturnUrl(String s) - { - _returnUrlForm.setReturnUrl(s); - } - } - - public static class ConceptURIForm - { - private String _conceptURI; - - public String getConceptURI() - { - return _conceptURI; - } - - public void setConceptURI(String conceptURI) - { - _conceptURI = conceptURI; - } - } - - @RequiresPermission(AdminPermission.class) - public static class RemoveConceptMappingAction extends MutatingApiAction - { - @Override - public void validateForm(ConceptURIForm form, Errors errors) - { - if (form.getConceptURI() == null || ConceptURIProperties.getLookup(getContainer(), form.getConceptURI()) == null) - errors.reject(ERROR_MSG, "Concept URI not found: " + form.getConceptURI()); - } - - @Override - public Object execute(ConceptURIForm form, BindException errors) - { - ConceptURIProperties.removeLookup(getContainer(), form.getConceptURI()); - return new ApiSimpleResponse("success", true); - } - } - - @RequiresPermission(ReadPermission.class) - public static class RunAttachmentDownloadAction extends BaseDownloadAction - { - @Nullable - @Override - public Pair getAttachment(AttachmentForm form) - { - if (form.getLsid() == null || form.getName() == null) - throw new NotFoundException("Error: missing required param 'lsid' or 'name'."); - - ExpRun run = ExperimentService.get().getExpRun(form.getLsid()); - if (run == null) - throw new NotFoundException("Run not found: " + form.getLsid()); - - if (!run.getContainer().equals(getContainer())) - { - if (run.getContainer().hasPermission(getUser(), ReadPermission.class)) - throw new RedirectException(getViewContext().cloneActionURL().setContainer(run.getContainer())); - else - throw new NotFoundException("Run not found"); - } - - AttachmentParent parent = new ExpRunAttachmentParent(run); - return new Pair<>(parent, form.getName()); - } - } - - @RequiresPermission(ReadPermission.class) - public static class DataClassAttachmentDownloadAction extends BaseDownloadAction - { - @Nullable - @Override - public Pair getAttachment(AttachmentForm form) - { - if (form.getLsid() == null || form.getName() == null) - throw new NotFoundException("Error: missing required param 'lsid' or 'name'."); - - Lsid lsid = new Lsid(form.getLsid()); - ExpData data = ExperimentServiceImpl.get().getExpData(lsid.toString()); - if (data == null) - throw new NotFoundException("Error: Data object not found for the given LSID: " + lsid); - AttachmentParent parent = new ExpDataClassAttachmentParent(data.getContainer(), lsid); - - return new Pair<>(parent, form.getName()); - } - } - - public static class AttachmentForm extends LsidForm implements BaseDownloadAction.InlineDownloader - { - private String _name; - private boolean _inline = true; - - public String getName() - { - return _name; - } - - public void setName(String name) - { - _name = name; - } - - @Override - public boolean isInline() - { - return _inline; - } - - public void setInline(boolean inline) - { - _inline = inline; - } - } - - // - // END DataClass actions - // - - public static ActionURL getRunGraphURL(Container c, long runId) - { - return new ActionURL(ShowRunGraphAction.class, c).addParameter("rowId", runId); - } - - - @RequiresPermission(ReadPermission.class) - public class ShowRunGraphAction extends AbstractShowRunAction - { - @Override - protected VBox createLowerView(ExpRunImpl experimentRun, BindException errors) - { - return new VBox( - createRunViewTabs(experimentRun, false, true, true), - new ExperimentRunGraphView(experimentRun, false)); - } - } - - - @RequiresPermission(ReadPermission.class) - public static class DownloadGraphAction extends SimpleViewAction - { - @Override - public ModelAndView getView(ExperimentRunForm form, BindException errors) throws Exception - { - boolean detail = form.isDetail(); - String focus = form.getFocus(); - String focusType = form.getFocusType(); - - ExpRunImpl experimentRun = (ExpRunImpl) form.lookupRun(); - ensureCorrectContainer(getContainer(), experimentRun, getViewContext()); - - ExperimentRunGraph.RunGraphFiles files; - try - { - files = ExperimentRunGraph.generateRunGraph(getViewContext(), experimentRun, detail, focus, focusType); - } - catch (ExperimentException e) - { - PageFlowUtil.streamTextAsImage(getViewContext().getResponse(), "ERROR: " + e.getMessage(), 600, 150, Color.RED); - return null; - } - - try - { - PageFlowUtil.streamFile(getViewContext().getResponse(), new File(files.getImageFile().getAbsolutePath()), false); - } - catch (FileNotFoundException e) - { - getViewContext().getResponse().sendRedirect(getViewContext().getRequest().getContextPath() + "/experiment/ExperimentRunNotFound.gif"); - } - finally - { - files.release(); - } - return null; - } - - @Override - public void addNavTrail(NavTree root) - { - throw new UnsupportedOperationException(); - } - } - - private abstract class AbstractShowRunAction extends SimpleViewAction - { - private ExpRunImpl _experimentRun; - - @Override - public ModelAndView getView(ExperimentRunForm form, BindException errors) - { - _experimentRun = (ExpRunImpl) form.lookupRun(); - ensureCorrectContainer(getContainer(), _experimentRun, getViewContext()); - - VBox vbox = new VBox(); - - JspView detailsView = new JspView<>("/org/labkey/experiment/ExperimentRunDetails.jsp", _experimentRun); - detailsView.setTitle("Standard Properties"); - - var attachmentParent = new ExpRunAttachmentParent(_experimentRun); - var attachments = AttachmentService.get().getAttachments(attachmentParent) - .stream() - .map(att -> Pair.of(att.getName(), new ActionURL(RunAttachmentDownloadAction.class, _experimentRun.getContainer()).addParameter("name", att.getName()).addParameter("lsid", _experimentRun.getLSID()))) - .collect(toList()); - CustomPropertiesView cpv = new CustomPropertiesView(_experimentRun.getLSID(), getContainer(), attachments); - - vbox.addView(new StandardAndCustomPropertiesView(detailsView, cpv)); - - HtmlStringBuilder updateLinks = HtmlStringBuilder.of(); - List runEditors = ExperimentService.get().getRunEditors(); - for (ExpRunEditor editor : runEditors) - { - if (editor.isProtocolEditor(form.lookupRun().getProtocol())) - { - updateLinks.append(LinkBuilder.labkeyLink("edit " + editor.getDisplayName() + " run", editor.getEditUrl(getContainer()).addParameter("rowId", form.getRowId()))); - } - } - - if (!updateLinks.isEmpty()) - { - HtmlView view = new HtmlView(updateLinks); - vbox.addView(view); - } - - VBox lowerView = createLowerView(_experimentRun, errors); - lowerView.setFrame(WebPartView.FrameType.PORTAL); - lowerView.setTitle("Run Details"); - NavTree tree = new NavTree(""); - File runRoot = _experimentRun.getFilePathRoot(); - if (NetworkDrive.exists(runRoot)) - { - if (!runRoot.isDirectory()) - { - runRoot = runRoot.getParentFile(); - } - PipeRoot pipelineRoot = PipelineService.get().findPipelineRoot(_experimentRun.getContainer()); - if (pipelineRoot != null) - { - if (pipelineRoot.isUnderRoot(runRoot)) - { - String path = pipelineRoot.relativePath(runRoot); - tree.addChild("View Files", urlProvider(PipelineUrls.class).urlBrowse(_experimentRun.getContainer(), null, path)); - } - } - } - - final String exportFilesFormId = "exportFilesForm"; - NavTree downloadFiles = new NavTree("Download all files"); - downloadFiles.setScript("document.getElementById('" + exportFilesFormId + "').submit();"); - tree.addChild(downloadFiles); - - // CONSIDER: Show modal dialog using ExperimentService.get().createRunExportView() - NavTree exportXarFiles = new NavTree("Export XAR"); - exportXarFiles.setScript("LABKEY.Experiment.exportRuns({runIds: [" + _experimentRun.getRowId() + "] });"); - tree.addChild(exportXarFiles); - - lowerView.setNavMenu(tree); - lowerView.setIsWebPart(false); - - vbox.addView(lowerView); - vbox.addView(new ExperimentRunGroupsView(getUser(), getContainer(), _experimentRun, getViewContext().getActionURL(), errors)); - - DOM.Renderable exportFilesForm = LK.FORM(at( - id, exportFilesFormId, - method, "POST", - action, new ActionURL(ExportRunFilesAction.class, _experimentRun.getContainer())), - INPUT(at(type, "hidden", - name, DataRegionSelection.DATA_REGION_SELECTION_KEY, - value, "ExportSingleRun")), - INPUT(at(type, "hidden", - name, DataRegion.SELECT_CHECKBOX_NAME, - value, _experimentRun.getRowId())), - INPUT(at(type, "hidden", - name, "zipFileName", - value, _experimentRun.getName() + ".zip"))); - - HtmlView hiddenFormView = new HtmlView(exportFilesForm); - vbox.addView(hiddenFormView); - - return vbox; - } - - protected abstract VBox createLowerView(ExpRunImpl experimentRun, BindException errors); - - @Override - public void addNavTrail(NavTree root) - { - root.addChild(_experimentRun.getName()); - } - } - - public static class ToggleRunExperimentMembershipForm - { - private int _runId; - private int _experimentId; - private boolean _included; - - public int getRunId() - { - return _runId; - } - - public void setRunId(int runId) - { - _runId = runId; - } - - public int getExperimentId() - { - return _experimentId; - } - - public void setExperimentId(int experimentId) - { - _experimentId = experimentId; - } - - public boolean isIncluded() - { - return _included; - } - - public void setIncluded(boolean included) - { - _included = included; - } - } - - @RequiresPermission(UpdatePermission.class) - public static class ToggleRunExperimentMembershipAction extends FormHandlerAction - { - @Override - public boolean handlePost(ToggleRunExperimentMembershipForm form, BindException errors) - { - ExpRun run = ExperimentService.get().getExpRun(form.getRunId()); - // Check if the user has permission to update this run - if (run == null || !run.getContainer().hasPermission(getUser(), UpdatePermission.class)) - { - throw new NotFoundException(); - } - - ExpExperiment exp = ExperimentService.get().getExpExperiment(form.getExperimentId()); - if (exp == null) - { - throw new NotFoundException(); - } - // Check if this - if (!ExperimentService.get().getExperiments(run.getContainer(), getUser(), true, false).contains(exp)) - { - throw new NotFoundException(); - } - // Users must have permission to view, but not necessarily update, the container the holds the run group - if (!exp.getContainer().hasPermission(getUser(), ReadPermission.class)) - { - throw new UnauthorizedException(); - } - - if (form.isIncluded()) - { - exp.addRuns(getUser(), run); - } - else - { - exp.removeRun(getUser(), run); - } - - return true; - } - - @Override - public URLHelper getSuccessURL(ToggleRunExperimentMembershipForm form) - { - return null; - } - - @Override - public void validateCommand(ToggleRunExperimentMembershipForm target, Errors errors) - { - } - } - - private HtmlView createRunViewTabs(ExpRun expRun, boolean showGraphSummary, boolean showGraphDetail, boolean showText) - { - return new HtmlView( - TABLE(cl("labkey-tab-strip"), - TR( - createTabSpacer(false), - createTab("Graph Summary View", ExperimentUrlsImpl.get().getRunGraphURL(expRun), !showGraphSummary), - createTabSpacer(false), - createTab("Graph Detail View", ExperimentUrlsImpl.get().getRunGraphDetailURL(expRun), !showGraphDetail), - createTabSpacer(false), - createTab("Text View", ExperimentUrlsImpl.get().getRunTextURL(expRun), !showText), - createTabSpacer(true)))); - } - - private DOM.Renderable createTab(String text, ActionURL url, boolean selected) - { - return TD(cl(selected,"labkey-tab-selected", "labkey-tab"), - A(at(href, url), text)); - } - - private DOM.Renderable createTabSpacer(boolean fullWidth) - { - return TD(cl("labkey-tab-space").at(fullWidth, width, "100%"), - IMG(at(src, AppProps.getInstance().getContextPath() + "/_.gif", width, "5"))); - } - - @RequiresPermission(ReadPermission.class) - public class ShowRunTextAction extends AbstractShowRunAction - { - @Override - protected VBox createLowerView(ExpRunImpl expRun, BindException errors) - { - JspView applicationsView = new JspView<>("/org/labkey/experiment/ProtocolApplications.jsp", expRun); - applicationsView.setFrame(WebPartView.FrameType.TITLE); - applicationsView.setTitle("Protocol Applications"); - - HtmlView toggleView = createRunViewTabs(expRun, true, true, false); - - QuerySettings runDataInputsSettings = new QuerySettings(getViewContext(), "RunDataInputs", DataInputs.name()); - UsageQueryView runDataInputsView = new UsageQueryView("Data Inputs", getViewContext(), expRun, ExpProtocol.ApplicationType.ExperimentRun, runDataInputsSettings, errors); - runDataInputsView.setButtonBarPosition(DataRegion.ButtonBarPosition.TOP); - - QuerySettings runDataOutputsSettings = new QuerySettings(getViewContext(), "RunDataOutputs", DataInputs.name()); - UsageQueryView runDataOutputsView = new UsageQueryView("Data Outputs", getViewContext(), expRun, ExpProtocol.ApplicationType.ExperimentRunOutput, runDataOutputsSettings, errors); - runDataOutputsView.setButtonBarPosition(DataRegion.ButtonBarPosition.NONE); - - QuerySettings runMaterialInputsSetting = new QuerySettings(getViewContext(), "RunMaterialInputs", ExpSchema.TableType.MaterialInputs.name()); - UsageQueryView runMaterialInputsView = new UsageQueryView("Material Inputs", getViewContext(), expRun, ExpProtocol.ApplicationType.ExperimentRun, runMaterialInputsSetting, errors); - runMaterialInputsView.setButtonBarPosition(DataRegion.ButtonBarPosition.TOP); - - QuerySettings runMaterialOutputsSettings = new QuerySettings(getViewContext(), "RunMaterialOutputs", ExpSchema.TableType.MaterialInputs.name()); - UsageQueryView runMaterialOutputsView = new UsageQueryView("Material Outputs", getViewContext(), expRun, ExpProtocol.ApplicationType.ExperimentRunOutput, runMaterialOutputsSettings, errors); - runMaterialOutputsView.setButtonBarPosition(DataRegion.ButtonBarPosition.NONE); - - HBox inputsView = new HBox(runDataInputsView, runMaterialInputsView); - HBox registeredInputsView = new HBox(); - - var expService = ExperimentService.get(); - expService.getRunInputsViewProviders().forEach(provider -> - { - var queryView = provider.createView(getViewContext(), expRun, errors); - if (queryView != null) - { - registeredInputsView.addView(queryView); - } - }); - HBox outputsView = new HBox(runDataOutputsView, runMaterialOutputsView); - HBox registeredOutputsView = new HBox(); - expService.getRunOutputsViewProviders().forEach(provider -> - { - var queryView = provider.createView(getViewContext(), expRun, errors); - if (queryView != null) - { - registeredOutputsView.addView(queryView); - } - }); - - var vBox = new VBox(); - vBox.addView(toggleView); - vBox.addView(inputsView); - if (!registeredInputsView.isEmpty()) - vBox.addView(registeredInputsView); - vBox.addView(outputsView); - if (!registeredOutputsView.isEmpty()) - vBox.addView(registeredOutputsView); - vBox.addView(applicationsView); - - return vBox; - } - } - - private static class UsageQueryView extends QueryView - { - private final ExpRun _run; - private final ExpProtocol.ApplicationType _type; - - public UsageQueryView(String title, ViewContext context, ExpRun run, ExpProtocol.ApplicationType type, - QuerySettings settings, BindException errors) - { - super(new ExpSchema(context.getUser(), context.getContainer()), settings, errors); - setTitle(title); - setFrame(FrameType.TITLE); - _run = run; - _type = type; - setShowBorders(true); - setShadeAlternatingRows(true); - setShowExportButtons(false); - setShowPagination(false); - disableContainerFilterSelection(); - } - - @Override - protected TableInfo createTable() - { - String tableName = getSettings().getQueryName(); - ExpInputTable tableInfo = (ExpInputTable) getSchema().getTable(tableName, new ContainerFilter.AllFolders(getUser()), true, true); - tableInfo.setRun(_run, _type); - tableInfo.setLocked(true); - return tableInfo; - } - } - - - public static ActionURL getShowRunGraphDetailURL(Container c, long rowId) - { - ActionURL url = new ActionURL(ShowRunGraphDetailAction.class, c); - url.addParameter("rowId", rowId); - return url; - } - - - @RequiresPermission(ReadPermission.class) - public class ShowRunGraphDetailAction extends AbstractShowRunAction - { - @Override - protected VBox createLowerView(ExpRunImpl run, BindException errors) - { - ExperimentRunGraphView gw = new ExperimentRunGraphView(run, true); - if (null != getViewContext().getActionURL().getParameter("focus")) - gw.setFocus(getViewContext().getActionURL().getParameter("focus")); - if (null != getViewContext().getActionURL().getParameter("focusType")) - gw.setFocusType(getViewContext().getActionURL().getParameter("focusType")); - return new VBox(createRunViewTabs(run, true, false, true), gw); - } - } - - private abstract class AbstractDataAction extends SimpleViewAction - { - protected ExpDataImpl _data; - - @Override - public final ModelAndView getView(DataForm form, BindException errors) throws Exception - { - _data = form.lookupData(); - if (_data == null) - { - throw new NotFoundException("Could not find a data with RowId " + form.getRowId()); - } - - ensureCorrectContainer(getContainer(), _data, getViewContext()); - return getDataView(form, errors); - } - - protected abstract ModelAndView getDataView(DataForm form, BindException errors) throws Exception; - - @Override - public void addNavTrail(NavTree root) - { - addRootNavTrail(root); - root.addChild("Data " + _data.getName()); - } - } - - @RequiresPermission(ReadPermission.class) - public class ShowDataAction extends AbstractDataAction - { - @Override - public ModelAndView getDataView(DataForm form, BindException errors) - { - ExpRun run = _data.getRun(); - ExpProtocol sourceProtocol = _data.getSourceProtocol(); - ExpProtocolApplication sourceProtocolApplication = _data.getSourceApplication(); - ExpDataClass dataClass = _data.getDataClass(getUser()); - - ExpSchema schema = new ExpSchema(getUser(), getContainer()); - TableInfo table; - long pk; - if (dataClass == null) - { - table = schema.getDatasTable(); - pk = _data.getRowId(); - } - else - { - table = schema.getSchema(ExpSchema.NestedSchemas.data).getTable(dataClass.getName()); - pk = new TableSelector(table, Collections.singleton("rowId"), new SimpleFilter(FieldKey.fromParts("lsid"), _data.getLSID()), null).getObject(Integer.class); - } - - DataRegion dr = new DataRegion(); - dr.setTable(table); - List cols = table.getColumns().stream().filter(ColumnInfo::isShownInDetailsView).collect(toList()); - dr.addColumns(cols); - dr.removeColumns("RowId", "Created", "CreatedBy", "Modified", "ModifiedBy", "DataFileUrl", "Run", "LSID", "CpasType", "SourceApplicationId", "Folder", "Generated"); - dr.addDisplayColumn(new ExperimentRunDisplayColumn(run, "Source Experiment Run")); - dr.addDisplayColumn(new ProtocolDisplayColumn(sourceProtocol, "Source Protocol")); - dr.addDisplayColumn(new ProtocolApplicationDisplayColumn(sourceProtocolApplication, "Source Protocol Application")); - dr.addDisplayColumn(new LineageGraphDisplayColumn(_data, run)); - DetailsView detailsView = new DetailsView(dr, pk); - detailsView.setTitle("Standard Properties"); - detailsView.setFrame(WebPartView.FrameType.PORTAL); - ButtonBar bb = new ButtonBar(); - bb.setStyle(ButtonBar.Style.separateButtons); - - ExperimentDataHandler handler = _data.findDataHandler(); - ActionURL viewDataURL = handler == null ? null : handler.getContentURL(_data); - if (viewDataURL != null) - { - bb.add(new ActionButton("View data", viewDataURL)); - } - - if (_data.isPathAccessible()) - { - bb.add(new ActionButton("View file", ExperimentUrlsImpl.get().getShowFileURL(_data, true))); - bb.add(new ActionButton("Download file", ExperimentUrlsImpl.get().getShowFileURL(_data, false))); - - if (getContainer().hasPermission(getUser(), InsertPermission.class)) - { - String relativePath = null; - PipeRoot root = PipelineService.get().findPipelineRoot(getContainer()); - if (root != null) - { - Path rootFile = root.getRootNioPath(); - Path dataFile = _data.getFilePath(); - if (dataFile != null) - { - Path pathRelative; - try - { - pathRelative = rootFile.relativize(dataFile); - if (null != pathRelative) - relativePath = pathRelative.toString(); - } - catch (IllegalArgumentException e) - { - // dataFile not relative to root - } - } - } - ActionURL browseURL = urlProvider(PipelineUrls.class).urlBrowse(getContainer(), getViewContext().getActionURL(), relativePath); - bb.add(new ActionButton("Browse in pipeline", browseURL)); - } - } - - // add links to any other exp.data that share the same dataFileUrl path - var altDataList = ExperimentService.get().getAllExpDataByURL(_data.getDataFileUrl(), getContainer()); - altDataList.removeIf(_data::equals); - if (!altDataList.isEmpty()) - { - MenuButton menu = new MenuButton("Alternate Data"); - for (ExpData altData : altDataList) - { - ExpRun altDataRun = altData.getRun(); - StringBuilder sb = new StringBuilder(altData.getName()); - if (altDataRun != null) - sb.append(" created by run '").append(altDataRun.getName()).append("' (").append(altDataRun.getProtocol().getName()).append(")"); - menu.addMenuItem(sb.toString(), altData.detailsURL()); - } - bb.add(menu); - } - - dr.setButtonBarPosition(DataRegion.ButtonBarPosition.TOP); - dr.setButtonBar(bb); - - CustomPropertiesView cpv = new CustomPropertiesView(_data.getLSID(), getContainer()); - HBox hbox = new StandardAndCustomPropertiesView(detailsView, cpv); - - VBox vbox = new VBox(hbox); - - ParentChildView pv = new ParentChildView(_data, getViewContext()); - vbox.addView(pv); - - ExperimentRunListView runListView = ExperimentRunListView.createView(getViewContext(), ExperimentRunType.ALL_RUNS_TYPE, true); - runListView.getRunTable().setInputData(_data); - runListView.getRunTable().setContainerFilter(new ContainerFilter.AllFolders(getUser())); - runListView.getRunTable().setLocked(true); - runListView.setTitle("Runs using this data as an input"); - vbox.addView(runListView); - - if (_data.isInlineImage() && _data.isFileOnDisk()) - { - ActionURL showFileURL = new ActionURL(ShowFileAction.class, getContainer()).addParameter("rowId", _data.getRowId()); - HtmlView imageView = new HtmlView(IMG(at(src, showFileURL))); - return new VBox(vbox, imageView); - } - return vbox; - } - } - - @RequiresPermission(AdminPermission.class) - public static class CheckDataFileAction extends MutatingApiAction - { - private ExpDataImpl _data; - - @Override - public void validateForm(DataFileForm form, Errors errors) - { - _data = form.lookupData(); - if (_data == null) - { - errors.reject(ERROR_MSG, "No ExpData found for id: " + form.getRowId()); - } - } - - @Override - public ApiResponse execute(DataFileForm form, BindException errors) - { - File dataFile = _data.getFile(); - Container dataContainer = _data.getContainer(); - boolean fileExists = _data.isFileOnDisk(); - boolean fileExistsAtCurrent = false; - File newDataFile = null; - - ApiSimpleResponse response = new ApiSimpleResponse(); - response.put("dataFileUrl", _data.getDataFileUrl()); - response.put("fileExists", fileExists); - response.put("containerPath", dataContainer.getPath()); - - if (!fileExists) - { - PipeRoot pipelineRoot = PipelineService.get().findPipelineRoot(dataContainer); - if (pipelineRoot != null && pipelineRoot.isValid() && dataFile != null) - { - newDataFile = pipelineRoot.resolvePath("/" + AssayFileWriter.DIR_NAME + "/" + dataFile.getName()); - fileExistsAtCurrent = NetworkDrive.exists(newDataFile); - response.put("fileExistsAtCurrent", fileExistsAtCurrent); - } - } - - // if the current dataFileUrl does not exist on disk and we have the file at the current - // pipeline root /assaydata dir, fix the dataFileUrl value - if (form.isAttemptFilePathFix()) - { - if (fileExistsAtCurrent) - { - ExpDataFileListener fileListener = new ExpDataFileListener(); - fileListener.fileMoved(dataFile, newDataFile, getUser(), dataContainer); - response.put("filePathFixed", true); - - // update the ExpData object so that we can get the new dataFileUrl - _data = form.lookupData(); - response.put("newDataFileUrl", _data.getDataFileUrl()); - } - else - { - response.put("filePathFixed", false); - } - } - - response.put("success", true); - return response; - } - } - - public static class DataFileForm extends DataForm - { - private boolean _attemptFilePathFix; - - public boolean isAttemptFilePathFix() - { - return _attemptFilePathFix; - } - - public void setAttemptFilePathFix(boolean attemptFilePathFix) - { - _attemptFilePathFix = attemptFilePathFix; - } - } - - @RequiresPermission(ReadPermission.class) - public class ShowFileAction extends AbstractDataAction - { - @Override - protected ModelAndView getDataView(DataForm form, BindException errors) throws IOException - { - if (!_data.isPathAccessible()) - { - throw new NotFoundException("Data file " + _data.getDataFileUrl() + " does not exist on disk"); - } - - PipeRoot root = PipelineService.get().findPipelineRoot(getContainer()); - if (root != null && !root.isUnderRoot(_data.getFilePath())) - { - // Issue 35649: ImmPort module "publish" creates exp.data object in this container for paths that originate in a different container - FileContentService fileSvc = FileContentService.get(); - if (fileSvc == null) - throw new UnauthorizedException("Data file is not under the pipeline root for this folder"); - - List containers = fileSvc.getContainersForFilePath(_data.getFilePath()); - if (containers.isEmpty() || containers.stream().noneMatch(c -> c.hasPermission(getUser(), ReadPermission.class))) - throw new UnauthorizedException("Data file is not under the pipeline root for this folder"); - } - - //Issues 25667 and 31152 - if (form.isInline()) - { - ExperimentDataHandler h = _data.findDataHandler(); - if (h != null) - { - URLHelper url = h.getShowFileURL(_data); - if (url != null) - { - throw new RedirectException(url, false); - } - } - } - - try - { - Path realContent = _data.getFilePath(); - if (null == realContent) - throw new IllegalStateException("Path not found."); - - boolean inline = _data.isInlineImage() || form.isInline() || "inlineImage".equalsIgnoreCase(form.getFormat()); - if (_data.isInlineImage() && form.getMaxDimension() != null) - { - try (InputStream inputStream = Files.newInputStream(realContent)) - { - BufferedImage image = ImageIO.read(inputStream); - // If image, create a thumbnail, otherwise fall through as a regular download attempt - if (image != null) - { - int imageMax = Math.max(image.getHeight(), image.getWidth()); - if (imageMax > form.getMaxDimension().intValue()) - { - double scale = (double) form.getMaxDimension().intValue() / (double) imageMax; - ByteArrayOutputStream bOut = new ByteArrayOutputStream(); - ImageUtil.resizeImage(image, bOut, scale, 1); - PageFlowUtil.streamFileBytes(getViewContext().getResponse(), FileUtil.getFileName(realContent) + ".png", bOut.toByteArray(), !inline); - return null; - } - } - } - } - - boolean extended = "jsonTSVExtended".equalsIgnoreCase(form.getFormat()); - boolean ignoreTypes = "jsonTSVIgnoreTypes".equalsIgnoreCase(form.getFormat()); - if ("jsonTSV".equalsIgnoreCase(form.getFormat()) || extended || ignoreTypes) - { - if (!FileUtil.hasCloudScheme(realContent)) // TODO: handle streaming from S3 to JSON - streamToJSON(FileSystemLike.wrapFile(realContent), form.getFormat(), -1, null); - return null; - } - - try (InputStream inputStream = Files.newInputStream(realContent)) - { - PageFlowUtil.streamFile(getViewContext().getResponse(), Collections.emptyMap(), FileUtil.getFileName(realContent), inputStream, !inline); - } - } - catch (IOException e) - { - try - { - // Try to write the exception back to the caller if we haven't already flushed the buffer - ApiJsonWriter writer = new ApiJsonWriter(getViewContext().getResponse()); - writer.writeResponse(e); - } - catch (IllegalStateException ise) - { - // Most likely that a disconnected client caused the IOException writing back the response - } - } - - return null; - } - } - - - public static class ParseForm - { - String format = "jsonTSV"; - int maxRows = -1; - - public String getFormat() - { - return format; - } - - public void setFormat(String format) - { - this.format = format; - } - - public int getMaxRows() - { - return maxRows; - } - - public void setMaxRows(int maxRow) - { - this.maxRows = maxRow; - } - } - - @RequiresNoPermission - public class ParseFileAction extends MutatingApiAction - { - @Override - public Object execute(ParseForm form, BindException errors) throws Exception - { - if (!(getViewContext().getRequest() instanceof MultipartHttpServletRequest)) - throw new BadRequestException("Expected MultipartHttpServletRequest when posting files."); - - MultipartFile formFile = getFileMap().get("file"); - if (formFile == null) - { - return true; - } - - FileLike tempFile = null; - try - { - tempFile = FileUtil.createTempFileLike("parse", formFile.getOriginalFilename()); - FileUtil.copyData(formFile.getInputStream(), tempFile.openOutputStream()); - streamToJSON(tempFile, form.getFormat(), form.getMaxRows(), formFile.getOriginalFilename()); - } - finally - { - if (null != tempFile) - tempFile.delete(); - } - return null; - } - } - - - // SampleTypeTest - private void streamToJSON(FileLike realContent, String format, int maxRow, String originalFileName) throws IOException - { - String lowerCaseFileName = realContent.getName().toLowerCase(); - boolean extended = "jsonTSVExtended".equalsIgnoreCase(format); - boolean ignoreTypes = "jsonTSVIgnoreTypes".equalsIgnoreCase(format); - - JSONArray sheetsArray; - if (lowerCaseFileName.endsWith(".xls") || lowerCaseFileName.endsWith(".xlsx")) - { - try (InputStream in = realContent.openInputStream()) - { - sheetsArray = ExcelFactory.convertExcelToJSON(in, extended, maxRow); - } - } - else - { - DataLoaderFactory dlf = DataLoader.get().findFactory(realContent, null); - if (null == dlf) - { - throw new ApiUsageException("Unable to parse file " + realContent + ", it is likely of an unsupported file type"); - } - - try (InputStream in = realContent.openInputStream(); - DataLoader tabLoader = dlf.createLoader(in, true)) - { - tabLoader.setScanAheadLineCount(5000); - ColumnDescriptor[] cols = tabLoader.getColumns(); - - if (ignoreTypes) - for (ColumnDescriptor col : cols) - col.clazz = String.class; - - JSONArray rowsArray = new JSONArray(); - JSONArray headerArray = new JSONArray(); - for (ColumnDescriptor col : cols) - { - if (extended) - { - JSONObject valueObject = new JSONObject(); - valueObject.put("value", col.name); - headerArray.put(valueObject); - } - else - { - headerArray.put(col.name); - } - } - rowsArray.put(headerArray); - for (Map rowMap : tabLoader) - { - // headers count as a row to be consistent - if (maxRow > -1 && maxRow <= rowsArray.length() + 1) - break; - - JSONArray rowArray = new JSONArray(); - for (ColumnDescriptor col : cols) - { - Object value = rowMap.get(col.name); - if (extended) - { - JSONObject valueObject = new JSONObject(); - valueObject.put("value", value); - rowArray.put(valueObject); - } - else - { - rowArray.put(value); - } - } - rowsArray.put(rowArray); - } - - JSONObject sheetJSON = new JSONObject(); - sheetJSON.put("name", "flat"); - sheetJSON.put("data", rowsArray); - sheetsArray = new JSONArray(); - sheetsArray.put(sheetJSON); - } - } - - try (ApiJsonWriter writer = new ApiJsonWriter(getViewContext().getResponse())) - { - JSONObject workbookJSON = new JSONObject(); - workbookJSON.put("fileName", realContent.getName()); - workbookJSON.put("sheets", sheetsArray); - if (originalFileName != null) - workbookJSON.put("originalFileName", originalFileName); - writer.writeResponse(new ApiSimpleResponse(workbookJSON)); - } - } - - - public static class ConvertArraysToExcelForm - { - private String _json; - - public String getJson() - { - return _json; - } - - public void setJson(String json) - { - _json = json; - } - } - - @RequiresPermission(ReadPermission.class) - public static class ConvertArraysToExcelAction extends ExportAction - { - @Override - public void validate(ConvertArraysToExcelForm form, BindException errors) - { - if (form.getJson() == null) - { - errors.reject(ERROR_MSG, "Unable to convert to Excel - no spreadsheet data given"); - } - } - - @Override - public void export(ConvertArraysToExcelForm form, HttpServletResponse response, BindException errors) throws Exception - { - try - { - JSONObject rootObject; - JSONArray sheetsArray; - if (form.getJson() == null || form.getJson().trim().isEmpty()) - { - // Create JSON so that we return an empty file - rootObject = new JSONObject(); - sheetsArray = new JSONArray(); - JSONObject sheetObject = new JSONObject(); - sheetsArray.put(sheetObject); - } - else - { - rootObject = new JSONObject(form.getJson()); - sheetsArray = rootObject.getJSONArray("sheets"); - } - String filename = rootObject.has("fileName") ? rootObject.getString("fileName") : "ExcelExport.xls"; - ExcelWriter.ExcelDocumentType docType = filename.toLowerCase().endsWith(".xlsx") ? ExcelWriter.ExcelDocumentType.xlsx : ExcelWriter.ExcelDocumentType.xls; - - try (Workbook workbook = ExcelFactory.createFromArray(sheetsArray, docType)) - { - response.setContentType(docType.getMimeType()); - ResponseHelper.setContentDisposition(response, ResponseHelper.ContentDispositionType.attachment, filename); - ResponseHelper.setPrivate(response); - workbook.write(response.getOutputStream()); - - JSONObject qInfo = rootObject.has("queryinfo") ? rootObject.getJSONObject("queryinfo") : null; - if (qInfo != null) - { - QueryService.get().addAuditEvent(getUser(), getContainer(), qInfo.getString("schema"), - qInfo.getString("query"), getViewContext().getActionURL(), - rootObject.getString("auditMessage") + filename, - null); - SimpleMetricsService.get().increment(ExperimentService.MODULE_NAME, "convertTable", "asExcel"); - } - } - } - catch (JSONException | ClassCastException e) - { - // We can get a ClassCastException if we expect an array and get a simple String, for example - ExceptionUtil.renderErrorView(getViewContext(), getPageConfig(), ErrorRenderer.ErrorType.notFound, HttpServletResponse.SC_BAD_REQUEST, "Failed to convert to Excel - invalid input", e, false, false); - } - } - } - - @RequiresPermission(ReadPermission.class) - public static class ConvertArraysToTableAction extends ExportAction - { - @Override - public void validate(ConvertArraysToExcelForm form, BindException errors) - { - if (form.getJson() == null) - { - errors.reject(ERROR_MSG, "Unable to convert to table - no data given"); - } - } - - @Override - public void export(ConvertArraysToExcelForm form, HttpServletResponse response, BindException errors) throws Exception - { - try - { - JSONObject rootObject; - JSONArray rowsArray; - if (form.getJson() == null || form.getJson().trim().isEmpty()) - { - // Create JSON so that we return an empty file - rootObject = new JSONObject(); - rowsArray = new JSONArray(); - } - else - { - rootObject = new JSONObject(form.getJson()); - rowsArray = rootObject.getJSONArray("rows"); - } - - TSVWriter.DELIM delimType = (!rootObject.isNull("delim") ? TSVWriter.DELIM.valueOf(rootObject.getString("delim")) : TSVWriter.DELIM.TAB); - TSVWriter.QUOTE quoteType = (!rootObject.isNull("quoteChar") ? TSVWriter.QUOTE.valueOf(rootObject.getString("quoteChar")) : TSVWriter.QUOTE.NONE); - String filenamePrefix = (!rootObject.isNull("fileNamePrefix") ? rootObject.getString("fileNamePrefix") : "Export"); - String filename = filenamePrefix + "." + delimType.extension; - String newlineChar = !rootObject.isNull("newlineChar") ? rootObject.getString("newlineChar") : "\n"; - - PageFlowUtil.prepareResponseForFile(response, Collections.emptyMap(), filename, true); - response.setContentType(delimType.contentType); - - //NOTE: we could also have used TSVWriter; however, this is in use elsewhere and we dont need a custom subclass - try (CSVWriter writer = new CSVWriter(response.getWriter(), delimType.delim, quoteType.quoteChar, newlineChar)) - { - for (int i = 0; i < rowsArray.length(); i++) - { - List objectList = rowsArray.getJSONArray(i).toList(); - Iterator it = objectList.iterator(); - List list = new ArrayList<>(); - - while (it.hasNext()) - { - Object o = it.next(); - if (o != null) - list.add(o.toString()); - else - list.add(""); - } - - writer.writeNext(list.toArray(new String[0])); - } - } - - JSONObject qInfo = rootObject.optJSONObject("queryinfo"); - if (qInfo != null) - { - QueryService.get().addAuditEvent(getUser(), getContainer(), qInfo.getString("schema"), qInfo.getString("query"), - getViewContext().getActionURL(), - rootObject.getString("auditMessage") + filename, - rowsArray.length()); - SimpleMetricsService.get().increment(ExperimentService.MODULE_NAME, "convertTable", "asDelimited"); - } - } - catch (JSONException e) - { - ExceptionUtil.renderErrorView(getViewContext(), getPageConfig(), ErrorRenderer.ErrorType.notFound, HttpServletResponse.SC_BAD_REQUEST, "Failed to convert to table - invalid input", e, false, false); - } - } - } - - - public static class ConvertHtmlToExcelForm - { - private String _baseUrl; - private String _htmlFragment; - private String _name = "workbook.xls"; - - - public String getName() - { - return _name; - } - - public void setName(String name) - { - _name = name; - } - - public String getBaseUrl() - { - return _baseUrl; - } - - public void setBaseUrl(String baseUrl) - { - _baseUrl = baseUrl; - } - - public String getHtmlFragment() - { - return _htmlFragment; - } - - public void setHtmlFragment(String htmlFragment) - { - _htmlFragment = htmlFragment; - } - } - - - @RequiresPermission(ReadPermission.class) - public static class ConvertHtmlToExcelAction extends FormViewAction - { - String _responseHtml = null; - - @Override - public void validateCommand(ConvertHtmlToExcelForm target, Errors errors) - { - } - - @Override - public ModelAndView getView(ConvertHtmlToExcelForm form, boolean reshow, BindException errors) - { - String html = - "

" + - "" + - new CsrfInput(getViewContext()) + - "
"; - return HtmlView.unsafe(html); - } - - @Override - public boolean handlePost(ConvertHtmlToExcelForm form, BindException errors) - { - ActionURL url = getViewContext().getActionURL(); - String base = url.getBaseServerURI(); - if (!base.endsWith("/")) base += "/"; - - String baseTag = ""; - SafeToRender css = PageFlowUtil.getStylesheetIncludes(getContainer()); - String htmlFragment = StringUtils.trimToEmpty(form.getHtmlFragment()); - String html = "" + baseTag + css + "" + htmlFragment + ""; - - // UNDONE: strip script - List tidyErrors = new ArrayList<>(); - String tidy = JSoupUtil.tidyHTML(html, false, tidyErrors); - - if (!tidyErrors.isEmpty()) - { - for (String err : tidyErrors) - { - errors.reject(ERROR_MSG, err); - } - return false; - } - - _responseHtml = tidy; - return true; - } - - @Override - public ModelAndView getSuccessView(ConvertHtmlToExcelForm form) - { - ResponseHelper.setContentDisposition(getViewContext().getResponse(), ResponseHelper.ContentDispositionType.attachment, form.getName()); - getPageConfig().setTemplate(PageConfig.Template.None); - HtmlView v = HtmlView.unsafe(_responseHtml); - v.setContentType("application/vnd.ms-excel"); - v.setFrame(WebPartView.FrameType.NONE); - return v; - } - - @Override - public URLHelper getSuccessURL(ConvertHtmlToExcelForm convertHtmlToExcelForm) - { - return null; - } - - @Override - public void addNavTrail(NavTree root) - { - } - } - - - public static ActionURL getShowApplicationURL(Container c, long rowId) - { - ActionURL url = new ActionURL(ShowApplicationAction.class, c); - url.addParameter("rowId", rowId); - - return url; - } - - - @RequiresPermission(ReadPermission.class) - public class ShowApplicationAction extends SimpleViewAction - { - private ExpProtocolApplicationImpl _app; - private ExpRun _run; - - @Override - public ModelAndView getView(ExpObjectForm form, BindException errors) - { - _app = ExperimentServiceImpl.get().getExpProtocolApplication(form.getRowId()); - if (_app == null) - { - throw new NotFoundException("Could not find Protocol Application"); - } - _run = _app.getRun(); - if (_run == null) - { - throw new NotFoundException("No experiment run associated with Protocol Application"); - } - ensureCorrectContainer(getContainer(), _app, getViewContext()); - - ExpProtocol protocol = _app.getProtocol(); - - DataRegion dr = new DataRegion(); - dr.addColumns(ExperimentServiceImpl.get().getTinfoProtocolApplication().getUserEditableColumns()); - DetailsView detailsView = new DetailsView(dr, form.getRowId()); - dr.removeColumns("RunId", "ProtocolLSID", "RowId", "LSID"); - dr.addDisplayColumn(new ExperimentRunDisplayColumn(_run)); - dr.addDisplayColumn(new ProtocolDisplayColumn(protocol)); - dr.addDisplayColumn(new LineageGraphDisplayColumn(_app, _run)); - detailsView.setTitle("Protocol Application"); - - Container c = getContainer(); - ApplicationOutputGrid outMGrid = new ApplicationOutputGrid(c, _app.getRowId(), ExperimentServiceImpl.get().getTinfoMaterial()); - ApplicationOutputGrid outDGrid = new ApplicationOutputGrid(c, _app.getRowId(), ExperimentServiceImpl.get().getTinfoData()); - Map map = new HashMap<>(); - for (ProtocolApplicationParameter param : ExperimentService.get().getProtocolApplicationParameters(_app.getRowId())) - { - map.put(param.getOntologyEntryURI(), param); - } - - JspView> paramsView = new JspView<>("/org/labkey/experiment/Parameters.jsp", map); - paramsView.setTitle("Protocol Application Parameters"); - CustomPropertiesView cpv = new CustomPropertiesView(_app.getLSID(), c); - return new VBox(new StandardAndCustomPropertiesView(detailsView, cpv), paramsView, outMGrid, outDGrid); - } - - @Override - public void addNavTrail(NavTree root) - { - addRootNavTrail(root); - root.addChild("Experiment Run", ExperimentUrlsImpl.get().getRunGraphDetailURL(_run)); - root.addChild("Protocol Application " + _app.getName()); - } - } - - @RequiresPermission(ReadPermission.class) - public class ShowProtocolGridAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object o, BindException errors) - { - return new ProtocolWebPart(false, getViewContext()); - } - - @Override - public void addNavTrail(NavTree root) - { - addRootNavTrail(root); - root.addChild("Protocols"); - } - } - - @RequiresPermission(ReadPermission.class) - public class ProtocolDetailsAction extends SimpleViewAction - { - private ExpProtocolImpl _protocol; - - @Override - public ModelAndView getView(ExpObjectForm form, BindException errors) - { - _protocol = ExperimentServiceImpl.get().getExpProtocol(form.getRowId()); - if (_protocol == null) - { - _protocol = ExperimentServiceImpl.get().getExpProtocol(form.getLSID()); - } - - if (_protocol == null) - { - throw new NotFoundException("Unable to find a matching protocol"); - } - ensureCorrectContainer(getContainer(), _protocol, getViewContext()); - - JspView detailsView = new JspView<>("/org/labkey/experiment/ProtocolDetails.jsp", _protocol); - detailsView.setTitle("Standard Properties"); - - CustomPropertiesView cpv = new CustomPropertiesView(_protocol.getLSID(), getContainer()); - ProtocolParametersView parametersView = new ProtocolParametersView(_protocol); - - VBox protocolDetails = new VBox(); - protocolDetails.setFrame(WebPartView.FrameType.PORTAL); - protocolDetails.setTitle("Protocol Details"); - protocolDetails.addView(new ProtocolInputOutputsView(_protocol, errors)); - - JspView stepsView = new JspView<>("/org/labkey/experiment/ProtocolSteps.jsp", _protocol); - stepsView.setTitle("Protocol Steps"); - stepsView.setFrame(WebPartView.FrameType.TITLE); - protocolDetails.addView(stepsView); - - ExpSchema schema = new ExpSchema(getUser(), getContainer()); - ExperimentRunListView runView = new ExperimentRunListView(schema, ExperimentRunListView.getRunListQuerySettings(schema, getViewContext(), ExpSchema.TableType.Runs.name(), true), ExperimentRunType.ALL_RUNS_TYPE) - { - @Override - public DataView createDataView() - { - DataView result = super.createDataView(); - result.getRenderContext().setBaseFilter(new SimpleFilter(FieldKey.fromParts("Protocol", "LSID"), _protocol.getLSID())); - return result; - } - }; - - runView.setTitle("Runs Using This Protocol"); - - return new VBox(new StandardAndCustomPropertiesView(detailsView, cpv), parametersView, protocolDetails, runView); - } - - @Override - public void addNavTrail(NavTree root) - { - addRootNavTrail(root); - root.addChild("Protocols", ExperimentUrlsImpl.get().getProtocolGridURL(getContainer())); - root.addChild("Protocol: " + _protocol.getName()); - } - } - - public class ProtocolInputOutputsView extends VBox - { - ProtocolInputOutputsView(ExpProtocol protocol, Errors errors) - { - HBox inputsView = new HBox(); - addView(inputsView); - - HBox outputsView = new HBox(); - addView(outputsView); - - UserSchema expSchema = QueryService.get().getUserSchema(getUser(), getContainer(), ExpSchema.SCHEMA_NAME); - - class ProtocolInputGrid extends QueryView - { - public ProtocolInputGrid(String title, QuerySettings settings, @Nullable Errors errors) - { - super(expSchema, settings, errors); - - setFrame(FrameType.TITLE); - setTitle(title); - setButtonBarPosition(DataRegion.ButtonBarPosition.NONE); - setShowBorders(true); - setShadeAlternatingRows(true); - setShowExportButtons(false); - setShowPagination(false); - disableContainerFilterSelection(); - } - } - - // INPUTS - - QuerySettings materialInputsSettings = expSchema.getSettings("mpi", ExpSchema.TableType.MaterialProtocolInputs.toString()); - materialInputsSettings.getBaseFilter().addCondition(FieldKey.fromParts(ExpMaterialProtocolInputTable.Column.Protocol.toString()), protocol.getRowId()); - materialInputsSettings.setFieldKeys(Arrays.asList( - FieldKey.fromParts(ExpMaterialProtocolInputTable.Column.Name.toString()), - FieldKey.fromParts(ExpMaterialProtocolInputTable.Column.SampleSet.toString()) - )); - QueryView materialInputsView = new ProtocolInputGrid("Material Inputs", materialInputsSettings, errors); - inputsView.addView(materialInputsView); - - QuerySettings dataInputsSettings = expSchema.getSettings("dpi", ExpSchema.TableType.DataProtocolInputs.toString()); - dataInputsSettings.getBaseFilter().addCondition(FieldKey.fromParts(ExpDataProtocolInputTable.Column.Protocol.toString()), protocol.getRowId()); - dataInputsSettings.setFieldKeys(Arrays.asList( - FieldKey.fromParts(ExpDataProtocolInputTable.Column.Name.toString()), - FieldKey.fromParts(ExpDataProtocolInputTable.Column.DataClass.toString()) - )); - QueryView dataInputsView = new ProtocolInputGrid("Data Inputs", dataInputsSettings, errors); - inputsView.addView(dataInputsView); - - // OUTPUTS - - QuerySettings materialOutputsSettings = expSchema.getSettings("mpo", ExpSchema.TableType.MaterialProtocolInputs.toString()); - materialOutputsSettings.getBaseFilter().addCondition(FieldKey.fromParts(ExpMaterialProtocolInputTable.Column.Protocol.toString()), protocol.getRowId()); - materialOutputsSettings.setFieldKeys(Arrays.asList( - FieldKey.fromParts(ExpMaterialProtocolInputTable.Column.Name.toString()), - FieldKey.fromParts(ExpMaterialProtocolInputTable.Column.SampleSet.toString()) - )); - QueryView materialOutputsView = new ProtocolInputGrid("Material Outputs", materialOutputsSettings, errors); - outputsView.addView(materialOutputsView); - - QuerySettings dataOutputsSettings = expSchema.getSettings("dpo", ExpSchema.TableType.DataProtocolInputs.toString()); - dataOutputsSettings.getBaseFilter().addCondition(FieldKey.fromParts(ExpDataProtocolInputTable.Column.Protocol.toString()), protocol.getRowId()); - dataOutputsSettings.setFieldKeys(Arrays.asList( - FieldKey.fromParts(ExpDataProtocolInputTable.Column.Name.toString()), - FieldKey.fromParts(ExpDataProtocolInputTable.Column.DataClass.toString()) - )); - QueryView dataOutputsView = new ProtocolInputGrid("Data Outputs", dataOutputsSettings, errors); - outputsView.addView(dataOutputsView); - } - } - - - @RequiresPermission(ReadPermission.class) - public class ProtocolPredecessorsAction extends SimpleViewAction - { - private ExpProtocol _parentProtocol; - private ProtocolActionStepDetail _actionStep; - - @Override - public ModelAndView getView(Object o, BindException errors) - { - ActionURL url = getViewContext().getActionURL(); - - String parentProtocolLSID = url.getParameter("ParentLSID"); - int actionSequence; - try - { - actionSequence = Integer.parseInt(url.getParameter("Sequence")); - } - catch (NumberFormatException e) - { - throw new NotFoundException("Could not find SequenceId " + url.getParameter("Sequence")); - } - - _parentProtocol = ExperimentService.get().getExpProtocol(parentProtocolLSID); - if (_parentProtocol == null) - { - throw new NotFoundException("Unable to find a matching protocol"); - } - - ensureCorrectContainer(getContainer(), _parentProtocol, getViewContext()); - - _actionStep = ExperimentServiceImpl.get().getProtocolActionStepDetail(parentProtocolLSID, actionSequence); - - if (_actionStep == null) - { - throw new NotFoundException("Unable to find a matching protocol action step"); - } - - ExpProtocol childProtocol = ExperimentService.get().getExpProtocol(_actionStep.getChildProtocolLSID()); - - JspView detailsView = new JspView<>("/org/labkey/experiment/ProtocolDetails.jsp", childProtocol); - detailsView.setTitle("Standard Properties"); - - CustomPropertiesView cpv = new CustomPropertiesView(childProtocol.getLSID(), getContainer()); - - ProtocolParametersView parametersView = new ProtocolParametersView(childProtocol); - - VBox protocolDetails = new VBox(); - protocolDetails.setFrame(WebPartView.FrameType.PORTAL); - protocolDetails.setTitle("Protocol Details"); - protocolDetails.addView(new ProtocolInputOutputsView(childProtocol, errors)); - protocolDetails.addView(new ProtocolSuccessorPredecessorView(parentProtocolLSID, actionSequence, getContainer(), "PredecessorChildLSID", "PredecessorSequence", "ActionSequence", "Protocol Predecessors")); - protocolDetails.addView(new ProtocolSuccessorPredecessorView(parentProtocolLSID, actionSequence, getContainer(), "ChildProtocolLSID", "ActionSequence", "PredecessorSequence", "Protocol Successors")); - - return new VBox(new StandardAndCustomPropertiesView(detailsView, cpv), parametersView, protocolDetails); - } - - @Override - public void addNavTrail(NavTree root) - { - addRootNavTrail(root); - root.addChild("Protocols", ExperimentUrlsImpl.get().getProtocolGridURL(getContainer())); - root.addChild("Parent Protocol '" + _parentProtocol.getName() + "'", ExperimentUrlsImpl.get().getProtocolDetailsURL(_parentProtocol)); - root.addChild("Protocol Step: " + _actionStep.getName()); - } - } - - public static class DataForm - { - private boolean _inline; - private long _rowId; - private String _lsid; - private Integer _maxDimension; - private String _format; - - public boolean isInline() - { - return _inline; - } - - public void setInline(boolean inline) - { - _inline = inline; - } - - public long getRowId() - { - return _rowId; - } - - public void setRowId(long rowId) - { - _rowId = rowId; - } - - public String getLsid() - { - return _lsid; - } - - public void setLsid(String lsid) - { - _lsid = lsid; - } - - public ExpDataImpl lookupData() - { - ExpDataImpl result = ExperimentServiceImpl.get().getExpData(getRowId()); - if (result == null && getLsid() != null) - { - result = ExperimentServiceImpl.get().getExpData(getLsid()); - } - return result; - } - - public Integer getMaxDimension() - { - return _maxDimension; - } - - public void setMaxDimension(Integer maxDimension) - { - _maxDimension = maxDimension; - } - - public String getFormat() - { - return _format; - } - - public void setFormat(String format) - { - _format = format; - } - } - - public static class ExpObjectForm extends QueryViewAction.QueryExportForm - { - private long _rowId; - private String _lsid; - - public String getLsid() - { - return _lsid; - } - - public void setLsid(String lsid) - { - _lsid = lsid; - } - - public String getLSID() - { - return getLsid(); - } - - public void setLSID(String lsid) - { - setLsid(lsid); - } - - public long getRowId() - { - return _rowId; - } - - public void setRowId(long rowId) - { - _rowId = rowId; - } - } - - @RequiresAnyOf({DeletePermission.class, SampleWorkflowDeletePermission.class}) - public class DeleteSelectedExpRunsAction extends AbstractDeleteAction - { - @Override - public void addNavTrail(NavTree root) - { - // UNDONE: Need help topic on Runs - setHelpTopic("experiment"); - super.addNavTrail(root); - } - - @Override - public ModelAndView getView(DeleteForm deleteForm, boolean reshow, BindException errors) - { - List runs = new ArrayList<>(); - - Map idToRunMap = new LongHashMap<>(); - for (long runId : deleteForm.getIds(false)) - { - ExpRun run = ExperimentService.get().getExpRun(runId); - if (run != null) - { - if (!run.canDelete(getUser())) - throw new UnauthorizedException("You do not have permission to delete " + - (ExpProtocol.isSampleWorkflowProtocol(run.getProtocol().getLSID()) ? "jobs" : "runs") - + " in " + run.getContainer()); - - runs.add(run); - idToRunMap.put(run.getRowId(), run); - } - } - - Map referencedItems = new LongHashMap<>(); - List referenceDescriptions = new ArrayList<>(); - AssayService assayService = AssayService.get(); - if (!idToRunMap.isEmpty() && assayService != null ) - { - // using the first run as a representative, since all interactions here are (I believe) using the same protocol. - ExpProtocol protocol = runs.get(0).getProtocol(); - AssayProvider provider = assayService.getProvider(protocol); - if (provider != null) - { - SchemaKey key = AssayProtocolSchema.schemaName(provider, protocol); - ExperimentService.get().getObjectReferencers() - .forEach(referencer -> { - Collection referenced = referencer.getItemsWithReferences( - idToRunMap.keySet(), - key.toString(), - "Runs" - ); - referenced.forEach(id -> referencedItems.put(id, idToRunMap.get(id))); - referenceDescriptions.add(referencer.getObjectReferenceDescription(ExpRun.class)); - } - ); - } - - } - - List> permissionDatasetRows = new ArrayList<>(); - List> noPermissionDatasetRows = new ArrayList<>(); - if (StudyPublishService.get() != null) - { - for (Dataset dataset : StudyPublishService.get().getDatasetsForAssayRuns(runs, getUser())) - { - ActionURL url = urlProvider(StudyUrls.class).getDatasetURL(dataset.getContainer(), dataset.getDatasetId()); - TableInfo t = dataset.getTableInfo(getUser()); - if (null != t && t.hasPermission(getUser(),DeletePermission.class)) - { - permissionDatasetRows.add(new Pair<>(dataset, url)); - } - else - { - noPermissionDatasetRows.add(new Pair<>(dataset, url)); - } - } - } - - return new ConfirmDeleteView( - "run", - ShowRunGraphAction.class, - runs.stream().filter(run -> !referencedItems.containsKey(run.getRowId())).toList(), - deleteForm, - Collections.emptyList(), - "dataset(s) have one or more rows which", - permissionDatasetRows, - noPermissionDatasetRows, - referencedItems.values().stream().toList(), - referenceDescriptions.stream().filter(Objects::nonNull).collect(Collectors.joining(", or "))); - } - - @Override - protected void deleteObjects(DeleteForm deleteForm) - { - ExperimentServiceImpl.get().deleteExperimentRunsByRowIds(getContainer(), getUser(), deleteForm.getUserComment(), deleteForm.getIds(false)); - } - } - - public static class DeleteRunForm - { - private int _runId; - - public int getRunId() - { - return _runId; - } - - public void setRunId(int runId) - { - _runId = runId; - } - } - - /** - * Separate delete action from the client API - */ - @RequiresAnyOf({DeletePermission.class, SampleWorkflowDeletePermission.class}) - public static class DeleteRunAction extends MutatingApiAction - { - @Override - public ApiResponse execute(DeleteRunForm form, BindException errors) - { - ExpRun run = ExperimentService.get().getExpRun(form.getRunId()); - if (run == null) - { - throw new NotFoundException("Could not find run with ID " + form.getRunId()); - } - if (!run.canDelete(getUser())) - throw new UnauthorizedException("You do not have permission to delete " - + (ExpProtocol.isSampleWorkflowProtocol(run.getProtocol().getLSID()) ? "jobs" : "runs") + " in this container."); - - run.delete(getUser()); - return new ApiSimpleResponse("success", true); - } - } - - - @RequiresAnyOf({DeletePermission.class, SampleWorkflowDeletePermission.class}) - public static class DeleteRunsAction extends AbstractDeleteAPIAction - { - @Override - protected ApiSimpleResponse deleteObjects(CascadeDeleteForm form) - { - Set runIdsToDelete = new HashSet<>(form.getIds(true)); - Set runIdsCascadeDeleted = new HashSet<>(); - - if (form.isCascade()) - { - for (long runId : runIdsToDelete) - { - ExpRun run = ExperimentService.get().getExpRun(runId); - if (run != null) - addReplacesRuns(run, runIdsCascadeDeleted); - } - - if (!runIdsCascadeDeleted.isEmpty()) - runIdsToDelete.addAll(runIdsCascadeDeleted); - } - - ExperimentService.get().deleteExperimentRunsByRowIds(getContainer(), getUser(), form.getUserComment(), runIdsToDelete); - - ApiSimpleResponse response = new ApiSimpleResponse("success", true); - response.put("runIdsDeleted", runIdsToDelete); - if (!runIdsCascadeDeleted.isEmpty()) - response.put("runIdsCascadeDeleted", runIdsCascadeDeleted); - return response; - } - - private void addReplacesRuns(ExpRun run, Set runIds) - { - for (ExpRun replacedRun : run.getReplacesRuns()) - { - runIds.add(replacedRun.getRowId()); - addReplacesRuns(replacedRun, runIds); - } - } - } - - private abstract static class AbstractDeleteAPIAction extends MutatingApiAction - { - @Override - public void validateForm(CascadeDeleteForm form, Errors errors) - { - if (form.getSingleObjectRowId() == null && form.getDataRegionSelectionKey() == null && form.getRowIds() == null) - errors.reject(ERROR_REQUIRED, "Either singleObjectRowId, dataRegionSelectionKey, or rowIds is required"); - } - - @Override - public ApiResponse execute(CascadeDeleteForm form, BindException errors) throws Exception - { - ApiSimpleResponse response; - - try (DbScope.Transaction tx = ExperimentService.get().ensureTransaction()) - { - tx.addCommitTask(form::clearSelected, POSTCOMMIT); - - response = deleteObjects(form); - tx.commit(); - } - - if (null != response.get("success")) - response.put("success", !errors.hasErrors()); - - return response; - } - - protected abstract ApiSimpleResponse deleteObjects(CascadeDeleteForm form) throws Exception; - } - - public static class CascadeDeleteForm extends DeleteForm - { - private boolean _cascade; - - public boolean isCascade() - { - return _cascade; - } - - public void setCascade(boolean cascade) - { - _cascade = cascade; - } - } - - private abstract static class AbstractDeleteAction extends FormViewAction - { - @Override - public void validateCommand(DeleteForm target, Errors errors) - { - } - - @Override - public boolean handlePost(DeleteForm deleteForm, BindException errors) throws Exception - { - if (!deleteForm.isForceDelete()) - { - return false; - } - else - { - try (DbScope.Transaction tx = ExperimentService.get().ensureTransaction()) - { - tx.addCommitTask(deleteForm::clearSelected, POSTCOMMIT); - - deleteObjects(deleteForm); - tx.commit(); - } - catch (BatchValidationException v) - { - v.addToErrors(errors); - } - - return !errors.hasErrors(); - } - } - - @Override - public ActionURL getSuccessURL(DeleteForm form) - { - return form.getSuccessActionURL(ExperimentUrlsImpl.get().getOverviewURL(getContainer())); - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild("Confirm Deletion"); - } - - protected abstract void deleteObjects(DeleteForm form) throws Exception; - } - - @RequiresPermission(DesignAssayPermission.class) - public static class DeleteProtocolByRowIdsAPIAction extends AbstractDeleteAPIAction - { - @Override - protected ApiSimpleResponse deleteObjects(CascadeDeleteForm form) - { - for (ExpProtocol protocol : getProtocolsForDeletion(form)) - { - if (!protocol.getContainer().hasPermission(getUser(), DesignAssayPermission.class)) - throw new UnauthorizedException("You do not have sufficient permissions to delete this assay design."); - - protocol.delete(getUser(), form.getUserComment()); - } - - return new ApiSimpleResponse(); - } - } - - public static List getProtocolsForDeletion(DeleteForm form) - { - List protocols = new ArrayList<>(); - for (long protocolId : form.getIds(false)) - { - ExpProtocol protocol = ExperimentService.get().getExpProtocol(protocolId); - if (protocol != null) - { - protocols.add(protocol); - } - } - return protocols; - } - - @RequiresPermission(DesignAssayPermission.class) - public class DeleteProtocolByRowIdsAction extends AbstractDeleteAction - { - @Override - public void addNavTrail(NavTree root) - { - // UNDONE: Need help topic on protocols - setHelpTopic("experiment"); - super.addNavTrail(root); - } - - @Override - public ModelAndView getView(DeleteForm form, boolean reshow, BindException errors) - { - List runs = ExperimentService.get().getExpRunsForProtocolIds(false, form.getIds(false)); - List protocols = getProtocolsForDeletion(form); - String noun = "Assay Design"; - List> deleteableDatasets = new ArrayList<>(); - List> noPermissionDatasets = new ArrayList<>(); - if (AssayService.get() != null && StudyService.get() != null) - { - for (ExpProtocol protocol : protocols) - { - if (!protocol.getContainer().hasPermission(getUser(), DesignAssayPermission.class)) - throw new UnauthorizedException("You do not have sufficient permissions to delete this assay design."); - - if (AssayService.get().getProvider(protocol) == null) - { - noun = "Protocol"; - } - for (Dataset dataset : StudyPublishService.get().getDatasetsForPublishSource(protocol.getRowId(), Dataset.PublishSource.Assay)) - { - Pair entry = new Pair<>(dataset, urlProvider(StudyUrls.class).getDatasetURL(dataset.getContainer(), dataset.getDatasetId())); - if (dataset.canDeleteDefinition(getUser())) - { - deleteableDatasets.add(entry); - } - else - { - noPermissionDatasets.add(entry); - } - } - } - } - - return new ConfirmDeleteView(noun, ProtocolDetailsAction.class, protocols, form, runs, "Dataset", deleteableDatasets, noPermissionDatasets, Collections.emptyList(), null); - } - - @Override - protected void deleteObjects(DeleteForm form) - { - for (ExpProtocol protocol : getProtocolsForDeletion(form)) - { - protocol.delete(getUser(), form.getUserComment()); - } - } - } - - @Marshal(Marshaller.Jackson) - @RequiresPermission(ReadPermission.class) - public static class GetDataOperationConfirmationDataAction extends ReadOnlyApiAction - { - @Override - public void validateForm(DataOperationConfirmationForm form, Errors errors) - { - if (form.getDataRegionSelectionKey() == null && form.getRowIds() == null) - errors.reject(ERROR_REQUIRED, "You must provide either a set of rowIds or a dataRegionSelectionKey"); - if (form.getDataOperation() == null) - errors.reject(ERROR_REQUIRED, "An operation type must be provided."); - } - - @Override - public Object execute(DataOperationConfirmationForm form, BindException errors) - { - Collection requestIds = form.getIds(false); - ExperimentServiceImpl service = ExperimentServiceImpl.get(); - List allData = service.getExpDatas(requestIds); - - Set notAllowedIds = new HashSet<>(); - if (form.getDataOperation() == ExpDataImpl.DataOperations.Delete) - service.getObjectReferencers().forEach(referencer -> - notAllowedIds.addAll(referencer.getItemsWithReferences(requestIds, "exp.data"))); - - Map>> response = ExperimentServiceImpl.partitionRequestedOperationObjects(getUser(), requestIds, notAllowedIds, allData); - - Collection containers = new HashSet<>(); - Collection notPermittedIds = new ArrayList<>(); - Class permClass = form.getDataOperation().getPermissionClass(); - for (ExpDataImpl expData : allData) - { - Container c = expData.getContainer(); - if (c.hasPermission(getUser(), ReadPermission.class)) - containers.add(c); - if (permClass != null && !c.hasPermission(getUser(), permClass)) - notPermittedIds.add(expData.getRowId()); - } - - NameExpressionOptionService svc = NameExpressionOptionService.get(); - response.put("containers", containers.stream().map(c -> Map.of( - "id", c.getEntityId(), - "path", (Object) c.getPath(), - "permitted", permClass == null || c.hasPermission(getUser(), permClass), - "canEditName", svc.getAllowUserSpecificNamesValue(c) - )).toList()); - - response.put("notPermitted", notPermittedIds.stream().map(id -> Map.of("RowId", (Object) id)).toList()); - - return success(response); - } - } - - - public static class DataOperationConfirmationForm extends DataViewSnapshotSelectionForm - { - private ExpDataImpl.DataOperations _dataOperation; - - public ExpDataImpl.DataOperations getDataOperation() - { - return _dataOperation; - } - - public void setDataOperation(ExpDataImpl.DataOperations dataOperation) - { - _dataOperation = dataOperation; - } - - } - - @Marshal(Marshaller.Jackson) - @RequiresPermission(ReadPermission.class) - public static class GetMaterialOperationConfirmationDataAction extends ReadOnlyApiAction - { - @Override - public void validateForm(MaterialOperationConfirmationForm form, Errors errors) - { - if (form.getDataRegionSelectionKey() == null && form.getRowIds() == null) - errors.reject(ERROR_REQUIRED, "You must provide either a set of rowIds or a dataRegionSelectionKey."); - if (form.getSampleOperation() == null) - errors.reject(ERROR_REQUIRED, "An operation type must be provided."); - } - - @Override - public Object execute(MaterialOperationConfirmationForm form, BindException errors) - { - Set requestIds = form.getIds(false); - ExperimentServiceImpl service = ExperimentServiceImpl.get(); - List allMaterials = service.getExpMaterials(requestIds); - - Set notAllowedIds = new HashSet<>(); - // We prevent deletion if a sample is used as a parent, has assay data, is used in a job, etc. - if (form.getSampleOperation() == SampleTypeService.SampleOperations.Delete) - service.getObjectReferencers().forEach(referencer -> - notAllowedIds.addAll(referencer.getItemsWithReferences(requestIds, "samples"))); - - if (SampleStatusService.get().supportsSampleStatus()) - notAllowedIds.addAll(service.findIdsNotPermittedForOperation(allMaterials, form.getSampleOperation())); - - Map>> response = ExperimentServiceImpl.partitionRequestedOperationObjects(getUser(), requestIds, notAllowedIds, allMaterials); - - Collection containers = new HashSet<>(); - Collection notPermittedIds = new ArrayList<>(); - Class permClass = form.getSampleOperation().getPermissionClass(); - for (ExpMaterial material : allMaterials) - { - Container c = material.getContainer(); - if (c.hasPermission(getUser(), ReadPermission.class)) - containers.add(c); - if (permClass != null && !c.hasPermission(getUser(), permClass)) - notPermittedIds.add(material.getRowId()); - } - - NameExpressionOptionService svc = NameExpressionOptionService.get(); - - response.put("containers", containers.stream().map(c -> Map.of( - "id", c.getEntityId(), - "path", (Object) c.getPath(), - "permitted", permClass == null || c.hasPermission(getUser(), permClass), - "canEditName", svc.getAllowUserSpecificNamesValue(c) - )).toList()); - - response.put("notPermitted", notPermittedIds.stream().map(id -> Map.of("RowId", (Object) id)).toList()); - - if (form.getSampleOperation() == SampleTypeService.SampleOperations.Delete) - // String 'associatedDatasets' must be synced to its handling in confirmDelete.js, confirmDelete() - response.put("associatedDatasets", ExperimentServiceImpl.includeLinkedToStudyText(allMaterials, requestIds, getUser(), getContainer())); - - return success(response); - } - } - - public static class MaterialOperationConfirmationForm extends DataViewSnapshotSelectionForm - { - private SampleTypeService.SampleOperations _sampleOperation; - - public SampleTypeService.SampleOperations getSampleOperation() - { - return _sampleOperation; - } - - public void setSampleOperation(SampleTypeService.SampleOperations sampleOperation) - { - _sampleOperation = sampleOperation; - } - } - - @RequiresPermission(DeletePermission.class) - public class DeleteSelectedDataAction extends AbstractDeleteAction - { - @Override - public void addNavTrail(NavTree root) - { - // UNDONE: Need help topic on Datas - setHelpTopic("experiment"); - super.addNavTrail(root); - } - - @Override - protected void deleteObjects(DeleteForm deleteForm) throws Exception - { - List datas = getDatas(deleteForm, false); - - for (ExpRun run : getRuns(datas)) - { - if (!run.getContainer().hasPermission(getUser(), DeletePermission.class)) - throw new UnauthorizedException(); - } - - // Issue 32076: Delete the exp.Data objects using QueryUpdateService so trigger scripts will be executed - Map, List> byDataClass = datas.stream().collect(Collectors.groupingBy(d -> Optional.ofNullable(d.getDataClass(null)))); - for (Optional opt : byDataClass.keySet()) - { - SchemaKey schemaKey; - String queryName; - ExpDataClass dc = opt.orElse(null); - List ds = byDataClass.get(opt); - if (dc == null) - { - // Reference to exp.Data table - schemaKey = ExpSchema.SCHEMA_EXP; - queryName = ExpSchema.TableType.Data.name(); - } - else - { - // Reference to exp.data. table - schemaKey = ExpSchema.SCHEMA_EXP_DATA; - queryName = dc.getName(); - } - - UserSchema schema = QueryService.get().getUserSchema(getUser(), getContainer(), schemaKey); - if (schema == null) - throw new IllegalStateException("Failed to get schema '" + schemaKey + "'"); - - TableInfo table = schema.getTable(queryName); - if (table == null) - throw new IllegalStateException("Failed to get table '" + queryName + "' in schema '" + schemaKey + "'"); - - QueryUpdateService qus = table.getUpdateService(); - if (qus == null) - throw new IllegalStateException(); - - qus.deleteRows(getUser(), getContainer(), toKeys(ds), null, null); - } - } - - protected List> toKeys(List datas) - { - return datas.stream().map(d -> CaseInsensitiveHashMap.of("rowId", d.getRowId())).collect(toList()); - } - - @Override - public ModelAndView getView(DeleteForm deleteForm, boolean reshow, BindException errors) - { - if (errors.hasErrors()) - return new SimpleErrorView(errors, false); - - List datas = getDatas(deleteForm, false); - List runs = getRuns(datas); - - return new ConfirmDeleteView("Data", ShowDataAction.class, datas, deleteForm, runs); - } - - private List getRuns(List datas) - { - List runArray = ExperimentService.get().getRunsUsingDatas(datas); - return new ArrayList<>(ExperimentService.get().runsDeletedWithInput(runArray)); - } - - private List getDatas(DeleteForm deleteForm, boolean clear) - { - List datas = new ArrayList<>(); - for (long dataId : deleteForm.getIds(clear)) - { - ExpData data = ExperimentService.get().getExpData(dataId); - if (data != null) - { - datas.add(data); - } - } - return datas; - } - } - - @RequiresPermission(DeletePermission.class) - public class DeleteSelectedExperimentsAction extends AbstractDeleteAction - { - @Override - protected void deleteObjects(DeleteForm deleteForm) - { - for (ExpExperiment exp : lookupExperiments(deleteForm)) - { - exp.delete(getUser()); - } - } - - @Override - public ModelAndView getView(DeleteForm deleteForm, boolean reshow, BindException errors) - { - List experiments = lookupExperiments(deleteForm); - - List runs = new ArrayList<>(); - boolean allBatches = true; - for (ExpExperiment experiment : experiments) - { - // Deleting a batch also deletes all of its runs - if (experiment.getBatchProtocol() != null) - { - runs.addAll(experiment.getRuns()); - } - else - { - allBatches = false; - } - } - - return new ConfirmDeleteView(allBatches ? "batch" : "run group", DetailsAction.class, experiments, deleteForm, runs); - } - - private List lookupExperiments(DeleteForm deleteForm) - { - List experiments = new ArrayList<>(); - for (long experimentId : deleteForm.getIds(false)) - { - ExpExperiment experiment = ExperimentService.get().getExpExperiment(experimentId); - if (experiment != null) - { - experiments.add(experiment); - } - } - return experiments; - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("runGroups"); - super.addNavTrail(root); - } - } - - @RequiresPermission(DesignSampleTypePermission.class) - public class DeleteSampleTypesAction extends AbstractDeleteAction - { - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("sampleSets"); - super.addNavTrail(root); - } - - @Override - protected void deleteObjects(DeleteForm deleteForm) - { - List sampleTypes = getSampleTypes(deleteForm); - if (sampleTypes.isEmpty()) - { - throw new NotFoundException("No sample types found for ids provided."); - } - if (!ensureCorrectContainer(sampleTypes)) - { - throw new UnauthorizedException(); - } - - for (ExpRun run : getRuns(sampleTypes)) - { - if (!run.getContainer().hasPermission(getUser(), DeletePermission.class)) - { - throw new UnauthorizedException(); - } - } - - for (ExpSampleType source : sampleTypes) - { - Domain domain = source.getDomain(); - if (!domain.getDomainKind().canDeleteDefinition(getUser(), domain)) - { - throw new UnauthorizedException(); - } - - source.delete(getUser(), deleteForm.getUserComment()); - } - } - - @Override - public ModelAndView getView(DeleteForm deleteForm, boolean reshow, BindException errors) - { - List sampleTypes = getSampleTypes(deleteForm); - if (!ensureCorrectContainer(sampleTypes)) - { - throw new RedirectException(ExperimentUrlsImpl.get().getShowSampleTypeListURL(getContainer(), "To delete a sample type, you must be in its folder or project.")); - } - - List> deleteableDatasets = new ArrayList<>(); - List> noPermissionDatasets = new ArrayList<>(); - if (StudyService.get() != null && StudyPublishService.get() != null) - { - for (ExpSampleType sampleType: sampleTypes) - { - for (Dataset dataset : StudyPublishService.get().getDatasetsForPublishSource(sampleType.getRowId(), Dataset.PublishSource.SampleType)) - { - ActionURL datasetURL = StudyService.get().getDatasetURL(getContainer(), dataset.getDatasetId()); - Pair entry = new Pair<>(dataset, datasetURL); - if (dataset.canDeleteDefinition(getUser())) - { - deleteableDatasets.add(entry); - } - else - { - noPermissionDatasets.add(entry); - } - } - } - } - return new ConfirmDeleteView("Sample Type", ShowSampleTypeAction.class, sampleTypes, deleteForm, getRuns(sampleTypes), "Dataset", deleteableDatasets, noPermissionDatasets, Collections.emptyList(), null); - } - - private List getSampleTypes(DeleteForm deleteForm) - { - List sources = new ArrayList<>(); - for (long rowId : deleteForm.getIds(false)) - { - ExpSampleType sampleType = SampleTypeService.get().getSampleType(getContainer(), getUser(), rowId); - if (sampleType != null) - { - sources.add(sampleType); - } - } - return sources; - } - - private boolean ensureCorrectContainer(List sampleTypes) - { - for (ExpSampleType source : sampleTypes) - { - Container sourceContainer = source.getContainer(); - if (!sourceContainer.equals(getContainer())) - { - return false; - } - } - return true; - } - - private List getRuns(List sampleTypes) - { - if (!sampleTypes.isEmpty()) - { - List runArray = ExperimentService.get().getRunsUsingSampleTypes(sampleTypes.toArray(new ExpSampleType[0])); - return ExperimentService.get().runsDeletedWithInput(runArray); - } - else - { - return Collections.emptyList(); - } - } - } - - private DataRegion getSampleTypeRegion(ViewContext model) - { - TableInfo tableInfo = ExperimentServiceImpl.get().getTinfoSampleType(); - - QuerySettings settings = new QuerySettings(model, "SampleType"); - settings.setSelectionKey(DataRegionSelection.getSelectionKey(tableInfo.getSchema().getName(), tableInfo.getName(), "SampleType", settings.getDataRegionName())); - - DataRegion dr = new DataRegion(); - dr.setSettings(settings); - dr.addColumns(tableInfo.getUserEditableColumns()); - dr.removeColumns("lastindexed"); - dr.getDisplayColumn(0).setVisible(false); - - dr.getDisplayColumn("idcol1").setVisible(false); - dr.getDisplayColumn("idcol2").setVisible(false); - dr.getDisplayColumn("idcol3").setVisible(false); - dr.getDisplayColumn("lsid").setVisible(false); - dr.getDisplayColumn("materiallsidprefix").setVisible(false); - dr.getDisplayColumn("parentcol").setVisible(false); - - ActionURL url = new ActionURL(ShowSampleTypeAction.class, model.getContainer()); - dr.getDisplayColumn(1).setURL(url.addParameter("rowId", "${RowId}")); - dr.setShowRecordSelectors(getContainer().hasOneOf(getUser(), DeletePermission.class, UpdatePermission.class)); - - return dr; - } - - @RequiresPermission(ReadPermission.class) - @ActionNames("getSampleType,getSampleTypeApi") // Referenced in labkey-ui-components components/samples/actions.ts TODO: migrate getSampleTypeApi -> getSampleType - public static class GetSampleTypeAction extends ReadOnlyApiAction - { - @Override - public void validateForm(SampleTypeForm form, Errors errors) - { - if (form.getRowId() == null && form.getLSID() == null) - errors.reject(ERROR_REQUIRED, "RowId or LSID must be provided"); - } - - @Override - public Object execute(SampleTypeForm form, BindException errors) throws Exception - { - ExpSampleTypeImpl st = form.getSampleType(getContainer()); - - return getSampleTypeResponse(st); - } - } - - @NotNull - private static ApiSimpleResponse getSampleTypeResponse(ExpSampleType st) throws IOException - { - Map sampleType = new HashMap<>(); - sampleType.put("name", st.getName()); - sampleType.put("nameExpression", st.getNameExpression()); - sampleType.put("labelColor", st.getLabelColor()); - sampleType.put("metricUnit", st.getMetricUnit()); - sampleType.put("description", st.getDescription()); - sampleType.put("importAliases", st.getImportAliasMap()); - sampleType.put("lsid", st.getLSID()); - sampleType.put("rowId", st.getRowId()); - sampleType.put("domainId", st.getDomain().getTypeId()); - sampleType.put("category", st.getCategory()); - - return new ApiSimpleResponse(Map.of("sampleSet", sampleType, "success", true)); - } - - public static class DataTypesWithRequiredLineageForm - { - private Integer _parentDataTypeRowId; - private boolean _sampleParent; - - public Integer getParentDataTypeRowId() - { - return _parentDataTypeRowId; - } - - public void setParentDataTypeRowId(Integer parentDataTypeRowId) - { - this._parentDataTypeRowId = parentDataTypeRowId; - } - - public boolean isSampleParent() - { - return _sampleParent; - } - - public void setSampleParent(boolean sampleParent) - { - _sampleParent = sampleParent; - } - } - - @RequiresPermission(ReadPermission.class) - public static class GetDataTypesWithRequiredLineageAction extends ReadOnlyApiAction - { - @Override - public void validateForm(DataTypesWithRequiredLineageForm form, Errors errors) - { - if (form.getParentDataTypeRowId() == null) - errors.reject(ERROR_REQUIRED, "ParentDataTypeRowId must be provided"); - } - - @Override - public Object execute(DataTypesWithRequiredLineageForm form, BindException errors) throws Exception - { - return getDataTypesWithRequiredLineageResponse(form.getParentDataTypeRowId(), form.isSampleParent(), getContainer(), getUser()); - } - } - @NotNull - private static ApiSimpleResponse getDataTypesWithRequiredLineageResponse(Integer parentDataType, boolean isSampleParent, Container container, User user) - { - Pair, Set> requiredLineages = ExperimentServiceImpl.get().getDataTypesWithRequiredLineage(parentDataType, isSampleParent, container, user); - return new ApiSimpleResponse(Map.of("sampleTypes", requiredLineages.first, "dataClasses", requiredLineages.second,"success", true)); - } - - @RequiresPermission(DesignSampleTypePermission.class) - public static class EditSampleTypeAction extends SimpleViewAction - { - private ExpSampleTypeImpl _sampleType; - - @Override - public ModelAndView getView(SampleTypeForm form, BindException errors) - { - boolean create = form.getLSID() == null && form.getRowId() == null; - if (!create) - _sampleType = form.getSampleType(getContainer()); - - return ModuleHtmlView.get(ModuleLoader.getInstance().getModule("core"), ModuleHtmlView.getGeneratedViewPath("sampleTypeDesigner")); - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("sampleSets"); - - root.addChild("Sample Types", ExperimentUrlsImpl.get().getShowSampleTypeListURL(getContainer())); - if (_sampleType == null) - { - root.addChild("Create Sample Type"); - } - else - { - root.addChild(_sampleType.getName(), ExperimentUrlsImpl.get().getShowSampleTypeURL(_sampleType)); - root.addChild("Update Sample Type"); - } - } - } - - public static class SampleTypeForm extends ReturnUrlForm - { - private Integer rowId; - private String lsid; - - public Integer getRowId() - { - return rowId; - } - - public void setRowId(Integer rowId) - { - this.rowId = rowId; - } - - public String getLSID() - { - return this.lsid; - } - - public void setLSID(String lsid) - { - this.lsid = lsid; - } - - public ExpSampleTypeImpl getSampleType(Container container) throws NotFoundException - { - ExpSampleTypeImpl sampleType = SampleTypeServiceImpl.get().getSampleType(getLSID()); - if (sampleType == null) - sampleType = SampleTypeServiceImpl.get().getSampleType(getRowId()); - - if (sampleType == null) - { - throw new NotFoundException("Sample type not found: " + (getLSID() != null ? getLSID() : getRowId())); - } - - if (!container.equals(sampleType.getContainer())) - { - throw new NotFoundException("Sample type is not defined in the given container."); - } - - return sampleType; - } - } - - @RequiresPermission(InsertPermission.class) - public static class ImportSamplesAction extends AbstractExpDataImportAction - { - ExpSampleTypeImpl _sampleType; - boolean _isCrossTypeImport = false; - - @Override - public void validateForm(QueryForm queryForm, Errors errors) - { - _form = queryForm; - _insertOption = queryForm.getInsertOption(); - _isCrossTypeImport = getOptionParamValue(Params.crossTypeImport); - _form.setSchemaName(getTargetSchemaName()); - if (_isCrossTypeImport) - { - _form.setQueryName(getPipelineTargetQueryName()); - } - super.validateForm(queryForm, errors); - if (queryForm.getQueryName() == null) - errors.reject(ERROR_REQUIRED, "Sample type name is required"); - else - { - if (!_isCrossTypeImport) - { - _sampleType = SampleTypeServiceImpl.get().getSampleType(getContainer(), getUser(), queryForm.getQueryName()); - if (_sampleType == null) - { - errors.reject(ERROR_GENERIC, "Sample type '" + queryForm.getQueryName() + " not found."); - } - } - } - } - - private String getTargetSchemaName() - { - return getOptionParamValue(Params.crossTypeImport) ? ExpSchema.SCHEMA_NAME : "samples"; - } - - @Override - protected UserSchema getTargetSchema() - { - return getOptionParamValue(Params.crossTypeImport) ? QueryService.get().getUserSchema(getUser(), getContainer(), getTargetSchemaName()) : super.getTargetSchema(); - } - - @Override - protected String getPipelineTargetQueryName() - { - return getOptionParamValue(Params.crossTypeImport) ? "materials" : super.getPipelineTargetQueryName(); - } - - @Override - protected Map getRenamedColumns() - { - Map renamedColumns = super.getRenamedColumns(); - renamedColumns.putAll(SampleTypeUpdateServiceDI.SAMPLE_ALT_IMPORT_NAME_COLS); - return renamedColumns; - } - - @Override - protected @Nullable Set getLineageImportAliases() throws IOException - { - Set aliases = new CaseInsensitiveHashSet(); - // Issue 53419: Aliquot parent with number like names that starts with leading zeroes aren't resolved during import - aliases.add(ExpMaterial.ALIQUOTED_FROM_INPUT); - aliases.add(ExpMaterial.ALIQUOTED_FROM_INPUT_LABEL); - boolean crossTypeImport = getOptionParamValue(AbstractQueryImportAction.Params.crossTypeImport); - // Issue 51894: We need to stop conversion to numbers for alias fields for all type - // If there are aliases defined for one type that are number fields in another type, this will prevent - // conversion to numbers during the initial partitioning, but the conversion will happen when the partition - // file is loaded. - if (crossTypeImport) - { - List sampleTypes = SampleTypeServiceImpl.get().getSampleTypes(getContainer(), true); - for (ExpSampleTypeImpl sampleType : sampleTypes) - aliases.addAll(sampleType.getImportAliases().keySet()); - } - else - { - ExpSampleTypeImpl sampleType = SampleTypeServiceImpl.get().getSampleType(getContainer(), getUser(), _form.getQueryName()); - aliases.addAll(sampleType.getImportAliases().keySet()); - } - return aliases; - } - - @Override - protected int importData( - DataLoader dl, - FileStream file, - String originalName, - BatchValidationException errors, - @Nullable AuditBehaviorType auditBehaviorType, - TransactionAuditProvider.@Nullable TransactionAuditEvent auditEvent, - @Nullable String auditUserComment - ) throws IOException - { - initContext(dl, errors, auditBehaviorType, auditUserComment); - - TableInfo tInfo = _target; - QueryUpdateService updateService = _updateService; - if (getOptionParamValue(Params.crossTypeImport)) - { - tInfo = ExperimentService.get().createMaterialTable(new SamplesSchema(getUser(), getContainer()), ContainerFilter.current(this), null); - updateService = tInfo.getUpdateService(); - } - - int count = importData(dl, tInfo, updateService, _context, auditEvent, getUser(), getContainer()); - - if (getOptionParamValue(Params.crossTypeImport)) - { - SimpleMetricsService.get().increment(ExperimentService.MODULE_NAME, "sampleImport", "crossTypeImport"); - if (_context.getInsertOption() == QueryUpdateService.InsertOption.UPDATE) - SimpleMetricsService.get().increment(ExperimentService.MODULE_NAME, "sampleImport", "crossTypeUpdate"); - else if (_context.getInsertOption() == QueryUpdateService.InsertOption.MERGE) - SimpleMetricsService.get().increment(ExperimentService.MODULE_NAME, "sampleImport", "crossTypeMerge"); - } - - return count; - } - - @Override - public ModelAndView getView(QueryForm form, BindException errors) throws Exception - { - initRequest(form); - setHelpTopic("importSampleSets"); // page-wide help topic - setImportHelpTopic("importSampleSets"); // importOptions help topic - setTypeName("samples"); - return getDefaultImportView(form, errors); - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild("Sample Types", ExperimentUrlsImpl.get().getShowSampleTypeListURL(getContainer())); - ActionURL url = _form.urlFor(QueryAction.executeQuery); - if (_form.getQueryName() != null && url != null) - root.addChild(_form.getQueryName(), url); - root.addChild("Import Data"); - } - - @Override - protected JSONObject createSuccessResponse(int rowCount) - { - JSONObject json = super.createSuccessResponse(rowCount); - if (!_context.getResponseInfo().isEmpty()) - { - for (String key : _context.getResponseInfo().keySet()) - json.put(key, _context.getResponseInfo().get(key)); - } - return json; - } - - @Override - protected void configureLoader(DataLoader loader) throws IOException - { - if (getOptionParamValue(Params.crossTypeImport)) - loader.setInferTypes(false); - configureLoader(loader, _target, getRenamedColumns(), allowLineageColumns(), getLineageImportAliases()); - } - } - - public abstract static class AbstractExpDataImportAction extends AbstractQueryImportAction - { - protected QueryForm _form; - protected DataIteratorContext _context; - - @Override - public void validateForm(QueryForm form, Errors errors) - { - QueryDefinition query = form.getQueryDef(); - if (query.getContainerFilter() != null && query.getContainerFilter().getType() != null) - { - // cross folder import not supported - if (query.getContainerFilter().getType() != ContainerFilter.Type.Current) - errors.reject(ERROR_GENERIC, "ContainerFilter is not supported for import actions."); - } - } - - @Override - protected void initRequest(QueryForm form) throws ServletException - { - QueryDefinition query = form.getQueryDef(); - setContainerFilterForImport(query, getContainer(), getUser()); - List qpe = new ArrayList<>(); - TableInfo t = query.getTable(form.getSchema(), qpe, true); - - if (!qpe.isEmpty()) - throw qpe.get(0); - if (!getOptionParamValue(Params.crossTypeImport) && null != t) - { - setTarget(t); - setShowMergeOption(t.supportsInsertOption(QueryUpdateService.InsertOption.MERGE)); - setShowUpdateOption(t.supportsInsertOption(QueryUpdateService.InsertOption.UPDATE)); - } - - _auditBehaviorType = form.getAuditBehavior(); - _auditUserComment = form.getAuditUserComment(); - } - - @Override - protected Map getRenamedColumns() - { - final String renameParamPrefix = "importAlias."; - Map renameColumns = new CaseInsensitiveHashMap<>(); - PropertyValue[] pvs = _form.getInitParameters().getPropertyValues(); - for (PropertyValue pv : pvs) - { - String paramName = pv.getName(); - if (!paramName.startsWith(renameParamPrefix) || pv.getValue() == null) - continue; - - renameColumns.put(paramName.substring(renameParamPrefix.length()), (String) pv.getValue()); - } - return renameColumns; - } - - @Override - protected Set getLineageImportAliases() throws IOException - { - ExpDataClass dataClass = ExperimentServiceImpl.get().getDataClass(getContainer(), getUser(), _form.getQueryName()); - return new CaseInsensitiveHashSet(dataClass.getImportAliases().keySet()); - } - - protected void initContext(DataLoader dl, BatchValidationException errors, @Nullable AuditBehaviorType auditBehaviorType, @Nullable String auditUserComment) - { - _context = createDataIteratorContext(_insertOption, getOptionParamsMap(), getLookupResolutionType(), auditBehaviorType, auditUserComment, errors, null, getContainer()); - - if (_context.isCrossFolderImport() && !getContainer().hasProductFolders()) - _context.setCrossFolderImport(false); - } - - @Override - protected int importData(DataLoader dl, FileStream file, String originalName, BatchValidationException errors, @Nullable AuditBehaviorType auditBehaviorType, TransactionAuditProvider.@Nullable TransactionAuditEvent auditEvent, @Nullable String auditUserComment) throws IOException - { - initContext(dl, errors, auditBehaviorType, auditUserComment); - return importData(dl, _target, _updateService, _context, auditEvent, getUser(), getContainer()); - } - - @Override - protected String getQueryImportProviderName() - { - PropertyValue pv = _form.getInitParameters().getPropertyValue(QUERY_IMPORT_PIPELINE_PROVIDER_PARAM); - return pv == null ? null : (String) pv.getValue(); - } - - @Override - protected String getQueryImportDescription() - { - PropertyValue pv = _form.getInitParameters().getPropertyValue(QUERY_IMPORT_PIPELINE_DESCRIPTION_PARAM); - return pv == null ? null : (String) pv.getValue(); - } - - @Override - protected String getQueryImportJobNotificationProviderName() - { - PropertyValue pv = _form.getInitParameters().getPropertyValue(QUERY_IMPORT_NOTIFICATION_PROVIDER_PARAM); - return pv == null ? null : (String) pv.getValue(); - } - - @Override - protected boolean isBackgroundImportSupported() - { - return true; - } - - @Override - protected boolean allowLineageColumns() - { - return true; - } - - } - - @RequiresPermission(InsertPermission.class) - public static class ImportDataAction extends AbstractExpDataImportAction - { - @Override - public void validateForm(QueryForm queryForm, Errors errors) - { - _form = queryForm; - _form.setSchemaName("exp.data"); - _insertOption = queryForm.getInsertOption(); - super.validateForm(queryForm, errors); - if (queryForm.getQueryName() == null) - errors.reject(ERROR_REQUIRED, "Data class name is required"); - else - { - ExpDataClass dataClass = ExperimentService.get().getDataClass(getContainer(), getUser(), queryForm.getQueryName()); - if (dataClass == null) - { - errors.reject(ERROR_GENERIC, "Data class '" + queryForm.getQueryName() + " not found."); - } - } - } - - @Override - public ModelAndView getView(QueryForm form, BindException errors) throws Exception - { - initRequest(form); - setHelpTopic("dataClass"); // page wide help topic - setImportHelpTopic("dataClass#ui"); // importOptions help topic - setTypeName("data"); - return getDefaultImportView(form, errors); - } - - - @Override - public void addNavTrail(NavTree root) - { - root.addChild("Data Classes", ExperimentUrlsImpl.get().getDataClassListURL(getContainer())); - ActionURL url = _form.urlFor(QueryAction.executeQuery); - if (_form.getQueryName() != null && url != null) - root.addChild(_form.getQueryName(), url); - root.addChild("Import Data"); - } - - @Override - protected void configureLoader(DataLoader loader) throws IOException - { - configureLoader(loader, _target, getRenamedColumns(), allowLineageColumns(), getLineageImportAliases()); - } - - } - - @RequiresPermission(UpdatePermission.class) - public class ShowUpdateAction extends SimpleViewAction - { - @Override - public ModelAndView getView(ExperimentForm form, BindException errors) - { - form.refreshFromDb(); - Experiment exp = form.getBean(); - if (exp == null) - { - throw new NotFoundException(); - } - ensureCorrectContainer(getContainer(), ExperimentService.get().getExpExperiment(exp.getRowId()), getViewContext()); - - return new ExperimentUpdateView(new DataRegion(), form, errors); - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("runGroups"); - addRootNavTrail(root); - root.addChild("Update Run Group"); - } - } - - @RequiresPermission(UpdatePermission.class) - public static class UpdateAction extends FormHandlerAction - { - private Experiment _exp; - - @Override - public void validateCommand(ExperimentForm target, Errors errors) - { - } - - @Override - public boolean handlePost(ExperimentForm form, BindException errors) throws Exception - { - form.doUpdate(); - form.refreshFromDb(); - _exp = form.getBean(); - return true; - } - - @Override - public ActionURL getSuccessURL(ExperimentForm experimentForm) - { - return ExperimentUrlsImpl.get().getExperimentDetailsURL(getContainer(), ExperimentService.get().getExpExperiment(_exp.getRowId())); - } - } - - public static class ExportBean - { - private final LSIDRelativizer _selectedRelativizer; - private final XarExportType _selectedExportType; - private final String _fileName; - private final String _dataRegionSelectionKey; - private final String _error; - private final Long _expRowId; - private final Long _protocolId; - private final ActionURL _postURL; - private final Set _roles; - - public ExportBean(LSIDRelativizer selectedRelativizer, XarExportType selectedExportType, String fileName, ExportOptionsForm form, Set roles, ActionURL postURL) - { - _selectedRelativizer = selectedRelativizer; - _selectedExportType = selectedExportType; - _fileName = fileName; - _dataRegionSelectionKey = form.getDataRegionSelectionKey(); - _error = form.getError(); - _expRowId = form.getExpRowId(); - _postURL = postURL; - _roles = roles; - _protocolId = form.getProtocolId(); - } - - public LSIDRelativizer getSelectedRelativizer() - { - return _selectedRelativizer; - } - - public XarExportType getSelectedExportType() - { - return _selectedExportType; - } - - public String getError() - { - return _error; - } - - public String getFileName() - { - return _fileName; - } - - public Set getRoles() - { - return _roles; - } - - public String getDataRegionSelectionKey() - { - return _dataRegionSelectionKey; - } - - public ActionURL getPostURL() - { - return _postURL; - } - - public Long getProtocolId() - { - return _protocolId; - } - - public Long getExpRowId() - { - return _expRowId; - } - } - - - private String fixupExportName(String runName) - { - runName = runName.replace('/', '-'); - runName = runName.replace('\\', '-'); - return runName; - } - - public static class ExportOptionsForm extends ExperimentRunListForm - { - private String _error; - private XarExportType _exportType; - private LSIDRelativizer _lsidOutputType; - private String _xarFileName; - private String _zipFileName; - private String _fileExportType; - private Long _protocolId; - private Integer _sampleTypeId; - private long[] _dataIds; - private String[] _roles = new String[0]; - - public String getError() - { - return _error; - } - - public void setError(String error) - { - _error = error; - } - - public XarExportType getExportType() - { - return _exportType; - } - - public LSIDRelativizer getLsidOutputType() - { - return _lsidOutputType; - } - - public String getFileExportType() - { - return _fileExportType; - } - - public void setFileExportType(String fileExportType) - { - _fileExportType = fileExportType; - } - - public String getXarFileName() - { - return _xarFileName; - } - - public void setXarFileName(String xarFileName) - { - _xarFileName = xarFileName; - } - - public String getZipFileName() - { - return _zipFileName; - } - - public void setZipFileName(String zipFileName) - { - _zipFileName = zipFileName; - } - - public void setExportType(XarExportType exportType) - { - _exportType = exportType; - } - - public void setLsidOutputType(LSIDRelativizer lsidOutputType) - { - _lsidOutputType = lsidOutputType; - } - - public Long getProtocolId() - { - return _protocolId; - } - - public void setProtocolId(Long protocolId) - { - _protocolId = protocolId; - } - - public String[] getRoles() - { - return _roles; - } - - public void setRoles(String[] roles) - { - _roles = roles; - } - - public Integer getSampleTypeId() - { - return _sampleTypeId; - } - - public void setSampleTypeId(Integer sampleTypeId) - { - _sampleTypeId = sampleTypeId; - } - - public long[] getDataIds() - { - return _dataIds; - } - - public void setDataIds(long[] dataIds) - { - _dataIds = dataIds; - } - - public List lookupProtocols(ViewContext context, boolean clearSelection) - { - List protocols = new ArrayList<>(); - - if (_protocolId != null) - { - ExpProtocol protocol = ExperimentService.get().getExpProtocol(_protocolId.intValue()); - if (protocol == null || !protocol.getContainer().equals(context.getContainer())) - { - throw new NotFoundException(); - } - protocols.add(protocol); - return protocols; - } - - for (Long protocolId : DataRegionSelection.getSelectedIntegers(context, clearSelection)) - { - try - { - ExpProtocol protocol = ExperimentService.get().getExpProtocol(protocolId); - if (protocol == null || !protocol.getContainer().equals(context.getContainer())) - { - throw new NotFoundException(); - } - protocols.add(protocol); - } - catch (NumberFormatException e) - { - throw new NotFoundException("Invalid protocol id: " + protocolId); - } - } - if (protocols.isEmpty()) - { - throw new NotFoundException("No protocols selected"); - } - return protocols; - } - } - - private ActionURL exportXAR(@NotNull XarExportSelection selection, @Nullable String fileName) - throws ExperimentException, IOException, PipelineValidationException - { - return exportXAR(selection, null, null, fileName); - } - - private ActionURL exportXAR(@NotNull XarExportSelection selection, @Nullable LSIDRelativizer lsidRelativizer, @Nullable XarExportType exportType, @Nullable String fileName) - throws ExperimentException, IOException, PipelineValidationException - { - if (lsidRelativizer == null) - lsidRelativizer = LSIDRelativizer.FOLDER_RELATIVE; - - if (exportType == null) - exportType = XarExportType.BROWSER_DOWNLOAD; - - if (fileName == null || fileName.isEmpty()) - fileName = "export.xar"; - - fileName = fixupExportName(fileName); - String xarXmlFileName = null; - if (Strings.CI.endsWith(fileName, ".xar")) - xarXmlFileName = fileName + ".xml"; - - switch (exportType) - { - case BROWSER_DOWNLOAD: - XarExporter exporter = new XarExporter(lsidRelativizer, selection, getUser(), xarXmlFileName, null, getContainer()); - - getViewContext().getResponse().setContentType("application/zip"); - ResponseHelper.setContentDisposition(getViewContext().getResponse(), ResponseHelper.ContentDispositionType.attachment, fileName); - ResponseHelper.setPrivate(getViewContext().getResponse()); - - exporter.writeAsArchive(getViewContext().getResponse().getOutputStream()); - return null; - case PIPELINE_FILE: - if (!PipelineService.get().hasValidPipelineRoot(getContainer())) - { - throw new IllegalStateException("You must set a valid pipeline root before you can export a XAR to it."); - } - PipeRoot pipeRoot = PipelineService.get().findPipelineRoot(getContainer()); - XarExportPipelineJob job = new XarExportPipelineJob(getViewBackgroundInfo(), pipeRoot, fileName, lsidRelativizer, selection, xarXmlFileName); - PipelineService.get().queueJob(job); - PipelineStatusFile status = PipelineService.get().getStatusFile(job.getJobGUID()); - return PageFlowUtil.urlProvider(PipelineUrls.class).statusDetails(getContainer(), status.getRowId()); - default: - throw new IllegalArgumentException("Unknown export type: " + exportType); - } - } - - @RequiresPermission(ReadPermission.class) - public class ExportProtocolsAction extends AbstractExportAction - { - @Override - public boolean handlePost(ExportOptionsForm form, BindException errors) throws Exception - { - List protocols = form.lookupProtocols(getViewContext(), false); - - long[] ids = new long[protocols.size()]; - for (int i = 0; i < ids.length; i++) - { - ids[i] = protocols.get(i).getRowId(); - } - XarExportSelection selection = new XarExportSelection(); - selection.addProtocolIds(ids); - - exportXAR(selection, form.getLsidOutputType(), form.getExportType(), form.getXarFileName()); - - if (form.getDataRegionSelectionKey() != null) - { - // Clear the selection - form.lookupProtocols(getViewContext(), true); - } - return true; - } - } - - public abstract static class AbstractExportAction extends FormViewAction - { - protected ActionURL _resultURL; - - @Override - public void validateCommand(ExportOptionsForm target, Errors errors) - { - } - - @Override - public ActionURL getSuccessURL(ExportOptionsForm exportOptionsForm) - { - return _resultURL; - } - - @Override - public ModelAndView getSuccessView(ExportOptionsForm exportOptionsForm) - { - return null; - } - - @Override - public ModelAndView getView(ExportOptionsForm form, boolean reshow, BindException errors) throws Exception - { - // FormViewAction can reinvoke getView() in response to a POST if we're not redirecting the browser, - // so avoid double-creating the export - if ("get".equalsIgnoreCase(getViewContext().getRequest().getMethod())) - handlePost(form, errors); - return null; - } - - @Override - public void addNavTrail(NavTree root) - { - } - - public List lookupRuns(ExportOptionsForm form) - { - Set runIds; - if (form.getRunIds() != null && form.getRunIds().length > 0) - runIds = new HashSet<>(Arrays.asList(form.getRunIds())); - else - runIds = DataRegionSelection.getSelectedIntegers(getViewContext(), form.getDataRegionSelectionKey(), false); - - if (runIds.isEmpty()) - { - throw new NotFoundException(); - } - List result = new ArrayList<>(); - - for (long id : runIds) - { - ExpRun run = ExperimentService.get().getExpRun(id); - if (run == null || !run.getContainer().hasPermission(getUser(), ReadPermission.class)) - { - throw new NotFoundException("Could not find run " + id); - } - result.add(run); - } - return result; - } - } - - @RequiresPermission(ReadPermission.class) - public class ExportRunsAction extends AbstractExportAction - { - @Override - public boolean handlePost(ExportOptionsForm form, BindException errors) throws Exception - { - XarExportSelection selection = new XarExportSelection(); - if (form.getExpRowId() != null) - { - ExpExperiment experiment = ExperimentService.get().getExpExperiment(form.getExpRowId()); - if (experiment != null && !experiment.getContainer().hasPermission(getUser(), ReadPermission.class)) - { - throw new NotFoundException("Run group " + form.getExpRowId()); - } - selection.addExperimentIds(experiment.getRowId()); - } - selection.addRuns(lookupRuns(form)); - - _resultURL = exportXAR(selection, form.getLsidOutputType(), form.getExportType(), form.getXarFileName()); - if (form.getDataRegionSelectionKey() != null) - DataRegionSelection.clearAll(getViewContext(), form.getDataRegionSelectionKey()); - return true; - } - } - - @RequiresPermission(ReadPermission.class) - public class ExportSampleTypeAction extends AbstractExportAction - { - @Override - public boolean handlePost(ExportOptionsForm form, BindException errors) throws Exception - { - Integer rowId = form.getSampleTypeId(); - if (rowId == null) - { - throw new NotFoundException("No sampleTypeId parameter specified"); - } - ExpSampleType sampleType = SampleTypeService.get().getSampleType(getContainer(), getUser(), rowId.intValue()); - if (sampleType == null) - { - throw new NotFoundException("No such sample type with RowId " + rowId); - } - if (!sampleType.getContainer().hasPermission(getUser(), ReadPermission.class)) - { - throw new UnauthorizedException(); - } - - XarExportSelection selection = new XarExportSelection(); - selection.addSampleType(sampleType); - - _resultURL = exportXAR(selection, form.getLsidOutputType(), form.getExportType(), FileUtil.makeLegalName(sampleType.getName() + ".xar")); - return true; - } - } - - @RequiresPermission(ReadPermission.class) - public class ExportRunFilesAction extends AbstractExportAction - { - @Override - public boolean handlePost(ExportOptionsForm form, BindException errors) throws Exception - { - XarExportSelection selection = new XarExportSelection(); - selection.setIncludeXarXml(false); - if ("role".equalsIgnoreCase(form.getFileExportType())) - { - selection.addRoles(form.getRoles()); - } - selection.addRuns(lookupRuns(form)); - - _resultURL = exportXAR(selection, form.getZipFileName()); - if (form.getDataRegionSelectionKey() != null) - DataRegionSelection.clearAll(getViewContext(), form.getDataRegionSelectionKey()); - return true; - } - } - - @RequiresPermission(ReadPermission.class) - public class ExportFilesAction extends AbstractExportAction - { - @Override - public boolean handlePost(ExportOptionsForm form, BindException errors) throws Exception - { - long[] dataIds = form.getDataIds(); - if (dataIds == null || dataIds.length == 0) - { - throw new NotFoundException(); - } - - try - { - for (long id : dataIds) - { - ExpData data = ExperimentService.get().getExpData(id); - if (data == null || !data.getContainer().hasPermission(getUser(), ReadPermission.class)) - { - throw new NotFoundException("Could not find file " + id); - } - } - - XarExportSelection selection = new XarExportSelection(); - selection.setIncludeXarXml(false); - selection.addDataIds(dataIds); - - _resultURL = exportXAR(selection, form.getZipFileName()); - return true; - } - catch (NumberFormatException e) - { - throw new NotFoundException(Arrays.toString(dataIds)); - } - } - } - - public static class ExperimentRunListForm implements DataRegionSelection.DataSelectionKeyForm - { - private String _dataRegionSelectionKey; - private Long _expRowId; - private Long[] _runIds; - - @Override - public String getDataRegionSelectionKey() - { - return _dataRegionSelectionKey; - } - - @Override - public void setDataRegionSelectionKey(String key) - { - _dataRegionSelectionKey = key; - } - - public Long getExpRowId() - { - return _expRowId; - } - - public void setExpRowId(Long expRowId) - { - _expRowId = expRowId; - } - - public Long[] getRunIds() - { - return _runIds; - } - - public void setRunIds(Long[] runIds) - { - _runIds = runIds; - } - - public ExpExperiment lookupExperiment() - { - return getExpRowId() == null ? null : ExperimentService.get().getExpExperiment(getExpRowId().intValue()); - } - } - - private void addSelectedRunsToExperiment(ExpExperiment exp, String dataRegionSelectionKey) - { - Collection runIds = DataRegionSelection.getSelectedIntegers(getViewContext(), dataRegionSelectionKey, true); - List runs = new ArrayList<>(); - for (long runId : runIds) - { - ExpRun run = ExperimentServiceImpl.get().getExpRun(runId); - if (run != null) - { - runs.add(run); - } - } - exp.addRuns(getUser(), runs.toArray(new ExpRun[0])); - } - - - @RequiresPermission(InsertPermission.class) - public class AddRunsToExperimentAction extends FormHandlerAction - { - @Override - public void validateCommand(ExperimentRunListForm target, Errors errors) - { - } - - @Override - public boolean handlePost(ExperimentRunListForm form, BindException errors) - { - addSelectedRunsToExperiment(form.lookupExperiment(), form.getDataRegionSelectionKey()); - return true; - } - - @Override - public ActionURL getSuccessURL(ExperimentRunListForm form) - { - return ExperimentUrlsImpl.get().getExperimentDetailsURL(getContainer(), form.lookupExperiment()); - } - } - - @RequiresPermission(DeletePermission.class) - public static class RemoveSelectedExpRunsAction extends FormHandlerAction - { - @Override - public void validateCommand(ExperimentRunListForm target, Errors errors) - { - } - - @Override - public boolean handlePost(ExperimentRunListForm form, BindException errors) - { - ExpExperiment exp = form.lookupExperiment(); - if (exp == null || !exp.getContainer().hasPermission(getUser(), DeletePermission.class)) - { - throw new NotFoundException("Could not find run group with RowId " + form.getExpRowId()); - } - - for (long runId : DataRegionSelection.getSelectedIntegers(getViewContext(), form.getDataRegionSelectionKey(), false)) - { - ExpRun run = ExperimentService.get().getExpRun(runId); - if (run == null || !run.getContainer().hasPermission(getUser(), DeletePermission.class)) - { - throw new NotFoundException("Could not find run with RowId " + runId); - } - exp.removeRun(getUser(), run); - } - if (form.getDataRegionSelectionKey() != null) - DataRegionSelection.clearAll(getViewContext(), form.getDataRegionSelectionKey()); - return true; - } - - @Override - public ActionURL getSuccessURL(ExperimentRunListForm form) - { - return ExperimentUrlsImpl.get().getExperimentDetailsURL(getContainer(), form.lookupExperiment()); - } - } - - public static ActionURL getResolveLsidURL(Container c, @NotNull String type, @NotNull String lsid) - { - ActionURL url = new ActionURL(ResolveLSIDAction.class, c); - url.addParameter("type", type); - url.addParameter("lsid", lsid); - - return url; - } - - - @RequiresPermission(ReadPermission.class) - public static class ResolveLSIDAction extends SimpleViewAction - { - @Override - public ModelAndView getView(LsidForm form, BindException errors) - { - String message = ""; - if (!PageFlowUtil.empty(form.getLsid())) - { - try - { - String lsid = Lsid.canonical(form.getLsid().trim()); - ActionURL url = LsidManager.get().getDisplayURL(lsid); - if (url == null && form.getType() != null) - { - url = switch (form.getType().toLowerCase()) - { - case "data" -> LsidType.Data.getDisplayURL(new Lsid(lsid)); - case "material" -> LsidType.Material.getDisplayURL(new Lsid(lsid)); - default -> url; - }; - } - if (null != url) - { - throw new RedirectException(url); - } - message = "Could not map LSID to URL"; - } - catch (IllegalArgumentException e) - { - message = "Invalid LSID"; - } - } - - return new HtmlView("Enter LSID", - DOM.createHtmlFragment( - message, - DOM.FORM(at(action, getViewContext().cloneActionURL().setAction(ResolveLSIDAction.class)), - "LSID: ", - DOM.INPUT(at(type, "text", name, "lsid", size, "80", value, form.getLsid())), - PageFlowUtil.button("Go").submit(true)))); - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild("Resolve LSID"); - } - } - - public static class LsidForm - { - private String _lsid; - - public String getType() - { - return _type; - } - - public void setType(String type) - { - _type = type; - } - - private String _type; - - public void setLsid(String lsid) - { - _lsid = lsid; - } - - public String getLsid() - { - return _lsid; - } - } - - public static class SetFlagForm extends LsidForm - { - private String _comment; - private boolean _redirect = true; - - public String getComment() - { - return _comment; - } - - public void setComment(String comment) - { - _comment = comment; - } - - public boolean isRedirect() - { - return _redirect; - } - - public void setRedirect(boolean redirect) - { - _redirect = redirect; - } - } - - /** - * Check for update on the object itself - */ - @RequiresNoPermission - public static class SetFlagAction extends FormHandlerAction - { - @Override - public void validateCommand(SetFlagForm target, Errors errors) - { - } - - @Override - public boolean handlePost(SetFlagForm form, BindException errors) throws Exception - { - String lsid = form.getLsid(); - if (lsid == null) - throw new NotFoundException(); - ExpObject obj = ExperimentService.get().findObjectFromLSID(lsid); - if (obj == null) - throw new NotFoundException(); - Container container = obj.getContainer(); - if (!container.hasPermission(getUser(), UpdatePermission.class)) - { - throw new UnauthorizedException(); - } - - obj.setComment(getUser(), form.getComment()); - return true; - } - - @Override - public URLHelper getSuccessURL(SetFlagForm form) - { - return null; - } - } - - @RequiresPermission(InsertPermission.class) - public class DeriveSamplesChooseTargetAction extends SimpleViewAction - { - private List _materials; - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("sampleSets"); - addRootNavTrail(root); - root.addChild("Sample Types", ExperimentUrlsImpl.get().getShowSampleTypeListURL(getContainer())); - ExpSampleType sampleType = _materials != null && !_materials.isEmpty() ? _materials.get(0).getSampleType() : null; - if (sampleType != null) - { - root.addChild(sampleType.getName(), ExperimentUrlsImpl.get().getShowSampleTypeURL(sampleType)); - } - root.addChild("Derive Samples"); - } - - @Override - public void validate(DeriveMaterialForm form, BindException errors) - { - _materials = form.lookupMaterials(); - if (_materials.isEmpty()) - { - throw new NotFoundException("Could not find any matching materials"); - } - } - - @Override - public ModelAndView getView(DeriveMaterialForm form, BindException errors) - { - Container c = getContainer(); - PipeRoot root = PipelineService.get().findPipelineRoot(c); - - if (root == null || !root.isValid()) - { - ActionURL pipelineURL = urlProvider(PipelineUrls.class).urlSetup(c); - return new HtmlView(DIV("You must ", - DOM.A(DOM.at(href, pipelineURL), "configure a valid pipeline root for this folder"), - " before deriving samples.")); - } - else - { - Set materialInputRoles = new TreeSet<>(ExperimentService.get().getMaterialInputRoles(getContainer(), getUser())); - Map materialsWithRoles = new LinkedHashMap<>(); - for (ExpMaterial material : _materials) - { - materialsWithRoles.put(material, null); - } - - List sampleTypes = getUploadableSampleTypes(); - - DeriveSamplesChooseTargetBean bean = new DeriveSamplesChooseTargetBean(form.getDataRegionSelectionKey(), form.getTargetSampleTypeId(), sampleTypes, materialsWithRoles, form.getOutputCount(), materialInputRoles, null); - return new JspView<>("/org/labkey/experiment/deriveSamplesChooseTarget.jsp", bean); - } - } - } - - public static class DeriveSamplesChooseTargetBean implements DataRegionSelection.DataSelectionKeyForm - { - private String _dataRegionSelectionKey; - - private final Integer _targetSampleTypeId; - private final List _sampleTypes; - private final Map _sourceMaterials; - private final int _sampleCount; - private final Collection _inputRoles; - private final DerivedSamplePropertyHelper _propertyHelper; - - public static final String CUSTOM_ROLE = "--CUSTOM--"; - - public DeriveSamplesChooseTargetBean(String dataRegionSelectionKey, Integer targetSampleTypeId, List sampleTypes, Map sourceMaterials, int sampleCount, Collection inputRoles, DerivedSamplePropertyHelper helper) - { - _dataRegionSelectionKey = dataRegionSelectionKey; - _targetSampleTypeId = targetSampleTypeId; - _sampleTypes = sampleTypes; - _sourceMaterials = sourceMaterials; - _sampleCount = sampleCount; - _inputRoles = inputRoles; - _propertyHelper = helper; - } - - public Integer getTargetSampleTypeId() - { - return _targetSampleTypeId; - } - - public DerivedSamplePropertyHelper getPropertyHelper() - { - return _propertyHelper; - } - - public int getSampleCount() - { - return _sampleCount; - } - - public Map getSourceMaterials() - { - return _sourceMaterials; - } - - public List getSampleTypes() - { - return _sampleTypes; - } - - public Collection getInputRoles() - { - return _inputRoles; - } - - @Override - public String getDataRegionSelectionKey() - { - return _dataRegionSelectionKey; - } - - @Override - public void setDataRegionSelectionKey(String key) - { - _dataRegionSelectionKey = key; - } - } - - private List getUploadableSampleTypes() - { - // Make a copy so we can modify it - List sampleTypes = new ArrayList<>(SampleTypeService.get().getSampleTypes(getContainer(), getUser(), true)); - sampleTypes.removeIf(sampleType -> !sampleType.canImportMoreSamples()); - return sampleTypes; - } - - @RequiresPermission(InsertPermission.class) - public class DeriveSamplesAction extends FormViewAction - { - private List _materials; - private ActionURL _successUrl; - private final Map _inputMaterials = new LinkedHashMap<>(); - - @Override - public ModelAndView getView(DeriveMaterialForm form, boolean reshow, BindException errors) - { - _materials = form.lookupMaterials(); - if (_materials.isEmpty()) - { - throw new NotFoundException("Could not find any matching materials"); - } - - Container c = getContainer(); - - if (form.getOutputCount() <= 0) - { - form.setOutputCount(1); - } - - if (form.getTargetSampleTypeId() == 0) - throw new NotFoundException("Target sample type required for the derived samples"); - - ExpSampleTypeImpl sampleType = SampleTypeServiceImpl.get().getSampleType(getContainer(), getUser(), form.getTargetSampleTypeId()); - if (sampleType == null) - throw new NotFoundException("Could not find sample type with rowId " + form.getTargetSampleTypeId()); - - InsertView insertView = new InsertView(new DataRegion(), errors); - - DerivedSamplePropertyHelper helper = new DerivedSamplePropertyHelper(sampleType, form.getOutputCount(), c, getUser()); - helper.addSampleColumns(insertView, getUser()); - - int[] rowIds = form.getRowIds(); - for (int i = 0; i < rowIds.length; i++) - { - insertView.getDataRegion().addHiddenFormField("rowIds", Integer.toString(rowIds[i])); - insertView.getDataRegion().addHiddenFormField("inputRole" + i, form.getInputRole(i) == null ? "" : form.getInputRole(i)); - insertView.getDataRegion().addHiddenFormField("customRole" + i, form.getCustomRole(i) == null ? "" : form.getCustomRole(i)); - } - - insertView.getDataRegion().addHiddenFormField("targetSampleTypeId", Integer.toString(form.getTargetSampleTypeId())); - insertView.getDataRegion().addHiddenFormField("outputCount", Integer.toString(form.getOutputCount())); - if (form.getDataRegionSelectionKey() != null) - insertView.getDataRegion().addHiddenFormField(DataRegionSelection.DATA_REGION_SELECTION_KEY, form.getDataRegionSelectionKey()); - insertView.setInitialValues(ViewServlet.adaptParameterMap(getViewContext().getRequest().getParameterMap())); - ButtonBar bar = new ButtonBar(); - bar.setStyle(ButtonBar.Style.separateButtons); - ActionButton submitButton = new ActionButton(DeriveSamplesAction.class, "Submit"); - submitButton.setActionType(ActionButton.Action.POST); - bar.add(submitButton); - insertView.getDataRegion().setButtonBar(bar); - insertView.setTitle("Output Samples"); - - Map materialsWithRoles = new LinkedHashMap<>(); - List materials = form.lookupMaterials(); - for (int i = 0; i < materials.size(); i++) - { - materialsWithRoles.put(materials.get(i), form.determineLabel(i)); - } - - DeriveSamplesChooseTargetBean bean = new DeriveSamplesChooseTargetBean(form.getDataRegionSelectionKey(), form.getTargetSampleTypeId(), getUploadableSampleTypes(), materialsWithRoles, form.getOutputCount(), Collections.emptyList(), helper); - JspView view = new JspView<>("/org/labkey/experiment/summarizeMaterialInputs.jsp", bean); - view.setTitle("Input Samples"); - - return new VBox(view, insertView); - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("sampleSets"); - addRootNavTrail(root); - root.addChild("Sample Types", ExperimentUrlsImpl.get().getShowSampleTypeListURL(getContainer())); - ExpSampleType sampleType = _materials != null && !_materials.isEmpty() ? _materials.get(0).getSampleType() : null; - if (sampleType != null) - { - root.addChild(sampleType.getName(), ExperimentUrlsImpl.get().getShowSampleTypeURL(sampleType)); - } - root.addChild("Derive Samples"); - } - - @Override - public void validateCommand(DeriveMaterialForm form, Errors errors) - { - List materials = form.lookupMaterials(); - - List lockedSamples = new ArrayList<>(); - for (int i = 0; i < materials.size(); i++) - { - ExpMaterial m = materials.get(i); - if (!m.isOperationPermitted(SampleTypeService.SampleOperations.EditLineage)) - { - lockedSamples.add(m); - } - String inputRole = form.determineLabel(i); - if (inputRole == null || inputRole.isEmpty()) - { - ExpSampleType st = m.getSampleType(); - inputRole = st != null ? st.getName() : ExpMaterialRunInput.DEFAULT_ROLE; - } - _inputMaterials.put(materials.get(i), inputRole); - } - - if (!lockedSamples.isEmpty()) - { - errors.reject(ERROR_MSG, SampleTypeService.get().getOperationNotPermittedMessage(lockedSamples, SampleTypeService.SampleOperations.EditLineage)); - } - } - - @Override - public boolean handlePost(DeriveMaterialForm form, BindException errors) - { - ExpSampleTypeImpl sampleType = SampleTypeServiceImpl.get().getSampleType(getContainer(), getUser(), form.getTargetSampleTypeId()); - - DerivedSamplePropertyHelper helper = new DerivedSamplePropertyHelper(sampleType, form.getOutputCount(), getContainer(), getUser()); - - Map, Map> allProperties; - try - { - boolean valid = true; - for (Map.Entry> entry : helper.getPostedPropertyValues(getViewContext().getRequest()).entrySet()) - valid = UploadWizardAction.validatePostedProperties(getViewContext(), entry.getValue(), errors) && valid; - if (!valid) - return false; - - allProperties = helper.getSampleProperties(getViewContext().getRequest(), _inputMaterials.keySet()); - } - catch (DuplicateMaterialException e) - { - errors.addError(new ObjectError(e.getColName(), null, null, e.getMessage())); - return false; - } - catch (ExperimentException e) - { - errors.reject(SpringActionController.ERROR_MSG, e.getMessage()); - return false; - } - - try (DbScope.Transaction tx = ExperimentService.get().ensureTransaction()) - { - Map outputMaterials = new HashMap<>(); - int i = 0; - for (Map.Entry, Map> entry : allProperties.entrySet()) - { - Lsid lsid = entry.getKey().first; - String name = entry.getKey().second; - assert name != null; - - ExpMaterialImpl outputMaterial = ExperimentServiceImpl.get().createExpMaterial(getContainer(), lsid.toString(), name); - if (sampleType != null) - { - outputMaterial.setCpasType(sampleType.getLSID()); - } - outputMaterial.save(getUser()); - - if (sampleType != null) - { - Map pvs = new HashMap<>(); - for (Map.Entry propertyEntry : entry.getValue().entrySet()) - pvs.put(propertyEntry.getKey().getName(), propertyEntry.getValue()); - outputMaterial.setProperties(getUser(), pvs, false); - } - - outputMaterials.put(outputMaterial, helper.getSampleNames().get(i++)); - } - - ExperimentService.get().deriveSamples(_inputMaterials, outputMaterials, getViewBackgroundInfo(), _log); - - tx.commit(); - - // automatically link samples to study, if configured - StudyPublishService.get().autoLinkDerivedSamples(sampleType, outputMaterials.keySet().stream().map(ExpObject::getRowId).collect(toList()), getContainer(), getUser()); - - _successUrl = ExperimentUrlsImpl.get().getShowSampleURL(getContainer(), outputMaterials.keySet().iterator().next()); - - if (form.getDataRegionSelectionKey() != null) - DataRegionSelection.clearAll(getViewContext(), form.getDataRegionSelectionKey()); - } - catch (Exception e) - { - errors.reject(SpringActionController.ERROR_MSG, e.getMessage()); - return false; - } - - return true; - } - - @Override - public URLHelper getSuccessURL(DeriveMaterialForm deriveMaterialForm) - { - return _successUrl; - } - } - - public static class DeriveMaterialForm implements HasViewContext, DataRegionSelection.DataSelectionKeyForm - { - private String _dataRegionSelectionKey; - private int _outputCount = 1; - private int _targetSampleTypeId; - private int[] _rowIds; - private String _name; - - private ViewContext _context; - - @Override - public void setViewContext(ViewContext context) - { - _context = context; - } - - @Override - public ViewContext getViewContext() - { - return _context; - } - - public List lookupMaterials() - { - List result = new ArrayList<>(); - for (int rowId : getRowIds()) - { - ExpMaterial material = ExperimentService.get().getExpMaterial(rowId); - if (material != null) - { - if (material.getContainer().hasPermission(_context.getUser(), ReadPermission.class)) - { - result.add(material); - } - else - { - throw new UnauthorizedException(); - } - } - else - { - throw new NotFoundException("No material with RowId " + rowId); - } - } - result.sort(Comparator.comparing(Identifiable::getName)); - return result; - } - - public String getName() - { - return _name; - } - - public void setName(String name) - { - _name = name; - } - - @Override - public String getDataRegionSelectionKey() - { - return _dataRegionSelectionKey; - } - - @Override - public void setDataRegionSelectionKey(String dataRegionSelectionKey) - { - _dataRegionSelectionKey = dataRegionSelectionKey; - } - - public int[] getRowIds() - { - if (_rowIds == null) - { - _rowIds = PageFlowUtil.toInts(DataRegionSelection.getSelected(getViewContext(), getDataRegionSelectionKey(), false)); - } - return _rowIds; - } - - public void setRowIds(int[] rowIds) - { - _rowIds = rowIds; - } - - public int getOutputCount() - { - return _outputCount; - } - - public void setOutputCount(int outputCount) - { - _outputCount = outputCount; - } - - public int getTargetSampleTypeId() - { - return _targetSampleTypeId; - } - - public void setTargetSampleTypeId(int targetSampleTypeId) - { - _targetSampleTypeId = targetSampleTypeId; - } - - public String getInputRole(int i) - { - return _context.getRequest().getParameter("inputRole" + i); - } - - public String getCustomRole(int i) - { - return _context.getRequest().getParameter("customRole" + i); - } - - public String determineLabel(int index) - { - String result = getInputRole(index); - if (DeriveSamplesChooseTargetBean.CUSTOM_ROLE.equals(result)) - { - result = getCustomRole(index); - } - if (result != null) - { - result = result.trim(); - } - return result; - } - } - - - public static class ExpInput - { - public String role; - public int rowId; - public Lsid lsid; - } - - public static class DerivationSpec - { - public String role; - public Map values; - } - - public static class DerivationForm - { - public List dataInputs; - public List materialInputs; - - public int dataOutputCount; - public Lsid targetDataClass; - public Map dataDefault; - public List dataOutputs; - - public int materialOutputCount; - public Lsid targetSampleType; - public Map materialDefault; - public List materialOutputs; - } - - @Marshal(Marshaller.Jackson) - @RequiresPermission(InsertPermission.class) - public static class DeriveAction extends MutatingApiAction - { - @Override - public void validateForm(DerivationForm form, Errors errors) - { - if (errors.hasErrors()) - return; - - if (form.materialOutputCount > 0 && form.materialOutputs != null && !form.materialOutputs.isEmpty()) - errors.reject(ERROR_MSG, "Either 'materialOutputCount' or 'materialOutputs' property can be specified, but not both."); - - if (form.dataOutputCount > 0 && form.dataOutputs != null && !form.dataOutputs.isEmpty()) - errors.reject(ERROR_MSG, "Either 'dataOutputCount' or 'dataOutputs' property can be specified, but not both."); - - boolean hasMaterialOutputs = form.materialOutputCount > 0 || form.materialOutputs != null && !form.materialOutputs.isEmpty(); - boolean hasDataOutputs = form.dataOutputCount > 0 || form.dataOutputs != null && !form.dataOutputs.isEmpty(); - - if (!hasMaterialOutputs && !hasDataOutputs) - errors.reject(ERROR_MSG, "At least one data output or material output is required"); - - if (hasMaterialOutputs && form.targetSampleType == null) - errors.reject(ERROR_MSG, "targetSampleType lsid required for material outputs"); - - if (hasDataOutputs && form.targetDataClass == null) - errors.reject(ERROR_MSG, "targetDataClass lsid required for data outputs"); - } - - @Override - public Object execute(DerivationForm form, BindException errors) throws Exception - { - // Find material inputs - Map materialInputs = new LinkedHashMap<>(); - if (form.materialInputs != null) - { - for (ExpInput in : form.materialInputs) - { - ExpMaterial m = null; - if (in.lsid != null) - { - m = ExperimentService.get().getExpMaterial(in.lsid.toString()); - if (m == null) - errors.reject(ERROR_MSG, "Can't resolve sample '" + in.lsid + "'"); - } - else if (in.rowId > 0) - { - m = ExperimentService.get().getExpMaterial(in.rowId); - if (m == null) - errors.reject(ERROR_MSG, "Can't resolve sample '" + in.rowId + "'"); - } - - if (m == null) - { - errors.reject(ERROR_MSG, "Material input lsid or rowId required"); - continue; - } - - ExpSampleType st = m.getSampleType(); - if (st == null) - { - errors.reject(ERROR_MSG, "Material input is not a member of a SampleType"); - continue; - } - - String role = in.role; - if (role == null || role.isEmpty()) - { - role = st.getName(); - } - materialInputs.put(m, role); - } - } - - // Find input data - Map dataInputs = new LinkedHashMap<>(); - if (form.dataInputs != null) - { - for (ExpInput in : form.dataInputs) - { - ExpData d = null; - if (in.lsid != null) - { - d = ExperimentService.get().getExpData(in.lsid.toString()); - if (d == null) - errors.reject(ERROR_MSG, "Can't resolve data '" + in.lsid + "'"); - } - else if (in.rowId > 0) - { - d = ExperimentService.get().getExpData(in.rowId); - if (d == null) - errors.reject(ERROR_MSG, "Can't resolve data '" + in.rowId + "'"); - } - - if (d == null) - { - errors.reject(ERROR_MSG, "Data input lsid or rowId required"); - continue; - } - - ExpDataClass dc = d.getDataClass(getUser()); - if (dc == null) - { - errors.reject(ERROR_MSG, "Data input is not a member of a DataClass"); - continue; - } - - String role = in.role; - if (role == null || role.isEmpty()) - { - role = dc.getName(); - } - dataInputs.put(d, role); - } - } - - ExpSampleType outSampleType; - if (form.targetSampleType != null) - { - // TODO: check in scope and has permission - outSampleType = SampleTypeService.get().getSampleType(form.targetSampleType.toString()); - if (outSampleType == null) - errors.reject(ERROR_MSG, "Sample type not found: " + form.targetSampleType.toString()); - } - else - { - outSampleType = null; - } - - ExpDataClass outDataClass; - if (form.targetDataClass != null) - { - // TODO: check in scope and has permission - outDataClass = ExperimentServiceImpl.get().getDataClass(form.targetDataClass.toString()); - if (outDataClass == null) - errors.reject(ERROR_MSG, "DataClass not found: " + form.targetDataClass.toString()); - } - else - { - outDataClass = null; - } - - if (errors.hasErrors()) - return null; - - // TODO: support list of resolved ExpData or ExpMaterial instead of string concatenated names - // Create "MaterialInputs/" columns with a value containing a comma-separated list of Material names - final Map> parentInputNames = new HashMap<>(); - Set inputTypes = new CaseInsensitiveHashSet(); - for (ExpMaterial material : materialInputs.keySet()) - { - ExpSampleType st = material.getSampleType(); - String keyName = ExpMaterial.MATERIAL_INPUT_PARENT + "/" + st.getName(); - inputTypes.add(keyName); - parentInputNames.computeIfAbsent(keyName, (x) -> new LinkedHashSet<>()).add(material.getName()); - } - - // TODO: support list of resolved ExpData or ExpMaterial instead of string concatenated names - // Create "DataInputs/" columns with a value containing a comma-separated list of ExpData names - for (ExpData d : dataInputs.keySet()) - { - ExpDataClass dc = d.getDataClass(getUser()); - String keyName = ExpData.DATA_INPUT_PARENT + "/" + dc.getName(); - inputTypes.add(keyName); - parentInputNames.computeIfAbsent(keyName, (x) -> new LinkedHashSet<>()).add(d.getName()); - } - - - try (DbScope.Transaction tx = ExperimentService.get().ensureTransaction()) - { - Set requiredParentTypes = new CaseInsensitiveHashSet(); - - // output materials - Map outputMaterials = new HashMap<>(); - int materialOutputCount = Math.max(form.materialOutputCount, form.materialOutputs != null ? form.materialOutputs.size() : 0); - if (materialOutputCount > 0 && outSampleType != null) - { - requiredParentTypes.addAll(outSampleType.getRequiredImportAliases().values()); - DerivedOutputs derived = new DerivedOutputs<>(parentInputNames, form.materialDefault, form.materialOutputs, materialOutputCount, ExpMaterial.DEFAULT_CPAS_TYPE) - { - @Override - protected TableInfo createTable() - { - SamplesSchema schema = new SamplesSchema(getUser(), getContainer()); - return schema.getTable(outSampleType.getName()); - } - - @Override - protected List getExpObject(List> insertedRows) - { - List rowIds = insertedRows.stream().map(r -> MapUtils.getLong(r,"rowid")).collect(toList()); - return ExperimentService.get().getExpMaterials(rowIds); - } - }; - - outputMaterials = derived.createOutputs(); - } - - - // create output data - Map outputData = new HashMap<>(); - int dataOutputCount = Math.max(form.dataOutputCount, form.dataOutputs != null ? form.dataOutputs.size() : 0); - if (dataOutputCount > 0 && outDataClass != null) - { - requiredParentTypes.addAll(outDataClass.getRequiredImportAliases().values()); - DerivedOutputs derived = new DerivedOutputs<>(parentInputNames, form.dataDefault, form.dataOutputs, dataOutputCount, ExpData.DEFAULT_CPAS_TYPE) - { - @Override - protected TableInfo createTable() - { - ExpSchema expSchema = new ExpSchema(getUser(), getContainer()); - UserSchema dataSchema = expSchema.getUserSchema(ExpSchema.NestedSchemas.data.name()); - return dataSchema.getTable(outDataClass.getName()); - } - - @Override - protected List getExpObject(List> insertedRows) - { - List lsids = insertedRows.stream().map(r -> (String) r.get("lsid")).collect(toList()); - return ExperimentService.get().getExpDatasByLSID(lsids); - } - }; - - outputData = derived.createOutputs(); - } - - if (outputMaterials.isEmpty() && outputData.isEmpty()) - throw new IllegalStateException("Expected to create " + materialOutputCount + " materials and " + dataOutputCount + " datas"); - - boolean hasMissingRequiredParent = false; - for (String required : requiredParentTypes) - { - if (!inputTypes.contains(required)) - { - hasMissingRequiredParent = true; - break; - } - } - if (hasMissingRequiredParent) - throw new IllegalStateException("Inputs are required: " + String.join(",", requiredParentTypes)); - - // finally, create the derived run if there are any parents - ExpRun run = null; - if (!materialInputs.isEmpty() || !dataInputs.isEmpty()) - run = ExperimentService.get().derive(materialInputs, dataInputs, outputMaterials, outputData, new ViewBackgroundInfo(getContainer(), getUser(), null), _log); - tx.commit(); - - StringBuilder successMessage = new StringBuilder("Created "); - if (!outputMaterials.isEmpty()) - successMessage.append(outputMaterials.size()).append(" materials"); - if (!outputData.isEmpty()) - successMessage.append(outputData.size()).append(" data"); - - JSONObject ret; - if (run != null) - ret = ExperimentJSONConverter.serializeRun(run, null, getUser(), ExperimentJSONConverter.DEFAULT_SETTINGS); - else - ret = ExperimentJSONConverter.serializeRunOutputs(outputData.keySet(), outputMaterials.keySet(), getUser(), ExperimentJSONConverter.DEFAULT_SETTINGS); - - return success(successMessage.toString(), ret); - } - } - - // Helper class that prepares and executes the QueryUpdateService.insertRows() on the data or material table. - private abstract class DerivedOutputs - { - private final @NotNull Map> _parentInputNames; - private final @Nullable Map _defaultValues; - private final @Nullable List _values; - private final int _outputCount; - private final String _rolePrefix; - - - public DerivedOutputs(@NotNull Map> parentInputNames, @Nullable Map defaultValues, @Nullable List values, int outputCount, String rolePrefix) - { - _parentInputNames = parentInputNames; - _defaultValues = defaultValues; - _values = values; - _outputCount = outputCount; - _rolePrefix = rolePrefix; - } - - public Pair>, List> prepareRows() - { - List> rows = new ArrayList<>(); - List roles = new ArrayList<>(); - int unknownOutputDataCount = 0; - - for (int i = 0; i < _outputCount; i++) - { - Map row = new CaseInsensitiveHashMap<>(); - if (_defaultValues != null) - row.putAll(_defaultValues); - DerivationSpec spec = _values != null && i < _values.size() ? _values.get(i) : null; - String role = null; - if (spec != null) - { - row.putAll(spec.values); - role = spec.role; - } - - // NOTE: Input parents are added to each row, but are only used for name generation and not for derivation. - // NOTE: We will derive the inserted samples in a single derivation run after the sample/date have been inserted. - row.putAll(_parentInputNames); - - rows.add(row); - - if (StringUtils.trimToNull(role) == null) - { - role = _rolePrefix + (unknownOutputDataCount == 0 ? "" : Integer.toString(unknownOutputDataCount + 1)); - unknownOutputDataCount++; - } - roles.add(role); - } - return Pair.of(rows, roles); - } - - protected abstract TableInfo createTable(); - - protected abstract List getExpObject(List> insertedRows); - - public Map createOutputs() throws BatchValidationException, DuplicateKeyException, SQLException, QueryUpdateServiceException - { - Pair>, List> pair = prepareRows(); - List> rows = pair.first; - List roles = pair.second; - - TableInfo table = createTable(); - QueryUpdateService qus = table.getUpdateService(); - if (qus == null) - throw new IllegalStateException(); - - Map configParams = new HashMap<>(); - // Skip derivation during insert -- DeriveAction will call ExperimentService.get().derive() after samples are inserted - configParams.put(SampleTypeUpdateServiceDI.Options.SkipDerivation, true); - - BatchValidationException qusErrors = new BatchValidationException(); - List> insertedRows = qus.insertRows(getUser(), getContainer(), rows, qusErrors, configParams, null); - if (qusErrors.hasErrors()) - throw qusErrors; - - if (insertedRows.size() != roles.size()) - throw new IllegalStateException("Expected to create " + roles.size() + " new exp objects for derivation"); - - List outputs = getExpObject(insertedRows); - if (outputs.size() != roles.size()) - throw new IllegalStateException("Expected to create " + roles.size() + " new exp objects for derivation"); - - Map outputMap = new HashMap<>(); - for (int i = 0; i < outputs.size(); i++) - { - String role = roles.get(i); - T data = outputs.get(i); - outputMap.put(data, role); - } - - return outputMap; - } - } - } - - public static class CreateExperimentForm extends ExperimentForm implements DataRegionSelection.DataSelectionKeyForm - { - private boolean _addSelectedRuns; - private String _dataRegionSelectionKey; - - public boolean isAddSelectedRuns() - { - return _addSelectedRuns; - } - - public void setAddSelectedRuns(boolean addSelectedRuns) - { - _addSelectedRuns = addSelectedRuns; - } - - @Override - public String getDataRegionSelectionKey() - { - return _dataRegionSelectionKey; - } - - @Override - public void setDataRegionSelectionKey(String dataRegionSelectionKey) - { - _dataRegionSelectionKey = dataRegionSelectionKey; - } - } - - @RequiresPermission(InsertPermission.class) - @ActionNames("createRunGroup, createExperiment") - public class CreateRunGroupAction extends FormViewAction - { - @Override - public ModelAndView getView(CreateExperimentForm form, boolean reshow, BindException errors) - { - // HACK - convert ExperimentForm to not be a BeanViewForm - form.setAddSelectedRuns("true".equals(getViewContext().getRequest().getParameter("addSelectedRuns"))); - form.setDataRegionSelectionKey(getViewContext().getRequest().getParameter(DataRegionSelection.DATA_REGION_SELECTION_KEY)); - - DataRegion drg = new DataRegion(); - - drg.addHiddenFormField(ActionURL.Param.returnUrl, getViewContext().getRequest().getParameter(ActionURL.Param.returnUrl.name())); - drg.addHiddenFormField("addSelectedRuns", Boolean.toString("true".equals(getViewContext().getRequest().getParameter("addSelectedRuns")))); - form.setDataRegionSelectionKey(getViewContext().getRequest().getParameter(DataRegionSelection.DATA_REGION_SELECTION_KEY)); - // Fix issue 27562 - include session-stored selection - if (form.getDataRegionSelectionKey() != null) - { - for (String rowId : DataRegionSelection.getSelected(getViewContext(), form.getDataRegionSelectionKey(), false)) - { - drg.addHiddenFormField(DataRegion.SELECT_CHECKBOX_NAME, rowId); - } - } - drg.addHiddenFormField(DataRegionSelection.DATA_REGION_SELECTION_KEY, getViewContext().getRequest().getParameter(DataRegionSelection.DATA_REGION_SELECTION_KEY)); - - drg.addColumns(ExperimentServiceImpl.get().getTinfoExperiment(), "RowId,Name,LSID,ContactId,ExperimentDescriptionURL,Hypothesis,Comments,Created"); - - DisplayColumn col = drg.getDisplayColumn("RowId"); - col.setVisible(false); - drg.getDisplayColumn("LSID").setVisible(false); - drg.getDisplayColumn("Created").setVisible(false); - - ButtonBar bb = new ButtonBar(); - bb.setStyle(ButtonBar.Style.separateButtons); - ActionButton insertButton = new ActionButton(new ActionURL(CreateRunGroupAction.class, getContainer()), "Submit", ActionButton.Action.POST); - bb.add(insertButton); - - drg.setButtonBar(bb); - - return new InsertView(drg, errors); - } - - - @Override - public boolean handlePost(CreateExperimentForm form, BindException errors) throws Exception - { - // This is strange... but the "Create new run group..." menu item on the run grid always POSTs, probably to - // allow for long lists of run IDs. This "noPost" parameter on the initial POST is used to inform the action - // that it wants to display the form, not try to save anything yet. - if (!"true".equals(getViewContext().getRequest().getParameter("noPost"))) - { - form.setAddSelectedRuns("true".equals(getViewContext().getRequest().getParameter("addSelectedRuns"))); - form.setDataRegionSelectionKey(getViewContext().getRequest().getParameter(DataRegionSelection.DATA_REGION_SELECTION_KEY)); - - Experiment exp = form.getBean(); - if (exp.getName() == null || exp.getName().trim().isEmpty()) - { - errors.reject(ERROR_MSG, "You must specify a name for the experiment"); - } - else - { - int maxNameLength = ExperimentService.get().getTinfoExperimentRun().getColumn("Name").getScale(); - if (exp.getName().length() > maxNameLength) - { - errors.reject(ERROR_MSG, "Name of the experiment must be " + maxNameLength + " characters or less."); - } - } - - String lsid; - int suffix = 1; - do - { - String template = "urn:lsid:" + XarContext.LSID_AUTHORITY_SUBSTITUTION + ":Experiment.Folder-" + XarContext.CONTAINER_ID_SUBSTITUTION + ":" + exp.getName(); - if (suffix > 1) - { - template = template + suffix; - } - suffix++; - lsid = LsidUtils.resolveLsidFromTemplate(template, new XarContext("Experiment Creation", getContainer(), getUser()), ExpExperiment.DEFAULT_CPAS_TYPE); - } - while (ExperimentService.get().getExpExperiment(lsid) != null); - exp.setLSID(lsid); - exp.setContainer(getContainer()); - - if (errors.getErrorCount() == 0) - { - ExpExperimentImpl wrapper = new ExpExperimentImpl(exp); - wrapper.save(getUser()); - - if (form.isAddSelectedRuns()) - { - addSelectedRunsToExperiment(wrapper, form.getDataRegionSelectionKey()); - } - - if (form.getReturnUrl() != null) - { - throw new RedirectException(form.getReturnUrl()); - } - throw new RedirectException(ExperimentUrlsImpl.get().getShowExperimentsURL(getContainer())); - } - } - return true; - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("runGroups"); - root.addChild("Create Run Group"); - } - - @Override - public URLHelper getSuccessURL(CreateExperimentForm createExperimentForm) - { - return null; // null is used to show the form in the case where IDs are POSTed from the grid - } - - @Override - public void validateCommand(CreateExperimentForm target, Errors errors) { } - } - - public static class MoveRunsForm implements DataRegionSelection.DataSelectionKeyForm - { - private String _targetContainerId; - private String _dataRegionSelectionKey; - - @Override - public String getDataRegionSelectionKey() - { - return _dataRegionSelectionKey; - } - - @Override - public void setDataRegionSelectionKey(String key) - { - _dataRegionSelectionKey = key; - } - - public String getTargetContainerId() - { - return _targetContainerId; - } - - public void setTargetContainerId(String targetContainerId) - { - _targetContainerId = targetContainerId; - } - } - - @RequiresPermission(DeletePermission.class) - public class MoveRunsLocationAction extends SimpleViewAction - { - @Override - public ModelAndView getView(MoveRunsForm form, BindException errors) - { - ActionURL moveURL = new ActionURL(MoveRunsAction.class, getContainer()); - PipelineRootContainerTree ct = new PipelineRootContainerTree(getUser(), moveURL) - { - private boolean _clickHandlerRegistered = false; - - @Override - protected void renderCellContents(StringBuilder html, Container c, ActionURL url, boolean hasRoot) - { - boolean renderLink = hasRoot && !c.equals(getContainer()); - - if (renderLink) - { - html.append(""); - } - html.append(PageFlowUtil.filter(c.getName())); - if (renderLink) - { - html.append(""); - } - - if (!_clickHandlerRegistered) - { - HttpView.currentPageConfig().addHandlerForQuerySelector("a.move-target-container", "click", "moveTo(this.attributes.getNamedItem('data-objectid').value);" ); - _clickHandlerRegistered = true; - } - } - }; - ct.setInitialLevel(1); - - MoveRunsBean bean = new MoveRunsBean(ct, form.getDataRegionSelectionKey()); - JspView result = new JspView<>("/org/labkey/experiment/moveRunsLocation.jsp", bean); - result.setTitle("Choose Destination Folder"); - result.setFrame(WebPartView.FrameType.PORTAL); - return result; - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild("Move Runs"); - } - } - - - @RequiresPermission(DeletePermission.class) - public class MoveRunsAction extends FormHandlerAction - { - private Container _targetContainer; - - @Override - public void validateCommand(MoveRunsForm target, Errors errors) - { - } - - @Override - public boolean handlePost(MoveRunsForm form, BindException errors) - { - _targetContainer = ContainerManager.getForId(form.getTargetContainerId()); - if (_targetContainer == null || !_targetContainer.hasPermission(getUser(), InsertPermission.class)) - { - throw new UnauthorizedException(); - } - - Set runIds = DataRegionSelection.getSelectedIntegers(getViewContext(), form.getDataRegionSelectionKey(), false); - List runs = new ArrayList<>(); - for (Long runId : runIds) - { - ExpRun run = ExperimentService.get().getExpRun(runId); - if (run != null) - { - runs.add(run); - } - } - - ViewBackgroundInfo info = getViewBackgroundInfo(); - info.setContainer(_targetContainer); - - try - { - ExperimentService.get().moveRuns(info, getContainer(), runs); - if (form.getDataRegionSelectionKey() != null) - DataRegionSelection.clearAll(getViewContext(), form.getDataRegionSelectionKey()); - } - catch (IOException e) - { - throw new NotFoundException("Failed to initialize move. Check that the pipeline root is configured correctly. " + e); - } - return true; - } - - @Override - public ActionURL getSuccessURL(MoveRunsForm form) - { - return urlProvider(PipelineUrls.class).urlBegin(_targetContainer); - } - } - - public static class ShowExternalDocsForm - { - private String _objectURI; - private String _propertyURI; - - public String getObjectURI() - { - return _objectURI; - } - - public void setObjectURI(String objectURI) - { - _objectURI = objectURI; - } - - public String getPropertyURI() - { - return _propertyURI; - } - - public void setPropertyURI(String propertyURI) - { - _propertyURI = propertyURI; - } - } - - @RequiresPermission(ReadPermission.class) - public static class ShowExternalDocsAction extends SimpleViewAction - { - @Override - public ModelAndView getView(ShowExternalDocsForm form, BindException errors) throws Exception - { - Map props = OntologyManager.getPropertyObjects(getContainer(), form.getObjectURI()); - ObjectProperty prop = props.get(form.getPropertyURI()); - if (prop == null || !getContainer().equals(prop.getContainer())) - { - throw new NotFoundException(); - } - URI uri = new URI(prop.getStringValue()); - File f = new File(uri); - if (!f.exists()) - { - throw new NotFoundException(); - } - - PageFlowUtil.streamFile(getViewContext().getResponse(), new File(f.getAbsolutePath()), false); - return null; - } - - @Override - public void addNavTrail(NavTree root) - { - } - } - - - // TODO: DotGraph has been adding a "runId" parameter, but ShowGraphMoreListAction - public static ActionURL getShowGraphMoreListURL(Container c, @Nullable Long runId, @NotNull String objtype) - { - ActionURL url = new ActionURL(ShowGraphMoreListAction.class, c); - - if (null != runId) - url.addParameter("runId", runId); - - url.addParameter("objtype", objtype); - - return url; - } - - - @RequiresPermission(ReadPermission.class) - public static class ShowGraphMoreListAction extends SimpleViewAction - { - private ExperimentRunForm _form; - - @Override - public ModelAndView getView(ExperimentRunForm form, BindException errors) - { - _form = form; - return new GraphMoreGrid(getContainer(), errors, getViewContext().getActionURL()); - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild(new NavTree("Experiments", ExperimentUrlsImpl.get().getShowExperimentsURL(getContainer()))); - ExpRun run = ExperimentService.get().getExpRun(_form.getRowId()); - if (run != null) - { - root.addChild(new NavTree("Experiment Run", ExperimentUrlsImpl.get().getRunGraphURL(_form.lookupRun()))); - } - root.addChild(new NavTree("Selected Protocol Applications")); - } - } - - @RequiresPermission(DesignAssayPermission.class) - public class AssayXarFileAction extends MutatingApiAction - { - - @Override - public Object execute(Object o, BindException errors) throws Exception - { - ApiSimpleResponse response = new ApiSimpleResponse(); - - if (!(getViewContext().getRequest() instanceof MultipartHttpServletRequest)) - throw new BadRequestException("Expected MultipartHttpServletRequest when posting files."); - - if (!PipelineService.get().hasValidPipelineRoot(getContainer())) - { - return false; - } - - MultipartFile formFile = getFileMap().get("file"); - if (formFile == null) - { - errors.reject(ERROR_MSG, "No file was posted by the browser."); - return false; - } - - byte[] bytes = formFile.getBytes(); - if (bytes.length == 0) - { - errors.reject(ERROR_MSG, "No file was posted by the browser."); - return false; - } - - PipeRoot pipeRoot = PipelineService.get().findPipelineRoot(getContainer()); - FileLike systemDir = pipeRoot.ensureSystemFileLike(); - FileLike uploadDir = systemDir.resolveChild("UploadedXARs"); - FileUtil.createDirectories(uploadDir); - if (!uploadDir.isDirectory()) - { - errors.reject(ERROR_MSG, "Unable to create a 'system/UploadedXARs' directory under the pipeline root"); - return false; - } - String userDirName = getUser().getEmail(); - if (userDirName == null || userDirName.isEmpty()) - { - userDirName = GUEST_DIRECTORY_NAME; - } - FileLike userDir = uploadDir.resolveChild(userDirName); - FileUtil.createDirectories(userDir); - if (!userDir.isDirectory()) - { - errors.reject(ERROR_MSG, "Unable to create an 'UploadedXARs/" + userDirName + "' directory under the pipeline root"); - return false; - } - - FileLike xarFile = userDir.resolveChild(formFile.getOriginalFilename()); - - // As this is multi-part will need to use finally to close, to prevent a stream closure exception - try (OutputStream out = new BufferedOutputStream(xarFile.openOutputStream())) - { - out.write(bytes); - } - catch (IOException e) - { - errors.reject(ERROR_MSG, "Unable to write uploaded XAR file to " + xarFile); - return false; - } - //noinspection EmptyCatchBlock - - ExperimentPipelineJob job = new ExperimentPipelineJob(getViewBackgroundInfo(), xarFile, - "Uploaded file", true, pipeRoot); - PipelineService.get().queueJob(job); - - response.put("success", true); - return response; - } - } - - @RequiresPermission(InsertPermission.class) - public class ImportXarFileAction extends FormHandlerAction - { - @Override - public void validateCommand(ImportXarForm target, Errors errors) - { - } - - @Override - public boolean handlePost(ImportXarForm form, BindException errors) throws Exception - { - for (FileLike f : form.getValidatedFiles(getContainer())) - { - if (f.isFile()) - { - ExperimentPipelineJob job = new ExperimentPipelineJob(getViewBackgroundInfo(), f, "Experiment Import", false, form.getPipeRoot(getContainer())); - - // TODO: Configure module resources with the appropriate log location per container - if (form.getModule() != null) - { - FileLike logFile = form.getPipeRoot(getContainer()).getLogDirectoryFileLike(true).resolveChild("module-resource-xar.log"); - job.setLogFile(logFile); - } - - PipelineService.get().queueJob(job); - } - else - { - throw new NotFoundException("Expected a file but found a directory: " + f.getName()); - } - } - - return true; - } - - @Override - public URLHelper getSuccessURL(ImportXarForm importXarForm) - { - return getContainer().getStartURL(getUser()); - } - } - - - @RequiresPermission(InsertPermission.class) - public class ImportXarAction extends MutatingApiAction - { - @Override - public Object execute(ImportXarForm form, BindException errors) throws Exception - { - ApiSimpleResponse response = new ApiSimpleResponse(); - - List> archives = new ArrayList<>(); - for (FileLike f : form.getValidatedFiles(getContainer())) - { - Map archive = new HashMap<>(); - ExperimentPipelineJob job = new ExperimentPipelineJob(getViewBackgroundInfo(), f, "Experiment Import", false, form.getPipeRoot(getContainer())); - - // TODO: Configure module resources with the appropriate log location per container - if (form.getModule() != null) - { - FileLike logFile = form.getPipeRoot(getContainer()).getLogDirectoryFileLike(true).resolveChild("module-resource-xar.log"); - job.setLogFile(logFile); - } - - PipelineService.get().queueJob(job); - - archive.put("file", f.getName()); - archive.put("job", job.getJobGUID()); - archive.put("path", form.getPath()); // echo back the public path - - archives.add(archive); - } - - response.put("success", true); - response.put("archives", archives); - - return response; - } - } - - - /** - * User: jeckels - * Date: Jan 27, 2008 - */ - public static class ExperimentUrlsImpl implements ExperimentUrls - { - public ActionURL getOverviewURL(Container c) - { - return new ActionURL(BeginAction.class, c); - } - - @Override - public ActionURL getExperimentDetailsURL(Container c, ExpExperiment expExperiment) - { - return new ActionURL(DetailsAction.class, c).addParameter("rowId", expExperiment.getRowId()); - } - - public ActionURL getShowSampleURL(Container c, ExpMaterial material) - { - return getMaterialDetailsBaseURL(c, null).addParameter("rowId", material.getRowId()); - } - - @Override - public ActionURL getExportProtocolURL(Container container, ExpProtocol protocol) - { - return new ActionURL(ExperimentController.ExportProtocolsAction.class, container). - addParameter("protocolId", protocol.getRowId()). - addParameter("xarFileName", protocol.getName() + ".xar"); - } - - @Override - public ActionURL getMoveRunsLocationURL(Container container) - { - return new ActionURL(ExperimentController.MoveRunsLocationAction.class, container); - } - - @Override - public ActionURL getProtocolDetailsURL(ExpProtocol protocol) - { - return new ActionURL(ProtocolDetailsAction.class, protocol.getContainer()).addParameter("rowId", protocol.getRowId()); - } - - @Override - public ActionURL getProtocolApplicationDetailsURL(ExpProtocolApplication app) - { - return getShowApplicationURL(app.getContainer(), app.getRowId()); - } - - public ActionURL getProtocolGridURL(Container c) - { - return new ActionURL(ShowProtocolGridAction.class, c); - } - - public ActionURL getRunGraphDetailURL(ExpRun run) - { - return getShowRunGraphDetailURL(run.getContainer(), run.getRowId()); - } - - @Override - public ActionURL getRunGraphDetailURL(ExpRun run, @Nullable ExpData focus) - { - return getRunGraphDetailURL(run, focus, DotGraph.TYPECODE_DATA); - } - - public ActionURL getRunGraphDetailURL(ExpRun run, @Nullable ExpMaterial focus) - { - return getRunGraphDetailURL(run, focus, DotGraph.TYPECODE_MATERIAL); - } - - public ActionURL getRunGraphDetailURL(ExpRun run, @Nullable ExpProtocolApplication focus) - { - return getRunGraphDetailURL(run, focus, DotGraph.TYPECODE_PROT_APP); - } - - private ActionURL getRunGraphDetailURL(ExpRun run, @Nullable ExpObject focus, String typeCode) - { - ActionURL result = getShowRunGraphDetailURL(run.getContainer(), run.getRowId()); - result.addParameter("detail", "true"); - if (focus != null) - { - result.addParameter("focus", typeCode + focus.getRowId()); - } - return result; - } - - @Override - public ActionURL getRunGraphURL(Container container, long runId) - { - return ExperimentController.getRunGraphURL(container, runId); - } - - @Override - public ActionURL getRunGraphURL(ExpRun run) - { - return getRunGraphURL(run.getContainer(), run.getRowId()); - } - - @Override - public ActionURL getRunTextURL(Container c, long runId) - { - return new ActionURL(ShowRunTextAction.class, c).addParameter("rowId", runId); - } - - @Override - public ActionURL getRunTextURL(ExpRun run) - { - return getRunTextURL(run.getContainer(), run.getRowId()); - } - - @Override - public ActionURL getDeleteExperimentsURL(Container container, URLHelper returnUrl) - { - return new ActionURL(DeleteSelectedExperimentsAction.class, container).addReturnUrl(returnUrl); - } - - @Override - public ActionURL getDeleteProtocolURL(@NotNull ExpProtocol protocol, URLHelper returnUrl) - { - ActionURL result = new ActionURL(DeleteProtocolByRowIdsAction.class, protocol.getContainer()); - result.addParameter("singleObjectRowId", protocol.getRowId()); - if (returnUrl != null) - { - result.addReturnUrl(returnUrl); - } - return result; - } - - @Override - public ActionURL getAddRunsToExperimentURL(Container c, ExpExperiment exp) - { - return new ActionURL(AddRunsToExperimentAction.class, c).addParameter("expRowId", exp.getRowId()); - } - - @Override - public ActionURL getShowRunsURL(Container c, ExperimentRunType type) - { - ActionURL result = new ActionURL(ShowRunsAction.class, c); - result.addParameter("experimentRunFilter", type.getDescription()); - return result; - } - - public ActionURL getShowExperimentsURL(Container c) - { - return new ActionURL(ShowRunGroupsAction.class, c); - } - - @Override - public ActionURL getShowSampleTypeListURL(Container c) - { - return getShowSampleTypeListURL(c, null); - } - - @Override - public ActionURL getShowSampleTypeURL(ExpSampleType sampleType) - { - return getShowSampleTypeURL(sampleType, sampleType.getContainer()); - } - - @Override - public ActionURL getShowSampleTypeURL(ExpSampleType sampleType, Container container) - { - return new ActionURL(ShowSampleTypeAction.class, container).addParameter("rowId", sampleType.getRowId()); - } - - public ActionURL getExperimentListURL(Container container) - { - return new ActionURL(ShowRunGroupsAction.class, container); - } - - public ActionURL getShowSampleTypeListURL(Container c, String errorMessage) - { - ActionURL url = new ActionURL(ListSampleTypesAction.class, c); - if (errorMessage != null) - { - url.addParameter("errorMessage", errorMessage); - } - return url; - } - - @Override - public ActionURL getDataClassListURL(Container c) - { - return getDataClassListURL(c, null); - } - - public ActionURL getDataClassListURL(Container c, String errorMessage) - { - ActionURL url = new ActionURL(ListDataClassAction.class, c); - if (errorMessage != null) - { - url.addParameter("errorMessage", errorMessage); - } - return url; - } - - @Override - public ActionURL getDeleteDatasURL(Container c, URLHelper returnUrl) - { - ActionURL url = new ActionURL(DeleteSelectedDataAction.class, c); - if (returnUrl != null) - url.addReturnUrl(returnUrl); - return url; - } - - public ActionURL getDeleteSelectedExperimentsURL(Container c, URLHelper returnUrl) - { - ActionURL result = new ActionURL(DeleteSelectedExperimentsAction.class, c); - if (returnUrl != null) - result.addReturnUrl(returnUrl); - return result; - } - - @Override - public ActionURL getDeleteSelectedExpRunsURL(Container container, URLHelper returnUrl) - { - return new ActionURL(DeleteSelectedExpRunsAction.class, container).addReturnUrl(returnUrl); - } - - public ActionURL getShowUpdateURL(ExpExperiment experiment) - { - return new ActionURL(ShowUpdateAction.class, experiment.getContainer()).addParameter("rowId", experiment.getRowId()); - } - - @Override - public ActionURL getRemoveSelectedExpRunsURL(Container container, URLHelper returnUrl, ExpExperiment exp) - { - return new ActionURL(RemoveSelectedExpRunsAction.class, container).addReturnUrl(returnUrl).addParameter("expRowId", exp.getRowId()); - } - - @Override - public ActionURL getCreateRunGroupURL(Container container, URLHelper returnUrl, boolean addSelectedRuns) - { - ActionURL result = new ActionURL(CreateRunGroupAction.class, container); - if (returnUrl != null) - { - result.addReturnUrl(returnUrl); - } - if (addSelectedRuns) - { - result.addParameter("addSelectedRuns", "true"); - } - return result; - } - - - public static ExperimentUrlsImpl get() - { - return (ExperimentUrlsImpl) urlProvider(ExperimentUrls.class); - } - - public ActionURL getDownloadGraphURL(ExpRun run, boolean detail, String focus, String focusType) - { - ActionURL result = new ActionURL(DownloadGraphAction.class, run.getContainer()); - result.addParameter("rowId", run.getRowId()).addParameter("detail", detail); - if (focus != null) - { - result.addParameter("focus", focus); - } - if (focusType != null) - { - result.addParameter("focusType", focusType); - } - return result; - } - - public ActionURL getBeginURL(Container container) - { - return new ActionURL(BeginAction.class, container); - } - - @Override - public ActionURL getDomainEditorURL(Container container, String domainURI, boolean createOrEdit) - { - Domain domain = PropertyService.get().getDomain(container, domainURI); - if (domain != null) - return getDomainEditorURL(container, domain); - - ActionURL url = new ActionURL(PropertyController.EditDomainAction.class, container); - url.addParameter("domainURI", domainURI); - if (createOrEdit) - url.addParameter("createOrEdit", true); - return url; - } - - @Override - public ActionURL getDomainEditorURL(Container container, Domain domain) - { - ActionURL url = new ActionURL(PropertyController.EditDomainAction.class, container); - url.addParameter("domainId", domain.getTypeId()); - return url; - } - - @Override - public ActionURL getCreateDataClassURL(Container container) - { - return new ActionURL(EditDataClassAction.class, container); - } - - @Override - public ActionURL getShowDataClassURL(Container container, long rowId) - { - ActionURL url = new ActionURL(ShowDataClassAction.class, container); - url.addParameter("rowId", rowId); - return url; - } - - @Override - public ActionURL getShowFileURL(ExpData data, boolean inline) - { - ActionURL result = getShowFileURL(data.getContainer()).addParameter("rowId", data.getRowId()); - if (inline) - { - result.addParameter("inline", inline); - } - return result; - } - - @Override - public ActionURL getMaterialDetailsURL(ExpMaterial material) - { - return getMaterialDetailsURL(material.getContainer(), material.getRowId()); - } - - @Override - public ActionURL getMaterialDetailsURL(Container c, long materialRowId) - { - return getMaterialDetailsBaseURL(c, null).addParameter("rowId", materialRowId); - } - - @Override - public ActionURL getMaterialDetailsBaseURL(Container c, @Nullable String materialIdFieldKey) - { - return new ActionURL(ShowMaterialAction.class, c); - } - - @Override - public ActionURL getCreateSampleTypeURL(Container container) - { - return new ActionURL(EditSampleTypeAction.class, container); - } - - @Override - public ActionURL getImportSamplesURL(Container container, String sampleTypeName) - { - ActionURL url = new ActionURL(ImportSamplesAction.class, container); - url.addParameter("query.queryName", sampleTypeName); - url.addParameter("schemaName", "exp.materials"); - return url; - } - - @Override - public ActionURL getImportDataURL(Container container, String dataClassName) - { - ActionURL url = new ActionURL(ImportDataAction.class, container); - url.addParameter("query.queryName", dataClassName); - url.addParameter("schemaName", "exp.data"); - return url; - } - - @Override - public ActionURL getDataDetailsURL(ExpData data) - { - return new ActionURL(ShowDataAction.class, data.getContainer()).addParameter("rowId", data.getRowId()); - } - - @Override - public ActionURL getShowFileURL(Container c) - { - return new ActionURL(ShowFileAction.class, c); - } - - @Override - public ActionURL getSetFlagURL(Container container) - { - return new ActionURL(SetFlagAction.class, container); - } - - @Override - public ActionURL getShowRunGraphURL(ExpRun run) - { - return ExperimentController.getRunGraphURL(run.getContainer(), run.getRowId()); - } - - @Override - public ActionURL getRepairTypeURL(Container container) - { - return new ActionURL(TypesController.RepairAction.class, container); - } - - @Override - public ActionURL getUpdateMaterialQueryRowAction(Container c, TableInfo table) - { - ActionURL url = new ActionURL(UpdateMaterialQueryRowAction.class, c); - url.addParameter("schemaName", "samples"); - url.addParameter(QueryView.DATAREGIONNAME_DEFAULT + "." + QueryParam.queryName, table.getName()); - - return url; - } - - @Override - public ActionURL getInsertMaterialQueryRowAction(Container c, TableInfo table) - { - ActionURL url = new ActionURL(InsertMaterialQueryRowAction.class, c); - url.addParameter(QueryView.DATAREGIONNAME_DEFAULT + "." + QueryParam.queryName, table.getName()); - - return url; - } - - @Override - public ActionURL getDataClassAttachmentDownloadAction(Container c) - { - return new ActionURL(ExperimentController.DataClassAttachmentDownloadAction.class, c); - } - - } - - private static abstract class BaseResolveLsidApiAction extends ReadOnlyApiAction - { - protected Set _seeds; - - @Override - public void validateForm(F form, Errors errors) - { - if (null != form.getLsids()) - { - _seeds = new LinkedHashSet<>(form.getLsids().size()); - for (String lsid : form.getLsids()) - { - Identifiable id = LsidManager.get().getObject(lsid); - if (id == null) - throw new NotFoundException("Unable to resolve object: " + lsid); - - // ensure the user has read permission in the seed container - if (!getContainer().equals(id.getContainer())) - { - if (!id.getContainer().hasPermission(getUser(), ReadPermission.class)) - throw new UnauthorizedException("User does not have permission to read object: " + lsid); - } - - _seeds.add(id); - } - } - else - { - throw new ApiUsageException("Starting lsids required"); - } - } - } - - @RequiresPermission(ReadPermission.class) - public static class ResolveAction extends BaseResolveLsidApiAction - { - @Override - public Object execute(ResolveLsidsForm form, BindException errors) - { - var settings = new ExperimentJSONConverter.Settings(form.isIncludeProperties(), form.isIncludeInputsAndOutputs(), form.isIncludeRunSteps()); - var data = _seeds.stream().map(n -> ExperimentJSONConverter.serialize(n, getUser(), settings)).collect(toList()); - return new ApiSimpleResponse("data", data); - } - } - - @RequiresPermission(ReadPermission.class) - public static class LineageAction extends BaseResolveLsidApiAction - { - @Override - public Object execute(ExpLineageOptions options, BindException errors) throws Exception - { - ExpLineageServiceImpl.get().streamLineage(getContainer(), getUser(), getViewContext().getResponse(), _seeds, options); - return null; - } - } - - @Marshal(Marshaller.Jackson) - @RequiresPermission(AdminPermission.class) - public static class RebuildEdgesAction extends MutatingApiAction - { - @Override - public Object execute(ExperimentRunForm form, BindException errors) - { - if (form.getRowId() != 0 || form.getLsid() != null) - { - ExpRun run = form.lookupRun(); - if (!run.getContainer().hasPermission(getUser(), ReadPermission.class)) - throw new UnauthorizedException("Not permitted"); - - ExperimentServiceImpl.get().syncRunEdges(run); - } - else - { - // should this require site admin permissions? - ExperimentServiceImpl.get().rebuildAllRunEdges(); - } - return success(); - } - } - - private static class VerifyEdgesForm extends ExperimentRunForm - { - private Integer _limit; - - public Integer getLimit() - { - return _limit; - } - - public void setLimit(Integer limit) - { - _limit = limit; - } - } - - @Marshal(Marshaller.Jackson) - @RequiresPermission(AdminPermission.class) - public static class VerifyEdgesAction extends ReadOnlyApiAction - { - @Override - public Object execute(VerifyEdgesForm form, BindException errors) - { - if (form.getRowId() != 0 || form.getLsid() != null) - { - ExpRun run = form.lookupRun(); - if (!run.getContainer().hasPermission(getUser(), ReadPermission.class)) - throw new UnauthorizedException("Not permitted"); - - ExperimentServiceImpl.get().verifyRunEdges(run); - } - else - { - ExperimentServiceImpl.get().verifyAllEdges(getContainer(), form.getLimit()); - } - return success(); - } - } - - @Marshal(Marshaller.Jackson) - @RequiresPermission(AdminPermission.class) - public static class RebuildAncestorsAction extends MutatingApiAction - { - @Override - public Object execute(Object form, BindException errors) - { - ClosureQueryHelper.truncateAndRecreate(); - return success(); - } - } - - - @Marshal(Marshaller.Jackson) - @RequiresPermission(AdminPermission.class) - public static class CheckDataClassesIndexedAction extends ReadOnlyApiAction - { - @Override - public Object execute(Object o, BindException errors) throws Exception - { - List> notInIndex = new ArrayList<>(100); - - List list = ExperimentService.get().getDataClasses(getContainer(), getUser(), false); - for (ExpDataClass dc : list) - { - for (ExpData d : dc.getDatas()) - { - String docId = d.getDocumentId(); - if (docId != null) - { - SearchService.SearchHit hit = SearchService.get().find(docId); - if (hit == null) - { - JSONObject props = ExperimentJSONConverter.serializeData(d, getUser(), ExperimentJSONConverter.DEFAULT_SETTINGS); - props.put("docid", docId); - notInIndex.add(props.toMap()); - } - } - } - } - - return success(notInIndex); - } - } - - @Marshal(Marshaller.Jackson) - @RequiresPermission(AdminPermission.class) - public static class CheckEdgesAction extends ReadOnlyApiAction - { - @Override - public Object execute(Object o, BindException errors) throws Exception - { - List result; - DbSchema schema = ExperimentService.get().getSchema(); - TableInfo edgeTable = schema.getTable("Edge"); - - if (null != edgeTable.getColumn("fromObjectId")) - { - var edges = new SqlSelector(ExperimentService.get().getSchema(), "SELECT fromObjectId, toObjectId FROM exp.Edge") - .resultSetStream() - .map(r -> { try { return new Pair<>(r.getInt(1), r.getInt(2)); } catch (SQLException x) { throw new RuntimeException(x); } }) - .collect(toList()); - var cycles = (new GraphAlgorithms()).detectCycleInDirectedGraph(edges); - result = cycles.stream().map(e -> new Integer[]{e.first, e.second}).collect(toList()); - } - else - { - var edges = new SqlSelector(ExperimentService.get().getSchema(), "SELECT fromLsid, toLsid FROM exp.Edge") - .resultSetStream() - .map(r -> { try { return new Pair<>(r.getString(1), r.getString(2)); } catch (SQLException x) { throw new RuntimeException(x); } }) - .collect(toList()); - var cycles = (new GraphAlgorithms()).detectCycleInDirectedGraph(edges); - result = cycles.stream().map(e -> new String[]{e.first, e.second}).collect(toList()); - } - - JSONObject ret = new JSONObject(); - ret.put("result", result); - ret.put("success", true); - return ret; - } - } - - @RequiresPermission(UpdatePermission.class) - public static class UpdateMaterialQueryRowAction extends UserSchemaAction - { - @Override - protected QueryForm createQueryForm(ViewContext context) - { - QueryForm form = new QueryForm("samples", null); - form.setViewContext(getViewContext()); - form.bindParameters(getViewContext().getBindPropertyValues()); - return form; - } - - @Override - public BindException bindParameters(PropertyValues m) throws Exception - { - BindException bind = super.bindParameters(m); - - QueryUpdateForm tableForm = (QueryUpdateForm)bind.getTarget(); - - int sampleId; - try - { - sampleId = Integer.parseInt((String) tableForm.getPkVal()); - } - catch (NumberFormatException e) - { - throw new NotFoundException("Invalid RowId: " + tableForm.getPkVal()); - } - - ExpMaterial material = ExperimentService.get().getExpMaterial(sampleId); - if (material == null) - throw new NotFoundException("Invalid material: " + tableForm.getPkVal()); - - return bind; - } - - @Override - public ModelAndView getView(QueryUpdateForm tableForm, boolean reshow, BindException errors) - { - int sampleId = Integer.parseInt((String) tableForm.getPkVal()); - - ExpMaterial material = ExperimentService.get().getExpMaterial(sampleId); - if (material == null) - throw new NotFoundException("Invalid material: " + tableForm.getPkVal()); - - boolean isAliquot = !StringUtils.isEmpty(material.getAliquotedFromLSID()); - - TableInfo tableInfo = tableForm.getTable(); - Map scopedFields = new CaseInsensitiveHashMap<>(); - for (DomainProperty dp : tableInfo.getDomain().getProperties()) - { - if (!ExpSchema.DerivationDataScopeType.All.name().equalsIgnoreCase(dp.getDerivationDataScope())) - scopedFields.put(dp.getName(), ExpSchema.DerivationDataScopeType.ChildOnly.name().equalsIgnoreCase(dp.getDerivationDataScope())); - } - - for (var column : tableInfo.getColumns()) - { - String columnName = column.getName(); - if (scopedFields.containsKey(columnName)) - { - boolean isAliquotField = scopedFields.get(columnName); - boolean show = (isAliquot && isAliquotField) || (!isAliquot && !isAliquotField); - ((BaseColumnInfo)column).setUserEditable(show); - ((BaseColumnInfo)column).setHidden(!show); - } - } - - ButtonBar bb = createSubmitCancelButtonBar(tableForm); - UpdateView view = new UpdateView(tableForm, errors); - view.getDataRegion().setButtonBar(bb); - return view; - } - - @Override - public boolean handlePost(QueryUpdateForm tableForm, BindException errors) - { - doInsertUpdate(tableForm, errors, false); - return 0 == errors.getErrorCount(); - } - - @Override - public void addNavTrail(NavTree root) - { - super.addNavTrail(root); - root.addChild("Edit " + _form.getQueryName()); - } - } - - @RequiresPermission(InsertPermission.class) - public static class InsertMaterialQueryRowAction extends UserSchemaAction - { - @Override - protected QueryForm createQueryForm(ViewContext context) - { - QueryForm form = new QueryForm("samples", null); - form.setViewContext(getViewContext()); - form.bindParameters(getViewContext().getBindPropertyValues()); - - return form; - } - - @Override - public ModelAndView getView(QueryUpdateForm tableForm, boolean reshow, BindException errors) - { - TableInfo tableInfo = tableForm.getTable(); - Map propertyFields = new CaseInsensitiveHashMap<>(); - for (DomainProperty dp : tableInfo.getDomain().getProperties()) - { - propertyFields.put(dp.getName(), ExpSchema.DerivationDataScopeType.ChildOnly.name().equalsIgnoreCase(dp.getDerivationDataScope())); - } - - for (var column : tableInfo.getColumns()) - { - String columnName = column.getName(); - if (propertyFields.containsKey(columnName)) - { - boolean isAliquotField = propertyFields.get(columnName); - ((BaseColumnInfo)column).setUserEditable(!isAliquotField); - ((BaseColumnInfo)column).setHidden(isAliquotField); - } - } - - InsertView view = new InsertView(tableForm, errors); - view.getDataRegion().setButtonBar(createSubmitCancelButtonBar(tableForm)); - return view; - } - - @Override - public boolean handlePost(QueryUpdateForm tableForm, BindException errors) - { - doInsertUpdate(tableForm, errors, true); - return 0 == errors.getErrorCount(); - } - - @Override - public void addNavTrail(NavTree root) - { - super.addNavTrail(root); - root.addChild("Insert " + _form.getQueryName()); - } - } - - @Marshal(Marshaller.Jackson) - @RequiresPermission(ReadPermission.class) - public static class SaveFindIdsAction extends ReadOnlyApiAction - { - - public static final String FIND_BY_IDS_SESSION_KEY_PREFIX = "findByIds"; - - @Override - public Object execute(FindByIdsForm form, BindException errors) throws Exception - { - HttpServletRequest request = getViewContext().getRequest(); - String key = form.getSessionKey(); - boolean removePrevious = false; - - if (key == null) - { - removePrevious = true; - key = FIND_BY_IDS_SESSION_KEY_PREFIX + "_" + UniqueID.getServerSessionScopedUID(); - } - - if (request != null) - { - if (removePrevious) - SessionHelper.clearAttributesWithPrefix(request, FIND_BY_IDS_SESSION_KEY_PREFIX); - HttpSession session = request.getSession(false); - if (session != null) - { - @SuppressWarnings("unchecked") - List existingIds = (List) session.getAttribute(key); - - // deduplicate from existing ids - if (existingIds != null && form.getSessionKey() != null) - { - existingIds.addAll(form.getIds().stream().filter(id -> !existingIds.contains(id)).toList()); - session.setAttribute(key, existingIds); - } - else - { - session.setAttribute(key, form.getIds()); - } - return success("Saved ids to session key", key); - } - } - - return new SimpleResponse<>(false, "Unable to save to session. Session or request may be null."); - } - } - - @Marshal(Marshaller.Jackson) - @RequiresPermission(ReadPermission.class) - public static class SaveOrderedSamplesQueryAction extends ReadOnlyApiAction - { - private static final String SAMPLE_ID_PREFIX = "s:"; - private static final String UNIQUE_ID_PREFIX = "u:"; - - private List _ids; - private Map> _uniqueIdLsids; - - @Override - public void validateForm(FindByIdsForm form, Errors errors) - { - if (form.getSessionKey() == null) - errors.reject(ERROR_REQUIRED, "sessionKey must be provided"); - else - { - _ids = getFindIdsFromSession(form.getSessionKey()); - if (_ids == null || _ids.isEmpty()) - errors.reject(ERROR_REQUIRED, "No ids found corresponding to session key " + form.getSessionKey()); - } - } - - private void ensureUniqueIdLsids() - { - boolean hasUniqueId = _ids.stream().anyMatch(s -> s.startsWith(UNIQUE_ID_PREFIX)); - if (hasUniqueId && _uniqueIdLsids == null) - { - List uniqueIds = _ids.stream().map(s -> s.substring(UNIQUE_ID_PREFIX.length())).toList(); - _uniqueIdLsids = ExperimentService.get().getUniqueIdLsids(uniqueIds, getUser(), getContainer()); - } - } - - @Override - public Object execute(FindByIdsForm form, BindException errors) throws Exception - { - ensureUniqueIdLsids(); - - SQLFragment select = getOrderedRowsSql(); - // need to set the key field so selections are possible - // need the SampleTypeUnits so we will display using that unit - String metadata = - """ - - - - - true - true - - - true - - - true - - -
-
"""; - QueryDefinition def = QueryService.get().saveSessionQuery(getViewContext(), getContainer(), ExperimentServiceImpl.getExpSchema().getName(), select.getSQL(), metadata); - return success("Session query created", Map.of("queryName", def.getName(), "ids", _ids)); - } - - - private List getFindIdsFromSession(String sessionKey) - { - HttpServletRequest request = getViewContext().getRequest(); - List ids = new ArrayList<>(); - if (request != null) - { - HttpSession session = request.getSession(false); - if (session != null) - { - ids = (List) session.getAttribute(sessionKey); - } - } - return ids; - } - - private SQLFragment getOrderedRowsSql() - { - boolean isFMEnabled = InventoryService.isFreezerManagementEnabled(getContainer()); - String samplesTable = isFMEnabled ? "inventory.SampleItems" : "exp.materials"; - List orderedIdCols = new ArrayList<>(Arrays.asList("Id AS ProvidedID", "RowId", "Ordinal")); - List sampleColumns = new ArrayList<>(); - if (!isFMEnabled) - { - sampleColumns.addAll(Arrays.asList( - "S.Name AS SampleID", - "S.MaterialExpDate AS ExpirationDate", - "S.SampleSet as SampleType", - "S.SampleState", - "S.isAliquot", - "S.Created", - "S.CreatedBy" - )); - } - else - { - sampleColumns.addAll(Arrays.asList( - "S.Name AS SampleID", - "S.MaterialExpDate AS ExpirationDate", - "S.LabelColor", - "S.SampleSet", - "S.SampleState", - "S.StoredAmount", - "S.Units", - "S.SampleTypeUnits", - "S.FreezeThawCount", - "S.StorageStatus", - "S.CheckedOutBy", - "S.StorageLocation", - "S.StorageRow", - "S.StorageCol", - "S.StoragePositionNumber", - "S.IsAliquot", - "S.Created", - "S.CreatedBy" - )); - } - - - String sampleIdComma = ""; - String uniqueIdComma = ""; - int index = 1; - SQLFragment sampleIdValuesSql = new SQLFragment(); - SQLFragment uniqueIdValuesSql = new SQLFragment(); - for (String id : _ids) - { - if (id.startsWith(SAMPLE_ID_PREFIX)) - { - sampleIdValuesSql.append(sampleIdComma).append("\t(").appendValue(index); - sampleIdValuesSql.append(", "); - sampleIdValuesSql.append(LabKeySql.quoteString(id.substring(SAMPLE_ID_PREFIX.length()))); - sampleIdValuesSql.append(", "); - sampleIdValuesSql.append(LabKeySql.quoteString("null")); - sampleIdValuesSql.append(")"); - sampleIdComma = "\n,"; - } - else if (id.startsWith(UNIQUE_ID_PREFIX)) - { - String idClean = id.substring(UNIQUE_ID_PREFIX.length()); - - List lsids = _uniqueIdLsids.get(idClean); - if (lsids != null) - { - for (String lsid : lsids) - { - uniqueIdValuesSql.append(uniqueIdComma).append("\t(").appendValue(index); - uniqueIdValuesSql.append(", "); - uniqueIdValuesSql.append(LabKeySql.quoteString(idClean)); - uniqueIdValuesSql.append(", "); - uniqueIdValuesSql.append(LabKeySql.quoteString(lsid)); - uniqueIdValuesSql.append(")"); - uniqueIdComma = "\n,"; - } - } - } - index++; - } - - boolean haveData = !sampleIdValuesSql.isEmpty() || !_uniqueIdLsids.isEmpty(); - SQLFragment sql = new SQLFragment(); - if (!sampleIdValuesSql.isEmpty()) - { - sql.append("WITH _ordered_ids_ AS (\nSELECT * FROM (VALUES\n"); - sql.append(sampleIdValuesSql); - sql.append("\n) AS _values_ )\n"); // name of the alias here doesn't matter - } - if (!uniqueIdValuesSql.isEmpty()) - { - if (!sampleIdValuesSql.isEmpty()) - sql.append(",\n"); - else - sql.append("WITH "); - - sql.append("_ordered_unique_ids_ AS (\nSELECT * FROM (VALUES\n"); - sql.append(uniqueIdValuesSql); - sql.append("\n) AS _values_ )\n"); // name of the alias here doesn't matter - } - - sql.append("SELECT "); - sql.append("\n\tOID.").append(StringUtils.join(orderedIdCols, ",\n\tOID.")); - sql.append(",\n\t").append(StringUtils.join( sampleColumns, ",\n\t")); - sql.append("\nFROM\n("); - if (!sampleIdValuesSql.isEmpty()) - { - sql.append("SELECT\n\tM.RowId,\n\t_ordered_ids_.column1 as Ordinal,\n\t_ordered_ids_.column2 as Id,\n\t_ordered_ids_.column2 as lsid"); - sql.append("\nFROM _ordered_ids_\n"); - sql.append("INNER JOIN exp.materials M ON _ordered_ids_.column2 = M.Name"); - sql.append("\n"); - } - if (!uniqueIdValuesSql.isEmpty()) - { - if (!sampleIdValuesSql.isEmpty()) - sql.append("\nUNION ALL\n\n"); - - sql.append("SELECT\n\tM.RowId,\n\t_ordered_unique_ids_.column1 as Ordinal,\n\t_ordered_unique_ids_.column2 as Id,\n\t_ordered_unique_ids_.column3 as lsid"); - sql.append("\nFROM _ordered_unique_ids_\n"); - sql.append("INNER JOIN exp.materials M ON _ordered_unique_ids_.column3 = M.lsid"); - sql.append("\n"); - } - if (!haveData) // no data to return but return data in the expected shape. - { - sql = new SQLFragment("SELECT\n"); - sql.append(orderedIdCols.stream() - .map(col -> { - int asIndex = col.indexOf("AS"); - if (asIndex > 0) - return "NULL AS " + col.substring(asIndex+ 3); - else - return "NULL AS " + col; - }) - .collect(Collectors.joining(",\t\n"))); - sql.append(",\t\n").append(StringUtils.join(sampleColumns, ",\t\n")); - sql.append("\nFROM ").append(samplesTable).append(" S WHERE 1 = 2"); - return sql; - } - else - { - sql.append(") OID"); - if (isFMEnabled) - sql.append("\nLEFT JOIN inventory.SampleItems S on S.RowId = OID.RowId"); - else - sql.append("\nINNER JOIN exp.materials S on S.RowId = OID.RowId"); - sql.append("\n\nORDER BY Ordinal"); - return sql; - } - } - } - - public static class FindByIdsForm extends FindSessionKeyForm - { - List _ids; - - public List getIds() - { - return _ids; - } - - public void setIds(List ids) - { - _ids = ids; - } - } - - - public static class FindSessionKeyForm - { - private String _sessionKey; - - public String getSessionKey() - { - return _sessionKey; - } - - public void setSessionKey(String sessionKey) - { - _sessionKey = sessionKey; - } - } - - static void validateEntitySequenceForm(EntitySequenceForm form, Errors errors) - { - String kindName = form.getKindName(); - if (StringUtils.isEmpty(kindName) || form.getSeqType() == null) - { - errors.reject(ERROR_REQUIRED, "KindName and SeqType must be provided"); - return; - } - - if (form.getSeqType() == NameGenerator.EntityCounter.genId) - { - if (form.getRowId() == null) - errors.reject(ERROR_REQUIRED, "Data type RowId must be provided for genId"); - } - else if (!SampleTypeDomainKind.NAME.equalsIgnoreCase(kindName)) - { - errors.reject(ERROR_MSG, form.getSeqType() + " is not supported for " + kindName); - } - - if (!SampleTypeDomainKind.NAME.equalsIgnoreCase(kindName) && !DataClassDomainKind.NAME.equalsIgnoreCase(kindName)) - errors.reject(ERROR_MSG, "Invalid KindName. Should be either " + SampleTypeDomainKind.NAME + " or " + DataClassDomainKind.NAME + "."); - - } - - @RequiresPermission(ReadPermission.class) - public static class GetEntitySequenceAction extends ReadOnlyApiAction - { - @Override - public void validateForm(EntitySequenceForm form, Errors errors) - { - validateEntitySequenceForm(form, errors); - } - - @Override - public Object execute(EntitySequenceForm form, BindException errors) throws Exception - { - long value = -1; - if (SampleTypeDomainKind.NAME.equalsIgnoreCase(form.getKindName())) - { - if (form.getSeqType() == NameGenerator.EntityCounter.genId) - { - ExpSampleType sampleType = SampleTypeService.get().getSampleType(form.getRowId()); - if (sampleType != null) - value = sampleType.getCurrentGenId(); - } - else - { - value = SampleTypeService.get().getCurrentCount(form.getSeqType(), getContainer()); - } - - } - else if (DataClassDomainKind.NAME.equalsIgnoreCase(form.getKindName())) - { - ExpDataClass dataClass = ExperimentService.get().getDataClass(form.getRowId()); - if (dataClass != null) - value = dataClass.getCurrentGenId(); - } - - ApiSimpleResponse resp = new ApiSimpleResponse(); - resp.put("success", value > -1); - resp.put("value", value); - return resp; - } - } - - @RequiresPermission(ReadPermission.class) // actual permission checked later - public static class SetEntitySequenceAction extends MutatingApiAction - { - @Override - public void validateForm(EntitySequenceForm form, Errors errors) - { - validateEntitySequenceForm(form, errors); - - if (form.getNewValue() == null || form.getNewValue() < 0) - errors.reject(ERROR_MSG, "Invalid newValue."); - } - - @Override - public Object execute(EntitySequenceForm form, BindException errors) - { - ApiSimpleResponse resp = new ApiSimpleResponse(); - resp.put("success", true); - - try - { - Domain domain = null; - if (SampleTypeDomainKind.NAME.equalsIgnoreCase(form.getKindName())) - { - if (form.getSeqType() == NameGenerator.EntityCounter.genId) - { - if (!getContainer().hasPermission(getUser(), DesignSampleTypePermission.class)) - throw new UnauthorizedException("Insufficient permissions."); - - ExpSampleType sampleType = SampleTypeService.get().getSampleType(form.getRowId()); - if (sampleType != null) - { - sampleType.ensureMinGenId(form.getNewValue()); - domain = sampleType.getDomain(); - } - else - { - resp.put("success", false); - resp.put("error", "Sample type does not exist."); - } - } - else - { - if (!getContainer().hasPermission(getUser(), AdminPermission.class)) - throw new UnauthorizedException("Insufficient permissions."); - - SampleTypeService.get().ensureMinSampleCount(form.getNewValue(), form.getSeqType(), getContainer()); - } - } - else if (DataClassDomainKind.NAME.equalsIgnoreCase(form.getKindName())) - { - if (!getContainer().hasPermission(getUser(), DesignDataClassPermission.class)) - throw new BadRequestException("Insufficient permissions."); - - ExpDataClass dataClass = ExperimentService.get().getDataClass(form.getRowId()); - if (dataClass != null) - { - dataClass.ensureMinGenId(form.getNewValue(), getContainer()); - domain = dataClass.getDomain(); - } - else - { - resp.put("success", false); - resp.put("error", "DataClass does not exist."); - } - } - - if (domain != null) - { - DomainAuditProvider.DomainAuditEvent event = new DomainAuditProvider.DomainAuditEvent(getContainer(), "The genId for domain " + domain.getName() + " has been updated to " + form.getNewValue() + "."); - event.setDomainUri(domain.getTypeURI()); - event.setDomainName(domain.getName()); - AuditLogService.get().addEvent(getUser(), event); - } - } - catch (ExperimentException e) - { - resp.put("success", false); - resp.put("error", e.getMessage()); - } - - return resp; - } - } - - public static class EntitySequenceForm - { - private String _kindName; - private NameGenerator.EntityCounter _seqType; - private Integer _rowId; - private Long _newValue; - - public Integer getRowId() - { - return _rowId; - } - - public void setRowId(Integer rowId) - { - _rowId = rowId; - } - - public String getKindName() - { - return _kindName; - } - - public void setKindName(String kindName) - { - _kindName = kindName; - } - - public Long getNewValue() - { - return _newValue; - } - - public void setNewValue(Long newValue) - { - this._newValue = newValue; - } - - public NameGenerator.EntityCounter getSeqType() - { - return _seqType; - } - - public void setSeqType(String seqType) - { - _seqType = NameGenerator.EntityCounter.valueOf(seqType); - } - - } - - @Marshal(Marshaller.Jackson) - @RequiresPermission(ReadPermission.class) - public static class GetCrossFolderDataSelectionAction extends ReadOnlyApiAction - { - @Override - public void validateForm(CrossFolderSelectionForm form, Errors errors) - { - if (form.getDataRegionSelectionKey() == null && form.getRowIds() == null) - errors.reject(ERROR_REQUIRED, "You must provide either a set of rowIds or a dataRegionSelectionKey."); - if (!"samples".equalsIgnoreCase(form.getDataType()) && !"exp.data".equalsIgnoreCase(form.getDataType())&& !"assay".equalsIgnoreCase(form.getDataType())) - errors.reject(ERROR_REQUIRED, "Data type (sample, data or assayrun) must be specified."); - } - - @Override - public Object execute(CrossFolderSelectionForm form, BindException errors) - { - Pair result = ExperimentServiceImpl.getCurrentAndCrossFolderDataCount(form.getIds(false), form.getDataType(), getContainer()); - - ApiSimpleResponse resp = new ApiSimpleResponse(); - resp.put("success", true); - resp.put("currentFolderSelectionCount", result.first); - resp.put("crossFolderSelectionCount", result.second); - - return success(resp); - } - } - - public static class CrossFolderSelectionForm extends DataViewSnapshotSelectionForm - { - private String _dataType; - private String _picklistName; - - public String getDataType() - { - return _dataType; - } - - public void setDataType(String dataType) - { - _dataType = dataType; - } - - public String getPicklistName() - { - return _picklistName; - } - - public void setPicklistName(String picklistName) - { - _picklistName = picklistName; - } - - @Override - public Set getIds(boolean clear) - { - Set selectedIds; - - if (_rowIds != null) - selectedIds = _rowIds; - else if (isUseSnapshotSelection()) - selectedIds = new HashSet<>(DataRegionSelection.getSnapshotSelectedIntegers(getViewContext(), getDataRegionSelectionKey())); - else - selectedIds = DataRegionSelection.getSelectedIntegers(getViewContext(), getDataRegionSelectionKey(), clear); - - if (_picklistName != null) - { - User user = getViewContext().getUser(); - Container container = getViewContext().getContainer(); - UserSchema schema = ListService.get().getUserSchema(user, container); - TableInfo tInfo = schema.getTable(_picklistName); - if (tInfo != null) - { - SimpleFilter filter = SimpleFilter.createContainerFilter(container); - filter.addInClause(FieldKey.fromParts("id"), selectedIds); - TableSelector selector = new TableSelector(tInfo, Collections.singleton("SampleID"), filter, null); - return new HashSet<>(selector.getArrayList(Long.class)); - } - } - return selectedIds; - } - } - - @RequiresPermission(AdminPermission.class) - public static class RecomputeAliquotRollup extends SimpleViewAction - { - @Override - public void addNavTrail(NavTree root) - { - } - - @Override - public ModelAndView getView(Object o, BindException errors) throws SQLException - { - try (var ignore = SpringActionController.ignoreSqlUpdates()) - { - Container container = getContainer(); - User user = getUser(); - - List sampleTypes = SampleTypeService.get() - .getSampleTypes(container, user, true); - - HtmlStringBuilder builder = HtmlStringBuilder.of(); - builder.unsafeAppend(""); - - SampleTypeService service = SampleTypeService.get(); - for (ExpSampleType sampleType : sampleTypes) - { - int updatedCount; - updatedCount = service.recomputeSampleTypeRollup(sampleType, container); - // we could check "if (0 < updatedCount) refresh(rollup)", but since this is a "manual" usage lets just always refresh - SampleTypeServiceImpl.get().refreshSampleTypeMaterializedView(sampleType, update); - builder.unsafeAppend(""); - } - - builder.unsafeAppend("
Sample Type#Recomputed
") - .append(sampleType.getName()) - .unsafeAppend("") - .append(updatedCount) - .unsafeAppend("
"); - return new HtmlView("Aliquot Rollup Recalculation Result", builder); - } - } - } - - /* Also see API CheckEdgesAction */ - @RequiresPermission(TroubleshooterPermission.class) - public static class CycleCheckAction extends FormViewAction - { - List cycleObjectIds = null; - - @Override - public void validateCommand(Object target, Errors errors) - { - - } - - @Override - public ModelAndView getView(Object o, boolean reshow, BindException errors) - { - if (!reshow) - { - return new HtmlView( - DIV("This operation can use a lot of memory.", - LK.FORM(at(method,"POST"), - PageFlowUtil.button("Continue").submit(true))) - ); - } - - if (null == cycleObjectIds) - return new HtmlView(HtmlString.of("No cycles found")); - - Map map = new LongHashMap<>(); - var cf = new ContainerFilter.AllFolders(getUser()); - var materials = ExperimentServiceImpl.get().getExpMaterialsByObjectId(cf, cycleObjectIds); - materials.forEach( (m) -> map.put(m.getObjectId(), m)); - var datas = ExperimentServiceImpl.get().getExpDatasByObjectId(cf, cycleObjectIds); - datas.forEach( (d) -> map.put(d.getObjectId(), d)); - var runs = ExperimentServiceImpl.get().getRunsByObjectId(cf, cycleObjectIds); - runs.forEach( (r) -> map.put(r.getObjectId(), r)); - - ExperimentUrls urls = ExperimentUrls.get(); - return new HtmlView( - DIV("Cycle found involving these objects.", - UL(cycleObjectIds.stream().map((objectid) -> - { - ExpObject exp = map.get(objectid); - if (exp instanceof ExpMaterial mat) - return LI(A(at(target, "_blank", href, urls.getMaterialDetailsURL(mat)), objectid + " : material - " + mat.getName())); - else if (exp instanceof ExpRun run) - return LI(A(at(target, "_blank", href, urls.getRunTextURL(run)), objectid + " : run - " + run.getName())); - else if (exp instanceof ExpData data) - return LI(A(at(target, "_blank", href, urls.getDataDetailsURL(data)), objectid + " : run - " + data.getName())); - else - return LI(String.valueOf(objectid)); - })) - ) - ); - } - - @Override - public boolean handlePost(Object o, BindException errors) - { - var edges = new SqlSelector(ExperimentService.get().getSchema(), "SELECT fromObjectId, toObjectId FROM exp.Edge") - .resultSetStream() - .map(r -> { try { return new Pair<>(r.getInt(1), r.getInt(2)); } catch (SQLException x) { throw new RuntimeException(x); } }) - .collect(toList()); - var cyclesEdges = (new GraphAlgorithms()).detectCycleInDirectedGraph(edges); - - var set = new LinkedHashSet(); - cyclesEdges.forEach( (edge) -> { - set.add(edge.first); - set.add(edge.second); - }); - cycleObjectIds = set.stream().toList(); - return false; - } - - @Override - public URLHelper getSuccessURL(Object o) - { - return null; - } - - @Override - public void addNavTrail(NavTree root) - { - - } - } - - @RequiresPermission(AdminPermission.class) - public static class MissingFilesCheckAction extends ReadOnlyApiAction - { - @Override - public Object execute(Object o, BindException errors) throws Exception - { - Map> info = ExperimentServiceImpl.get().doMissingFilesCheck(getUser(), getContainer(), true); - JSONObject results = new JSONObject(); - for (String containerId : info.keySet()) - { - JSONObject containerResults = new JSONObject(); - for (String sourceName : info.get(containerId).keySet()) - containerResults.put(sourceName, info.get(containerId).get(sourceName).toJSON()); - results.put(containerId, containerResults); - } - - ApiSimpleResponse response = new ApiSimpleResponse(); - response.put("success", true); - response.put("result", results); - return response; - } - } - -} +/* + * Copyright (c) 2008-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.labkey.experiment.controllers.exp; + +import au.com.bytecode.opencsv.CSVWriter; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import org.apache.commons.collections4.MapUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.poi.ss.usermodel.Workbook; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.labkey.api.action.ApiJsonWriter; +import org.labkey.api.action.ApiResponse; +import org.labkey.api.action.ApiSimpleResponse; +import org.labkey.api.action.ApiUsageException; +import org.labkey.api.action.ExportAction; +import org.labkey.api.action.FormHandlerAction; +import org.labkey.api.action.FormViewAction; +import org.labkey.api.action.HasViewContext; +import org.labkey.api.action.Marshal; +import org.labkey.api.action.Marshaller; +import org.labkey.api.action.MutatingApiAction; +import org.labkey.api.action.QueryViewAction; +import org.labkey.api.action.ReadOnlyApiAction; +import org.labkey.api.action.ReturnUrlForm; +import org.labkey.api.action.SimpleApiJsonForm; +import org.labkey.api.action.SimpleErrorView; +import org.labkey.api.action.SimpleResponse; +import org.labkey.api.action.SimpleViewAction; +import org.labkey.api.action.SpringActionController; +import org.labkey.api.assay.AssayFileWriter; +import org.labkey.api.assay.AssayProtocolSchema; +import org.labkey.api.assay.AssayProvider; +import org.labkey.api.assay.AssayService; +import org.labkey.api.assay.actions.UploadWizardAction; +import org.labkey.api.assay.security.DesignAssayPermission; +import org.labkey.api.attachments.AttachmentParent; +import org.labkey.api.attachments.AttachmentService; +import org.labkey.api.attachments.BaseDownloadAction; +import org.labkey.api.audit.AbstractAuditTypeProvider; +import org.labkey.api.audit.AuditLogService; +import org.labkey.api.audit.DetailedAuditTypeEvent; +import org.labkey.api.audit.SampleTimelineAuditEvent; +import org.labkey.api.audit.TransactionAuditProvider; +import org.labkey.api.collections.CaseInsensitiveHashMap; +import org.labkey.api.collections.CaseInsensitiveHashSet; +import org.labkey.api.collections.LongHashMap; +import org.labkey.api.data.ActionButton; +import org.labkey.api.data.BaseColumnInfo; +import org.labkey.api.data.ButtonBar; +import org.labkey.api.data.ColumnInfo; +import org.labkey.api.data.CompareType; +import org.labkey.api.data.Container; +import org.labkey.api.data.ContainerFilter; +import org.labkey.api.data.ContainerManager; +import org.labkey.api.data.DataRegion; +import org.labkey.api.data.DataRegionSelection; +import org.labkey.api.data.DbSchema; +import org.labkey.api.data.DbScope; +import org.labkey.api.data.DisplayColumn; +import org.labkey.api.data.ExcelWriter; +import org.labkey.api.data.MenuButton; +import org.labkey.api.data.NameGenerator; +import org.labkey.api.data.SQLFragment; +import org.labkey.api.data.ShowRows; +import org.labkey.api.data.SimpleDisplayColumn; +import org.labkey.api.data.SimpleFilter; +import org.labkey.api.data.Sort; +import org.labkey.api.data.SqlSelector; +import org.labkey.api.data.TSVWriter; +import org.labkey.api.data.TableInfo; +import org.labkey.api.data.TableSelector; +import org.labkey.api.dataiterator.DataIteratorContext; +import org.labkey.api.exp.AbstractParameter; +import org.labkey.api.exp.DeleteForm; +import org.labkey.api.exp.DuplicateMaterialException; +import org.labkey.api.exp.ExperimentDataHandler; +import org.labkey.api.exp.ExperimentException; +import org.labkey.api.exp.ExperimentRunForm; +import org.labkey.api.exp.ExperimentRunListView; +import org.labkey.api.exp.ExperimentRunType; +import org.labkey.api.exp.Identifiable; +import org.labkey.api.exp.Lsid; +import org.labkey.api.exp.LsidManager; +import org.labkey.api.exp.LsidType; +import org.labkey.api.exp.ObjectProperty; +import org.labkey.api.exp.OntologyManager; +import org.labkey.api.exp.ProtocolApplicationParameter; +import org.labkey.api.exp.XarContext; +import org.labkey.api.exp.api.DataClassDomainKindProperties; +import org.labkey.api.exp.api.ExpData; +import org.labkey.api.exp.api.ExpDataClass; +import org.labkey.api.exp.api.ExpExperiment; +import org.labkey.api.exp.api.ExpLineageOptions; +import org.labkey.api.exp.api.ExpMaterial; +import org.labkey.api.exp.api.ExpMaterialRunInput; +import org.labkey.api.exp.api.ExpObject; +import org.labkey.api.exp.api.ExpProtocol; +import org.labkey.api.exp.api.ExpProtocolApplication; +import org.labkey.api.exp.api.ExpRun; +import org.labkey.api.exp.api.ExpRunAttachmentParent; +import org.labkey.api.exp.api.ExpRunEditor; +import org.labkey.api.exp.api.ExpRunItem; +import org.labkey.api.exp.api.ExpSampleType; +import org.labkey.api.exp.api.ExperimentJSONConverter; +import org.labkey.api.exp.api.ExperimentService; +import org.labkey.api.exp.api.ExperimentUrls; +import org.labkey.api.exp.api.NameExpressionOptionService; +import org.labkey.api.exp.api.ResolveLsidsForm; +import org.labkey.api.exp.api.SampleTypeDomainKind; +import org.labkey.api.exp.api.SampleTypeService; +import org.labkey.api.exp.list.ListService; +import org.labkey.api.exp.property.Domain; +import org.labkey.api.exp.property.DomainAuditProvider; +import org.labkey.api.exp.property.DomainKind; +import org.labkey.api.exp.property.DomainProperty; +import org.labkey.api.exp.property.DomainTemplate; +import org.labkey.api.exp.property.DomainTemplateGroup; +import org.labkey.api.exp.property.DomainUtil; +import org.labkey.api.exp.property.PropertyService; +import org.labkey.api.exp.query.ExpDataProtocolInputTable; +import org.labkey.api.exp.query.ExpInputTable; +import org.labkey.api.exp.query.ExpMaterialProtocolInputTable; +import org.labkey.api.exp.query.ExpSchema; +import org.labkey.api.exp.query.SamplesSchema; +import org.labkey.api.exp.xar.LSIDRelativizer; +import org.labkey.api.exp.xar.LsidUtils; +import org.labkey.api.files.FileContentService; +import org.labkey.api.gwt.client.AuditBehaviorType; +import org.labkey.api.inventory.InventoryService; +import org.labkey.api.module.ModuleHtmlView; +import org.labkey.api.module.ModuleLoader; +import org.labkey.api.pipeline.PipeRoot; +import org.labkey.api.pipeline.PipelineService; +import org.labkey.api.pipeline.PipelineStatusFile; +import org.labkey.api.pipeline.PipelineUrls; +import org.labkey.api.pipeline.PipelineValidationException; +import org.labkey.api.qc.SampleStatusService; +import org.labkey.api.query.AbstractQueryImportAction; +import org.labkey.api.query.BatchValidationException; +import org.labkey.api.query.DetailsURL; +import org.labkey.api.query.DuplicateKeyException; +import org.labkey.api.query.FieldKey; +import org.labkey.api.query.QueryAction; +import org.labkey.api.query.QueryDefinition; +import org.labkey.api.query.QueryException; +import org.labkey.api.query.QueryForm; +import org.labkey.api.query.QueryParam; +import org.labkey.api.query.QueryService; +import org.labkey.api.query.QuerySettings; +import org.labkey.api.query.QueryUpdateForm; +import org.labkey.api.query.QueryUpdateService; +import org.labkey.api.query.QueryUpdateServiceException; +import org.labkey.api.query.QueryView; +import org.labkey.api.query.SchemaKey; +import org.labkey.api.query.UserSchema; +import org.labkey.api.query.UserSchemaAction; +import org.labkey.api.reader.ColumnDescriptor; +import org.labkey.api.reader.DataLoader; +import org.labkey.api.reader.DataLoaderFactory; +import org.labkey.api.reader.ExcelFactory; +import org.labkey.api.search.SearchService; +import org.labkey.api.security.ActionNames; +import org.labkey.api.security.RequiresAnyOf; +import org.labkey.api.security.RequiresNoPermission; +import org.labkey.api.security.RequiresPermission; +import org.labkey.api.security.SecurableResource; +import org.labkey.api.security.User; +import org.labkey.api.security.permissions.AdminPermission; +import org.labkey.api.security.permissions.DeletePermission; +import org.labkey.api.security.permissions.DesignDataClassPermission; +import org.labkey.api.security.permissions.DesignSampleTypePermission; +import org.labkey.api.security.permissions.InsertPermission; +import org.labkey.api.security.permissions.Permission; +import org.labkey.api.security.permissions.ReadPermission; +import org.labkey.api.security.permissions.SampleWorkflowDeletePermission; +import org.labkey.api.security.permissions.SiteAdminPermission; +import org.labkey.api.security.permissions.TroubleshooterPermission; +import org.labkey.api.security.permissions.UpdatePermission; +import org.labkey.api.settings.AppProps; +import org.labkey.api.settings.ConceptURIProperties; +import org.labkey.api.sql.LabKeySql; +import org.labkey.api.study.Dataset; +import org.labkey.api.study.StudyService; +import org.labkey.api.study.StudyUrls; +import org.labkey.api.study.publish.StudyPublishService; +import org.labkey.api.usageMetrics.SimpleMetricsService; +import org.labkey.api.util.DOM; +import org.labkey.api.util.DOM.LK; +import org.labkey.api.util.ErrorRenderer; +import org.labkey.api.util.ExceptionUtil; +import org.labkey.api.util.FileStream; +import org.labkey.api.util.FileUtil; +import org.labkey.api.util.HtmlString; +import org.labkey.api.util.HtmlStringBuilder; +import org.labkey.api.util.ImageUtil; +import org.labkey.api.util.JSoupUtil; +import org.labkey.api.util.LinkBuilder; +import org.labkey.api.util.NetworkDrive; +import org.labkey.api.util.PageFlowUtil; +import org.labkey.api.util.Pair; +import org.labkey.api.util.ResponseHelper; +import org.labkey.api.util.SafeToRender; +import org.labkey.api.util.SessionHelper; +import org.labkey.api.util.StringExpression; +import org.labkey.api.util.URLHelper; +import org.labkey.api.util.UniqueID; +import org.labkey.api.util.CsrfInput; +import org.labkey.api.view.ActionURL; +import org.labkey.api.view.BadRequestException; +import org.labkey.api.view.DataView; +import org.labkey.api.view.DataViewSnapshotSelectionForm; +import org.labkey.api.view.DetailsView; +import org.labkey.api.view.HBox; +import org.labkey.api.view.HtmlView; +import org.labkey.api.view.HttpView; +import org.labkey.api.view.InsertView; +import org.labkey.api.view.JspView; +import org.labkey.api.view.NavTree; +import org.labkey.api.view.NotFoundException; +import org.labkey.api.view.RedirectException; +import org.labkey.api.view.UnauthorizedException; +import org.labkey.api.view.UpdateView; +import org.labkey.api.view.VBox; +import org.labkey.api.view.ViewBackgroundInfo; +import org.labkey.api.view.ViewContext; +import org.labkey.api.view.ViewServlet; +import org.labkey.api.view.WebPartView; +import org.labkey.api.view.template.ClientDependency; +import org.labkey.api.view.template.PageConfig; +import org.labkey.experiment.ChooseExperimentTypeBean; +import org.labkey.experiment.ConfirmDeleteView; +import org.labkey.experiment.CustomPropertiesView; +import org.labkey.experiment.DataClassWebPart; +import org.labkey.experiment.DerivedSamplePropertyHelper; +import org.labkey.experiment.DotGraph; +import org.labkey.experiment.ExpDataFileListener; +import org.labkey.experiment.ExperimentRunDisplayColumn; +import org.labkey.experiment.ExperimentRunGraph; +import org.labkey.experiment.LineageGraphDisplayColumn; +import org.labkey.experiment.MissingFilesCheckInfo; +import org.labkey.experiment.MoveRunsBean; +import org.labkey.experiment.ParentChildView; +import org.labkey.experiment.ProtocolApplicationDisplayColumn; +import org.labkey.experiment.ProtocolDisplayColumn; +import org.labkey.experiment.ProtocolWebPart; +import org.labkey.experiment.RunGroupWebPart; +import org.labkey.experiment.SampleTypeDisplayColumn; +import org.labkey.experiment.SampleTypeWebPart; +import org.labkey.experiment.StandardAndCustomPropertiesView; +import org.labkey.experiment.XarExportPipelineJob; +import org.labkey.experiment.XarExportType; +import org.labkey.experiment.XarExporter; +import org.labkey.experiment.api.ClosureQueryHelper; +import org.labkey.experiment.api.DataClass; +import org.labkey.experiment.api.DataClassDomainKind; +import org.labkey.experiment.api.ExpDataClassAttachmentParent; +import org.labkey.experiment.api.ExpDataClassImpl; +import org.labkey.experiment.api.ExpDataImpl; +import org.labkey.experiment.api.ExpExperimentImpl; +import org.labkey.experiment.api.ExpMaterialImpl; +import org.labkey.experiment.api.ExpProtocolApplicationImpl; +import org.labkey.experiment.api.ExpProtocolImpl; +import org.labkey.experiment.api.ExpRunImpl; +import org.labkey.experiment.api.ExpSampleTypeImpl; +import org.labkey.experiment.api.Experiment; +import org.labkey.experiment.api.ExperimentServiceImpl; +import org.labkey.experiment.api.GraphAlgorithms; +import org.labkey.experiment.api.ProtocolActionStepDetail; +import org.labkey.experiment.api.SampleTypeServiceImpl; +import org.labkey.experiment.api.SampleTypeUpdateServiceDI; +import org.labkey.experiment.controllers.property.PropertyController; +import org.labkey.experiment.lineage.ExpLineageServiceImpl; +import org.labkey.experiment.pipeline.ExperimentPipelineJob; +import org.labkey.experiment.types.TypesController; +import org.labkey.experiment.xar.XarExportSelection; +import org.labkey.vfs.FileLike; +import org.labkey.vfs.FileSystemLike; +import org.springframework.beans.PropertyValue; +import org.springframework.beans.PropertyValues; +import org.springframework.validation.BindException; +import org.springframework.validation.Errors; +import org.springframework.validation.ObjectError; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.multipart.MultipartHttpServletRequest; +import org.springframework.web.servlet.ModelAndView; + +import javax.imageio.ImageIO; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.BufferedOutputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.TreeSet; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static java.util.stream.Collectors.toList; +import static org.labkey.api.data.DbScope.CommitTaskOption.POSTCOMMIT; +import static org.labkey.api.exp.query.ExpSchema.TableType.DataInputs; +import static org.labkey.api.query.QueryImportPipelineJob.QUERY_IMPORT_NOTIFICATION_PROVIDER_PARAM; +import static org.labkey.api.query.QueryImportPipelineJob.QUERY_IMPORT_PIPELINE_DESCRIPTION_PARAM; +import static org.labkey.api.query.QueryImportPipelineJob.QUERY_IMPORT_PIPELINE_PROVIDER_PARAM; +import static org.labkey.api.util.DOM.A; +import static org.labkey.api.util.DOM.Attribute.action; +import static org.labkey.api.util.DOM.Attribute.href; +import static org.labkey.api.util.DOM.Attribute.id; +import static org.labkey.api.util.DOM.Attribute.method; +import static org.labkey.api.util.DOM.Attribute.name; +import static org.labkey.api.util.DOM.Attribute.size; +import static org.labkey.api.util.DOM.Attribute.src; +import static org.labkey.api.util.DOM.Attribute.target; +import static org.labkey.api.util.DOM.Attribute.type; +import static org.labkey.api.util.DOM.Attribute.value; +import static org.labkey.api.util.DOM.Attribute.width; +import static org.labkey.api.util.DOM.DIV; +import static org.labkey.api.util.DOM.IMG; +import static org.labkey.api.util.DOM.INPUT; +import static org.labkey.api.util.DOM.LI; +import static org.labkey.api.util.DOM.TABLE; +import static org.labkey.api.util.DOM.TD; +import static org.labkey.api.util.DOM.TR; +import static org.labkey.api.util.DOM.UL; +import static org.labkey.api.util.DOM.at; +import static org.labkey.api.util.DOM.cl; +import static org.labkey.experiment.ExpDataIterators.setContainerFilterForImport; +import static org.labkey.experiment.api.SampleTypeServiceImpl.SampleChangeType.update; + +public class ExperimentController extends SpringActionController +{ + private static final Logger _log = LogManager.getLogger(ExperimentController.class); + private static final DefaultActionResolver _actionResolver = new DefaultActionResolver( + ExperimentController.class + ); + private static final String GUEST_DIRECTORY_NAME = "guest"; + + public ExperimentController() + { + setActionResolver(_actionResolver); + } + + public static void ensureCorrectContainer(Container requestContainer, ExpObject object, ViewContext viewContext) + { + Container objectContainer = object.getContainer(); + if (!requestContainer.equals(objectContainer)) + { + ActionURL url = viewContext.cloneActionURL(); + url.setContainer(objectContainer); + throw new RedirectException(url); + } + } + + // Complete no-op, but leave in place in case we decide to adjust the base nav trail + private void addRootNavTrail(NavTree root) + { + // Intentionally don't add an "Experiment" node to the list because it's too overloaded. All content on the + // default action can be added to a portal page if desired. + } + + @Override + public PageConfig defaultPageConfig() + { + // set default help topic for controller + PageConfig config = super.defaultPageConfig(); + config.setHelpTopic("experiment"); + return config; + } + + @ActionNames("begin,gridView") + @RequiresPermission(ReadPermission.class) + public class BeginAction extends SimpleViewAction + { + @Override + public VBox getView(Object o, BindException errors) + { + VBox result = new VBox(); + + VBox runListView = createRunListView(20); + result.addView(runListView); + + RunGroupWebPart runGroups = new RunGroupWebPart(getViewContext(), false); + runGroups.showHeader(); + result.addView(runGroups); + + result.addView(new ProtocolWebPart(false, getViewContext())); + result.addView(new SampleTypeWebPart(false, getViewContext())); + result.addView(new DataClassWebPart(false, getViewContext(), null)); + + return result; + } + + @Override + public void addNavTrail(NavTree root) + { + addRootNavTrail(root); + root.addChild("Experiment"); + } + } + + @RequiresPermission(ReadPermission.class) + public class ShowRunsAction extends SimpleViewAction + { + @Override + public VBox getView(Object o, BindException errors) + { + return createRunListView(100); + } + + @Override + public void addNavTrail(NavTree root) + { + addRootNavTrail(root); + root.addChild("Experiment Runs"); + } + } + + private VBox createRunListView(int defaultMaxRows) + { + Set types = ExperimentService.get().getExperimentRunTypes(getContainer()); + ChooseExperimentTypeBean bean = new ChooseExperimentTypeBean(types, ExperimentRunType.getSelectedFilter(types, getViewContext().getRequest().getParameter("experimentRunFilter")), getViewContext().getActionURL().clone(), Collections.emptyList()); + JspView chooserView = new JspView<>("/org/labkey/experiment/experimentRunQueryHeader.jsp", bean); + + ExperimentRunListView view = ExperimentService.get().createExperimentRunWebPart(getViewContext(), bean.getSelectedFilter()); + view.setFrame(WebPartView.FrameType.NONE); + + // When paginated and the user hasn't explicitly set a maxRows, use the default maxRows size. + QuerySettings settings = view.getSettings(); + if (!settings.isMaxRowsSet() && settings.getShowRows() == ShowRows.PAGINATED) + { + settings.setMaxRows(defaultMaxRows); + } + + VBox result = new VBox(chooserView, view); + result.setFrame(WebPartView.FrameType.PORTAL); + return result; + } + + @RequiresPermission(ReadPermission.class) + @ActionNames("showRunGroups, showExperiments") + public class ShowRunGroupsAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object o, BindException errors) + { + RunGroupWebPart webPart = new RunGroupWebPart(getViewContext(), false); + webPart.setFrame(WebPartView.FrameType.NONE); + return webPart; + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("runGroups"); + addRootNavTrail(root); + root.addChild("Run Groups"); + } + } + + public record Field(String domainURI, String domainName, String name, Container container) {} + public record MiniExpObject(Object rowId, String name) {} + public record TimelineSummary(MiniExpObject miniExpObject, String mostRecentValue) {} + public record ProblemType(String tableName, String fieldName, String pkName) { + public Object toHtml(List summaries) + { + return DOM.DIV( + DOM.H4(tableName), + DOM.TABLE(at(cl("table-condensed", "labkey-data-region", "table-bordered")), + DOM.THEAD(DOM.TH(pkName), DOM.TH(fieldName)), + summaries.stream().map(summary -> + DOM.TR(DOM.TD(summary.miniExpObject.name), DOM.TD(summary.mostRecentValue))) + )); + } + } + + @RequiresPermission(SiteAdminPermission.class) + public static class ReportLostFieldValuesAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object o, BindException errors) + { + // Find all the fields that could have lost data due to issue 52666 + TableInfo t = new ExpSchema(getUser(), ContainerManager.getRoot()).getTable(ExpSchema.TableType.Fields.name(), ContainerFilter.getUnsafeEverythingFilter()); + List fields = new TableSelector(t, + new SimpleFilter(FieldKey.fromParts("StorageColumnNameMatch"), false). + addCondition(FieldKey.fromParts("DomainURI"), ":AssayDomain-Data.", CompareType.DOES_NOT_CONTAIN), + null). + getArrayList(Field.class); + + // Prep audit table for querying + UserSchema auditSchema = AuditLogService.get().createSchema(getUser(), ContainerManager.getRoot()); + + Map> sampleTypeSummaries = new HashMap<>(); + Map> dataClassSummaries = new HashMap<>(); + Map> listSummaries = new HashMap<>(); + + Map> problematicFields = new LinkedHashMap<>(); + + for (Field field : fields) + { + String domainURI = field.domainURI; + String fieldName = field.name; + Container container = field.container; + Domain domain = PropertyService.get().getDomain(container, domainURI); + if (domain != null && domain.getDomainKind() != null) + { + TableInfo table = domain.getDomainKind().getTableInfo(getUser(), container, domain, ContainerFilter.getUnsafeEverythingFilter()); + + if (table != null) + { + // Drill into sample types + if (domain.getDomainKind().getClass().equals(SampleTypeDomainKind.class)) + { + // rows that currently have no value for the field with potential for data loss + List rowsWithNull = new TableSelector(table, + new HashSet<>(List.of("RowId", "Name")), + new SimpleFilter(new CompareType.CompareClause(FieldKey.fromParts(fieldName), CompareType.ISBLANK, null)), + null). + getArrayList(MiniExpObject.class); + + List fixupsNeeded = checkData( + rowsWithNull, + fieldName, + obj -> new SimpleFilter(FieldKey.fromParts("SampleId"), obj.rowId), + auditSchema.getTable(SampleTimelineAuditEvent.EVENT_TYPE, ContainerFilter.getUnsafeEverythingFilter())); + if (!fixupsNeeded.isEmpty()) + { + sampleTypeSummaries.put(new ProblemType(table.getName(), fieldName, "SampleID"), fixupsNeeded); + } + } + // and data classes/sample sources + if (domain.getDomainKind().getClass().equals(DataClassDomainKind.class)) + { + // rows samples that current have no value for the field with potential for data loss + List rowsWithNull = new TableSelector(table, + new HashSet<>(List.of("RowId", "Name")), + new SimpleFilter(new CompareType.CompareClause(FieldKey.fromParts(fieldName), CompareType.ISBLANK, null)), + null). + getArrayList(MiniExpObject.class); + + List fixupsNeeded = checkData( + rowsWithNull, + fieldName, + obj -> new SimpleFilter(FieldKey.fromParts("RowPk"), Objects.toString(obj.rowId)). + addCondition(FieldKey.fromParts("SchemaName"), "exp.data"). + addCondition(FieldKey.fromParts("QueryName"), domain.getName()), + auditSchema.getTable("QueryUpdateAuditEvent", ContainerFilter.getUnsafeEverythingFilter())); + + if (!fixupsNeeded.isEmpty()) + { + dataClassSummaries.put(new ProblemType(table.getName(), fieldName, "SourceID"), fixupsNeeded); + } + } + // and lists + if ("lists".equals(table.getUserSchema().getName())) + { + // rows samples that current have no value for the field with potential for data loss + List rowsWithNull = new ArrayList<>(); + + ColumnInfo entityIdCol = table.getColumn("EntityId"); + ColumnInfo pkCol = table.getPkColumns().get(0); + + new TableSelector(table, + List.of(entityIdCol, pkCol), + new SimpleFilter(new CompareType.CompareClause(FieldKey.fromParts(fieldName), CompareType.ISBLANK, null)), + null). + forEachResults(r -> + { + Object entityId = entityIdCol.getValue(r); + Object pk = pkCol.getValue(r); + rowsWithNull.add(new MiniExpObject(entityId, pk.toString())); + }); + + + List fixupsNeeded = checkData( + rowsWithNull, + fieldName, + obj -> new SimpleFilter(FieldKey.fromParts("ListItemEntityId"), obj.rowId), + auditSchema.getTable("ListAuditEvent", ContainerFilter.getUnsafeEverythingFilter())); + + if (!fixupsNeeded.isEmpty()) + { + listSummaries.put(new ProblemType(table.getName(), fieldName, table.getPkColumnNames().get(0)), fixupsNeeded); + } + } + + long totalRows = new TableSelector(table).getRowCount(); + long emptyRows = new TableSelector(table, new SimpleFilter(new CompareType.CompareClause(FieldKey.fromParts(fieldName), CompareType.ISBLANK, null)), null).getRowCount(); + problematicFields.put(field, Pair.of(totalRows, emptyRows)); + } + else + { + problematicFields.put(field, Pair.of(null, null)); + } + } + } + + return new HtmlView("Fixups Needed", + DOM.createHtmlFragment( + DOM.H2("Potentially Problematic Fields"), + problematicFields.isEmpty() ? "No problematic fields detected!" : + DOM.TABLE(at(cl("table-condensed", "labkey-data-region", "table-bordered")), + DOM.THEAD(DOM.TH("Domain Name"), DOM.TH("Domain URI"), DOM.TH("Field Name"), DOM.TH("Container"), DOM.TH("Total Rows"), DOM.TH("Rows with Nulls")), + problematicFields.entrySet().stream().map(e -> { + Field f = e.getKey(); + Pair counts = e.getValue(); + return DOM.TR( + DOM.TD(f.domainName), + DOM.TD(f.domainURI), + DOM.TD(f.name), + DOM.TD(f.container.getPath()), + DOM.TD(counts.first), + DOM.TD(counts.second) + ); + } + )), + + DOM.H2("Sample Types"), + sampleTypeSummaries.isEmpty() ? "No problems detected!" : + sampleTypeSummaries.entrySet().stream().map(e -> + e.getKey().toHtml(e.getValue())), + + DOM.H2("Data Classes"), + dataClassSummaries.isEmpty() ? "No problems detected!" : + dataClassSummaries.entrySet().stream().map(e -> + e.getKey().toHtml(e.getValue())), + + DOM.H2("Lists"), + listSummaries.isEmpty() ? "No problems detected!" : + listSummaries.entrySet().stream().map(e -> + e.getKey().toHtml(e.getValue())) + )); + } + + @NotNull + private List checkData(List rowsWithNull, String fieldName, Function filterGenerator, TableInfo auditTable) + { + List fixupsNeeded = new ArrayList<>(); + + // For each sample without a value today, check the audit history + for (MiniExpObject row : rowsWithNull) + { + // Order by RowId to get them in the sequence they happened in + var events = new TableSelector(auditTable, filterGenerator.apply(row), new Sort("RowId")).getArrayList(DetailedAuditTypeEvent.class); + // Remember the most recently set value + String mostRecentValue = null; + for (DetailedAuditTypeEvent event : events) + { + Map newValues = new CaseInsensitiveHashMap<>(AbstractAuditTypeProvider.decodeFromDataMap(event.getNewRecordMap())); + if (newValues.containsKey(fieldName)) + { + // Will be the empty string if the value was intentionally set to blank + mostRecentValue = newValues.get(fieldName); + } + } + // If the value had been set before, and its most recent insert/update wasn't setting it blank, + // it's most likely a lost value + if (mostRecentValue != null && !mostRecentValue.isEmpty()) + { + fixupsNeeded.add(new TimelineSummary(row, mostRecentValue)); + } + } + return fixupsNeeded; + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("Accidentally Nulled Field Report"); + } + } + + @RequiresPermission(ReadPermission.class) + public static class CreateHiddenRunGroupAction extends MutatingApiAction + { + @Override + public ApiResponse execute(SimpleApiJsonForm form, BindException errors) throws Exception + { + JSONObject json = form.getJsonObject(); + String selectionKey = json.optString("selectionKey", null); + List runs = new ArrayList<>(); + + // Accept either an explicit list of run IDs + if (json.has("runIds")) + { + JSONArray runIds = json.getJSONArray("runIds"); + for (int i = 0; i < runIds.length(); i++) + { + ExpRunImpl run = ExperimentServiceImpl.get().getExpRun(runIds.getInt(i)); + if (run != null) + { + runs.add(run); + } + } + } + // Or a reference to a DataRegion selection key + else if (selectionKey != null) + { + Set ids = DataRegionSelection.getSelectedIntegers(getViewContext(), selectionKey, false); + for (Long id : ids) + { + ExpRunImpl run = ExperimentServiceImpl.get().getExpRun(id); + if (run != null) + { + runs.add(run); + } + } + } + if (runs.isEmpty()) + { + throw new NotFoundException(); + } + + ExpExperiment group = ExperimentService.get().createHiddenRunGroup(getContainer(), getUser(), runs.toArray(new ExpRun[0])); + if (selectionKey != null) + DataRegionSelection.clearAll(getViewContext(), selectionKey); + + ApiSimpleResponse response = new ApiSimpleResponse(); + response.putBean(group, "rowId", "LSID", "name", "hidden"); + return response; + } + } + + + @RequiresPermission(ReadPermission.class) + public class DetailsAction extends QueryViewAction + { + private ExpExperimentImpl _experiment; + + public DetailsAction() + { + super(ExpObjectForm.class); + } + + private Pair> createViews(ExpObjectForm form, BindException errors) + { + _experiment = ExperimentServiceImpl.get().getExpExperiment(form.getRowId()); + if (_experiment == null) + { + throw new NotFoundException("Could not find an experiment with RowId " + form.getRowId()); + } + + if (!_experiment.getContainer().equals(getContainer())) + { + throw new RedirectException(getViewContext().cloneActionURL().setContainer(_experiment.getContainer())); + } + + List protocols = _experiment.getAllProtocols(); + + Set types = new TreeSet<>(ExperimentService.get().getExperimentRunTypes(getContainer())); + ExperimentRunType selectedType = ExperimentRunType.getSelectedFilter(types, getViewContext().getRequest().getParameter("experimentRunFilter")); + + ChooseExperimentTypeBean bean = new ChooseExperimentTypeBean(types, selectedType, getViewContext().getActionURL().clone(), protocols); + JspView chooserView = new JspView<>("/org/labkey/experiment/experimentRunQueryHeader.jsp", bean, errors); + + ExperimentRunListView runListView = ExperimentRunListView.createView(getViewContext(), bean.getSelectedFilter(), true); + runListView.getRunTable().setExperiment(_experiment); + runListView.setShowRemoveFromExperimentButton(true); + runListView.setShowDeleteButton(true); + runListView.setShowAddToRunGroupButton(true); + runListView.setShowExportButtons(true); + runListView.setShowMoveRunsButton(true); + return new Pair<>(runListView, chooserView); + } + + @Override + protected ModelAndView getHtmlView(ExpObjectForm form, BindException errors) throws Exception + { + Pair> views = createViews(form, errors); + + CustomPropertiesView customPropertiesView = new CustomPropertiesView(_experiment.getLSID(), getContainer()); + + TableInfo runGroupsTable = new ExpSchema(getUser(), getContainer()).getTable(ExpSchema.TableType.RunGroups); + + DetailsView detailsView = new DetailsView(new DataRegion(), _experiment.getRowId()); + detailsView.getDataRegion().setTable(runGroupsTable); + detailsView.getDataRegion().addColumns(runGroupsTable, "RowId,Name,Created,Modified,Contact,ExperimentDescriptionURL,Hypothesis,Comments"); + detailsView.getDataRegion().getDisplayColumn(0).setVisible(false); + detailsView.getDataRegion().getDisplayColumn(2).setWidth("60%"); + + ButtonBar bb = new ButtonBar(); + bb.setStyle(ButtonBar.Style.separateButtons); + ActionButton b = new ActionButton(ExperimentUrlsImpl.get().getShowUpdateURL(_experiment), "Edit"); + b.setDisplayPermission(UpdatePermission.class); + bb.add(b); + detailsView.getDataRegion().setButtonBar(bb); + if (_experiment.getBatchProtocol() != null) + { + detailsView.setTitle("Batch Details"); + detailsView.getDataRegion().addColumns(runGroupsTable, "BatchProtocolId"); + } + else + { + detailsView.setTitle("Run Group Details"); + } + + VBox runsVBox = new VBox(views.second, createInitializedQueryView(form, errors, false, null)); + runsVBox.setTitle("Experiment Runs"); + runsVBox.setFrame(WebPartView.FrameType.PORTAL); + + return new VBox(new StandardAndCustomPropertiesView(detailsView, customPropertiesView), runsVBox); + } + + @Override + protected ExperimentRunListView createQueryView(ExpObjectForm form, BindException errors, boolean forExport, String dataRegion) + { + return createViews(form, errors).first; + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("runGroups"); + addRootNavTrail(root); + root.addChild("Run Groups", ExperimentUrlsImpl.get().getShowExperimentsURL(getContainer())); + root.addChild(_experiment.getName()); + } + } + + @RequiresPermission(ReadPermission.class) + public class ListSampleTypesAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object o, BindException errors) + { + SampleTypeWebPart view = new SampleTypeWebPart(false, getViewContext()); + view.setFrame(WebPartView.FrameType.NONE); + view.setErrorMessage(getViewContext().getRequest().getParameter("errorMessage")); + + return view; + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("sampleSets"); + addRootNavTrail(root); + root.addChild("Sample Types"); + } + } + + @RequiresPermission(ReadPermission.class) + public class ShowSampleTypeAction extends SimpleViewAction + { + private ExpSampleTypeImpl _sampleType; + + @Override + public ModelAndView getView(ExpObjectForm form, BindException errors) + { + _sampleType = SampleTypeServiceImpl.get().getSampleType(getContainer(), getUser(), form.getRowId()); + if (_sampleType == null && form.getLsid() != null) + { + if (form.getLsid().equalsIgnoreCase("Material") || form.getLsid().equalsIgnoreCase("Sample")) + { + // Not a real sample type - just show all the materials instead + throw new RedirectException(new ActionURL(ShowAllMaterialsAction.class, getContainer())); + } + // Check if the URL specifies the LSID, and stick the bean back into the form + _sampleType = SampleTypeServiceImpl.get().getSampleType(form.getLsid()); + } + + if (_sampleType == null) + { + throw new NotFoundException("No matching sample type found"); + } + + List allScopedSampleTypes = (List) SampleTypeService.get().getSampleTypes(getContainer(), getUser(), true); + if (!allScopedSampleTypes.contains(_sampleType)) + { + ensureCorrectContainer(getContainer(), _sampleType, getViewContext()); + } + + SamplesSchema schema = new SamplesSchema(getUser(), getContainer()); + QuerySettings settings = schema.getSettings(getViewContext(), "Material", _sampleType.getName()); + QueryView queryView = new SampleTypeContentsView(_sampleType, schema, settings, errors); + + DetailsView detailsView = new DetailsView(getSampleTypeRegion(getViewContext()), _sampleType.getRowId()); + detailsView.getDataRegion().getDisplayColumn("Name").setURL((ActionURL)null); + detailsView.getDataRegion().getDisplayColumn("LSID").setVisible(false); + detailsView.getDataRegion().getDisplayColumn("MaterialLSIDPrefix").setVisible(false); + detailsView.getDataRegion().getDisplayColumn("LabelColor").setVisible(false); + detailsView.getDataRegion().getDisplayColumn("MetricUnit").setVisible(false); + detailsView.getDataRegion().getDisplayColumn("Category").setVisible(false); + + detailsView.setTitle("Sample Type Properties"); + detailsView.getDataRegion().getButtonBar(DataRegion.MODE_DETAILS).setStyle(ButtonBar.Style.separateButtons); + + Container autoLinkContainer = _sampleType.getAutoLinkTargetContainer(); + if (null != autoLinkContainer) + { + DisplayColumn autoLinkTargetColumn = detailsView.getDataRegion().getDisplayColumn("autoLinkTargetContainer"); + autoLinkTargetColumn.setVisible(false); + + SimpleDisplayColumn displayAutoLinkTargetColumn = new SimpleDisplayColumn(); + displayAutoLinkTargetColumn.setCaption("Auto Link Target Container:"); + String path = autoLinkContainer.getPath(); + displayAutoLinkTargetColumn.setDisplayHtml(path.equals("/") ? "" : path); + detailsView.getDataRegion().addDisplayColumn(displayAutoLinkTargetColumn); + } + + DisplayColumn autoLinkCategoryColumn = detailsView.getDataRegion().getDisplayColumn("autoLinkCategory"); + autoLinkCategoryColumn.setVisible(false); + SimpleDisplayColumn displayAutoLinkCategoryColumn = new SimpleDisplayColumn(); + displayAutoLinkCategoryColumn.setCaption("Auto Link Category:"); + displayAutoLinkCategoryColumn.setDisplayHtml(_sampleType.getAutoLinkCategory()); + detailsView.getDataRegion().addDisplayColumn(displayAutoLinkCategoryColumn); + + if (_sampleType.hasNameAsIdCol()) + { + SimpleDisplayColumn nameIdCol = new SimpleDisplayColumn(); + nameIdCol.setCaption("Has Name Id Column:"); + nameIdCol.setDisplayHtml("true"); + detailsView.getDataRegion().addDisplayColumn(nameIdCol); + } + + if (_sampleType.hasIdColumns()) + { + SimpleDisplayColumn idCols = new SimpleDisplayColumn(); + idCols.setCaption("Id Column(s):"); + String names = _sampleType.getIdCols().stream() + .filter(Objects::nonNull) + .map(DomainProperty::getName) + .collect(Collectors.joining(", ")); + if (!names.isEmpty()) + { + idCols.setDisplayHtml(PageFlowUtil.filter(names)); + detailsView.getDataRegion().addDisplayColumn(idCols); + } + } + + if (_sampleType.getParentCol() != null) + { + SimpleDisplayColumn parentCol = new SimpleDisplayColumn(PageFlowUtil.filter(_sampleType.getParentCol().getName())); + parentCol.setCaption("Parent Column:"); + detailsView.getDataRegion().addDisplayColumn(parentCol); + } + + try + { + SimpleDisplayColumn importAliasCol = new SimpleDisplayColumn(); + importAliasCol.setCaption("Parent Import Alias(es):"); + if (!_sampleType.getImportAliases().isEmpty()) + importAliasCol.setDisplayHtml(PageFlowUtil.filter(StringUtils.join(_sampleType.getImportAliases().keySet(), ", "))); + detailsView.getDataRegion().addDisplayColumn(importAliasCol); + } + catch (IOException e) + { + // unable to parse import alias map from JSON + } + + if (!getContainer().equals(_sampleType.getContainer())) + { + ActionURL definitionURL = urlProvider(ExperimentUrls.class).getShowSampleTypeURL(_sampleType); + SimpleDisplayColumn definedInCol = new SimpleDisplayColumn("" + + PageFlowUtil.filter(_sampleType.getContainer().getPath()) + + ""); + definedInCol.setCaption("Defined In:"); + detailsView.getDataRegion().addDisplayColumn(definedInCol); + } + + // Not all sample types can be edited + DomainKind domainKind = _sampleType.getDomain().getDomainKind(); + if (domainKind != null && domainKind.canEditDefinition(getUser(), _sampleType.getDomain())) + { + if (domainKind instanceof SampleTypeDomainKind) + { + ActionURL updateURL = new ActionURL(EditSampleTypeAction.class, _sampleType.getContainer()); + updateURL.addParameter("RowId", _sampleType.getRowId()); + updateURL.addReturnUrl(getViewContext().getActionURL()); + + if (!getContainer().equals(_sampleType.getContainer())) + { + String editLink = updateURL.toString(); + ActionButton updateButton = new ActionButton("Edit Type"); + updateButton.setActionType(ActionButton.Action.SCRIPT); + updateButton.setScript("if (window.confirm('This sample type is defined in the " + _sampleType.getContainer().getPath() + " folder. Would you still like to edit it?')) { window.location = '" + editLink + "' }"); + detailsView.getDataRegion().getButtonBar(DataRegion.MODE_DETAILS).add(updateButton); + } + else + { + ActionButton updateButton = new ActionButton(updateURL, "Edit Type", ActionButton.Action.LINK); + updateButton.setDisplayPermission(DesignSampleTypePermission.class); + updateButton.setPrimary(true); + detailsView.getDataRegion().getButtonBar(DataRegion.MODE_DETAILS).add(updateButton); + } + + ActionURL deleteURL = new ActionURL(DeleteSampleTypesAction.class, _sampleType.getContainer()); + deleteURL.addParameter("singleObjectRowId", _sampleType.getRowId()); + deleteURL.addReturnUrl(ExperimentUrlsImpl.get().getShowSampleTypeListURL(getContainer())); + ActionButton deleteButton = new ActionButton(deleteURL, "Delete Type", ActionButton.Action.LINK); + deleteButton.setDisplayPermission(DesignSampleTypePermission.class); + detailsView.getDataRegion().getButtonBar(DataRegion.MODE_DETAILS).add(deleteButton); + } + else + { + ActionURL editURL = domainKind.urlEditDefinition(_sampleType.getDomain(), new ViewBackgroundInfo(_sampleType.getContainer(), getUser(), getViewContext().getActionURL())); + if (editURL != null) + { + editURL.addReturnUrl(getViewContext().getActionURL()); + ActionButton editTypeButton = new ActionButton(editURL, "Edit Fields"); + editTypeButton.setDisplayPermission(UpdatePermission.class); + detailsView.getDataRegion().getButtonBar(DataRegion.MODE_DETAILS).add(editTypeButton); + } + } + } + + if (_sampleType.canImportMoreSamples()) + { + TableInfo table = queryView.getTable(); + if (table != null) + { + ActionURL importURL = table.getImportDataURL(getContainer()); + if (importURL != null) + { + importURL = importURL.clone(); + importURL.addReturnUrl(getViewContext().getActionURL()); + ActionButton uploadButton = new ActionButton(importURL, "Import More Samples", ActionButton.Action.LINK); + uploadButton.setDisplayPermission(UpdatePermission.class); + detailsView.getDataRegion().getButtonBar(DataRegion.MODE_DETAILS).add(uploadButton); + } + } + } + + var publish = StudyPublishService.get(); + if (AuditLogService.get().isViewable() && publish != null) + { + ContainerFilter cf = ContainerFilter.Type.CurrentAndSubfolders.create(getContainer(), getUser()); + ActionURL linkToStudyHistoryURL = publish.getPublishHistory(getContainer(), Dataset.PublishSource.SampleType, _sampleType.getRowId(), cf); + ActionButton linkToStudyHistoryButton = new ActionButton(linkToStudyHistoryURL, "Link to Study History", ActionButton.Action.LINK); + linkToStudyHistoryButton.setDisplayPermission(InsertPermission.class); + detailsView.getDataRegion().getButtonBar(DataRegion.MODE_DETAILS).add(linkToStudyHistoryButton); + } + + return new VBox(detailsView, queryView); + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("sampleSets"); + ActionURL url = ExperimentUrls.get().getShowSampleTypeListURL(getContainer()); + addRootNavTrail(root); + root.addChild("Sample Types", url); + root.addChild("Sample Type " + _sampleType.getName()); + } + } + + @RequiresPermission(ReadPermission.class) + public class ShowAllMaterialsAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object o, BindException errors) + { + ExpSchema schema = new ExpSchema(getUser(), getContainer()); + QuerySettings settings = schema.getSettings(getViewContext(), "Materials", ExpSchema.TableType.Materials.toString()); + QueryView view = new QueryView(schema, settings, errors) + { + @Override + protected void populateButtonBar(DataView view, ButtonBar bar) + { + super.populateButtonBar(view, bar); + bar.add(SampleTypeContentsView.getDeriveSamplesButton(getContainer(),null)); + } + }; + view.setShowDetailsColumn(false); + return view; + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("sampleSets"); + addRootNavTrail(root); + root.addChild("All Materials"); + } + } + + /** + * Only shows standard and custom properties, not parent and child samples. Used for indexing + */ + @RequiresPermission(ReadPermission.class) + public class ShowMaterialSimpleAction extends SimpleViewAction + { + protected ExpMaterialImpl _material; + + @Override + public VBox getView(ExpObjectForm form, BindException errors) throws Exception + { + Container c = getContainer(); + _material = ExperimentServiceImpl.get().getExpMaterial(form.getRowId()); + if (_material == null && form.getLsid() != null) + { + _material = ExperimentServiceImpl.get().getExpMaterial(form.getLsid()); + } + if (_material == null) + { + throw new NotFoundException("Could not find a material with RowId " + form.getRowId()); + } + + ensureCorrectContainer(getContainer(), _material, getViewContext()); + + ExpRunImpl run = _material.getRun(); + ExpProtocol sourceProtocol = _material.getSourceProtocol(); + ExpProtocolApplication sourceProtocolApplication = _material.getSourceApplication(); + + DataRegion dr = new DataRegion(); + dr.addColumns(ExperimentServiceImpl.get().getTinfoMaterial().getUserEditableColumns()); + dr.removeColumns("RowId", "RunId", "LastIndexed", "LSID", "SourceApplicationId", "CpasType"); + + //dr.addColumns(extraProps); + dr.addDisplayColumn(new ExperimentRunDisplayColumn(run, "Source Experiment Run")); + dr.addDisplayColumn(new ProtocolDisplayColumn(sourceProtocol, "Source Protocol")); + dr.addDisplayColumn(new ProtocolApplicationDisplayColumn(sourceProtocolApplication, "Source Protocol Application")); + dr.addDisplayColumn(new LineageGraphDisplayColumn(_material, run)); + dr.addDisplayColumn(new SampleTypeDisplayColumn(_material)); + + //TODO: Can't yet edit materials uploaded from a material source + dr.setButtonBar(new ButtonBar()); + DetailsView detailsView = new DetailsView(dr, _material.getRowId()); + detailsView.setTitle("Standard Properties"); + detailsView.setFrame(WebPartView.FrameType.PORTAL); + + CustomPropertiesView cpv = new CustomPropertiesView(_material, c, getUser()); + + return new VBox(new StandardAndCustomPropertiesView(detailsView, cpv)); + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("sampleSets"); + addRootNavTrail(root); + root.addChild("Sample Types", ExperimentUrlsImpl.get().getShowSampleTypeListURL(getContainer())); + ExpSampleType sampleType = _material.getSampleType(); + if (sampleType != null) + { + root.addChild(sampleType.getName(), ExperimentUrlsImpl.get().getShowSampleTypeURL(sampleType)); + } + root.addChild("Sample " + _material.getName()); + } + } + + @RequiresPermission(ReadPermission.class) + public class ShowMaterialAction extends ShowMaterialSimpleAction + { + @Override + public VBox getView(ExpObjectForm form, BindException errors) throws Exception + { + VBox vbox = super.getView(form, errors); + + List materialsToInvestigate = new ArrayList<>(); + final Set successorRuns = new HashSet<>(); + materialsToInvestigate.add(_material); + Set investigatedMaterials = new HashSet<>(); + do + { + // Query for all the next tier of materials at once - issue 45402 + List followupRuns = ExperimentService.get().getRunsUsingMaterials(materialsToInvestigate); + + // Mark this set as investigated and reset for the next cycle + investigatedMaterials.addAll(materialsToInvestigate); + materialsToInvestigate = new ArrayList<>(); + + for (ExpRun r : followupRuns) + { + // Only expand the material outputs of the run if it's our first time visiting it + if (successorRuns.add(r)) + { + materialsToInvestigate.addAll(r.getMaterialOutputs()); + } + } + + if (successorRuns.size() > 1000) + { + // Give up - there may be a cycle or other problematic data + break; + } + + // Cull the ones we've already looked up + materialsToInvestigate.removeAll(investigatedMaterials); + } + while (!materialsToInvestigate.isEmpty()); + + HtmlStringBuilder updateLinks = HtmlStringBuilder.of(); + ExpSampleType st = _material.getSampleType(); + if (st != null && st.getContainer() != null && st.getContainer().hasPermission(getUser(), UpdatePermission.class)) + { + // XXX: ridiculous amount of work to get a update url expression for the sample type's table. + UserSchema samplesSchema = QueryService.get().getUserSchema(getUser(), st.getContainer(), "Samples"); + QueryDefinition queryDef = samplesSchema.getQueryDefForTable(st.getName()); + StringExpression expr = queryDef.urlExpr(QueryAction.updateQueryRow, null); + if (expr != null) + { + // Since we're building a detailsURL outside the context of a "row" need to set the correct + // container context on the generated expr. + ((DetailsURL) expr).setContainerContext(st.getContainer()); + String url = expr.eval(Collections.singletonMap(new FieldKey(null, "RowId"), _material.getRowId())); + updateLinks.append(LinkBuilder.labkeyLink("edit", url)).append(" "); + } + } + + if (getContainer().hasPermission(getUser(), InsertPermission.class)) + { + ActionURL deriveURL = new ActionURL(DeriveSamplesChooseTargetAction.class, getContainer()); + deriveURL.addParameter("rowIds", _material.getRowId()); + if (st != null) + deriveURL.addParameter("targetSampleTypeId", st.getRowId()); + + updateLinks.append(LinkBuilder.labkeyLink("derive samples from this sample", deriveURL)).append(" "); + } + + vbox.addView(new HtmlView(updateLinks)); + + ExperimentRunListView runListView = ExperimentRunListView.createView(getViewContext(), ExperimentRunType.ALL_RUNS_TYPE, true); + runListView.setShowRecordSelectors(false); + runListView.getRunTable().setRuns(successorRuns); + runListView.getRunTable().setContainerFilter(new ContainerFilter.AllFolders(getUser())); + runListView.setAllowableContainerFilterTypes(ContainerFilter.Type.Current, ContainerFilter.Type.CurrentAndSubfolders, ContainerFilter.Type.AllFolders); + runListView.setTitle("Runs associated with this material or a derived material"); + + ParentChildView pv = new ParentChildView(_material, getViewContext()); + vbox.addView(pv); + vbox.addView(runListView); + + return vbox; + } + } + + + // + // DataClass + // + + @RequiresPermission(ReadPermission.class) + public class ListDataClassAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object o, BindException errors) + { + DataClassWebPart view = new DataClassWebPart(false, getViewContext(), null); + view.setFrame(WebPartView.FrameType.NONE); + view.setErrorMessage(getViewContext().getRequest().getParameter("errorMessage")); + + return view; + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("dataClass"); + addRootNavTrail(root); + root.addChild("Data Classes"); + } + } + + public static class DataClassForm extends ExpObjectForm + { + private String _name; + + public String getName() + { + return _name; + } + + public void setName(String name) + { + _name = name; + } + + public ExpDataClassImpl getDataClass(@Nullable Container container) + { + ExpDataClassImpl dataClass = null; + + if (getName() != null) + { + dataClass = ExperimentServiceImpl.get().getDataClass(getContainer(), getUser(), getName()); + if (dataClass == null) + throw new NotFoundException("No data class found for name '" + getName() + "'."); + } + else if (getRowId() > 0) + { + dataClass = ExperimentServiceImpl.get().getDataClass(getContainer(), getUser(), getRowId()); + } + + if (dataClass == null) + throw new NotFoundException("No data class found."); + else if (container != null && !container.equals(dataClass.getContainer())) + throw new NotFoundException("Data class is not defined in the given container."); + + return dataClass; + } + } + + @RequiresPermission(ReadPermission.class) + public class ShowDataClassAction extends SimpleViewAction + { + private ExpDataClassImpl _dataClass; + + @Override + public ModelAndView getView(DataClassForm form, BindException errors) + { + _dataClass = form.getDataClass(null); + return new VBox(getDataClassPropertiesView(), getDataClassContentsView(errors)); + } + + private DetailsView getDataClassPropertiesView() + { + ExpSchema expSchema = new ExpSchema(getUser(), _dataClass.getContainer()); + + TableInfo table = ExpSchema.TableType.DataClasses.createTable(expSchema, null, null); + QueryUpdateForm tvf = new QueryUpdateForm(table, getViewContext(), null); + tvf.setPkVal(_dataClass.getRowId()); + DetailsView detailsView = new DetailsView(tvf); + detailsView.setTitle("Data Class Properties"); + + ButtonBar bb = new ButtonBar(); + bb.setStyle(ButtonBar.Style.separateButtons); + boolean inDefinitionContainer = getContainer().equals(_dataClass.getContainer()); + + DomainKind domainKind = _dataClass.getDomain().getDomainKind(); + if (domainKind != null && domainKind.canEditDefinition(getUser(), _dataClass.getDomain())) + { + ActionURL updateURL = new ActionURL(EditDataClassAction.class, _dataClass.getContainer()); + updateURL.addParameter("rowId", _dataClass.getRowId()); + updateURL.addReturnUrl(urlProvider(ExperimentUrls.class).getShowDataClassURL(_dataClass.getContainer(), _dataClass.getRowId())); + + if (inDefinitionContainer) + { + ActionButton updateButton = new ActionButton(updateURL, "Edit Data Class", ActionButton.Action.LINK); + updateButton.setDisplayPermission(DesignDataClassPermission.class); + updateButton.setPrimary(true); + bb.add(updateButton); + } + else if (_dataClass.getContainer().hasPermission(getUser(), DesignDataClassPermission.class)) + { + ActionButton updateButton = new ActionButton("Edit Data Class"); + updateButton.setActionType(ActionButton.Action.SCRIPT); + updateButton.setScript("if (window.confirm('This data class is defined in the " + _dataClass.getContainer().getPath() + " folder. Would you still like to edit it?')) { window.location = '" + updateURL + "' }"); + updateButton.setPrimary(true); + bb.add(updateButton); + } + + ActionURL deleteURL = new ActionURL(DeleteDataClassAction.class, _dataClass.getContainer()); + deleteURL.addParameter("singleObjectRowId", _dataClass.getRowId()); + deleteURL.addReturnUrl(ExperimentUrlsImpl.get().getDataClassListURL(getContainer())); + ActionButton deleteButton = new ActionButton(deleteURL, "Delete Data Class", ActionButton.Action.LINK); + + if (inDefinitionContainer) + { + deleteButton.setDisplayPermission(DesignDataClassPermission.class); + bb.add(deleteButton); + } + else if (_dataClass.getContainer().hasPermission(getUser(), DesignDataClassPermission.class)) + { + bb.add(deleteButton); + } + } + detailsView.getDataRegion().setButtonBar(bb); + + if (!inDefinitionContainer) + { + ActionURL definitionURL = urlProvider(ExperimentUrls.class).getShowDataClassURL(_dataClass.getContainer(), _dataClass.getRowId()); + LinkBuilder link = LinkBuilder.simpleLink(_dataClass.getContainer().getPath(), definitionURL); + SimpleDisplayColumn definedInCol = new SimpleDisplayColumn(link.toString()); + definedInCol.setCaption("Defined In:"); + detailsView.getDataRegion().addDisplayColumn(definedInCol); + } + + return detailsView; + } + + private QueryView getDataClassContentsView(BindException errors) + { + UserSchema dataClassSchema = QueryService.get().getUserSchema(getUser(), getContainer(), ExpSchema.SCHEMA_EXP_DATA); + QuerySettings settings = dataClassSchema.getSettings(getViewContext(), QueryView.DATAREGIONNAME_DEFAULT, _dataClass.getName()); + + return new QueryView(dataClassSchema, settings, errors) + { + @Override + public @NotNull LinkedHashSet getClientDependencies() + { + LinkedHashSet resources = super.getClientDependencies(); + resources.add(ClientDependency.fromPath("Ext4")); + resources.add(ClientDependency.fromPath("dataregion/confirmDelete.js")); + return resources; + } + + @Override + public ActionButton createDeleteButton() + { + ActionButton button = super.createDeleteButton(); + if (button != null) + { + String dependencyText = ExperimentService.get() + .getObjectReferencers() + .stream() + .map(referencer -> referencer.getObjectReferenceDescription(ExpData.class)) + .collect(Collectors.joining(" or ")); + + button.setScript("LABKEY.dataregion.confirmDelete(" + + PageFlowUtil.jsString(getDataRegionName()) + ", " + + PageFlowUtil.jsString(ExpSchema.SCHEMA_EXP_DATA.toString()) + ", " + + PageFlowUtil.jsString(getQueryDef().getName()) + ", " + + "'experiment', 'getDataOperationConfirmationData.api', " + + PageFlowUtil.jsString(getSelectionKey()) + ", " + + "'data object', 'data objects', '" + dependencyText + "', {dataOperation: 'Delete'})"); + button.setRequiresSelection(true); + } + return button; + } + }; + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("dataClass"); + addRootNavTrail(root); + root.addChild("Data Classes", ExperimentUrlsImpl.get().getDataClassListURL(getContainer())); + root.addChild(_dataClass.getName()); + } + } + + @RequiresPermission(DesignDataClassPermission.class) + public class DeleteDataClassAction extends AbstractDeleteAction + { + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("dataClass"); + super.addNavTrail(root); + } + + @Override + protected void deleteObjects(DeleteForm deleteForm) + { + List dataClasses = getDataClasses(deleteForm); + if (!ensureCorrectContainer(dataClasses)) + { + throw new UnauthorizedException(); + } + for (ExpRun run : getRuns(dataClasses)) + { + if (!run.getContainer().hasPermission(getUser(), DeletePermission.class)) + { + throw new UnauthorizedException(); + } + } + for (ExpDataClass dataClass : dataClasses) + { + dataClass.delete(getUser(), deleteForm.getUserComment()); + } + } + + @Override + public ModelAndView getView(DeleteForm deleteForm, boolean reshow, BindException errors) + { + List dataClasses = getDataClasses(deleteForm); + + if (!ensureCorrectContainer(dataClasses)) + { + throw new RedirectException(ExperimentUrlsImpl.get().getDataClassListURL(getContainer(), "To delete a data class, you must be in its folder or project.")); + } + + return new ConfirmDeleteView("Data Class", ShowDataClassAction.class, dataClasses, deleteForm, getRuns(dataClasses)); + } + + private List getDataClasses(DeleteForm deleteForm) + { + List dataClasses = new ArrayList<>(); + for (long rowId : deleteForm.getIds(false)) + { + ExpDataClass dataClass = ExperimentServiceImpl.get().getDataClass(getContainer(), getUser(), rowId); + if (dataClass != null) + { + dataClasses.add(dataClass); + } + } + return dataClasses; + } + + private boolean ensureCorrectContainer(List dataClasses) + { + for (ExpDataClass dataClass : dataClasses) + { + Container sourceContainer = dataClass.getContainer(); + if (!sourceContainer.equals(getContainer())) + { + return false; + } + } + return true; + } + + private List getRuns(List dataClasses) + { + if (!dataClasses.isEmpty()) + { + List runArray = ExperimentService.get().getRunsUsingDataClasses(dataClasses); + return ExperimentService.get().runsDeletedWithInput(runArray); + } + else + { + return Collections.emptyList(); + } + } + } + + @Marshal(Marshaller.Jackson) + @RequiresPermission(ReadPermission.class) + public static class GetDataClassPropertiesAction extends ReadOnlyApiAction + { + @Override + public Object execute(DataClassForm form, BindException errors) throws Exception + { + ExpDataClass dataClass = form.getDataClass(getContainer()); + if (dataClass != null) + return new DataClassDomainKindProperties(dataClass); + else + throw new NotFoundException("Data class does not exist in this container for rowId " + form.getRowId() + "."); + } + } + + @RequiresPermission(DesignDataClassPermission.class) + public static class EditDataClassAction extends SimpleViewAction + { + private ExpDataClassImpl _dataClass; + + @Override + public ModelAndView getView(DataClassForm form, BindException errors) + { + boolean create = form.getLSID() == null && form.getRowId() == 0 && form.getName() == null; + if (!create) + _dataClass = form.getDataClass(getContainer()); + + return ModuleHtmlView.get(ModuleLoader.getInstance().getModule("core"), ModuleHtmlView.getGeneratedViewPath("dataClassDesigner")); + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("dataClass"); + + root.addChild("Data Classes", ExperimentUrlsImpl.get().getDataClassListURL(getContainer())); + if (_dataClass == null) + { + root.addChild("Create Data Class"); + } + else + { + root.addChild(_dataClass.getName(), ExperimentUrlsImpl.get().getShowDataClassURL(getContainer(), _dataClass.getRowId())); + root.addChild("Update Data Class"); + } + } + } + + @RequiresPermission(DesignDataClassPermission.class) + public static class CreateDataClassFromTemplateAction extends FormViewAction + { + private ActionURL _successUrl; + private Map _domainTemplates; + + @Override + public void validateCommand(CreateDataClassFromTemplateForm form, Errors errors) + { + String name = null; + _domainTemplates = DomainTemplateGroup.getAllTemplates(getContainer()); + + if (!_domainTemplates.containsKey(form.getDomainTemplate())) + { + errors.reject(ERROR_MSG, "Unknown template selected: " + form.getDomainTemplate()); + } + else + { + DomainTemplate template = _domainTemplates.get(form.getDomainTemplate()); + name = template.getTemplateName(); + + // Issue 40230: if template includes sample type option, verify that it exists + if (template.getOptions().containsKey("sampleSet")) + { + String sampleTypeName = template.getOptions().get("sampleSet").toString(); + ExpSampleType sampleType = SampleTypeServiceImpl.get().getSampleType(getContainer(), getUser(), sampleTypeName); + if (sampleType == null) + errors.reject(ERROR_MSG, "Unable to find a sample type in this container with name: " + sampleTypeName + "."); + } + } + + if (StringUtils.isBlank(name)) + errors.reject(ERROR_MSG, "DataClass template selection is required."); + else if (ExperimentService.get().getDataClass(getContainer(), getUser(), name) != null) + errors.reject(ERROR_MSG, "DataClass '" + name + "' already exists."); + + } + + @Override + public ModelAndView getView(CreateDataClassFromTemplateForm form, boolean reshow, BindException errors) + { + Set templates = DomainTemplateGroup.getTemplatesForDomainKind(getContainer(), DataClassDomainKind.NAME); + form.setAvailableDomainTemplateNames(templates); + + Set messages = new HashSet<>(); + Map groups = DomainTemplateGroup.getAllGroups(getContainer()); + for (DomainTemplateGroup g : groups.values()) + messages.addAll(g.getErrors()); + form.setXmlParseErrors(messages); + + return new JspView<>("/org/labkey/experiment/createDataClassFromTemplate.jsp", form, errors); + } + + @Override + public boolean handlePost(CreateDataClassFromTemplateForm form, BindException errors) throws Exception + { + DomainTemplate template = _domainTemplates.get(form.getDomainTemplate()); + Domain domain = DomainUtil.createDomain(template, getContainer(), getUser(), form.getName()); + + _successUrl = domain.getDomainKind().urlEditDefinition(domain, getViewContext()); + return true; + } + + @Override + public URLHelper getSuccessURL(CreateDataClassFromTemplateForm form) + { + return _successUrl; + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("dataClass"); + root.addChild("Create Data Class from Template"); + } + } + + public static class CreateDataClassFromTemplateForm extends DataClass + { + private String _domainTemplate; + private Set _availableDomainTemplateNames; + private Set _xmlParseErrors; + private final ReturnUrlForm _returnUrlForm = new ReturnUrlForm(); + + public String getDomainTemplate() + { + return _domainTemplate; + } + + public void setDomainTemplate(String domainTemplate) + { + _domainTemplate = domainTemplate; + } + + public Set getAvailableDomainTemplateNames() + { + return _availableDomainTemplateNames; + } + + public void setAvailableDomainTemplateNames(Set availableDomainTemplateNames) + { + _availableDomainTemplateNames = availableDomainTemplateNames; + } + + public Set getXmlParseErrors() + { + return _xmlParseErrors; + } + + public void setXmlParseErrors(Set xmlParseErrors) + { + _xmlParseErrors = xmlParseErrors; + } + + @Nullable + public String getReturnUrl() + { + return _returnUrlForm.getReturnUrl(); + } + + public void setReturnUrl(String s) + { + _returnUrlForm.setReturnUrl(s); + } + } + + public static class ConceptURIForm + { + private String _conceptURI; + + public String getConceptURI() + { + return _conceptURI; + } + + public void setConceptURI(String conceptURI) + { + _conceptURI = conceptURI; + } + } + + @RequiresPermission(AdminPermission.class) + public static class RemoveConceptMappingAction extends MutatingApiAction + { + @Override + public void validateForm(ConceptURIForm form, Errors errors) + { + if (form.getConceptURI() == null || ConceptURIProperties.getLookup(getContainer(), form.getConceptURI()) == null) + errors.reject(ERROR_MSG, "Concept URI not found: " + form.getConceptURI()); + } + + @Override + public Object execute(ConceptURIForm form, BindException errors) + { + ConceptURIProperties.removeLookup(getContainer(), form.getConceptURI()); + return new ApiSimpleResponse("success", true); + } + } + + @RequiresPermission(ReadPermission.class) + public static class RunAttachmentDownloadAction extends BaseDownloadAction + { + @Nullable + @Override + public Pair getAttachment(AttachmentForm form) + { + if (form.getLsid() == null || form.getName() == null) + throw new NotFoundException("Error: missing required param 'lsid' or 'name'."); + + ExpRun run = ExperimentService.get().getExpRun(form.getLsid()); + if (run == null) + throw new NotFoundException("Run not found: " + form.getLsid()); + + if (!run.getContainer().equals(getContainer())) + { + if (run.getContainer().hasPermission(getUser(), ReadPermission.class)) + throw new RedirectException(getViewContext().cloneActionURL().setContainer(run.getContainer())); + else + throw new NotFoundException("Run not found"); + } + + AttachmentParent parent = new ExpRunAttachmentParent(run); + return new Pair<>(parent, form.getName()); + } + } + + @RequiresPermission(ReadPermission.class) + public static class DataClassAttachmentDownloadAction extends BaseDownloadAction + { + @Nullable + @Override + public Pair getAttachment(AttachmentForm form) + { + if (form.getLsid() == null || form.getName() == null) + throw new NotFoundException("Error: missing required param 'lsid' or 'name'."); + + Lsid lsid = new Lsid(form.getLsid()); + ExpData data = ExperimentServiceImpl.get().getExpData(lsid.toString()); + if (data == null) + throw new NotFoundException("Error: Data object not found for the given LSID: " + lsid); + AttachmentParent parent = new ExpDataClassAttachmentParent(data.getContainer(), lsid); + + return new Pair<>(parent, form.getName()); + } + } + + public static class AttachmentForm extends LsidForm implements BaseDownloadAction.InlineDownloader + { + private String _name; + private boolean _inline = true; + + public String getName() + { + return _name; + } + + public void setName(String name) + { + _name = name; + } + + @Override + public boolean isInline() + { + return _inline; + } + + public void setInline(boolean inline) + { + _inline = inline; + } + } + + // + // END DataClass actions + // + + public static ActionURL getRunGraphURL(Container c, long runId) + { + return new ActionURL(ShowRunGraphAction.class, c).addParameter("rowId", runId); + } + + + @RequiresPermission(ReadPermission.class) + public class ShowRunGraphAction extends AbstractShowRunAction + { + @Override + protected VBox createLowerView(ExpRunImpl experimentRun, BindException errors) + { + return new VBox( + createRunViewTabs(experimentRun, false, true, true), + new ExperimentRunGraphView(experimentRun, false)); + } + } + + + @RequiresPermission(ReadPermission.class) + public static class DownloadGraphAction extends SimpleViewAction + { + @Override + public ModelAndView getView(ExperimentRunForm form, BindException errors) throws Exception + { + boolean detail = form.isDetail(); + String focus = form.getFocus(); + String focusType = form.getFocusType(); + + ExpRunImpl experimentRun = (ExpRunImpl) form.lookupRun(); + ensureCorrectContainer(getContainer(), experimentRun, getViewContext()); + + ExperimentRunGraph.RunGraphFiles files; + try + { + files = ExperimentRunGraph.generateRunGraph(getViewContext(), experimentRun, detail, focus, focusType); + } + catch (ExperimentException e) + { + PageFlowUtil.streamTextAsImage(getViewContext().getResponse(), "ERROR: " + e.getMessage(), 600, 150, Color.RED); + return null; + } + + try + { + PageFlowUtil.streamFile(getViewContext().getResponse(), new File(files.getImageFile().getAbsolutePath()), false); + } + catch (FileNotFoundException e) + { + getViewContext().getResponse().sendRedirect(getViewContext().getRequest().getContextPath() + "/experiment/ExperimentRunNotFound.gif"); + } + finally + { + files.release(); + } + return null; + } + + @Override + public void addNavTrail(NavTree root) + { + throw new UnsupportedOperationException(); + } + } + + private abstract class AbstractShowRunAction extends SimpleViewAction + { + private ExpRunImpl _experimentRun; + + @Override + public ModelAndView getView(ExperimentRunForm form, BindException errors) + { + _experimentRun = (ExpRunImpl) form.lookupRun(); + ensureCorrectContainer(getContainer(), _experimentRun, getViewContext()); + + VBox vbox = new VBox(); + + JspView detailsView = new JspView<>("/org/labkey/experiment/ExperimentRunDetails.jsp", _experimentRun); + detailsView.setTitle("Standard Properties"); + + var attachmentParent = new ExpRunAttachmentParent(_experimentRun); + var attachments = AttachmentService.get().getAttachments(attachmentParent) + .stream() + .map(att -> Pair.of(att.getName(), new ActionURL(RunAttachmentDownloadAction.class, _experimentRun.getContainer()).addParameter("name", att.getName()).addParameter("lsid", _experimentRun.getLSID()))) + .collect(toList()); + CustomPropertiesView cpv = new CustomPropertiesView(_experimentRun.getLSID(), getContainer(), attachments); + + vbox.addView(new StandardAndCustomPropertiesView(detailsView, cpv)); + + HtmlStringBuilder updateLinks = HtmlStringBuilder.of(); + List runEditors = ExperimentService.get().getRunEditors(); + for (ExpRunEditor editor : runEditors) + { + if (editor.isProtocolEditor(form.lookupRun().getProtocol())) + { + updateLinks.append(LinkBuilder.labkeyLink("edit " + editor.getDisplayName() + " run", editor.getEditUrl(getContainer()).addParameter("rowId", form.getRowId()))); + } + } + + if (!updateLinks.isEmpty()) + { + HtmlView view = new HtmlView(updateLinks); + vbox.addView(view); + } + + VBox lowerView = createLowerView(_experimentRun, errors); + lowerView.setFrame(WebPartView.FrameType.PORTAL); + lowerView.setTitle("Run Details"); + NavTree tree = new NavTree(""); + File runRoot = _experimentRun.getFilePathRoot(); + if (NetworkDrive.exists(runRoot)) + { + if (!runRoot.isDirectory()) + { + runRoot = runRoot.getParentFile(); + } + PipeRoot pipelineRoot = PipelineService.get().findPipelineRoot(_experimentRun.getContainer()); + if (pipelineRoot != null) + { + if (pipelineRoot.isUnderRoot(runRoot)) + { + String path = pipelineRoot.relativePath(runRoot); + tree.addChild("View Files", urlProvider(PipelineUrls.class).urlBrowse(_experimentRun.getContainer(), null, path)); + } + } + } + + final String exportFilesFormId = "exportFilesForm"; + NavTree downloadFiles = new NavTree("Download all files"); + downloadFiles.setScript("document.getElementById('" + exportFilesFormId + "').submit();"); + tree.addChild(downloadFiles); + + // CONSIDER: Show modal dialog using ExperimentService.get().createRunExportView() + NavTree exportXarFiles = new NavTree("Export XAR"); + exportXarFiles.setScript("LABKEY.Experiment.exportRuns({runIds: [" + _experimentRun.getRowId() + "] });"); + tree.addChild(exportXarFiles); + + lowerView.setNavMenu(tree); + lowerView.setIsWebPart(false); + + vbox.addView(lowerView); + vbox.addView(new ExperimentRunGroupsView(getUser(), getContainer(), _experimentRun, getViewContext().getActionURL(), errors)); + + DOM.Renderable exportFilesForm = LK.FORM(at( + id, exportFilesFormId, + method, "POST", + action, new ActionURL(ExportRunFilesAction.class, _experimentRun.getContainer())), + INPUT(at(type, "hidden", + name, DataRegionSelection.DATA_REGION_SELECTION_KEY, + value, "ExportSingleRun")), + INPUT(at(type, "hidden", + name, DataRegion.SELECT_CHECKBOX_NAME, + value, _experimentRun.getRowId())), + INPUT(at(type, "hidden", + name, "zipFileName", + value, _experimentRun.getName() + ".zip"))); + + HtmlView hiddenFormView = new HtmlView(exportFilesForm); + vbox.addView(hiddenFormView); + + return vbox; + } + + protected abstract VBox createLowerView(ExpRunImpl experimentRun, BindException errors); + + @Override + public void addNavTrail(NavTree root) + { + root.addChild(_experimentRun.getName()); + } + } + + public static class ToggleRunExperimentMembershipForm + { + private int _runId; + private int _experimentId; + private boolean _included; + + public int getRunId() + { + return _runId; + } + + public void setRunId(int runId) + { + _runId = runId; + } + + public int getExperimentId() + { + return _experimentId; + } + + public void setExperimentId(int experimentId) + { + _experimentId = experimentId; + } + + public boolean isIncluded() + { + return _included; + } + + public void setIncluded(boolean included) + { + _included = included; + } + } + + @RequiresPermission(UpdatePermission.class) + public static class ToggleRunExperimentMembershipAction extends FormHandlerAction + { + @Override + public boolean handlePost(ToggleRunExperimentMembershipForm form, BindException errors) + { + ExpRun run = ExperimentService.get().getExpRun(form.getRunId()); + // Check if the user has permission to update this run + if (run == null || !run.getContainer().hasPermission(getUser(), UpdatePermission.class)) + { + throw new NotFoundException(); + } + + ExpExperiment exp = ExperimentService.get().getExpExperiment(form.getExperimentId()); + if (exp == null) + { + throw new NotFoundException(); + } + // Check if this + if (!ExperimentService.get().getExperiments(run.getContainer(), getUser(), true, false).contains(exp)) + { + throw new NotFoundException(); + } + // Users must have permission to view, but not necessarily update, the container the holds the run group + if (!exp.getContainer().hasPermission(getUser(), ReadPermission.class)) + { + throw new UnauthorizedException(); + } + + if (form.isIncluded()) + { + exp.addRuns(getUser(), run); + } + else + { + exp.removeRun(getUser(), run); + } + + return true; + } + + @Override + public URLHelper getSuccessURL(ToggleRunExperimentMembershipForm form) + { + return null; + } + + @Override + public void validateCommand(ToggleRunExperimentMembershipForm target, Errors errors) + { + } + } + + private HtmlView createRunViewTabs(ExpRun expRun, boolean showGraphSummary, boolean showGraphDetail, boolean showText) + { + return new HtmlView( + TABLE(cl("labkey-tab-strip"), + TR( + createTabSpacer(false), + createTab("Graph Summary View", ExperimentUrlsImpl.get().getRunGraphURL(expRun), !showGraphSummary), + createTabSpacer(false), + createTab("Graph Detail View", ExperimentUrlsImpl.get().getRunGraphDetailURL(expRun), !showGraphDetail), + createTabSpacer(false), + createTab("Text View", ExperimentUrlsImpl.get().getRunTextURL(expRun), !showText), + createTabSpacer(true)))); + } + + private DOM.Renderable createTab(String text, ActionURL url, boolean selected) + { + return TD(cl(selected,"labkey-tab-selected", "labkey-tab"), + A(at(href, url), text)); + } + + private DOM.Renderable createTabSpacer(boolean fullWidth) + { + return TD(cl("labkey-tab-space").at(fullWidth, width, "100%"), + IMG(at(src, AppProps.getInstance().getContextPath() + "/_.gif", width, "5"))); + } + + @RequiresPermission(ReadPermission.class) + public class ShowRunTextAction extends AbstractShowRunAction + { + @Override + protected VBox createLowerView(ExpRunImpl expRun, BindException errors) + { + JspView applicationsView = new JspView<>("/org/labkey/experiment/ProtocolApplications.jsp", expRun); + applicationsView.setFrame(WebPartView.FrameType.TITLE); + applicationsView.setTitle("Protocol Applications"); + + HtmlView toggleView = createRunViewTabs(expRun, true, true, false); + + QuerySettings runDataInputsSettings = new QuerySettings(getViewContext(), "RunDataInputs", DataInputs.name()); + UsageQueryView runDataInputsView = new UsageQueryView("Data Inputs", getViewContext(), expRun, ExpProtocol.ApplicationType.ExperimentRun, runDataInputsSettings, errors); + runDataInputsView.setButtonBarPosition(DataRegion.ButtonBarPosition.TOP); + + QuerySettings runDataOutputsSettings = new QuerySettings(getViewContext(), "RunDataOutputs", DataInputs.name()); + UsageQueryView runDataOutputsView = new UsageQueryView("Data Outputs", getViewContext(), expRun, ExpProtocol.ApplicationType.ExperimentRunOutput, runDataOutputsSettings, errors); + runDataOutputsView.setButtonBarPosition(DataRegion.ButtonBarPosition.NONE); + + QuerySettings runMaterialInputsSetting = new QuerySettings(getViewContext(), "RunMaterialInputs", ExpSchema.TableType.MaterialInputs.name()); + UsageQueryView runMaterialInputsView = new UsageQueryView("Material Inputs", getViewContext(), expRun, ExpProtocol.ApplicationType.ExperimentRun, runMaterialInputsSetting, errors); + runMaterialInputsView.setButtonBarPosition(DataRegion.ButtonBarPosition.TOP); + + QuerySettings runMaterialOutputsSettings = new QuerySettings(getViewContext(), "RunMaterialOutputs", ExpSchema.TableType.MaterialInputs.name()); + UsageQueryView runMaterialOutputsView = new UsageQueryView("Material Outputs", getViewContext(), expRun, ExpProtocol.ApplicationType.ExperimentRunOutput, runMaterialOutputsSettings, errors); + runMaterialOutputsView.setButtonBarPosition(DataRegion.ButtonBarPosition.NONE); + + HBox inputsView = new HBox(runDataInputsView, runMaterialInputsView); + HBox registeredInputsView = new HBox(); + + var expService = ExperimentService.get(); + expService.getRunInputsViewProviders().forEach(provider -> + { + var queryView = provider.createView(getViewContext(), expRun, errors); + if (queryView != null) + { + registeredInputsView.addView(queryView); + } + }); + HBox outputsView = new HBox(runDataOutputsView, runMaterialOutputsView); + HBox registeredOutputsView = new HBox(); + expService.getRunOutputsViewProviders().forEach(provider -> + { + var queryView = provider.createView(getViewContext(), expRun, errors); + if (queryView != null) + { + registeredOutputsView.addView(queryView); + } + }); + + var vBox = new VBox(); + vBox.addView(toggleView); + vBox.addView(inputsView); + if (!registeredInputsView.isEmpty()) + vBox.addView(registeredInputsView); + vBox.addView(outputsView); + if (!registeredOutputsView.isEmpty()) + vBox.addView(registeredOutputsView); + vBox.addView(applicationsView); + + return vBox; + } + } + + private static class UsageQueryView extends QueryView + { + private final ExpRun _run; + private final ExpProtocol.ApplicationType _type; + + public UsageQueryView(String title, ViewContext context, ExpRun run, ExpProtocol.ApplicationType type, + QuerySettings settings, BindException errors) + { + super(new ExpSchema(context.getUser(), context.getContainer()), settings, errors); + setTitle(title); + setFrame(FrameType.TITLE); + _run = run; + _type = type; + setShowBorders(true); + setShadeAlternatingRows(true); + setShowExportButtons(false); + setShowPagination(false); + disableContainerFilterSelection(); + } + + @Override + protected TableInfo createTable() + { + String tableName = getSettings().getQueryName(); + ExpInputTable tableInfo = (ExpInputTable) getSchema().getTable(tableName, new ContainerFilter.AllFolders(getUser()), true, true); + tableInfo.setRun(_run, _type); + tableInfo.setLocked(true); + return tableInfo; + } + } + + + public static ActionURL getShowRunGraphDetailURL(Container c, long rowId) + { + ActionURL url = new ActionURL(ShowRunGraphDetailAction.class, c); + url.addParameter("rowId", rowId); + return url; + } + + + @RequiresPermission(ReadPermission.class) + public class ShowRunGraphDetailAction extends AbstractShowRunAction + { + @Override + protected VBox createLowerView(ExpRunImpl run, BindException errors) + { + ExperimentRunGraphView gw = new ExperimentRunGraphView(run, true); + if (null != getViewContext().getActionURL().getParameter("focus")) + gw.setFocus(getViewContext().getActionURL().getParameter("focus")); + if (null != getViewContext().getActionURL().getParameter("focusType")) + gw.setFocusType(getViewContext().getActionURL().getParameter("focusType")); + return new VBox(createRunViewTabs(run, true, false, true), gw); + } + } + + private abstract class AbstractDataAction extends SimpleViewAction + { + protected ExpDataImpl _data; + + @Override + public final ModelAndView getView(DataForm form, BindException errors) throws Exception + { + _data = form.lookupData(); + if (_data == null) + { + throw new NotFoundException("Could not find a data with RowId " + form.getRowId()); + } + + ensureCorrectContainer(getContainer(), _data, getViewContext()); + return getDataView(form, errors); + } + + protected abstract ModelAndView getDataView(DataForm form, BindException errors) throws Exception; + + @Override + public void addNavTrail(NavTree root) + { + addRootNavTrail(root); + root.addChild("Data " + _data.getName()); + } + } + + @RequiresPermission(ReadPermission.class) + public class ShowDataAction extends AbstractDataAction + { + @Override + public ModelAndView getDataView(DataForm form, BindException errors) + { + ExpRun run = _data.getRun(); + ExpProtocol sourceProtocol = _data.getSourceProtocol(); + ExpProtocolApplication sourceProtocolApplication = _data.getSourceApplication(); + ExpDataClass dataClass = _data.getDataClass(getUser()); + + ExpSchema schema = new ExpSchema(getUser(), getContainer()); + TableInfo table; + long pk; + if (dataClass == null) + { + table = schema.getDatasTable(); + pk = _data.getRowId(); + } + else + { + table = schema.getSchema(ExpSchema.NestedSchemas.data).getTable(dataClass.getName()); + pk = new TableSelector(table, Collections.singleton("rowId"), new SimpleFilter(FieldKey.fromParts("lsid"), _data.getLSID()), null).getObject(Integer.class); + } + + DataRegion dr = new DataRegion(); + dr.setTable(table); + List cols = table.getColumns().stream().filter(ColumnInfo::isShownInDetailsView).collect(toList()); + dr.addColumns(cols); + dr.removeColumns("RowId", "Created", "CreatedBy", "Modified", "ModifiedBy", "DataFileUrl", "Run", "LSID", "CpasType", "SourceApplicationId", "Folder", "Generated"); + dr.addDisplayColumn(new ExperimentRunDisplayColumn(run, "Source Experiment Run")); + dr.addDisplayColumn(new ProtocolDisplayColumn(sourceProtocol, "Source Protocol")); + dr.addDisplayColumn(new ProtocolApplicationDisplayColumn(sourceProtocolApplication, "Source Protocol Application")); + dr.addDisplayColumn(new LineageGraphDisplayColumn(_data, run)); + DetailsView detailsView = new DetailsView(dr, pk); + detailsView.setTitle("Standard Properties"); + detailsView.setFrame(WebPartView.FrameType.PORTAL); + ButtonBar bb = new ButtonBar(); + bb.setStyle(ButtonBar.Style.separateButtons); + + ExperimentDataHandler handler = _data.findDataHandler(); + ActionURL viewDataURL = handler == null ? null : handler.getContentURL(_data); + if (viewDataURL != null) + { + bb.add(new ActionButton("View data", viewDataURL)); + } + + if (_data.isPathAccessible()) + { + bb.add(new ActionButton("View file", ExperimentUrlsImpl.get().getShowFileURL(_data, true))); + bb.add(new ActionButton("Download file", ExperimentUrlsImpl.get().getShowFileURL(_data, false))); + + if (getContainer().hasPermission(getUser(), InsertPermission.class)) + { + String relativePath = null; + PipeRoot root = PipelineService.get().findPipelineRoot(getContainer()); + if (root != null) + { + Path rootFile = root.getRootNioPath(); + Path dataFile = _data.getFilePath(); + if (dataFile != null) + { + Path pathRelative; + try + { + pathRelative = rootFile.relativize(dataFile); + if (null != pathRelative) + relativePath = pathRelative.toString(); + } + catch (IllegalArgumentException e) + { + // dataFile not relative to root + } + } + } + ActionURL browseURL = urlProvider(PipelineUrls.class).urlBrowse(getContainer(), getViewContext().getActionURL(), relativePath); + bb.add(new ActionButton("Browse in pipeline", browseURL)); + } + } + + // add links to any other exp.data that share the same dataFileUrl path + var altDataList = ExperimentService.get().getAllExpDataByURL(_data.getDataFileUrl(), getContainer()); + altDataList.removeIf(_data::equals); + if (!altDataList.isEmpty()) + { + MenuButton menu = new MenuButton("Alternate Data"); + for (ExpData altData : altDataList) + { + ExpRun altDataRun = altData.getRun(); + StringBuilder sb = new StringBuilder(altData.getName()); + if (altDataRun != null) + sb.append(" created by run '").append(altDataRun.getName()).append("' (").append(altDataRun.getProtocol().getName()).append(")"); + menu.addMenuItem(sb.toString(), altData.detailsURL()); + } + bb.add(menu); + } + + dr.setButtonBarPosition(DataRegion.ButtonBarPosition.TOP); + dr.setButtonBar(bb); + + CustomPropertiesView cpv = new CustomPropertiesView(_data.getLSID(), getContainer()); + HBox hbox = new StandardAndCustomPropertiesView(detailsView, cpv); + + VBox vbox = new VBox(hbox); + + ParentChildView pv = new ParentChildView(_data, getViewContext()); + vbox.addView(pv); + + ExperimentRunListView runListView = ExperimentRunListView.createView(getViewContext(), ExperimentRunType.ALL_RUNS_TYPE, true); + runListView.getRunTable().setInputData(_data); + runListView.getRunTable().setContainerFilter(new ContainerFilter.AllFolders(getUser())); + runListView.getRunTable().setLocked(true); + runListView.setTitle("Runs using this data as an input"); + vbox.addView(runListView); + + if (_data.isInlineImage() && _data.isFileOnDisk()) + { + ActionURL showFileURL = new ActionURL(ShowFileAction.class, getContainer()).addParameter("rowId", _data.getRowId()); + HtmlView imageView = new HtmlView(IMG(at(src, showFileURL))); + return new VBox(vbox, imageView); + } + return vbox; + } + } + + @RequiresPermission(AdminPermission.class) + public static class CheckDataFileAction extends MutatingApiAction + { + private ExpDataImpl _data; + + @Override + public void validateForm(DataFileForm form, Errors errors) + { + _data = form.lookupData(); + if (_data == null) + { + errors.reject(ERROR_MSG, "No ExpData found for id: " + form.getRowId()); + } + } + + @Override + public ApiResponse execute(DataFileForm form, BindException errors) + { + File dataFile = _data.getFile(); + Container dataContainer = _data.getContainer(); + boolean fileExists = _data.isFileOnDisk(); + boolean fileExistsAtCurrent = false; + File newDataFile = null; + + ApiSimpleResponse response = new ApiSimpleResponse(); + response.put("dataFileUrl", _data.getDataFileUrl()); + response.put("fileExists", fileExists); + response.put("containerPath", dataContainer.getPath()); + + if (!fileExists) + { + PipeRoot pipelineRoot = PipelineService.get().findPipelineRoot(dataContainer); + if (pipelineRoot != null && pipelineRoot.isValid() && dataFile != null) + { + newDataFile = pipelineRoot.resolvePath("/" + AssayFileWriter.DIR_NAME + "/" + dataFile.getName()); + fileExistsAtCurrent = NetworkDrive.exists(newDataFile); + response.put("fileExistsAtCurrent", fileExistsAtCurrent); + } + } + + // if the current dataFileUrl does not exist on disk and we have the file at the current + // pipeline root /assaydata dir, fix the dataFileUrl value + if (form.isAttemptFilePathFix()) + { + if (fileExistsAtCurrent) + { + ExpDataFileListener fileListener = new ExpDataFileListener(); + fileListener.fileMoved(dataFile, newDataFile, getUser(), dataContainer); + response.put("filePathFixed", true); + + // update the ExpData object so that we can get the new dataFileUrl + _data = form.lookupData(); + response.put("newDataFileUrl", _data.getDataFileUrl()); + } + else + { + response.put("filePathFixed", false); + } + } + + response.put("success", true); + return response; + } + } + + public static class DataFileForm extends DataForm + { + private boolean _attemptFilePathFix; + + public boolean isAttemptFilePathFix() + { + return _attemptFilePathFix; + } + + public void setAttemptFilePathFix(boolean attemptFilePathFix) + { + _attemptFilePathFix = attemptFilePathFix; + } + } + + @RequiresPermission(ReadPermission.class) + public class ShowFileAction extends AbstractDataAction + { + @Override + protected ModelAndView getDataView(DataForm form, BindException errors) throws IOException + { + if (!_data.isPathAccessible()) + { + throw new NotFoundException("Data file " + _data.getDataFileUrl() + " does not exist on disk"); + } + + PipeRoot root = PipelineService.get().findPipelineRoot(getContainer()); + if (root != null && !root.isUnderRoot(_data.getFilePath())) + { + // Issue 35649: ImmPort module "publish" creates exp.data object in this container for paths that originate in a different container + FileContentService fileSvc = FileContentService.get(); + if (fileSvc == null) + throw new UnauthorizedException("Data file is not under the pipeline root for this folder"); + + List containers = fileSvc.getContainersForFilePath(_data.getFilePath()); + if (containers.isEmpty() || containers.stream().noneMatch(c -> c.hasPermission(getUser(), ReadPermission.class))) + throw new UnauthorizedException("Data file is not under the pipeline root for this folder"); + } + + //Issues 25667 and 31152 + if (form.isInline()) + { + ExperimentDataHandler h = _data.findDataHandler(); + if (h != null) + { + URLHelper url = h.getShowFileURL(_data); + if (url != null) + { + throw new RedirectException(url, false); + } + } + } + + try + { + Path realContent = _data.getFilePath(); + if (null == realContent) + throw new IllegalStateException("Path not found."); + + boolean inline = _data.isInlineImage() || form.isInline() || "inlineImage".equalsIgnoreCase(form.getFormat()); + if (_data.isInlineImage() && form.getMaxDimension() != null) + { + try (InputStream inputStream = Files.newInputStream(realContent)) + { + BufferedImage image = ImageIO.read(inputStream); + // If image, create a thumbnail, otherwise fall through as a regular download attempt + if (image != null) + { + int imageMax = Math.max(image.getHeight(), image.getWidth()); + if (imageMax > form.getMaxDimension().intValue()) + { + double scale = (double) form.getMaxDimension().intValue() / (double) imageMax; + ByteArrayOutputStream bOut = new ByteArrayOutputStream(); + ImageUtil.resizeImage(image, bOut, scale, 1); + PageFlowUtil.streamFileBytes(getViewContext().getResponse(), FileUtil.getFileName(realContent) + ".png", bOut.toByteArray(), !inline); + return null; + } + } + } + } + + boolean extended = "jsonTSVExtended".equalsIgnoreCase(form.getFormat()); + boolean ignoreTypes = "jsonTSVIgnoreTypes".equalsIgnoreCase(form.getFormat()); + if ("jsonTSV".equalsIgnoreCase(form.getFormat()) || extended || ignoreTypes) + { + if (!FileUtil.hasCloudScheme(realContent)) // TODO: handle streaming from S3 to JSON + streamToJSON(FileSystemLike.wrapFile(realContent), form.getFormat(), -1, null); + return null; + } + + try (InputStream inputStream = Files.newInputStream(realContent)) + { + PageFlowUtil.streamFile(getViewContext().getResponse(), Collections.emptyMap(), FileUtil.getFileName(realContent), inputStream, !inline); + } + } + catch (IOException e) + { + try + { + // Try to write the exception back to the caller if we haven't already flushed the buffer + ApiJsonWriter writer = new ApiJsonWriter(getViewContext().getResponse()); + writer.writeResponse(e); + } + catch (IllegalStateException ise) + { + // Most likely that a disconnected client caused the IOException writing back the response + } + } + + return null; + } + } + + + public static class ParseForm + { + String format = "jsonTSV"; + int maxRows = -1; + + public String getFormat() + { + return format; + } + + public void setFormat(String format) + { + this.format = format; + } + + public int getMaxRows() + { + return maxRows; + } + + public void setMaxRows(int maxRow) + { + this.maxRows = maxRow; + } + } + + @RequiresNoPermission + public class ParseFileAction extends MutatingApiAction + { + @Override + public Object execute(ParseForm form, BindException errors) throws Exception + { + if (!(getViewContext().getRequest() instanceof MultipartHttpServletRequest)) + throw new BadRequestException("Expected MultipartHttpServletRequest when posting files."); + + MultipartFile formFile = getFileMap().get("file"); + if (formFile == null) + { + return true; + } + + FileLike tempFile = null; + try + { + tempFile = FileUtil.createTempFileLike("parse", formFile.getOriginalFilename()); + FileUtil.copyData(formFile.getInputStream(), tempFile.openOutputStream()); + streamToJSON(tempFile, form.getFormat(), form.getMaxRows(), formFile.getOriginalFilename()); + } + finally + { + if (null != tempFile) + tempFile.delete(); + } + return null; + } + } + + + // SampleTypeTest + private void streamToJSON(FileLike realContent, String format, int maxRow, String originalFileName) throws IOException + { + String lowerCaseFileName = realContent.getName().toLowerCase(); + boolean extended = "jsonTSVExtended".equalsIgnoreCase(format); + boolean ignoreTypes = "jsonTSVIgnoreTypes".equalsIgnoreCase(format); + + JSONArray sheetsArray; + if (lowerCaseFileName.endsWith(".xls") || lowerCaseFileName.endsWith(".xlsx")) + { + try (InputStream in = realContent.openInputStream()) + { + sheetsArray = ExcelFactory.convertExcelToJSON(in, extended, maxRow); + } + } + else + { + DataLoaderFactory dlf = DataLoader.get().findFactory(realContent, null); + if (null == dlf) + { + throw new ApiUsageException("Unable to parse file " + realContent + ", it is likely of an unsupported file type"); + } + + try (InputStream in = realContent.openInputStream(); + DataLoader tabLoader = dlf.createLoader(in, true)) + { + tabLoader.setScanAheadLineCount(5000); + ColumnDescriptor[] cols = tabLoader.getColumns(); + + if (ignoreTypes) + for (ColumnDescriptor col : cols) + col.clazz = String.class; + + JSONArray rowsArray = new JSONArray(); + JSONArray headerArray = new JSONArray(); + for (ColumnDescriptor col : cols) + { + if (extended) + { + JSONObject valueObject = new JSONObject(); + valueObject.put("value", col.name); + headerArray.put(valueObject); + } + else + { + headerArray.put(col.name); + } + } + rowsArray.put(headerArray); + for (Map rowMap : tabLoader) + { + // headers count as a row to be consistent + if (maxRow > -1 && maxRow <= rowsArray.length() + 1) + break; + + JSONArray rowArray = new JSONArray(); + for (ColumnDescriptor col : cols) + { + Object value = rowMap.get(col.name); + if (extended) + { + JSONObject valueObject = new JSONObject(); + valueObject.put("value", value); + rowArray.put(valueObject); + } + else + { + rowArray.put(value); + } + } + rowsArray.put(rowArray); + } + + JSONObject sheetJSON = new JSONObject(); + sheetJSON.put("name", "flat"); + sheetJSON.put("data", rowsArray); + sheetsArray = new JSONArray(); + sheetsArray.put(sheetJSON); + } + } + + try (ApiJsonWriter writer = new ApiJsonWriter(getViewContext().getResponse())) + { + JSONObject workbookJSON = new JSONObject(); + workbookJSON.put("fileName", realContent.getName()); + workbookJSON.put("sheets", sheetsArray); + if (originalFileName != null) + workbookJSON.put("originalFileName", originalFileName); + writer.writeResponse(new ApiSimpleResponse(workbookJSON)); + } + } + + + public static class ConvertArraysToExcelForm + { + private String _json; + + public String getJson() + { + return _json; + } + + public void setJson(String json) + { + _json = json; + } + } + + @RequiresPermission(ReadPermission.class) + public static class ConvertArraysToExcelAction extends ExportAction + { + @Override + public void validate(ConvertArraysToExcelForm form, BindException errors) + { + if (form.getJson() == null) + { + errors.reject(ERROR_MSG, "Unable to convert to Excel - no spreadsheet data given"); + } + } + + @Override + public void export(ConvertArraysToExcelForm form, HttpServletResponse response, BindException errors) throws Exception + { + try + { + JSONObject rootObject; + JSONArray sheetsArray; + if (form.getJson() == null || form.getJson().trim().isEmpty()) + { + // Create JSON so that we return an empty file + rootObject = new JSONObject(); + sheetsArray = new JSONArray(); + JSONObject sheetObject = new JSONObject(); + sheetsArray.put(sheetObject); + } + else + { + rootObject = new JSONObject(form.getJson()); + sheetsArray = rootObject.getJSONArray("sheets"); + } + String filename = rootObject.has("fileName") ? rootObject.getString("fileName") : "ExcelExport.xls"; + ExcelWriter.ExcelDocumentType docType = filename.toLowerCase().endsWith(".xlsx") ? ExcelWriter.ExcelDocumentType.xlsx : ExcelWriter.ExcelDocumentType.xls; + + try (Workbook workbook = ExcelFactory.createFromArray(sheetsArray, docType)) + { + response.setContentType(docType.getMimeType()); + ResponseHelper.setContentDisposition(response, ResponseHelper.ContentDispositionType.attachment, filename); + ResponseHelper.setPrivate(response); + workbook.write(response.getOutputStream()); + + JSONObject qInfo = rootObject.has("queryinfo") ? rootObject.getJSONObject("queryinfo") : null; + if (qInfo != null) + { + QueryService.get().addAuditEvent(getUser(), getContainer(), qInfo.getString("schema"), + qInfo.getString("query"), getViewContext().getActionURL(), + rootObject.getString("auditMessage") + filename, + null); + SimpleMetricsService.get().increment(ExperimentService.MODULE_NAME, "convertTable", "asExcel"); + } + } + } + catch (JSONException | ClassCastException e) + { + // We can get a ClassCastException if we expect an array and get a simple String, for example + ExceptionUtil.renderErrorView(getViewContext(), getPageConfig(), ErrorRenderer.ErrorType.notFound, HttpServletResponse.SC_BAD_REQUEST, "Failed to convert to Excel - invalid input", e, false, false); + } + } + } + + @RequiresPermission(ReadPermission.class) + public static class ConvertArraysToTableAction extends ExportAction + { + @Override + public void validate(ConvertArraysToExcelForm form, BindException errors) + { + if (form.getJson() == null) + { + errors.reject(ERROR_MSG, "Unable to convert to table - no data given"); + } + } + + @Override + public void export(ConvertArraysToExcelForm form, HttpServletResponse response, BindException errors) throws Exception + { + try + { + JSONObject rootObject; + JSONArray rowsArray; + if (form.getJson() == null || form.getJson().trim().isEmpty()) + { + // Create JSON so that we return an empty file + rootObject = new JSONObject(); + rowsArray = new JSONArray(); + } + else + { + rootObject = new JSONObject(form.getJson()); + rowsArray = rootObject.getJSONArray("rows"); + } + + TSVWriter.DELIM delimType = (!rootObject.isNull("delim") ? TSVWriter.DELIM.valueOf(rootObject.getString("delim")) : TSVWriter.DELIM.TAB); + TSVWriter.QUOTE quoteType = (!rootObject.isNull("quoteChar") ? TSVWriter.QUOTE.valueOf(rootObject.getString("quoteChar")) : TSVWriter.QUOTE.NONE); + String filenamePrefix = (!rootObject.isNull("fileNamePrefix") ? rootObject.getString("fileNamePrefix") : "Export"); + String filename = filenamePrefix + "." + delimType.extension; + String newlineChar = !rootObject.isNull("newlineChar") ? rootObject.getString("newlineChar") : "\n"; + + PageFlowUtil.prepareResponseForFile(response, Collections.emptyMap(), filename, true); + response.setContentType(delimType.contentType); + + //NOTE: we could also have used TSVWriter; however, this is in use elsewhere and we dont need a custom subclass + try (CSVWriter writer = new CSVWriter(response.getWriter(), delimType.delim, quoteType.quoteChar, newlineChar)) + { + for (int i = 0; i < rowsArray.length(); i++) + { + List objectList = rowsArray.getJSONArray(i).toList(); + Iterator it = objectList.iterator(); + List list = new ArrayList<>(); + + while (it.hasNext()) + { + Object o = it.next(); + if (o != null) + list.add(o.toString()); + else + list.add(""); + } + + writer.writeNext(list.toArray(new String[0])); + } + } + + JSONObject qInfo = rootObject.optJSONObject("queryinfo"); + if (qInfo != null) + { + QueryService.get().addAuditEvent(getUser(), getContainer(), qInfo.getString("schema"), qInfo.getString("query"), + getViewContext().getActionURL(), + rootObject.getString("auditMessage") + filename, + rowsArray.length()); + SimpleMetricsService.get().increment(ExperimentService.MODULE_NAME, "convertTable", "asDelimited"); + } + } + catch (JSONException e) + { + ExceptionUtil.renderErrorView(getViewContext(), getPageConfig(), ErrorRenderer.ErrorType.notFound, HttpServletResponse.SC_BAD_REQUEST, "Failed to convert to table - invalid input", e, false, false); + } + } + } + + + public static class ConvertHtmlToExcelForm + { + private String _baseUrl; + private String _htmlFragment; + private String _name = "workbook.xls"; + + + public String getName() + { + return _name; + } + + public void setName(String name) + { + _name = name; + } + + public String getBaseUrl() + { + return _baseUrl; + } + + public void setBaseUrl(String baseUrl) + { + _baseUrl = baseUrl; + } + + public String getHtmlFragment() + { + return _htmlFragment; + } + + public void setHtmlFragment(String htmlFragment) + { + _htmlFragment = htmlFragment; + } + } + + + @RequiresPermission(ReadPermission.class) + public static class ConvertHtmlToExcelAction extends FormViewAction + { + String _responseHtml = null; + + @Override + public void validateCommand(ConvertHtmlToExcelForm target, Errors errors) + { + } + + @Override + public ModelAndView getView(ConvertHtmlToExcelForm form, boolean reshow, BindException errors) + { + String html = + "

" + + "" + + new CsrfInput(getViewContext()) + + "
"; + return HtmlView.unsafe(html); + } + + @Override + public boolean handlePost(ConvertHtmlToExcelForm form, BindException errors) + { + ActionURL url = getViewContext().getActionURL(); + String base = url.getBaseServerURI(); + if (!base.endsWith("/")) base += "/"; + + String baseTag = ""; + SafeToRender css = PageFlowUtil.getStylesheetIncludes(getContainer()); + String htmlFragment = StringUtils.trimToEmpty(form.getHtmlFragment()); + String html = "" + baseTag + css + "" + htmlFragment + ""; + + // UNDONE: strip script + List tidyErrors = new ArrayList<>(); + String tidy = JSoupUtil.tidyHTML(html, false, tidyErrors); + + if (!tidyErrors.isEmpty()) + { + for (String err : tidyErrors) + { + errors.reject(ERROR_MSG, err); + } + return false; + } + + _responseHtml = tidy; + return true; + } + + @Override + public ModelAndView getSuccessView(ConvertHtmlToExcelForm form) + { + ResponseHelper.setContentDisposition(getViewContext().getResponse(), ResponseHelper.ContentDispositionType.attachment, form.getName()); + getPageConfig().setTemplate(PageConfig.Template.None); + HtmlView v = HtmlView.unsafe(_responseHtml); + v.setContentType("application/vnd.ms-excel"); + v.setFrame(WebPartView.FrameType.NONE); + return v; + } + + @Override + public URLHelper getSuccessURL(ConvertHtmlToExcelForm convertHtmlToExcelForm) + { + return null; + } + + @Override + public void addNavTrail(NavTree root) + { + } + } + + + public static ActionURL getShowApplicationURL(Container c, long rowId) + { + ActionURL url = new ActionURL(ShowApplicationAction.class, c); + url.addParameter("rowId", rowId); + + return url; + } + + + @RequiresPermission(ReadPermission.class) + public class ShowApplicationAction extends SimpleViewAction + { + private ExpProtocolApplicationImpl _app; + private ExpRun _run; + + @Override + public ModelAndView getView(ExpObjectForm form, BindException errors) + { + _app = ExperimentServiceImpl.get().getExpProtocolApplication(form.getRowId()); + if (_app == null) + { + throw new NotFoundException("Could not find Protocol Application"); + } + _run = _app.getRun(); + if (_run == null) + { + throw new NotFoundException("No experiment run associated with Protocol Application"); + } + ensureCorrectContainer(getContainer(), _app, getViewContext()); + + ExpProtocol protocol = _app.getProtocol(); + + DataRegion dr = new DataRegion(); + dr.addColumns(ExperimentServiceImpl.get().getTinfoProtocolApplication().getUserEditableColumns()); + DetailsView detailsView = new DetailsView(dr, form.getRowId()); + dr.removeColumns("RunId", "ProtocolLSID", "RowId", "LSID"); + dr.addDisplayColumn(new ExperimentRunDisplayColumn(_run)); + dr.addDisplayColumn(new ProtocolDisplayColumn(protocol)); + dr.addDisplayColumn(new LineageGraphDisplayColumn(_app, _run)); + detailsView.setTitle("Protocol Application"); + + Container c = getContainer(); + ApplicationOutputGrid outMGrid = new ApplicationOutputGrid(c, _app.getRowId(), ExperimentServiceImpl.get().getTinfoMaterial()); + ApplicationOutputGrid outDGrid = new ApplicationOutputGrid(c, _app.getRowId(), ExperimentServiceImpl.get().getTinfoData()); + Map map = new HashMap<>(); + for (ProtocolApplicationParameter param : ExperimentService.get().getProtocolApplicationParameters(_app.getRowId())) + { + map.put(param.getOntologyEntryURI(), param); + } + + JspView> paramsView = new JspView<>("/org/labkey/experiment/Parameters.jsp", map); + paramsView.setTitle("Protocol Application Parameters"); + CustomPropertiesView cpv = new CustomPropertiesView(_app.getLSID(), c); + return new VBox(new StandardAndCustomPropertiesView(detailsView, cpv), paramsView, outMGrid, outDGrid); + } + + @Override + public void addNavTrail(NavTree root) + { + addRootNavTrail(root); + root.addChild("Experiment Run", ExperimentUrlsImpl.get().getRunGraphDetailURL(_run)); + root.addChild("Protocol Application " + _app.getName()); + } + } + + @RequiresPermission(ReadPermission.class) + public class ShowProtocolGridAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object o, BindException errors) + { + return new ProtocolWebPart(false, getViewContext()); + } + + @Override + public void addNavTrail(NavTree root) + { + addRootNavTrail(root); + root.addChild("Protocols"); + } + } + + @RequiresPermission(ReadPermission.class) + public class ProtocolDetailsAction extends SimpleViewAction + { + private ExpProtocolImpl _protocol; + + @Override + public ModelAndView getView(ExpObjectForm form, BindException errors) + { + _protocol = ExperimentServiceImpl.get().getExpProtocol(form.getRowId()); + if (_protocol == null) + { + _protocol = ExperimentServiceImpl.get().getExpProtocol(form.getLSID()); + } + + if (_protocol == null) + { + throw new NotFoundException("Unable to find a matching protocol"); + } + ensureCorrectContainer(getContainer(), _protocol, getViewContext()); + + JspView detailsView = new JspView<>("/org/labkey/experiment/ProtocolDetails.jsp", _protocol); + detailsView.setTitle("Standard Properties"); + + CustomPropertiesView cpv = new CustomPropertiesView(_protocol.getLSID(), getContainer()); + ProtocolParametersView parametersView = new ProtocolParametersView(_protocol); + + VBox protocolDetails = new VBox(); + protocolDetails.setFrame(WebPartView.FrameType.PORTAL); + protocolDetails.setTitle("Protocol Details"); + protocolDetails.addView(new ProtocolInputOutputsView(_protocol, errors)); + + JspView stepsView = new JspView<>("/org/labkey/experiment/ProtocolSteps.jsp", _protocol); + stepsView.setTitle("Protocol Steps"); + stepsView.setFrame(WebPartView.FrameType.TITLE); + protocolDetails.addView(stepsView); + + ExpSchema schema = new ExpSchema(getUser(), getContainer()); + ExperimentRunListView runView = new ExperimentRunListView(schema, ExperimentRunListView.getRunListQuerySettings(schema, getViewContext(), ExpSchema.TableType.Runs.name(), true), ExperimentRunType.ALL_RUNS_TYPE) + { + @Override + public DataView createDataView() + { + DataView result = super.createDataView(); + result.getRenderContext().setBaseFilter(new SimpleFilter(FieldKey.fromParts("Protocol", "LSID"), _protocol.getLSID())); + return result; + } + }; + + runView.setTitle("Runs Using This Protocol"); + + return new VBox(new StandardAndCustomPropertiesView(detailsView, cpv), parametersView, protocolDetails, runView); + } + + @Override + public void addNavTrail(NavTree root) + { + addRootNavTrail(root); + root.addChild("Protocols", ExperimentUrlsImpl.get().getProtocolGridURL(getContainer())); + root.addChild("Protocol: " + _protocol.getName()); + } + } + + public class ProtocolInputOutputsView extends VBox + { + ProtocolInputOutputsView(ExpProtocol protocol, Errors errors) + { + HBox inputsView = new HBox(); + addView(inputsView); + + HBox outputsView = new HBox(); + addView(outputsView); + + UserSchema expSchema = QueryService.get().getUserSchema(getUser(), getContainer(), ExpSchema.SCHEMA_NAME); + + class ProtocolInputGrid extends QueryView + { + public ProtocolInputGrid(String title, QuerySettings settings, @Nullable Errors errors) + { + super(expSchema, settings, errors); + + setFrame(FrameType.TITLE); + setTitle(title); + setButtonBarPosition(DataRegion.ButtonBarPosition.NONE); + setShowBorders(true); + setShadeAlternatingRows(true); + setShowExportButtons(false); + setShowPagination(false); + disableContainerFilterSelection(); + } + } + + // INPUTS + + QuerySettings materialInputsSettings = expSchema.getSettings("mpi", ExpSchema.TableType.MaterialProtocolInputs.toString()); + materialInputsSettings.getBaseFilter().addCondition(FieldKey.fromParts(ExpMaterialProtocolInputTable.Column.Protocol.toString()), protocol.getRowId()); + materialInputsSettings.setFieldKeys(Arrays.asList( + FieldKey.fromParts(ExpMaterialProtocolInputTable.Column.Name.toString()), + FieldKey.fromParts(ExpMaterialProtocolInputTable.Column.SampleSet.toString()) + )); + QueryView materialInputsView = new ProtocolInputGrid("Material Inputs", materialInputsSettings, errors); + inputsView.addView(materialInputsView); + + QuerySettings dataInputsSettings = expSchema.getSettings("dpi", ExpSchema.TableType.DataProtocolInputs.toString()); + dataInputsSettings.getBaseFilter().addCondition(FieldKey.fromParts(ExpDataProtocolInputTable.Column.Protocol.toString()), protocol.getRowId()); + dataInputsSettings.setFieldKeys(Arrays.asList( + FieldKey.fromParts(ExpDataProtocolInputTable.Column.Name.toString()), + FieldKey.fromParts(ExpDataProtocolInputTable.Column.DataClass.toString()) + )); + QueryView dataInputsView = new ProtocolInputGrid("Data Inputs", dataInputsSettings, errors); + inputsView.addView(dataInputsView); + + // OUTPUTS + + QuerySettings materialOutputsSettings = expSchema.getSettings("mpo", ExpSchema.TableType.MaterialProtocolInputs.toString()); + materialOutputsSettings.getBaseFilter().addCondition(FieldKey.fromParts(ExpMaterialProtocolInputTable.Column.Protocol.toString()), protocol.getRowId()); + materialOutputsSettings.setFieldKeys(Arrays.asList( + FieldKey.fromParts(ExpMaterialProtocolInputTable.Column.Name.toString()), + FieldKey.fromParts(ExpMaterialProtocolInputTable.Column.SampleSet.toString()) + )); + QueryView materialOutputsView = new ProtocolInputGrid("Material Outputs", materialOutputsSettings, errors); + outputsView.addView(materialOutputsView); + + QuerySettings dataOutputsSettings = expSchema.getSettings("dpo", ExpSchema.TableType.DataProtocolInputs.toString()); + dataOutputsSettings.getBaseFilter().addCondition(FieldKey.fromParts(ExpDataProtocolInputTable.Column.Protocol.toString()), protocol.getRowId()); + dataOutputsSettings.setFieldKeys(Arrays.asList( + FieldKey.fromParts(ExpDataProtocolInputTable.Column.Name.toString()), + FieldKey.fromParts(ExpDataProtocolInputTable.Column.DataClass.toString()) + )); + QueryView dataOutputsView = new ProtocolInputGrid("Data Outputs", dataOutputsSettings, errors); + outputsView.addView(dataOutputsView); + } + } + + + @RequiresPermission(ReadPermission.class) + public class ProtocolPredecessorsAction extends SimpleViewAction + { + private ExpProtocol _parentProtocol; + private ProtocolActionStepDetail _actionStep; + + @Override + public ModelAndView getView(Object o, BindException errors) + { + ActionURL url = getViewContext().getActionURL(); + + String parentProtocolLSID = url.getParameter("ParentLSID"); + int actionSequence; + try + { + actionSequence = Integer.parseInt(url.getParameter("Sequence")); + } + catch (NumberFormatException e) + { + throw new NotFoundException("Could not find SequenceId " + url.getParameter("Sequence")); + } + + _parentProtocol = ExperimentService.get().getExpProtocol(parentProtocolLSID); + if (_parentProtocol == null) + { + throw new NotFoundException("Unable to find a matching protocol"); + } + + ensureCorrectContainer(getContainer(), _parentProtocol, getViewContext()); + + _actionStep = ExperimentServiceImpl.get().getProtocolActionStepDetail(parentProtocolLSID, actionSequence); + + if (_actionStep == null) + { + throw new NotFoundException("Unable to find a matching protocol action step"); + } + + ExpProtocol childProtocol = ExperimentService.get().getExpProtocol(_actionStep.getChildProtocolLSID()); + + JspView detailsView = new JspView<>("/org/labkey/experiment/ProtocolDetails.jsp", childProtocol); + detailsView.setTitle("Standard Properties"); + + CustomPropertiesView cpv = new CustomPropertiesView(childProtocol.getLSID(), getContainer()); + + ProtocolParametersView parametersView = new ProtocolParametersView(childProtocol); + + VBox protocolDetails = new VBox(); + protocolDetails.setFrame(WebPartView.FrameType.PORTAL); + protocolDetails.setTitle("Protocol Details"); + protocolDetails.addView(new ProtocolInputOutputsView(childProtocol, errors)); + protocolDetails.addView(new ProtocolSuccessorPredecessorView(parentProtocolLSID, actionSequence, getContainer(), "PredecessorChildLSID", "PredecessorSequence", "ActionSequence", "Protocol Predecessors")); + protocolDetails.addView(new ProtocolSuccessorPredecessorView(parentProtocolLSID, actionSequence, getContainer(), "ChildProtocolLSID", "ActionSequence", "PredecessorSequence", "Protocol Successors")); + + return new VBox(new StandardAndCustomPropertiesView(detailsView, cpv), parametersView, protocolDetails); + } + + @Override + public void addNavTrail(NavTree root) + { + addRootNavTrail(root); + root.addChild("Protocols", ExperimentUrlsImpl.get().getProtocolGridURL(getContainer())); + root.addChild("Parent Protocol '" + _parentProtocol.getName() + "'", ExperimentUrlsImpl.get().getProtocolDetailsURL(_parentProtocol)); + root.addChild("Protocol Step: " + _actionStep.getName()); + } + } + + public static class DataForm + { + private boolean _inline; + private long _rowId; + private String _lsid; + private Integer _maxDimension; + private String _format; + + public boolean isInline() + { + return _inline; + } + + public void setInline(boolean inline) + { + _inline = inline; + } + + public long getRowId() + { + return _rowId; + } + + public void setRowId(long rowId) + { + _rowId = rowId; + } + + public String getLsid() + { + return _lsid; + } + + public void setLsid(String lsid) + { + _lsid = lsid; + } + + public ExpDataImpl lookupData() + { + ExpDataImpl result = ExperimentServiceImpl.get().getExpData(getRowId()); + if (result == null && getLsid() != null) + { + result = ExperimentServiceImpl.get().getExpData(getLsid()); + } + return result; + } + + public Integer getMaxDimension() + { + return _maxDimension; + } + + public void setMaxDimension(Integer maxDimension) + { + _maxDimension = maxDimension; + } + + public String getFormat() + { + return _format; + } + + public void setFormat(String format) + { + _format = format; + } + } + + public static class ExpObjectForm extends QueryViewAction.QueryExportForm + { + private long _rowId; + private String _lsid; + + public String getLsid() + { + return _lsid; + } + + public void setLsid(String lsid) + { + _lsid = lsid; + } + + public String getLSID() + { + return getLsid(); + } + + public void setLSID(String lsid) + { + setLsid(lsid); + } + + public long getRowId() + { + return _rowId; + } + + public void setRowId(long rowId) + { + _rowId = rowId; + } + } + + @RequiresAnyOf({DeletePermission.class, SampleWorkflowDeletePermission.class}) + public class DeleteSelectedExpRunsAction extends AbstractDeleteAction + { + @Override + public void addNavTrail(NavTree root) + { + // UNDONE: Need help topic on Runs + setHelpTopic("experiment"); + super.addNavTrail(root); + } + + @Override + public ModelAndView getView(DeleteForm deleteForm, boolean reshow, BindException errors) + { + List runs = new ArrayList<>(); + + Map idToRunMap = new LongHashMap<>(); + for (long runId : deleteForm.getIds(false)) + { + ExpRun run = ExperimentService.get().getExpRun(runId); + if (run != null) + { + if (!run.canDelete(getUser())) + throw new UnauthorizedException("You do not have permission to delete " + + (ExpProtocol.isSampleWorkflowProtocol(run.getProtocol().getLSID()) ? "jobs" : "runs") + + " in " + run.getContainer()); + + runs.add(run); + idToRunMap.put(run.getRowId(), run); + } + } + + Map referencedItems = new LongHashMap<>(); + List referenceDescriptions = new ArrayList<>(); + AssayService assayService = AssayService.get(); + if (!idToRunMap.isEmpty() && assayService != null ) + { + // using the first run as a representative, since all interactions here are (I believe) using the same protocol. + ExpProtocol protocol = runs.get(0).getProtocol(); + AssayProvider provider = assayService.getProvider(protocol); + if (provider != null) + { + SchemaKey key = AssayProtocolSchema.schemaName(provider, protocol); + ExperimentService.get().getObjectReferencers() + .forEach(referencer -> { + Collection referenced = referencer.getItemsWithReferences( + idToRunMap.keySet(), + key.toString(), + "Runs" + ); + referenced.forEach(id -> referencedItems.put(id, idToRunMap.get(id))); + referenceDescriptions.add(referencer.getObjectReferenceDescription(ExpRun.class)); + } + ); + } + + } + + List> permissionDatasetRows = new ArrayList<>(); + List> noPermissionDatasetRows = new ArrayList<>(); + if (StudyPublishService.get() != null) + { + for (Dataset dataset : StudyPublishService.get().getDatasetsForAssayRuns(runs, getUser())) + { + ActionURL url = urlProvider(StudyUrls.class).getDatasetURL(dataset.getContainer(), dataset.getDatasetId()); + TableInfo t = dataset.getTableInfo(getUser()); + if (null != t && t.hasPermission(getUser(),DeletePermission.class)) + { + permissionDatasetRows.add(new Pair<>(dataset, url)); + } + else + { + noPermissionDatasetRows.add(new Pair<>(dataset, url)); + } + } + } + + return new ConfirmDeleteView( + "run", + ShowRunGraphAction.class, + runs.stream().filter(run -> !referencedItems.containsKey(run.getRowId())).toList(), + deleteForm, + Collections.emptyList(), + "dataset(s) have one or more rows which", + permissionDatasetRows, + noPermissionDatasetRows, + referencedItems.values().stream().toList(), + referenceDescriptions.stream().filter(Objects::nonNull).collect(Collectors.joining(", or "))); + } + + @Override + protected void deleteObjects(DeleteForm deleteForm) + { + ExperimentServiceImpl.get().deleteExperimentRunsByRowIds(getContainer(), getUser(), deleteForm.getUserComment(), deleteForm.getIds(false)); + } + } + + public static class DeleteRunForm + { + private int _runId; + + public int getRunId() + { + return _runId; + } + + public void setRunId(int runId) + { + _runId = runId; + } + } + + /** + * Separate delete action from the client API + */ + @RequiresAnyOf({DeletePermission.class, SampleWorkflowDeletePermission.class}) + public static class DeleteRunAction extends MutatingApiAction + { + @Override + public ApiResponse execute(DeleteRunForm form, BindException errors) + { + ExpRun run = ExperimentService.get().getExpRun(form.getRunId()); + if (run == null) + { + throw new NotFoundException("Could not find run with ID " + form.getRunId()); + } + if (!run.canDelete(getUser())) + throw new UnauthorizedException("You do not have permission to delete " + + (ExpProtocol.isSampleWorkflowProtocol(run.getProtocol().getLSID()) ? "jobs" : "runs") + " in this container."); + + run.delete(getUser()); + return new ApiSimpleResponse("success", true); + } + } + + + @RequiresAnyOf({DeletePermission.class, SampleWorkflowDeletePermission.class}) + public static class DeleteRunsAction extends AbstractDeleteAPIAction + { + @Override + protected ApiSimpleResponse deleteObjects(CascadeDeleteForm form) + { + Set runIdsToDelete = new HashSet<>(form.getIds(true)); + Set runIdsCascadeDeleted = new HashSet<>(); + + if (form.isCascade()) + { + for (long runId : runIdsToDelete) + { + ExpRun run = ExperimentService.get().getExpRun(runId); + if (run != null) + addReplacesRuns(run, runIdsCascadeDeleted); + } + + if (!runIdsCascadeDeleted.isEmpty()) + runIdsToDelete.addAll(runIdsCascadeDeleted); + } + + ExperimentService.get().deleteExperimentRunsByRowIds(getContainer(), getUser(), form.getUserComment(), runIdsToDelete); + + ApiSimpleResponse response = new ApiSimpleResponse("success", true); + response.put("runIdsDeleted", runIdsToDelete); + if (!runIdsCascadeDeleted.isEmpty()) + response.put("runIdsCascadeDeleted", runIdsCascadeDeleted); + return response; + } + + private void addReplacesRuns(ExpRun run, Set runIds) + { + for (ExpRun replacedRun : run.getReplacesRuns()) + { + runIds.add(replacedRun.getRowId()); + addReplacesRuns(replacedRun, runIds); + } + } + } + + private abstract static class AbstractDeleteAPIAction extends MutatingApiAction + { + @Override + public void validateForm(CascadeDeleteForm form, Errors errors) + { + if (form.getSingleObjectRowId() == null && form.getDataRegionSelectionKey() == null && form.getRowIds() == null) + errors.reject(ERROR_REQUIRED, "Either singleObjectRowId, dataRegionSelectionKey, or rowIds is required"); + } + + @Override + public ApiResponse execute(CascadeDeleteForm form, BindException errors) throws Exception + { + ApiSimpleResponse response; + + try (DbScope.Transaction tx = ExperimentService.get().ensureTransaction()) + { + tx.addCommitTask(form::clearSelected, POSTCOMMIT); + + response = deleteObjects(form); + tx.commit(); + } + + if (null != response.get("success")) + response.put("success", !errors.hasErrors()); + + return response; + } + + protected abstract ApiSimpleResponse deleteObjects(CascadeDeleteForm form) throws Exception; + } + + public static class CascadeDeleteForm extends DeleteForm + { + private boolean _cascade; + + public boolean isCascade() + { + return _cascade; + } + + public void setCascade(boolean cascade) + { + _cascade = cascade; + } + } + + private abstract static class AbstractDeleteAction extends FormViewAction + { + @Override + public void validateCommand(DeleteForm target, Errors errors) + { + } + + @Override + public boolean handlePost(DeleteForm deleteForm, BindException errors) throws Exception + { + if (!deleteForm.isForceDelete()) + { + return false; + } + else + { + try (DbScope.Transaction tx = ExperimentService.get().ensureTransaction()) + { + tx.addCommitTask(deleteForm::clearSelected, POSTCOMMIT); + + deleteObjects(deleteForm); + tx.commit(); + } + catch (BatchValidationException v) + { + v.addToErrors(errors); + } + + return !errors.hasErrors(); + } + } + + @Override + public ActionURL getSuccessURL(DeleteForm form) + { + return form.getSuccessActionURL(ExperimentUrlsImpl.get().getOverviewURL(getContainer())); + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("Confirm Deletion"); + } + + protected abstract void deleteObjects(DeleteForm form) throws Exception; + } + + @RequiresPermission(DesignAssayPermission.class) + public static class DeleteProtocolByRowIdsAPIAction extends AbstractDeleteAPIAction + { + @Override + protected ApiSimpleResponse deleteObjects(CascadeDeleteForm form) + { + for (ExpProtocol protocol : getProtocolsForDeletion(form)) + { + if (!protocol.getContainer().hasPermission(getUser(), DesignAssayPermission.class)) + throw new UnauthorizedException("You do not have sufficient permissions to delete this assay design."); + + protocol.delete(getUser(), form.getUserComment()); + } + + return new ApiSimpleResponse(); + } + } + + public static List getProtocolsForDeletion(DeleteForm form) + { + List protocols = new ArrayList<>(); + for (long protocolId : form.getIds(false)) + { + ExpProtocol protocol = ExperimentService.get().getExpProtocol(protocolId); + if (protocol != null) + { + protocols.add(protocol); + } + } + return protocols; + } + + @RequiresPermission(DesignAssayPermission.class) + public class DeleteProtocolByRowIdsAction extends AbstractDeleteAction + { + @Override + public void addNavTrail(NavTree root) + { + // UNDONE: Need help topic on protocols + setHelpTopic("experiment"); + super.addNavTrail(root); + } + + @Override + public ModelAndView getView(DeleteForm form, boolean reshow, BindException errors) + { + List runs = ExperimentService.get().getExpRunsForProtocolIds(false, form.getIds(false)); + List protocols = getProtocolsForDeletion(form); + String noun = "Assay Design"; + List> deleteableDatasets = new ArrayList<>(); + List> noPermissionDatasets = new ArrayList<>(); + if (AssayService.get() != null && StudyService.get() != null) + { + for (ExpProtocol protocol : protocols) + { + if (!protocol.getContainer().hasPermission(getUser(), DesignAssayPermission.class)) + throw new UnauthorizedException("You do not have sufficient permissions to delete this assay design."); + + if (AssayService.get().getProvider(protocol) == null) + { + noun = "Protocol"; + } + for (Dataset dataset : StudyPublishService.get().getDatasetsForPublishSource(protocol.getRowId(), Dataset.PublishSource.Assay)) + { + Pair entry = new Pair<>(dataset, urlProvider(StudyUrls.class).getDatasetURL(dataset.getContainer(), dataset.getDatasetId())); + if (dataset.canDeleteDefinition(getUser())) + { + deleteableDatasets.add(entry); + } + else + { + noPermissionDatasets.add(entry); + } + } + } + } + + return new ConfirmDeleteView(noun, ProtocolDetailsAction.class, protocols, form, runs, "Dataset", deleteableDatasets, noPermissionDatasets, Collections.emptyList(), null); + } + + @Override + protected void deleteObjects(DeleteForm form) + { + for (ExpProtocol protocol : getProtocolsForDeletion(form)) + { + protocol.delete(getUser(), form.getUserComment()); + } + } + } + + @Marshal(Marshaller.Jackson) + @RequiresPermission(ReadPermission.class) + public static class GetDataOperationConfirmationDataAction extends ReadOnlyApiAction + { + @Override + public void validateForm(DataOperationConfirmationForm form, Errors errors) + { + if (form.getDataRegionSelectionKey() == null && form.getRowIds() == null) + errors.reject(ERROR_REQUIRED, "You must provide either a set of rowIds or a dataRegionSelectionKey"); + if (form.getDataOperation() == null) + errors.reject(ERROR_REQUIRED, "An operation type must be provided."); + } + + @Override + public Object execute(DataOperationConfirmationForm form, BindException errors) + { + Collection requestIds = form.getIds(false); + ExperimentServiceImpl service = ExperimentServiceImpl.get(); + List allData = service.getExpDatas(requestIds); + + Set notAllowedIds = new HashSet<>(); + if (form.getDataOperation() == ExpDataImpl.DataOperations.Delete) + service.getObjectReferencers().forEach(referencer -> + notAllowedIds.addAll(referencer.getItemsWithReferences(requestIds, "exp.data"))); + + Map>> response = ExperimentServiceImpl.partitionRequestedOperationObjects(getUser(), requestIds, notAllowedIds, allData); + + Collection containers = new HashSet<>(); + Collection notPermittedIds = new ArrayList<>(); + Class permClass = form.getDataOperation().getPermissionClass(); + for (ExpDataImpl expData : allData) + { + Container c = expData.getContainer(); + if (c.hasPermission(getUser(), ReadPermission.class)) + containers.add(c); + if (permClass != null && !c.hasPermission(getUser(), permClass)) + notPermittedIds.add(expData.getRowId()); + } + + NameExpressionOptionService svc = NameExpressionOptionService.get(); + response.put("containers", containers.stream().map(c -> Map.of( + "id", c.getEntityId(), + "path", (Object) c.getPath(), + "permitted", permClass == null || c.hasPermission(getUser(), permClass), + "canEditName", svc.getAllowUserSpecificNamesValue(c) + )).toList()); + + response.put("notPermitted", notPermittedIds.stream().map(id -> Map.of("RowId", (Object) id)).toList()); + + return success(response); + } + } + + + public static class DataOperationConfirmationForm extends DataViewSnapshotSelectionForm + { + private ExpDataImpl.DataOperations _dataOperation; + + public ExpDataImpl.DataOperations getDataOperation() + { + return _dataOperation; + } + + public void setDataOperation(ExpDataImpl.DataOperations dataOperation) + { + _dataOperation = dataOperation; + } + + } + + @Marshal(Marshaller.Jackson) + @RequiresPermission(ReadPermission.class) + public static class GetMaterialOperationConfirmationDataAction extends ReadOnlyApiAction + { + @Override + public void validateForm(MaterialOperationConfirmationForm form, Errors errors) + { + if (form.getDataRegionSelectionKey() == null && form.getRowIds() == null) + errors.reject(ERROR_REQUIRED, "You must provide either a set of rowIds or a dataRegionSelectionKey."); + if (form.getSampleOperation() == null) + errors.reject(ERROR_REQUIRED, "An operation type must be provided."); + } + + @Override + public Object execute(MaterialOperationConfirmationForm form, BindException errors) + { + Set requestIds = form.getIds(false); + ExperimentServiceImpl service = ExperimentServiceImpl.get(); + List allMaterials = service.getExpMaterials(requestIds); + + Set notAllowedIds = new HashSet<>(); + // We prevent deletion if a sample is used as a parent, has assay data, is used in a job, etc. + if (form.getSampleOperation() == SampleTypeService.SampleOperations.Delete) + service.getObjectReferencers().forEach(referencer -> + notAllowedIds.addAll(referencer.getItemsWithReferences(requestIds, "samples"))); + + if (SampleStatusService.get().supportsSampleStatus()) + notAllowedIds.addAll(service.findIdsNotPermittedForOperation(allMaterials, form.getSampleOperation())); + + Map>> response = ExperimentServiceImpl.partitionRequestedOperationObjects(getUser(), requestIds, notAllowedIds, allMaterials); + + Collection containers = new HashSet<>(); + Collection notPermittedIds = new ArrayList<>(); + Class permClass = form.getSampleOperation().getPermissionClass(); + for (ExpMaterial material : allMaterials) + { + Container c = material.getContainer(); + if (c.hasPermission(getUser(), ReadPermission.class)) + containers.add(c); + if (permClass != null && !c.hasPermission(getUser(), permClass)) + notPermittedIds.add(material.getRowId()); + } + + NameExpressionOptionService svc = NameExpressionOptionService.get(); + + response.put("containers", containers.stream().map(c -> Map.of( + "id", c.getEntityId(), + "path", (Object) c.getPath(), + "permitted", permClass == null || c.hasPermission(getUser(), permClass), + "canEditName", svc.getAllowUserSpecificNamesValue(c) + )).toList()); + + response.put("notPermitted", notPermittedIds.stream().map(id -> Map.of("RowId", (Object) id)).toList()); + + if (form.getSampleOperation() == SampleTypeService.SampleOperations.Delete) + // String 'associatedDatasets' must be synced to its handling in confirmDelete.js, confirmDelete() + response.put("associatedDatasets", ExperimentServiceImpl.includeLinkedToStudyText(allMaterials, requestIds, getUser(), getContainer())); + + return success(response); + } + } + + public static class MaterialOperationConfirmationForm extends DataViewSnapshotSelectionForm + { + private SampleTypeService.SampleOperations _sampleOperation; + + public SampleTypeService.SampleOperations getSampleOperation() + { + return _sampleOperation; + } + + public void setSampleOperation(SampleTypeService.SampleOperations sampleOperation) + { + _sampleOperation = sampleOperation; + } + } + + @RequiresPermission(DeletePermission.class) + public class DeleteSelectedDataAction extends AbstractDeleteAction + { + @Override + public void addNavTrail(NavTree root) + { + // UNDONE: Need help topic on Datas + setHelpTopic("experiment"); + super.addNavTrail(root); + } + + @Override + protected void deleteObjects(DeleteForm deleteForm) throws Exception + { + List datas = getDatas(deleteForm, false); + + for (ExpRun run : getRuns(datas)) + { + if (!run.getContainer().hasPermission(getUser(), DeletePermission.class)) + throw new UnauthorizedException(); + } + + // Issue 32076: Delete the exp.Data objects using QueryUpdateService so trigger scripts will be executed + Map, List> byDataClass = datas.stream().collect(Collectors.groupingBy(d -> Optional.ofNullable(d.getDataClass(null)))); + for (Optional opt : byDataClass.keySet()) + { + SchemaKey schemaKey; + String queryName; + ExpDataClass dc = opt.orElse(null); + List ds = byDataClass.get(opt); + if (dc == null) + { + // Reference to exp.Data table + schemaKey = ExpSchema.SCHEMA_EXP; + queryName = ExpSchema.TableType.Data.name(); + } + else + { + // Reference to exp.data. table + schemaKey = ExpSchema.SCHEMA_EXP_DATA; + queryName = dc.getName(); + } + + UserSchema schema = QueryService.get().getUserSchema(getUser(), getContainer(), schemaKey); + if (schema == null) + throw new IllegalStateException("Failed to get schema '" + schemaKey + "'"); + + TableInfo table = schema.getTable(queryName); + if (table == null) + throw new IllegalStateException("Failed to get table '" + queryName + "' in schema '" + schemaKey + "'"); + + QueryUpdateService qus = table.getUpdateService(); + if (qus == null) + throw new IllegalStateException(); + + qus.deleteRows(getUser(), getContainer(), toKeys(ds), null, null); + } + } + + protected List> toKeys(List datas) + { + return datas.stream().map(d -> CaseInsensitiveHashMap.of("rowId", d.getRowId())).collect(toList()); + } + + @Override + public ModelAndView getView(DeleteForm deleteForm, boolean reshow, BindException errors) + { + if (errors.hasErrors()) + return new SimpleErrorView(errors, false); + + List datas = getDatas(deleteForm, false); + List runs = getRuns(datas); + + return new ConfirmDeleteView("Data", ShowDataAction.class, datas, deleteForm, runs); + } + + private List getRuns(List datas) + { + List runArray = ExperimentService.get().getRunsUsingDatas(datas); + return new ArrayList<>(ExperimentService.get().runsDeletedWithInput(runArray)); + } + + private List getDatas(DeleteForm deleteForm, boolean clear) + { + List datas = new ArrayList<>(); + for (long dataId : deleteForm.getIds(clear)) + { + ExpData data = ExperimentService.get().getExpData(dataId); + if (data != null) + { + datas.add(data); + } + } + return datas; + } + } + + @RequiresPermission(DeletePermission.class) + public class DeleteSelectedExperimentsAction extends AbstractDeleteAction + { + @Override + protected void deleteObjects(DeleteForm deleteForm) + { + for (ExpExperiment exp : lookupExperiments(deleteForm)) + { + exp.delete(getUser()); + } + } + + @Override + public ModelAndView getView(DeleteForm deleteForm, boolean reshow, BindException errors) + { + List experiments = lookupExperiments(deleteForm); + + List runs = new ArrayList<>(); + boolean allBatches = true; + for (ExpExperiment experiment : experiments) + { + // Deleting a batch also deletes all of its runs + if (experiment.getBatchProtocol() != null) + { + runs.addAll(experiment.getRuns()); + } + else + { + allBatches = false; + } + } + + return new ConfirmDeleteView(allBatches ? "batch" : "run group", DetailsAction.class, experiments, deleteForm, runs); + } + + private List lookupExperiments(DeleteForm deleteForm) + { + List experiments = new ArrayList<>(); + for (long experimentId : deleteForm.getIds(false)) + { + ExpExperiment experiment = ExperimentService.get().getExpExperiment(experimentId); + if (experiment != null) + { + experiments.add(experiment); + } + } + return experiments; + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("runGroups"); + super.addNavTrail(root); + } + } + + @RequiresPermission(DesignSampleTypePermission.class) + public class DeleteSampleTypesAction extends AbstractDeleteAction + { + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("sampleSets"); + super.addNavTrail(root); + } + + @Override + protected void deleteObjects(DeleteForm deleteForm) + { + List sampleTypes = getSampleTypes(deleteForm); + if (sampleTypes.isEmpty()) + { + throw new NotFoundException("No sample types found for ids provided."); + } + if (!ensureCorrectContainer(sampleTypes)) + { + throw new UnauthorizedException(); + } + + for (ExpRun run : getRuns(sampleTypes)) + { + if (!run.getContainer().hasPermission(getUser(), DeletePermission.class)) + { + throw new UnauthorizedException(); + } + } + + for (ExpSampleType source : sampleTypes) + { + Domain domain = source.getDomain(); + if (!domain.getDomainKind().canDeleteDefinition(getUser(), domain)) + { + throw new UnauthorizedException(); + } + + source.delete(getUser(), deleteForm.getUserComment()); + } + } + + @Override + public ModelAndView getView(DeleteForm deleteForm, boolean reshow, BindException errors) + { + List sampleTypes = getSampleTypes(deleteForm); + if (!ensureCorrectContainer(sampleTypes)) + { + throw new RedirectException(ExperimentUrlsImpl.get().getShowSampleTypeListURL(getContainer(), "To delete a sample type, you must be in its folder or project.")); + } + + List> deleteableDatasets = new ArrayList<>(); + List> noPermissionDatasets = new ArrayList<>(); + if (StudyService.get() != null && StudyPublishService.get() != null) + { + for (ExpSampleType sampleType: sampleTypes) + { + for (Dataset dataset : StudyPublishService.get().getDatasetsForPublishSource(sampleType.getRowId(), Dataset.PublishSource.SampleType)) + { + ActionURL datasetURL = StudyService.get().getDatasetURL(getContainer(), dataset.getDatasetId()); + Pair entry = new Pair<>(dataset, datasetURL); + if (dataset.canDeleteDefinition(getUser())) + { + deleteableDatasets.add(entry); + } + else + { + noPermissionDatasets.add(entry); + } + } + } + } + return new ConfirmDeleteView("Sample Type", ShowSampleTypeAction.class, sampleTypes, deleteForm, getRuns(sampleTypes), "Dataset", deleteableDatasets, noPermissionDatasets, Collections.emptyList(), null); + } + + private List getSampleTypes(DeleteForm deleteForm) + { + List sources = new ArrayList<>(); + for (long rowId : deleteForm.getIds(false)) + { + ExpSampleType sampleType = SampleTypeService.get().getSampleType(getContainer(), getUser(), rowId); + if (sampleType != null) + { + sources.add(sampleType); + } + } + return sources; + } + + private boolean ensureCorrectContainer(List sampleTypes) + { + for (ExpSampleType source : sampleTypes) + { + Container sourceContainer = source.getContainer(); + if (!sourceContainer.equals(getContainer())) + { + return false; + } + } + return true; + } + + private List getRuns(List sampleTypes) + { + if (!sampleTypes.isEmpty()) + { + List runArray = ExperimentService.get().getRunsUsingSampleTypes(sampleTypes.toArray(new ExpSampleType[0])); + return ExperimentService.get().runsDeletedWithInput(runArray); + } + else + { + return Collections.emptyList(); + } + } + } + + private DataRegion getSampleTypeRegion(ViewContext model) + { + TableInfo tableInfo = ExperimentServiceImpl.get().getTinfoSampleType(); + + QuerySettings settings = new QuerySettings(model, "SampleType"); + settings.setSelectionKey(DataRegionSelection.getSelectionKey(tableInfo.getSchema().getName(), tableInfo.getName(), "SampleType", settings.getDataRegionName())); + + DataRegion dr = new DataRegion(); + dr.setSettings(settings); + dr.addColumns(tableInfo.getUserEditableColumns()); + dr.removeColumns("lastindexed"); + dr.getDisplayColumn(0).setVisible(false); + + dr.getDisplayColumn("idcol1").setVisible(false); + dr.getDisplayColumn("idcol2").setVisible(false); + dr.getDisplayColumn("idcol3").setVisible(false); + dr.getDisplayColumn("lsid").setVisible(false); + dr.getDisplayColumn("materiallsidprefix").setVisible(false); + dr.getDisplayColumn("parentcol").setVisible(false); + + ActionURL url = new ActionURL(ShowSampleTypeAction.class, model.getContainer()); + dr.getDisplayColumn(1).setURL(url.addParameter("rowId", "${RowId}")); + dr.setShowRecordSelectors(getContainer().hasOneOf(getUser(), DeletePermission.class, UpdatePermission.class)); + + return dr; + } + + @RequiresPermission(ReadPermission.class) + @ActionNames("getSampleType,getSampleTypeApi") // Referenced in labkey-ui-components components/samples/actions.ts TODO: migrate getSampleTypeApi -> getSampleType + public static class GetSampleTypeAction extends ReadOnlyApiAction + { + @Override + public void validateForm(SampleTypeForm form, Errors errors) + { + if (form.getRowId() == null && form.getLSID() == null) + errors.reject(ERROR_REQUIRED, "RowId or LSID must be provided"); + } + + @Override + public Object execute(SampleTypeForm form, BindException errors) throws Exception + { + ExpSampleTypeImpl st = form.getSampleType(getContainer()); + + return getSampleTypeResponse(st); + } + } + + @NotNull + private static ApiSimpleResponse getSampleTypeResponse(ExpSampleType st) throws IOException + { + Map sampleType = new HashMap<>(); + sampleType.put("name", st.getName()); + sampleType.put("nameExpression", st.getNameExpression()); + sampleType.put("labelColor", st.getLabelColor()); + sampleType.put("metricUnit", st.getMetricUnit()); + sampleType.put("description", st.getDescription()); + sampleType.put("importAliases", st.getImportAliasMap()); + sampleType.put("lsid", st.getLSID()); + sampleType.put("rowId", st.getRowId()); + sampleType.put("domainId", st.getDomain().getTypeId()); + sampleType.put("category", st.getCategory()); + + return new ApiSimpleResponse(Map.of("sampleSet", sampleType, "success", true)); + } + + public static class DataTypesWithRequiredLineageForm + { + private Integer _parentDataTypeRowId; + private boolean _sampleParent; + + public Integer getParentDataTypeRowId() + { + return _parentDataTypeRowId; + } + + public void setParentDataTypeRowId(Integer parentDataTypeRowId) + { + this._parentDataTypeRowId = parentDataTypeRowId; + } + + public boolean isSampleParent() + { + return _sampleParent; + } + + public void setSampleParent(boolean sampleParent) + { + _sampleParent = sampleParent; + } + } + + @RequiresPermission(ReadPermission.class) + public static class GetDataTypesWithRequiredLineageAction extends ReadOnlyApiAction + { + @Override + public void validateForm(DataTypesWithRequiredLineageForm form, Errors errors) + { + if (form.getParentDataTypeRowId() == null) + errors.reject(ERROR_REQUIRED, "ParentDataTypeRowId must be provided"); + } + + @Override + public Object execute(DataTypesWithRequiredLineageForm form, BindException errors) throws Exception + { + return getDataTypesWithRequiredLineageResponse(form.getParentDataTypeRowId(), form.isSampleParent(), getContainer(), getUser()); + } + } + @NotNull + private static ApiSimpleResponse getDataTypesWithRequiredLineageResponse(Integer parentDataType, boolean isSampleParent, Container container, User user) + { + Pair, Set> requiredLineages = ExperimentServiceImpl.get().getDataTypesWithRequiredLineage(parentDataType, isSampleParent, container, user); + return new ApiSimpleResponse(Map.of("sampleTypes", requiredLineages.first, "dataClasses", requiredLineages.second,"success", true)); + } + + @RequiresPermission(DesignSampleTypePermission.class) + public static class EditSampleTypeAction extends SimpleViewAction + { + private ExpSampleTypeImpl _sampleType; + + @Override + public ModelAndView getView(SampleTypeForm form, BindException errors) + { + boolean create = form.getLSID() == null && form.getRowId() == null; + if (!create) + _sampleType = form.getSampleType(getContainer()); + + return ModuleHtmlView.get(ModuleLoader.getInstance().getModule("core"), ModuleHtmlView.getGeneratedViewPath("sampleTypeDesigner")); + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("sampleSets"); + + root.addChild("Sample Types", ExperimentUrlsImpl.get().getShowSampleTypeListURL(getContainer())); + if (_sampleType == null) + { + root.addChild("Create Sample Type"); + } + else + { + root.addChild(_sampleType.getName(), ExperimentUrlsImpl.get().getShowSampleTypeURL(_sampleType)); + root.addChild("Update Sample Type"); + } + } + } + + public static class SampleTypeForm extends ReturnUrlForm + { + private Integer rowId; + private String lsid; + + public Integer getRowId() + { + return rowId; + } + + public void setRowId(Integer rowId) + { + this.rowId = rowId; + } + + public String getLSID() + { + return this.lsid; + } + + public void setLSID(String lsid) + { + this.lsid = lsid; + } + + public ExpSampleTypeImpl getSampleType(Container container) throws NotFoundException + { + ExpSampleTypeImpl sampleType = SampleTypeServiceImpl.get().getSampleType(getLSID()); + if (sampleType == null) + sampleType = SampleTypeServiceImpl.get().getSampleType(getRowId()); + + if (sampleType == null) + { + throw new NotFoundException("Sample type not found: " + (getLSID() != null ? getLSID() : getRowId())); + } + + if (!container.equals(sampleType.getContainer())) + { + throw new NotFoundException("Sample type is not defined in the given container."); + } + + return sampleType; + } + } + + @RequiresPermission(InsertPermission.class) + public static class ImportSamplesAction extends AbstractExpDataImportAction + { + ExpSampleTypeImpl _sampleType; + boolean _isCrossTypeImport = false; + + @Override + public void validateForm(QueryForm queryForm, Errors errors) + { + _form = queryForm; + _insertOption = queryForm.getInsertOption(); + _isCrossTypeImport = getOptionParamValue(Params.crossTypeImport); + _form.setSchemaName(getTargetSchemaName()); + if (_isCrossTypeImport) + { + _form.setQueryName(getPipelineTargetQueryName()); + } + super.validateForm(queryForm, errors); + if (queryForm.getQueryName() == null) + errors.reject(ERROR_REQUIRED, "Sample type name is required"); + else + { + if (!_isCrossTypeImport) + { + _sampleType = SampleTypeServiceImpl.get().getSampleType(getContainer(), getUser(), queryForm.getQueryName()); + if (_sampleType == null) + { + errors.reject(ERROR_GENERIC, "Sample type '" + queryForm.getQueryName() + " not found."); + } + } + } + } + + private String getTargetSchemaName() + { + return getOptionParamValue(Params.crossTypeImport) ? ExpSchema.SCHEMA_NAME : "samples"; + } + + @Override + protected UserSchema getTargetSchema() + { + return getOptionParamValue(Params.crossTypeImport) ? QueryService.get().getUserSchema(getUser(), getContainer(), getTargetSchemaName()) : super.getTargetSchema(); + } + + @Override + protected String getPipelineTargetQueryName() + { + return getOptionParamValue(Params.crossTypeImport) ? "materials" : super.getPipelineTargetQueryName(); + } + + @Override + protected Map getRenamedColumns() + { + Map renamedColumns = super.getRenamedColumns(); + renamedColumns.putAll(SampleTypeUpdateServiceDI.SAMPLE_ALT_IMPORT_NAME_COLS); + return renamedColumns; + } + + @Override + protected @Nullable Set getLineageImportAliases() throws IOException + { + Set aliases = new CaseInsensitiveHashSet(); + // Issue 53419: Aliquot parent with number like names that starts with leading zeroes aren't resolved during import + aliases.add(ExpMaterial.ALIQUOTED_FROM_INPUT); + aliases.add(ExpMaterial.ALIQUOTED_FROM_INPUT_LABEL); + boolean crossTypeImport = getOptionParamValue(AbstractQueryImportAction.Params.crossTypeImport); + // Issue 51894: We need to stop conversion to numbers for alias fields for all type + // If there are aliases defined for one type that are number fields in another type, this will prevent + // conversion to numbers during the initial partitioning, but the conversion will happen when the partition + // file is loaded. + if (crossTypeImport) + { + List sampleTypes = SampleTypeServiceImpl.get().getSampleTypes(getContainer(), true); + for (ExpSampleTypeImpl sampleType : sampleTypes) + aliases.addAll(sampleType.getImportAliases().keySet()); + } + else + { + ExpSampleTypeImpl sampleType = SampleTypeServiceImpl.get().getSampleType(getContainer(), getUser(), _form.getQueryName()); + aliases.addAll(sampleType.getImportAliases().keySet()); + } + return aliases; + } + + @Override + protected int importData( + DataLoader dl, + FileStream file, + String originalName, + BatchValidationException errors, + @Nullable AuditBehaviorType auditBehaviorType, + TransactionAuditProvider.@Nullable TransactionAuditEvent auditEvent, + @Nullable String auditUserComment + ) throws IOException + { + initContext(dl, errors, auditBehaviorType, auditUserComment); + + TableInfo tInfo = _target; + QueryUpdateService updateService = _updateService; + if (getOptionParamValue(Params.crossTypeImport)) + { + tInfo = ExperimentService.get().createMaterialTable(new SamplesSchema(getUser(), getContainer()), ContainerFilter.current(this), null); + updateService = tInfo.getUpdateService(); + } + + int count = importData(dl, tInfo, updateService, _context, auditEvent, getUser(), getContainer()); + + if (getOptionParamValue(Params.crossTypeImport)) + { + SimpleMetricsService.get().increment(ExperimentService.MODULE_NAME, "sampleImport", "crossTypeImport"); + if (_context.getInsertOption() == QueryUpdateService.InsertOption.UPDATE) + SimpleMetricsService.get().increment(ExperimentService.MODULE_NAME, "sampleImport", "crossTypeUpdate"); + else if (_context.getInsertOption() == QueryUpdateService.InsertOption.MERGE) + SimpleMetricsService.get().increment(ExperimentService.MODULE_NAME, "sampleImport", "crossTypeMerge"); + } + + return count; + } + + @Override + public ModelAndView getView(QueryForm form, BindException errors) throws Exception + { + initRequest(form); + setHelpTopic("importSampleSets"); // page-wide help topic + setImportHelpTopic("importSampleSets"); // importOptions help topic + setTypeName("samples"); + return getDefaultImportView(form, errors); + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("Sample Types", ExperimentUrlsImpl.get().getShowSampleTypeListURL(getContainer())); + ActionURL url = _form.urlFor(QueryAction.executeQuery); + if (_form.getQueryName() != null && url != null) + root.addChild(_form.getQueryName(), url); + root.addChild("Import Data"); + } + + @Override + protected JSONObject createSuccessResponse(int rowCount) + { + JSONObject json = super.createSuccessResponse(rowCount); + if (!_context.getResponseInfo().isEmpty()) + { + for (String key : _context.getResponseInfo().keySet()) + json.put(key, _context.getResponseInfo().get(key)); + } + return json; + } + + @Override + protected void configureLoader(DataLoader loader) throws IOException + { + if (getOptionParamValue(Params.crossTypeImport)) + loader.setInferTypes(false); + configureLoader(loader, _target, getRenamedColumns(), allowLineageColumns(), getLineageImportAliases()); + } + } + + public abstract static class AbstractExpDataImportAction extends AbstractQueryImportAction + { + protected QueryForm _form; + protected DataIteratorContext _context; + + @Override + public void validateForm(QueryForm form, Errors errors) + { + QueryDefinition query = form.getQueryDef(); + if (query.getContainerFilter() != null && query.getContainerFilter().getType() != null) + { + // cross folder import not supported + if (query.getContainerFilter().getType() != ContainerFilter.Type.Current) + errors.reject(ERROR_GENERIC, "ContainerFilter is not supported for import actions."); + } + } + + @Override + protected void initRequest(QueryForm form) throws ServletException + { + QueryDefinition query = form.getQueryDef(); + setContainerFilterForImport(query, getContainer(), getUser()); + List qpe = new ArrayList<>(); + TableInfo t = query.getTable(form.getSchema(), qpe, true); + + if (!qpe.isEmpty()) + throw qpe.get(0); + if (!getOptionParamValue(Params.crossTypeImport) && null != t) + { + setTarget(t); + setShowMergeOption(t.supportsInsertOption(QueryUpdateService.InsertOption.MERGE)); + setShowUpdateOption(t.supportsInsertOption(QueryUpdateService.InsertOption.UPDATE)); + } + + _auditBehaviorType = form.getAuditBehavior(); + _auditUserComment = form.getAuditUserComment(); + } + + @Override + protected Map getRenamedColumns() + { + final String renameParamPrefix = "importAlias."; + Map renameColumns = new CaseInsensitiveHashMap<>(); + PropertyValue[] pvs = _form.getInitParameters().getPropertyValues(); + for (PropertyValue pv : pvs) + { + String paramName = pv.getName(); + if (!paramName.startsWith(renameParamPrefix) || pv.getValue() == null) + continue; + + renameColumns.put(paramName.substring(renameParamPrefix.length()), (String) pv.getValue()); + } + return renameColumns; + } + + @Override + protected Set getLineageImportAliases() throws IOException + { + ExpDataClass dataClass = ExperimentServiceImpl.get().getDataClass(getContainer(), getUser(), _form.getQueryName()); + return new CaseInsensitiveHashSet(dataClass.getImportAliases().keySet()); + } + + protected void initContext(DataLoader dl, BatchValidationException errors, @Nullable AuditBehaviorType auditBehaviorType, @Nullable String auditUserComment) + { + _context = createDataIteratorContext(_insertOption, getOptionParamsMap(), getLookupResolutionType(), auditBehaviorType, auditUserComment, errors, null, getContainer()); + + if (_context.isCrossFolderImport() && !getContainer().hasProductFolders()) + _context.setCrossFolderImport(false); + } + + @Override + protected int importData(DataLoader dl, FileStream file, String originalName, BatchValidationException errors, @Nullable AuditBehaviorType auditBehaviorType, TransactionAuditProvider.@Nullable TransactionAuditEvent auditEvent, @Nullable String auditUserComment) throws IOException + { + initContext(dl, errors, auditBehaviorType, auditUserComment); + return importData(dl, _target, _updateService, _context, auditEvent, getUser(), getContainer()); + } + + @Override + protected String getQueryImportProviderName() + { + PropertyValue pv = _form.getInitParameters().getPropertyValue(QUERY_IMPORT_PIPELINE_PROVIDER_PARAM); + return pv == null ? null : (String) pv.getValue(); + } + + @Override + protected String getQueryImportDescription() + { + PropertyValue pv = _form.getInitParameters().getPropertyValue(QUERY_IMPORT_PIPELINE_DESCRIPTION_PARAM); + return pv == null ? null : (String) pv.getValue(); + } + + @Override + protected String getQueryImportJobNotificationProviderName() + { + PropertyValue pv = _form.getInitParameters().getPropertyValue(QUERY_IMPORT_NOTIFICATION_PROVIDER_PARAM); + return pv == null ? null : (String) pv.getValue(); + } + + @Override + protected boolean isBackgroundImportSupported() + { + return true; + } + + @Override + protected boolean allowLineageColumns() + { + return true; + } + + } + + @RequiresPermission(InsertPermission.class) + public static class ImportDataAction extends AbstractExpDataImportAction + { + @Override + public void validateForm(QueryForm queryForm, Errors errors) + { + _form = queryForm; + _form.setSchemaName("exp.data"); + _insertOption = queryForm.getInsertOption(); + super.validateForm(queryForm, errors); + if (queryForm.getQueryName() == null) + errors.reject(ERROR_REQUIRED, "Data class name is required"); + else + { + ExpDataClass dataClass = ExperimentService.get().getDataClass(getContainer(), getUser(), queryForm.getQueryName()); + if (dataClass == null) + { + errors.reject(ERROR_GENERIC, "Data class '" + queryForm.getQueryName() + " not found."); + } + } + } + + @Override + public ModelAndView getView(QueryForm form, BindException errors) throws Exception + { + initRequest(form); + setHelpTopic("dataClass"); // page wide help topic + setImportHelpTopic("dataClass#ui"); // importOptions help topic + setTypeName("data"); + return getDefaultImportView(form, errors); + } + + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("Data Classes", ExperimentUrlsImpl.get().getDataClassListURL(getContainer())); + ActionURL url = _form.urlFor(QueryAction.executeQuery); + if (_form.getQueryName() != null && url != null) + root.addChild(_form.getQueryName(), url); + root.addChild("Import Data"); + } + + @Override + protected void configureLoader(DataLoader loader) throws IOException + { + configureLoader(loader, _target, getRenamedColumns(), allowLineageColumns(), getLineageImportAliases()); + } + + } + + @RequiresPermission(UpdatePermission.class) + public class ShowUpdateAction extends SimpleViewAction + { + @Override + public ModelAndView getView(ExperimentForm form, BindException errors) + { + form.refreshFromDb(); + Experiment exp = form.getBean(); + if (exp == null) + { + throw new NotFoundException(); + } + ensureCorrectContainer(getContainer(), ExperimentService.get().getExpExperiment(exp.getRowId()), getViewContext()); + + return new ExperimentUpdateView(new DataRegion(), form, errors); + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("runGroups"); + addRootNavTrail(root); + root.addChild("Update Run Group"); + } + } + + @RequiresPermission(UpdatePermission.class) + public static class UpdateAction extends FormHandlerAction + { + private Experiment _exp; + + @Override + public void validateCommand(ExperimentForm target, Errors errors) + { + } + + @Override + public boolean handlePost(ExperimentForm form, BindException errors) throws Exception + { + form.doUpdate(); + form.refreshFromDb(); + _exp = form.getBean(); + return true; + } + + @Override + public ActionURL getSuccessURL(ExperimentForm experimentForm) + { + return ExperimentUrlsImpl.get().getExperimentDetailsURL(getContainer(), ExperimentService.get().getExpExperiment(_exp.getRowId())); + } + } + + public static class ExportBean + { + private final LSIDRelativizer _selectedRelativizer; + private final XarExportType _selectedExportType; + private final String _fileName; + private final String _dataRegionSelectionKey; + private final String _error; + private final Long _expRowId; + private final Long _protocolId; + private final ActionURL _postURL; + private final Set _roles; + + public ExportBean(LSIDRelativizer selectedRelativizer, XarExportType selectedExportType, String fileName, ExportOptionsForm form, Set roles, ActionURL postURL) + { + _selectedRelativizer = selectedRelativizer; + _selectedExportType = selectedExportType; + _fileName = fileName; + _dataRegionSelectionKey = form.getDataRegionSelectionKey(); + _error = form.getError(); + _expRowId = form.getExpRowId(); + _postURL = postURL; + _roles = roles; + _protocolId = form.getProtocolId(); + } + + public LSIDRelativizer getSelectedRelativizer() + { + return _selectedRelativizer; + } + + public XarExportType getSelectedExportType() + { + return _selectedExportType; + } + + public String getError() + { + return _error; + } + + public String getFileName() + { + return _fileName; + } + + public Set getRoles() + { + return _roles; + } + + public String getDataRegionSelectionKey() + { + return _dataRegionSelectionKey; + } + + public ActionURL getPostURL() + { + return _postURL; + } + + public Long getProtocolId() + { + return _protocolId; + } + + public Long getExpRowId() + { + return _expRowId; + } + } + + + private String fixupExportName(String runName) + { + runName = runName.replace('/', '-'); + runName = runName.replace('\\', '-'); + return runName; + } + + public static class ExportOptionsForm extends ExperimentRunListForm + { + private String _error; + private XarExportType _exportType; + private LSIDRelativizer _lsidOutputType; + private String _xarFileName; + private String _zipFileName; + private String _fileExportType; + private Long _protocolId; + private Integer _sampleTypeId; + private long[] _dataIds; + private String[] _roles = new String[0]; + + public String getError() + { + return _error; + } + + public void setError(String error) + { + _error = error; + } + + public XarExportType getExportType() + { + return _exportType; + } + + public LSIDRelativizer getLsidOutputType() + { + return _lsidOutputType; + } + + public String getFileExportType() + { + return _fileExportType; + } + + public void setFileExportType(String fileExportType) + { + _fileExportType = fileExportType; + } + + public String getXarFileName() + { + return _xarFileName; + } + + public void setXarFileName(String xarFileName) + { + _xarFileName = xarFileName; + } + + public String getZipFileName() + { + return _zipFileName; + } + + public void setZipFileName(String zipFileName) + { + _zipFileName = zipFileName; + } + + public void setExportType(XarExportType exportType) + { + _exportType = exportType; + } + + public void setLsidOutputType(LSIDRelativizer lsidOutputType) + { + _lsidOutputType = lsidOutputType; + } + + public Long getProtocolId() + { + return _protocolId; + } + + public void setProtocolId(Long protocolId) + { + _protocolId = protocolId; + } + + public String[] getRoles() + { + return _roles; + } + + public void setRoles(String[] roles) + { + _roles = roles; + } + + public Integer getSampleTypeId() + { + return _sampleTypeId; + } + + public void setSampleTypeId(Integer sampleTypeId) + { + _sampleTypeId = sampleTypeId; + } + + public long[] getDataIds() + { + return _dataIds; + } + + public void setDataIds(long[] dataIds) + { + _dataIds = dataIds; + } + + public List lookupProtocols(ViewContext context, boolean clearSelection) + { + List protocols = new ArrayList<>(); + + if (_protocolId != null) + { + ExpProtocol protocol = ExperimentService.get().getExpProtocol(_protocolId.intValue()); + if (protocol == null || !protocol.getContainer().equals(context.getContainer())) + { + throw new NotFoundException(); + } + protocols.add(protocol); + return protocols; + } + + for (Long protocolId : DataRegionSelection.getSelectedIntegers(context, clearSelection)) + { + try + { + ExpProtocol protocol = ExperimentService.get().getExpProtocol(protocolId); + if (protocol == null || !protocol.getContainer().equals(context.getContainer())) + { + throw new NotFoundException(); + } + protocols.add(protocol); + } + catch (NumberFormatException e) + { + throw new NotFoundException("Invalid protocol id: " + protocolId); + } + } + if (protocols.isEmpty()) + { + throw new NotFoundException("No protocols selected"); + } + return protocols; + } + } + + private ActionURL exportXAR(@NotNull XarExportSelection selection, @Nullable String fileName) + throws ExperimentException, IOException, PipelineValidationException + { + return exportXAR(selection, null, null, fileName); + } + + private ActionURL exportXAR(@NotNull XarExportSelection selection, @Nullable LSIDRelativizer lsidRelativizer, @Nullable XarExportType exportType, @Nullable String fileName) + throws ExperimentException, IOException, PipelineValidationException + { + if (lsidRelativizer == null) + lsidRelativizer = LSIDRelativizer.FOLDER_RELATIVE; + + if (exportType == null) + exportType = XarExportType.BROWSER_DOWNLOAD; + + if (fileName == null || fileName.isEmpty()) + fileName = "export.xar"; + + fileName = fixupExportName(fileName); + String xarXmlFileName = null; + if (Strings.CI.endsWith(fileName, ".xar")) + xarXmlFileName = fileName + ".xml"; + + switch (exportType) + { + case BROWSER_DOWNLOAD: + XarExporter exporter = new XarExporter(lsidRelativizer, selection, getUser(), xarXmlFileName, null, getContainer()); + + getViewContext().getResponse().setContentType("application/zip"); + ResponseHelper.setContentDisposition(getViewContext().getResponse(), ResponseHelper.ContentDispositionType.attachment, fileName); + ResponseHelper.setPrivate(getViewContext().getResponse()); + + exporter.writeAsArchive(getViewContext().getResponse().getOutputStream()); + return null; + case PIPELINE_FILE: + if (!PipelineService.get().hasValidPipelineRoot(getContainer())) + { + throw new IllegalStateException("You must set a valid pipeline root before you can export a XAR to it."); + } + PipeRoot pipeRoot = PipelineService.get().findPipelineRoot(getContainer()); + XarExportPipelineJob job = new XarExportPipelineJob(getViewBackgroundInfo(), pipeRoot, fileName, lsidRelativizer, selection, xarXmlFileName); + PipelineService.get().queueJob(job); + PipelineStatusFile status = PipelineService.get().getStatusFile(job.getJobGUID()); + return PageFlowUtil.urlProvider(PipelineUrls.class).statusDetails(getContainer(), status.getRowId()); + default: + throw new IllegalArgumentException("Unknown export type: " + exportType); + } + } + + @RequiresPermission(ReadPermission.class) + public class ExportProtocolsAction extends AbstractExportAction + { + @Override + public boolean handlePost(ExportOptionsForm form, BindException errors) throws Exception + { + List protocols = form.lookupProtocols(getViewContext(), false); + + long[] ids = new long[protocols.size()]; + for (int i = 0; i < ids.length; i++) + { + ids[i] = protocols.get(i).getRowId(); + } + XarExportSelection selection = new XarExportSelection(); + selection.addProtocolIds(ids); + + exportXAR(selection, form.getLsidOutputType(), form.getExportType(), form.getXarFileName()); + + if (form.getDataRegionSelectionKey() != null) + { + // Clear the selection + form.lookupProtocols(getViewContext(), true); + } + return true; + } + } + + public abstract static class AbstractExportAction extends FormViewAction + { + protected ActionURL _resultURL; + + @Override + public void validateCommand(ExportOptionsForm target, Errors errors) + { + } + + @Override + public ActionURL getSuccessURL(ExportOptionsForm exportOptionsForm) + { + return _resultURL; + } + + @Override + public ModelAndView getSuccessView(ExportOptionsForm exportOptionsForm) + { + return null; + } + + @Override + public ModelAndView getView(ExportOptionsForm form, boolean reshow, BindException errors) throws Exception + { + // FormViewAction can reinvoke getView() in response to a POST if we're not redirecting the browser, + // so avoid double-creating the export + if ("get".equalsIgnoreCase(getViewContext().getRequest().getMethod())) + handlePost(form, errors); + return null; + } + + @Override + public void addNavTrail(NavTree root) + { + } + + public List lookupRuns(ExportOptionsForm form) + { + Set runIds; + if (form.getRunIds() != null && form.getRunIds().length > 0) + runIds = new HashSet<>(Arrays.asList(form.getRunIds())); + else + runIds = DataRegionSelection.getSelectedIntegers(getViewContext(), form.getDataRegionSelectionKey(), false); + + if (runIds.isEmpty()) + { + throw new NotFoundException(); + } + List result = new ArrayList<>(); + + for (long id : runIds) + { + ExpRun run = ExperimentService.get().getExpRun(id); + if (run == null || !run.getContainer().hasPermission(getUser(), ReadPermission.class)) + { + throw new NotFoundException("Could not find run " + id); + } + result.add(run); + } + return result; + } + } + + @RequiresPermission(ReadPermission.class) + public class ExportRunsAction extends AbstractExportAction + { + @Override + public boolean handlePost(ExportOptionsForm form, BindException errors) throws Exception + { + XarExportSelection selection = new XarExportSelection(); + if (form.getExpRowId() != null) + { + ExpExperiment experiment = ExperimentService.get().getExpExperiment(form.getExpRowId()); + if (experiment != null && !experiment.getContainer().hasPermission(getUser(), ReadPermission.class)) + { + throw new NotFoundException("Run group " + form.getExpRowId()); + } + selection.addExperimentIds(experiment.getRowId()); + } + selection.addRuns(lookupRuns(form)); + + _resultURL = exportXAR(selection, form.getLsidOutputType(), form.getExportType(), form.getXarFileName()); + if (form.getDataRegionSelectionKey() != null) + DataRegionSelection.clearAll(getViewContext(), form.getDataRegionSelectionKey()); + return true; + } + } + + @RequiresPermission(ReadPermission.class) + public class ExportSampleTypeAction extends AbstractExportAction + { + @Override + public boolean handlePost(ExportOptionsForm form, BindException errors) throws Exception + { + Integer rowId = form.getSampleTypeId(); + if (rowId == null) + { + throw new NotFoundException("No sampleTypeId parameter specified"); + } + ExpSampleType sampleType = SampleTypeService.get().getSampleType(getContainer(), getUser(), rowId.intValue()); + if (sampleType == null) + { + throw new NotFoundException("No such sample type with RowId " + rowId); + } + if (!sampleType.getContainer().hasPermission(getUser(), ReadPermission.class)) + { + throw new UnauthorizedException(); + } + + XarExportSelection selection = new XarExportSelection(); + selection.addSampleType(sampleType); + + _resultURL = exportXAR(selection, form.getLsidOutputType(), form.getExportType(), FileUtil.makeLegalName(sampleType.getName() + ".xar")); + return true; + } + } + + @RequiresPermission(ReadPermission.class) + public class ExportRunFilesAction extends AbstractExportAction + { + @Override + public boolean handlePost(ExportOptionsForm form, BindException errors) throws Exception + { + XarExportSelection selection = new XarExportSelection(); + selection.setIncludeXarXml(false); + if ("role".equalsIgnoreCase(form.getFileExportType())) + { + selection.addRoles(form.getRoles()); + } + selection.addRuns(lookupRuns(form)); + + _resultURL = exportXAR(selection, form.getZipFileName()); + if (form.getDataRegionSelectionKey() != null) + DataRegionSelection.clearAll(getViewContext(), form.getDataRegionSelectionKey()); + return true; + } + } + + @RequiresPermission(ReadPermission.class) + public class ExportFilesAction extends AbstractExportAction + { + @Override + public boolean handlePost(ExportOptionsForm form, BindException errors) throws Exception + { + long[] dataIds = form.getDataIds(); + if (dataIds == null || dataIds.length == 0) + { + throw new NotFoundException(); + } + + try + { + for (long id : dataIds) + { + ExpData data = ExperimentService.get().getExpData(id); + if (data == null || !data.getContainer().hasPermission(getUser(), ReadPermission.class)) + { + throw new NotFoundException("Could not find file " + id); + } + } + + XarExportSelection selection = new XarExportSelection(); + selection.setIncludeXarXml(false); + selection.addDataIds(dataIds); + + _resultURL = exportXAR(selection, form.getZipFileName()); + return true; + } + catch (NumberFormatException e) + { + throw new NotFoundException(Arrays.toString(dataIds)); + } + } + } + + public static class ExperimentRunListForm implements DataRegionSelection.DataSelectionKeyForm + { + private String _dataRegionSelectionKey; + private Long _expRowId; + private Long[] _runIds; + + @Override + public String getDataRegionSelectionKey() + { + return _dataRegionSelectionKey; + } + + @Override + public void setDataRegionSelectionKey(String key) + { + _dataRegionSelectionKey = key; + } + + public Long getExpRowId() + { + return _expRowId; + } + + public void setExpRowId(Long expRowId) + { + _expRowId = expRowId; + } + + public Long[] getRunIds() + { + return _runIds; + } + + public void setRunIds(Long[] runIds) + { + _runIds = runIds; + } + + public ExpExperiment lookupExperiment() + { + return getExpRowId() == null ? null : ExperimentService.get().getExpExperiment(getExpRowId().intValue()); + } + } + + private void addSelectedRunsToExperiment(ExpExperiment exp, String dataRegionSelectionKey) + { + Collection runIds = DataRegionSelection.getSelectedIntegers(getViewContext(), dataRegionSelectionKey, true); + List runs = new ArrayList<>(); + for (long runId : runIds) + { + ExpRun run = ExperimentServiceImpl.get().getExpRun(runId); + if (run != null) + { + runs.add(run); + } + } + exp.addRuns(getUser(), runs.toArray(new ExpRun[0])); + } + + + @RequiresPermission(InsertPermission.class) + public class AddRunsToExperimentAction extends FormHandlerAction + { + @Override + public void validateCommand(ExperimentRunListForm target, Errors errors) + { + } + + @Override + public boolean handlePost(ExperimentRunListForm form, BindException errors) + { + addSelectedRunsToExperiment(form.lookupExperiment(), form.getDataRegionSelectionKey()); + return true; + } + + @Override + public ActionURL getSuccessURL(ExperimentRunListForm form) + { + return ExperimentUrlsImpl.get().getExperimentDetailsURL(getContainer(), form.lookupExperiment()); + } + } + + @RequiresPermission(DeletePermission.class) + public static class RemoveSelectedExpRunsAction extends FormHandlerAction + { + @Override + public void validateCommand(ExperimentRunListForm target, Errors errors) + { + } + + @Override + public boolean handlePost(ExperimentRunListForm form, BindException errors) + { + ExpExperiment exp = form.lookupExperiment(); + if (exp == null || !exp.getContainer().hasPermission(getUser(), DeletePermission.class)) + { + throw new NotFoundException("Could not find run group with RowId " + form.getExpRowId()); + } + + for (long runId : DataRegionSelection.getSelectedIntegers(getViewContext(), form.getDataRegionSelectionKey(), false)) + { + ExpRun run = ExperimentService.get().getExpRun(runId); + if (run == null || !run.getContainer().hasPermission(getUser(), DeletePermission.class)) + { + throw new NotFoundException("Could not find run with RowId " + runId); + } + exp.removeRun(getUser(), run); + } + if (form.getDataRegionSelectionKey() != null) + DataRegionSelection.clearAll(getViewContext(), form.getDataRegionSelectionKey()); + return true; + } + + @Override + public ActionURL getSuccessURL(ExperimentRunListForm form) + { + return ExperimentUrlsImpl.get().getExperimentDetailsURL(getContainer(), form.lookupExperiment()); + } + } + + public static ActionURL getResolveLsidURL(Container c, @NotNull String type, @NotNull String lsid) + { + ActionURL url = new ActionURL(ResolveLSIDAction.class, c); + url.addParameter("type", type); + url.addParameter("lsid", lsid); + + return url; + } + + + @RequiresPermission(ReadPermission.class) + public static class ResolveLSIDAction extends SimpleViewAction + { + @Override + public ModelAndView getView(LsidForm form, BindException errors) + { + String message = ""; + if (!PageFlowUtil.empty(form.getLsid())) + { + try + { + String lsid = Lsid.canonical(form.getLsid().trim()); + ActionURL url = LsidManager.get().getDisplayURL(lsid); + if (url == null && form.getType() != null) + { + url = switch (form.getType().toLowerCase()) + { + case "data" -> LsidType.Data.getDisplayURL(new Lsid(lsid)); + case "material" -> LsidType.Material.getDisplayURL(new Lsid(lsid)); + default -> url; + }; + } + if (null != url) + { + throw new RedirectException(url); + } + message = "Could not map LSID to URL"; + } + catch (IllegalArgumentException e) + { + message = "Invalid LSID"; + } + } + + return new HtmlView("Enter LSID", + DOM.createHtmlFragment( + message, + DOM.FORM(at(action, getViewContext().cloneActionURL().setAction(ResolveLSIDAction.class)), + "LSID: ", + DOM.INPUT(at(type, "text", name, "lsid", size, "80", value, form.getLsid())), + PageFlowUtil.button("Go").submit(true)))); + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("Resolve LSID"); + } + } + + public static class LsidForm + { + private String _lsid; + + public String getType() + { + return _type; + } + + public void setType(String type) + { + _type = type; + } + + private String _type; + + public void setLsid(String lsid) + { + _lsid = lsid; + } + + public String getLsid() + { + return _lsid; + } + } + + public static class SetFlagForm extends LsidForm + { + private String _comment; + private boolean _redirect = true; + + public String getComment() + { + return _comment; + } + + public void setComment(String comment) + { + _comment = comment; + } + + public boolean isRedirect() + { + return _redirect; + } + + public void setRedirect(boolean redirect) + { + _redirect = redirect; + } + } + + /** + * Check for update on the object itself + */ + @RequiresNoPermission + public static class SetFlagAction extends FormHandlerAction + { + @Override + public void validateCommand(SetFlagForm target, Errors errors) + { + } + + @Override + public boolean handlePost(SetFlagForm form, BindException errors) throws Exception + { + String lsid = form.getLsid(); + if (lsid == null) + throw new NotFoundException(); + ExpObject obj = ExperimentService.get().findObjectFromLSID(lsid); + if (obj == null) + throw new NotFoundException(); + Container container = obj.getContainer(); + if (!container.hasPermission(getUser(), UpdatePermission.class)) + { + throw new UnauthorizedException(); + } + + obj.setComment(getUser(), form.getComment()); + return true; + } + + @Override + public URLHelper getSuccessURL(SetFlagForm form) + { + return null; + } + } + + @RequiresPermission(InsertPermission.class) + public class DeriveSamplesChooseTargetAction extends SimpleViewAction + { + private List _materials; + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("sampleSets"); + addRootNavTrail(root); + root.addChild("Sample Types", ExperimentUrlsImpl.get().getShowSampleTypeListURL(getContainer())); + ExpSampleType sampleType = _materials != null && !_materials.isEmpty() ? _materials.get(0).getSampleType() : null; + if (sampleType != null) + { + root.addChild(sampleType.getName(), ExperimentUrlsImpl.get().getShowSampleTypeURL(sampleType)); + } + root.addChild("Derive Samples"); + } + + @Override + public void validate(DeriveMaterialForm form, BindException errors) + { + _materials = form.lookupMaterials(); + if (_materials.isEmpty()) + { + throw new NotFoundException("Could not find any matching materials"); + } + } + + @Override + public ModelAndView getView(DeriveMaterialForm form, BindException errors) + { + Container c = getContainer(); + PipeRoot root = PipelineService.get().findPipelineRoot(c); + + if (root == null || !root.isValid()) + { + ActionURL pipelineURL = urlProvider(PipelineUrls.class).urlSetup(c); + return new HtmlView(DIV("You must ", + DOM.A(DOM.at(href, pipelineURL), "configure a valid pipeline root for this folder"), + " before deriving samples.")); + } + else + { + Set materialInputRoles = new TreeSet<>(ExperimentService.get().getMaterialInputRoles(getContainer(), getUser())); + Map materialsWithRoles = new LinkedHashMap<>(); + for (ExpMaterial material : _materials) + { + materialsWithRoles.put(material, null); + } + + List sampleTypes = getUploadableSampleTypes(); + + DeriveSamplesChooseTargetBean bean = new DeriveSamplesChooseTargetBean(form.getDataRegionSelectionKey(), form.getTargetSampleTypeId(), sampleTypes, materialsWithRoles, form.getOutputCount(), materialInputRoles, null); + return new JspView<>("/org/labkey/experiment/deriveSamplesChooseTarget.jsp", bean); + } + } + } + + public static class DeriveSamplesChooseTargetBean implements DataRegionSelection.DataSelectionKeyForm + { + private String _dataRegionSelectionKey; + + private final Integer _targetSampleTypeId; + private final List _sampleTypes; + private final Map _sourceMaterials; + private final int _sampleCount; + private final Collection _inputRoles; + private final DerivedSamplePropertyHelper _propertyHelper; + + public static final String CUSTOM_ROLE = "--CUSTOM--"; + + public DeriveSamplesChooseTargetBean(String dataRegionSelectionKey, Integer targetSampleTypeId, List sampleTypes, Map sourceMaterials, int sampleCount, Collection inputRoles, DerivedSamplePropertyHelper helper) + { + _dataRegionSelectionKey = dataRegionSelectionKey; + _targetSampleTypeId = targetSampleTypeId; + _sampleTypes = sampleTypes; + _sourceMaterials = sourceMaterials; + _sampleCount = sampleCount; + _inputRoles = inputRoles; + _propertyHelper = helper; + } + + public Integer getTargetSampleTypeId() + { + return _targetSampleTypeId; + } + + public DerivedSamplePropertyHelper getPropertyHelper() + { + return _propertyHelper; + } + + public int getSampleCount() + { + return _sampleCount; + } + + public Map getSourceMaterials() + { + return _sourceMaterials; + } + + public List getSampleTypes() + { + return _sampleTypes; + } + + public Collection getInputRoles() + { + return _inputRoles; + } + + @Override + public String getDataRegionSelectionKey() + { + return _dataRegionSelectionKey; + } + + @Override + public void setDataRegionSelectionKey(String key) + { + _dataRegionSelectionKey = key; + } + } + + private List getUploadableSampleTypes() + { + // Make a copy so we can modify it + List sampleTypes = new ArrayList<>(SampleTypeService.get().getSampleTypes(getContainer(), getUser(), true)); + sampleTypes.removeIf(sampleType -> !sampleType.canImportMoreSamples()); + return sampleTypes; + } + + @RequiresPermission(InsertPermission.class) + public class DeriveSamplesAction extends FormViewAction + { + private List _materials; + private ActionURL _successUrl; + private final Map _inputMaterials = new LinkedHashMap<>(); + + @Override + public ModelAndView getView(DeriveMaterialForm form, boolean reshow, BindException errors) + { + _materials = form.lookupMaterials(); + if (_materials.isEmpty()) + { + throw new NotFoundException("Could not find any matching materials"); + } + + Container c = getContainer(); + + if (form.getOutputCount() <= 0) + { + form.setOutputCount(1); + } + + if (form.getTargetSampleTypeId() == 0) + throw new NotFoundException("Target sample type required for the derived samples"); + + ExpSampleTypeImpl sampleType = SampleTypeServiceImpl.get().getSampleType(getContainer(), getUser(), form.getTargetSampleTypeId()); + if (sampleType == null) + throw new NotFoundException("Could not find sample type with rowId " + form.getTargetSampleTypeId()); + + InsertView insertView = new InsertView(new DataRegion(), errors); + + DerivedSamplePropertyHelper helper = new DerivedSamplePropertyHelper(sampleType, form.getOutputCount(), c, getUser()); + helper.addSampleColumns(insertView, getUser()); + + int[] rowIds = form.getRowIds(); + for (int i = 0; i < rowIds.length; i++) + { + insertView.getDataRegion().addHiddenFormField("rowIds", Integer.toString(rowIds[i])); + insertView.getDataRegion().addHiddenFormField("inputRole" + i, form.getInputRole(i) == null ? "" : form.getInputRole(i)); + insertView.getDataRegion().addHiddenFormField("customRole" + i, form.getCustomRole(i) == null ? "" : form.getCustomRole(i)); + } + + insertView.getDataRegion().addHiddenFormField("targetSampleTypeId", Integer.toString(form.getTargetSampleTypeId())); + insertView.getDataRegion().addHiddenFormField("outputCount", Integer.toString(form.getOutputCount())); + if (form.getDataRegionSelectionKey() != null) + insertView.getDataRegion().addHiddenFormField(DataRegionSelection.DATA_REGION_SELECTION_KEY, form.getDataRegionSelectionKey()); + insertView.setInitialValues(ViewServlet.adaptParameterMap(getViewContext().getRequest().getParameterMap())); + ButtonBar bar = new ButtonBar(); + bar.setStyle(ButtonBar.Style.separateButtons); + ActionButton submitButton = new ActionButton(DeriveSamplesAction.class, "Submit"); + submitButton.setActionType(ActionButton.Action.POST); + bar.add(submitButton); + insertView.getDataRegion().setButtonBar(bar); + insertView.setTitle("Output Samples"); + + Map materialsWithRoles = new LinkedHashMap<>(); + List materials = form.lookupMaterials(); + for (int i = 0; i < materials.size(); i++) + { + materialsWithRoles.put(materials.get(i), form.determineLabel(i)); + } + + DeriveSamplesChooseTargetBean bean = new DeriveSamplesChooseTargetBean(form.getDataRegionSelectionKey(), form.getTargetSampleTypeId(), getUploadableSampleTypes(), materialsWithRoles, form.getOutputCount(), Collections.emptyList(), helper); + JspView view = new JspView<>("/org/labkey/experiment/summarizeMaterialInputs.jsp", bean); + view.setTitle("Input Samples"); + + return new VBox(view, insertView); + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("sampleSets"); + addRootNavTrail(root); + root.addChild("Sample Types", ExperimentUrlsImpl.get().getShowSampleTypeListURL(getContainer())); + ExpSampleType sampleType = _materials != null && !_materials.isEmpty() ? _materials.get(0).getSampleType() : null; + if (sampleType != null) + { + root.addChild(sampleType.getName(), ExperimentUrlsImpl.get().getShowSampleTypeURL(sampleType)); + } + root.addChild("Derive Samples"); + } + + @Override + public void validateCommand(DeriveMaterialForm form, Errors errors) + { + List materials = form.lookupMaterials(); + + List lockedSamples = new ArrayList<>(); + for (int i = 0; i < materials.size(); i++) + { + ExpMaterial m = materials.get(i); + if (!m.isOperationPermitted(SampleTypeService.SampleOperations.EditLineage)) + { + lockedSamples.add(m); + } + String inputRole = form.determineLabel(i); + if (inputRole == null || inputRole.isEmpty()) + { + ExpSampleType st = m.getSampleType(); + inputRole = st != null ? st.getName() : ExpMaterialRunInput.DEFAULT_ROLE; + } + _inputMaterials.put(materials.get(i), inputRole); + } + + if (!lockedSamples.isEmpty()) + { + errors.reject(ERROR_MSG, SampleTypeService.get().getOperationNotPermittedMessage(lockedSamples, SampleTypeService.SampleOperations.EditLineage)); + } + } + + @Override + public boolean handlePost(DeriveMaterialForm form, BindException errors) + { + ExpSampleTypeImpl sampleType = SampleTypeServiceImpl.get().getSampleType(getContainer(), getUser(), form.getTargetSampleTypeId()); + + DerivedSamplePropertyHelper helper = new DerivedSamplePropertyHelper(sampleType, form.getOutputCount(), getContainer(), getUser()); + + Map, Map> allProperties; + try + { + boolean valid = true; + for (Map.Entry> entry : helper.getPostedPropertyValues(getViewContext().getRequest()).entrySet()) + valid = UploadWizardAction.validatePostedProperties(getViewContext(), entry.getValue(), errors) && valid; + if (!valid) + return false; + + allProperties = helper.getSampleProperties(getViewContext().getRequest(), _inputMaterials.keySet()); + } + catch (DuplicateMaterialException e) + { + errors.addError(new ObjectError(e.getColName(), null, null, e.getMessage())); + return false; + } + catch (ExperimentException e) + { + errors.reject(SpringActionController.ERROR_MSG, e.getMessage()); + return false; + } + + try (DbScope.Transaction tx = ExperimentService.get().ensureTransaction()) + { + Map outputMaterials = new HashMap<>(); + int i = 0; + for (Map.Entry, Map> entry : allProperties.entrySet()) + { + Lsid lsid = entry.getKey().first; + String name = entry.getKey().second; + assert name != null; + + ExpMaterialImpl outputMaterial = ExperimentServiceImpl.get().createExpMaterial(getContainer(), lsid.toString(), name); + if (sampleType != null) + { + outputMaterial.setCpasType(sampleType.getLSID()); + } + outputMaterial.save(getUser()); + + if (sampleType != null) + { + Map pvs = new HashMap<>(); + for (Map.Entry propertyEntry : entry.getValue().entrySet()) + pvs.put(propertyEntry.getKey().getName(), propertyEntry.getValue()); + outputMaterial.setProperties(getUser(), pvs, false); + } + + outputMaterials.put(outputMaterial, helper.getSampleNames().get(i++)); + } + + ExperimentService.get().deriveSamples(_inputMaterials, outputMaterials, getViewBackgroundInfo(), _log); + + tx.commit(); + + // automatically link samples to study, if configured + StudyPublishService.get().autoLinkDerivedSamples(sampleType, outputMaterials.keySet().stream().map(ExpObject::getRowId).collect(toList()), getContainer(), getUser()); + + _successUrl = ExperimentUrlsImpl.get().getShowSampleURL(getContainer(), outputMaterials.keySet().iterator().next()); + + if (form.getDataRegionSelectionKey() != null) + DataRegionSelection.clearAll(getViewContext(), form.getDataRegionSelectionKey()); + } + catch (Exception e) + { + errors.reject(SpringActionController.ERROR_MSG, e.getMessage()); + return false; + } + + return true; + } + + @Override + public URLHelper getSuccessURL(DeriveMaterialForm deriveMaterialForm) + { + return _successUrl; + } + } + + public static class DeriveMaterialForm implements HasViewContext, DataRegionSelection.DataSelectionKeyForm + { + private String _dataRegionSelectionKey; + private int _outputCount = 1; + private int _targetSampleTypeId; + private int[] _rowIds; + private String _name; + + private ViewContext _context; + + @Override + public void setViewContext(ViewContext context) + { + _context = context; + } + + @Override + public ViewContext getViewContext() + { + return _context; + } + + public List lookupMaterials() + { + List result = new ArrayList<>(); + for (int rowId : getRowIds()) + { + ExpMaterial material = ExperimentService.get().getExpMaterial(rowId); + if (material != null) + { + if (material.getContainer().hasPermission(_context.getUser(), ReadPermission.class)) + { + result.add(material); + } + else + { + throw new UnauthorizedException(); + } + } + else + { + throw new NotFoundException("No material with RowId " + rowId); + } + } + result.sort(Comparator.comparing(Identifiable::getName)); + return result; + } + + public String getName() + { + return _name; + } + + public void setName(String name) + { + _name = name; + } + + @Override + public String getDataRegionSelectionKey() + { + return _dataRegionSelectionKey; + } + + @Override + public void setDataRegionSelectionKey(String dataRegionSelectionKey) + { + _dataRegionSelectionKey = dataRegionSelectionKey; + } + + public int[] getRowIds() + { + if (_rowIds == null) + { + _rowIds = PageFlowUtil.toInts(DataRegionSelection.getSelected(getViewContext(), getDataRegionSelectionKey(), false)); + } + return _rowIds; + } + + public void setRowIds(int[] rowIds) + { + _rowIds = rowIds; + } + + public int getOutputCount() + { + return _outputCount; + } + + public void setOutputCount(int outputCount) + { + _outputCount = outputCount; + } + + public int getTargetSampleTypeId() + { + return _targetSampleTypeId; + } + + public void setTargetSampleTypeId(int targetSampleTypeId) + { + _targetSampleTypeId = targetSampleTypeId; + } + + public String getInputRole(int i) + { + return _context.getRequest().getParameter("inputRole" + i); + } + + public String getCustomRole(int i) + { + return _context.getRequest().getParameter("customRole" + i); + } + + public String determineLabel(int index) + { + String result = getInputRole(index); + if (DeriveSamplesChooseTargetBean.CUSTOM_ROLE.equals(result)) + { + result = getCustomRole(index); + } + if (result != null) + { + result = result.trim(); + } + return result; + } + } + + + public static class ExpInput + { + public String role; + public int rowId; + public Lsid lsid; + } + + public static class DerivationSpec + { + public String role; + public Map values; + } + + public static class DerivationForm + { + public List dataInputs; + public List materialInputs; + + public int dataOutputCount; + public Lsid targetDataClass; + public Map dataDefault; + public List dataOutputs; + + public int materialOutputCount; + public Lsid targetSampleType; + public Map materialDefault; + public List materialOutputs; + } + + @Marshal(Marshaller.Jackson) + @RequiresPermission(InsertPermission.class) + public static class DeriveAction extends MutatingApiAction + { + @Override + public void validateForm(DerivationForm form, Errors errors) + { + if (errors.hasErrors()) + return; + + if (form.materialOutputCount > 0 && form.materialOutputs != null && !form.materialOutputs.isEmpty()) + errors.reject(ERROR_MSG, "Either 'materialOutputCount' or 'materialOutputs' property can be specified, but not both."); + + if (form.dataOutputCount > 0 && form.dataOutputs != null && !form.dataOutputs.isEmpty()) + errors.reject(ERROR_MSG, "Either 'dataOutputCount' or 'dataOutputs' property can be specified, but not both."); + + boolean hasMaterialOutputs = form.materialOutputCount > 0 || form.materialOutputs != null && !form.materialOutputs.isEmpty(); + boolean hasDataOutputs = form.dataOutputCount > 0 || form.dataOutputs != null && !form.dataOutputs.isEmpty(); + + if (!hasMaterialOutputs && !hasDataOutputs) + errors.reject(ERROR_MSG, "At least one data output or material output is required"); + + if (hasMaterialOutputs && form.targetSampleType == null) + errors.reject(ERROR_MSG, "targetSampleType lsid required for material outputs"); + + if (hasDataOutputs && form.targetDataClass == null) + errors.reject(ERROR_MSG, "targetDataClass lsid required for data outputs"); + } + + @Override + public Object execute(DerivationForm form, BindException errors) throws Exception + { + // Find material inputs + Map materialInputs = new LinkedHashMap<>(); + if (form.materialInputs != null) + { + for (ExpInput in : form.materialInputs) + { + ExpMaterial m = null; + if (in.lsid != null) + { + m = ExperimentService.get().getExpMaterial(in.lsid.toString()); + if (m == null) + errors.reject(ERROR_MSG, "Can't resolve sample '" + in.lsid + "'"); + } + else if (in.rowId > 0) + { + m = ExperimentService.get().getExpMaterial(in.rowId); + if (m == null) + errors.reject(ERROR_MSG, "Can't resolve sample '" + in.rowId + "'"); + } + + if (m == null) + { + errors.reject(ERROR_MSG, "Material input lsid or rowId required"); + continue; + } + + ExpSampleType st = m.getSampleType(); + if (st == null) + { + errors.reject(ERROR_MSG, "Material input is not a member of a SampleType"); + continue; + } + + String role = in.role; + if (role == null || role.isEmpty()) + { + role = st.getName(); + } + materialInputs.put(m, role); + } + } + + // Find input data + Map dataInputs = new LinkedHashMap<>(); + if (form.dataInputs != null) + { + for (ExpInput in : form.dataInputs) + { + ExpData d = null; + if (in.lsid != null) + { + d = ExperimentService.get().getExpData(in.lsid.toString()); + if (d == null) + errors.reject(ERROR_MSG, "Can't resolve data '" + in.lsid + "'"); + } + else if (in.rowId > 0) + { + d = ExperimentService.get().getExpData(in.rowId); + if (d == null) + errors.reject(ERROR_MSG, "Can't resolve data '" + in.rowId + "'"); + } + + if (d == null) + { + errors.reject(ERROR_MSG, "Data input lsid or rowId required"); + continue; + } + + ExpDataClass dc = d.getDataClass(getUser()); + if (dc == null) + { + errors.reject(ERROR_MSG, "Data input is not a member of a DataClass"); + continue; + } + + String role = in.role; + if (role == null || role.isEmpty()) + { + role = dc.getName(); + } + dataInputs.put(d, role); + } + } + + ExpSampleType outSampleType; + if (form.targetSampleType != null) + { + // TODO: check in scope and has permission + outSampleType = SampleTypeService.get().getSampleType(form.targetSampleType.toString()); + if (outSampleType == null) + errors.reject(ERROR_MSG, "Sample type not found: " + form.targetSampleType.toString()); + } + else + { + outSampleType = null; + } + + ExpDataClass outDataClass; + if (form.targetDataClass != null) + { + // TODO: check in scope and has permission + outDataClass = ExperimentServiceImpl.get().getDataClass(form.targetDataClass.toString()); + if (outDataClass == null) + errors.reject(ERROR_MSG, "DataClass not found: " + form.targetDataClass.toString()); + } + else + { + outDataClass = null; + } + + if (errors.hasErrors()) + return null; + + // TODO: support list of resolved ExpData or ExpMaterial instead of string concatenated names + // Create "MaterialInputs/" columns with a value containing a comma-separated list of Material names + final Map> parentInputNames = new HashMap<>(); + Set inputTypes = new CaseInsensitiveHashSet(); + for (ExpMaterial material : materialInputs.keySet()) + { + ExpSampleType st = material.getSampleType(); + String keyName = ExpMaterial.MATERIAL_INPUT_PARENT + "/" + st.getName(); + inputTypes.add(keyName); + parentInputNames.computeIfAbsent(keyName, (x) -> new LinkedHashSet<>()).add(material.getName()); + } + + // TODO: support list of resolved ExpData or ExpMaterial instead of string concatenated names + // Create "DataInputs/" columns with a value containing a comma-separated list of ExpData names + for (ExpData d : dataInputs.keySet()) + { + ExpDataClass dc = d.getDataClass(getUser()); + String keyName = ExpData.DATA_INPUT_PARENT + "/" + dc.getName(); + inputTypes.add(keyName); + parentInputNames.computeIfAbsent(keyName, (x) -> new LinkedHashSet<>()).add(d.getName()); + } + + + try (DbScope.Transaction tx = ExperimentService.get().ensureTransaction()) + { + Set requiredParentTypes = new CaseInsensitiveHashSet(); + + // output materials + Map outputMaterials = new HashMap<>(); + int materialOutputCount = Math.max(form.materialOutputCount, form.materialOutputs != null ? form.materialOutputs.size() : 0); + if (materialOutputCount > 0 && outSampleType != null) + { + requiredParentTypes.addAll(outSampleType.getRequiredImportAliases().values()); + DerivedOutputs derived = new DerivedOutputs<>(parentInputNames, form.materialDefault, form.materialOutputs, materialOutputCount, ExpMaterial.DEFAULT_CPAS_TYPE) + { + @Override + protected TableInfo createTable() + { + SamplesSchema schema = new SamplesSchema(getUser(), getContainer()); + return schema.getTable(outSampleType.getName()); + } + + @Override + protected List getExpObject(List> insertedRows) + { + List rowIds = insertedRows.stream().map(r -> MapUtils.getLong(r,"rowid")).collect(toList()); + return ExperimentService.get().getExpMaterials(rowIds); + } + }; + + outputMaterials = derived.createOutputs(); + } + + + // create output data + Map outputData = new HashMap<>(); + int dataOutputCount = Math.max(form.dataOutputCount, form.dataOutputs != null ? form.dataOutputs.size() : 0); + if (dataOutputCount > 0 && outDataClass != null) + { + requiredParentTypes.addAll(outDataClass.getRequiredImportAliases().values()); + DerivedOutputs derived = new DerivedOutputs<>(parentInputNames, form.dataDefault, form.dataOutputs, dataOutputCount, ExpData.DEFAULT_CPAS_TYPE) + { + @Override + protected TableInfo createTable() + { + ExpSchema expSchema = new ExpSchema(getUser(), getContainer()); + UserSchema dataSchema = expSchema.getUserSchema(ExpSchema.NestedSchemas.data.name()); + return dataSchema.getTable(outDataClass.getName()); + } + + @Override + protected List getExpObject(List> insertedRows) + { + List lsids = insertedRows.stream().map(r -> (String) r.get("lsid")).collect(toList()); + return ExperimentService.get().getExpDatasByLSID(lsids); + } + }; + + outputData = derived.createOutputs(); + } + + if (outputMaterials.isEmpty() && outputData.isEmpty()) + throw new IllegalStateException("Expected to create " + materialOutputCount + " materials and " + dataOutputCount + " datas"); + + boolean hasMissingRequiredParent = false; + for (String required : requiredParentTypes) + { + if (!inputTypes.contains(required)) + { + hasMissingRequiredParent = true; + break; + } + } + if (hasMissingRequiredParent) + throw new IllegalStateException("Inputs are required: " + String.join(",", requiredParentTypes)); + + // finally, create the derived run if there are any parents + ExpRun run = null; + if (!materialInputs.isEmpty() || !dataInputs.isEmpty()) + run = ExperimentService.get().derive(materialInputs, dataInputs, outputMaterials, outputData, new ViewBackgroundInfo(getContainer(), getUser(), null), _log); + tx.commit(); + + StringBuilder successMessage = new StringBuilder("Created "); + if (!outputMaterials.isEmpty()) + successMessage.append(outputMaterials.size()).append(" materials"); + if (!outputData.isEmpty()) + successMessage.append(outputData.size()).append(" data"); + + JSONObject ret; + if (run != null) + ret = ExperimentJSONConverter.serializeRun(run, null, getUser(), ExperimentJSONConverter.DEFAULT_SETTINGS); + else + ret = ExperimentJSONConverter.serializeRunOutputs(outputData.keySet(), outputMaterials.keySet(), getUser(), ExperimentJSONConverter.DEFAULT_SETTINGS); + + return success(successMessage.toString(), ret); + } + } + + // Helper class that prepares and executes the QueryUpdateService.insertRows() on the data or material table. + private abstract class DerivedOutputs + { + private final @NotNull Map> _parentInputNames; + private final @Nullable Map _defaultValues; + private final @Nullable List _values; + private final int _outputCount; + private final String _rolePrefix; + + + public DerivedOutputs(@NotNull Map> parentInputNames, @Nullable Map defaultValues, @Nullable List values, int outputCount, String rolePrefix) + { + _parentInputNames = parentInputNames; + _defaultValues = defaultValues; + _values = values; + _outputCount = outputCount; + _rolePrefix = rolePrefix; + } + + public Pair>, List> prepareRows() + { + List> rows = new ArrayList<>(); + List roles = new ArrayList<>(); + int unknownOutputDataCount = 0; + + for (int i = 0; i < _outputCount; i++) + { + Map row = new CaseInsensitiveHashMap<>(); + if (_defaultValues != null) + row.putAll(_defaultValues); + DerivationSpec spec = _values != null && i < _values.size() ? _values.get(i) : null; + String role = null; + if (spec != null) + { + row.putAll(spec.values); + role = spec.role; + } + + // NOTE: Input parents are added to each row, but are only used for name generation and not for derivation. + // NOTE: We will derive the inserted samples in a single derivation run after the sample/date have been inserted. + row.putAll(_parentInputNames); + + rows.add(row); + + if (StringUtils.trimToNull(role) == null) + { + role = _rolePrefix + (unknownOutputDataCount == 0 ? "" : Integer.toString(unknownOutputDataCount + 1)); + unknownOutputDataCount++; + } + roles.add(role); + } + return Pair.of(rows, roles); + } + + protected abstract TableInfo createTable(); + + protected abstract List getExpObject(List> insertedRows); + + public Map createOutputs() throws BatchValidationException, DuplicateKeyException, SQLException, QueryUpdateServiceException + { + Pair>, List> pair = prepareRows(); + List> rows = pair.first; + List roles = pair.second; + + TableInfo table = createTable(); + QueryUpdateService qus = table.getUpdateService(); + if (qus == null) + throw new IllegalStateException(); + + Map configParams = new HashMap<>(); + // Skip derivation during insert -- DeriveAction will call ExperimentService.get().derive() after samples are inserted + configParams.put(SampleTypeUpdateServiceDI.Options.SkipDerivation, true); + + BatchValidationException qusErrors = new BatchValidationException(); + List> insertedRows = qus.insertRows(getUser(), getContainer(), rows, qusErrors, configParams, null); + if (qusErrors.hasErrors()) + throw qusErrors; + + if (insertedRows.size() != roles.size()) + throw new IllegalStateException("Expected to create " + roles.size() + " new exp objects for derivation"); + + List outputs = getExpObject(insertedRows); + if (outputs.size() != roles.size()) + throw new IllegalStateException("Expected to create " + roles.size() + " new exp objects for derivation"); + + Map outputMap = new HashMap<>(); + for (int i = 0; i < outputs.size(); i++) + { + String role = roles.get(i); + T data = outputs.get(i); + outputMap.put(data, role); + } + + return outputMap; + } + } + } + + public static class CreateExperimentForm extends ExperimentForm implements DataRegionSelection.DataSelectionKeyForm + { + private boolean _addSelectedRuns; + private String _dataRegionSelectionKey; + + public boolean isAddSelectedRuns() + { + return _addSelectedRuns; + } + + public void setAddSelectedRuns(boolean addSelectedRuns) + { + _addSelectedRuns = addSelectedRuns; + } + + @Override + public String getDataRegionSelectionKey() + { + return _dataRegionSelectionKey; + } + + @Override + public void setDataRegionSelectionKey(String dataRegionSelectionKey) + { + _dataRegionSelectionKey = dataRegionSelectionKey; + } + } + + @RequiresPermission(InsertPermission.class) + @ActionNames("createRunGroup, createExperiment") + public class CreateRunGroupAction extends FormViewAction + { + @Override + public ModelAndView getView(CreateExperimentForm form, boolean reshow, BindException errors) + { + // HACK - convert ExperimentForm to not be a BeanViewForm + form.setAddSelectedRuns("true".equals(getViewContext().getRequest().getParameter("addSelectedRuns"))); + form.setDataRegionSelectionKey(getViewContext().getRequest().getParameter(DataRegionSelection.DATA_REGION_SELECTION_KEY)); + + DataRegion drg = new DataRegion(); + + drg.addHiddenFormField(ActionURL.Param.returnUrl, getViewContext().getRequest().getParameter(ActionURL.Param.returnUrl.name())); + drg.addHiddenFormField("addSelectedRuns", Boolean.toString("true".equals(getViewContext().getRequest().getParameter("addSelectedRuns")))); + form.setDataRegionSelectionKey(getViewContext().getRequest().getParameter(DataRegionSelection.DATA_REGION_SELECTION_KEY)); + // Fix issue 27562 - include session-stored selection + if (form.getDataRegionSelectionKey() != null) + { + for (String rowId : DataRegionSelection.getSelected(getViewContext(), form.getDataRegionSelectionKey(), false)) + { + drg.addHiddenFormField(DataRegion.SELECT_CHECKBOX_NAME, rowId); + } + } + drg.addHiddenFormField(DataRegionSelection.DATA_REGION_SELECTION_KEY, getViewContext().getRequest().getParameter(DataRegionSelection.DATA_REGION_SELECTION_KEY)); + + drg.addColumns(ExperimentServiceImpl.get().getTinfoExperiment(), "RowId,Name,LSID,ContactId,ExperimentDescriptionURL,Hypothesis,Comments,Created"); + + DisplayColumn col = drg.getDisplayColumn("RowId"); + col.setVisible(false); + drg.getDisplayColumn("LSID").setVisible(false); + drg.getDisplayColumn("Created").setVisible(false); + + ButtonBar bb = new ButtonBar(); + bb.setStyle(ButtonBar.Style.separateButtons); + ActionButton insertButton = new ActionButton(new ActionURL(CreateRunGroupAction.class, getContainer()), "Submit", ActionButton.Action.POST); + bb.add(insertButton); + + drg.setButtonBar(bb); + + return new InsertView(drg, errors); + } + + + @Override + public boolean handlePost(CreateExperimentForm form, BindException errors) throws Exception + { + // This is strange... but the "Create new run group..." menu item on the run grid always POSTs, probably to + // allow for long lists of run IDs. This "noPost" parameter on the initial POST is used to inform the action + // that it wants to display the form, not try to save anything yet. + if (!"true".equals(getViewContext().getRequest().getParameter("noPost"))) + { + form.setAddSelectedRuns("true".equals(getViewContext().getRequest().getParameter("addSelectedRuns"))); + form.setDataRegionSelectionKey(getViewContext().getRequest().getParameter(DataRegionSelection.DATA_REGION_SELECTION_KEY)); + + Experiment exp = form.getBean(); + if (exp.getName() == null || exp.getName().trim().isEmpty()) + { + errors.reject(ERROR_MSG, "You must specify a name for the experiment"); + } + else + { + int maxNameLength = ExperimentService.get().getTinfoExperimentRun().getColumn("Name").getScale(); + if (exp.getName().length() > maxNameLength) + { + errors.reject(ERROR_MSG, "Name of the experiment must be " + maxNameLength + " characters or less."); + } + } + + String lsid; + int suffix = 1; + do + { + String template = "urn:lsid:" + XarContext.LSID_AUTHORITY_SUBSTITUTION + ":Experiment.Folder-" + XarContext.CONTAINER_ID_SUBSTITUTION + ":" + exp.getName(); + if (suffix > 1) + { + template = template + suffix; + } + suffix++; + lsid = LsidUtils.resolveLsidFromTemplate(template, new XarContext("Experiment Creation", getContainer(), getUser()), ExpExperiment.DEFAULT_CPAS_TYPE); + } + while (ExperimentService.get().getExpExperiment(lsid) != null); + exp.setLSID(lsid); + exp.setContainer(getContainer()); + + if (errors.getErrorCount() == 0) + { + ExpExperimentImpl wrapper = new ExpExperimentImpl(exp); + wrapper.save(getUser()); + + if (form.isAddSelectedRuns()) + { + addSelectedRunsToExperiment(wrapper, form.getDataRegionSelectionKey()); + } + + if (form.getReturnUrl() != null) + { + throw new RedirectException(form.getReturnUrl()); + } + throw new RedirectException(ExperimentUrlsImpl.get().getShowExperimentsURL(getContainer())); + } + } + return true; + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("runGroups"); + root.addChild("Create Run Group"); + } + + @Override + public URLHelper getSuccessURL(CreateExperimentForm createExperimentForm) + { + return null; // null is used to show the form in the case where IDs are POSTed from the grid + } + + @Override + public void validateCommand(CreateExperimentForm target, Errors errors) { } + } + + public static class MoveRunsForm implements DataRegionSelection.DataSelectionKeyForm + { + private String _targetContainerId; + private String _dataRegionSelectionKey; + + @Override + public String getDataRegionSelectionKey() + { + return _dataRegionSelectionKey; + } + + @Override + public void setDataRegionSelectionKey(String key) + { + _dataRegionSelectionKey = key; + } + + public String getTargetContainerId() + { + return _targetContainerId; + } + + public void setTargetContainerId(String targetContainerId) + { + _targetContainerId = targetContainerId; + } + } + + @RequiresPermission(DeletePermission.class) + public class MoveRunsLocationAction extends SimpleViewAction + { + @Override + public ModelAndView getView(MoveRunsForm form, BindException errors) + { + ActionURL moveURL = new ActionURL(MoveRunsAction.class, getContainer()); + PipelineRootContainerTree ct = new PipelineRootContainerTree(getUser(), moveURL) + { + private boolean _clickHandlerRegistered = false; + + @Override + protected void renderCellContents(StringBuilder html, Container c, ActionURL url, boolean hasRoot) + { + boolean renderLink = hasRoot && !c.equals(getContainer()); + + if (renderLink) + { + html.append(""); + } + html.append(PageFlowUtil.filter(c.getName())); + if (renderLink) + { + html.append(""); + } + + if (!_clickHandlerRegistered) + { + HttpView.currentPageConfig().addHandlerForQuerySelector("a.move-target-container", "click", "moveTo(this.attributes.getNamedItem('data-objectid').value);" ); + _clickHandlerRegistered = true; + } + } + }; + ct.setInitialLevel(1); + + MoveRunsBean bean = new MoveRunsBean(ct, form.getDataRegionSelectionKey()); + JspView result = new JspView<>("/org/labkey/experiment/moveRunsLocation.jsp", bean); + result.setTitle("Choose Destination Folder"); + result.setFrame(WebPartView.FrameType.PORTAL); + return result; + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("Move Runs"); + } + } + + + @RequiresPermission(DeletePermission.class) + public class MoveRunsAction extends FormHandlerAction + { + private Container _targetContainer; + + @Override + public void validateCommand(MoveRunsForm target, Errors errors) + { + } + + @Override + public boolean handlePost(MoveRunsForm form, BindException errors) + { + _targetContainer = ContainerManager.getForId(form.getTargetContainerId()); + if (_targetContainer == null || !_targetContainer.hasPermission(getUser(), InsertPermission.class)) + { + throw new UnauthorizedException(); + } + + Set runIds = DataRegionSelection.getSelectedIntegers(getViewContext(), form.getDataRegionSelectionKey(), false); + List runs = new ArrayList<>(); + for (Long runId : runIds) + { + ExpRun run = ExperimentService.get().getExpRun(runId); + if (run != null) + { + runs.add(run); + } + } + + ViewBackgroundInfo info = getViewBackgroundInfo(); + info.setContainer(_targetContainer); + + try + { + ExperimentService.get().moveRuns(info, getContainer(), runs); + if (form.getDataRegionSelectionKey() != null) + DataRegionSelection.clearAll(getViewContext(), form.getDataRegionSelectionKey()); + } + catch (IOException e) + { + throw new NotFoundException("Failed to initialize move. Check that the pipeline root is configured correctly. " + e); + } + return true; + } + + @Override + public ActionURL getSuccessURL(MoveRunsForm form) + { + return urlProvider(PipelineUrls.class).urlBegin(_targetContainer); + } + } + + public static class ShowExternalDocsForm + { + private String _objectURI; + private String _propertyURI; + + public String getObjectURI() + { + return _objectURI; + } + + public void setObjectURI(String objectURI) + { + _objectURI = objectURI; + } + + public String getPropertyURI() + { + return _propertyURI; + } + + public void setPropertyURI(String propertyURI) + { + _propertyURI = propertyURI; + } + } + + @RequiresPermission(ReadPermission.class) + public static class ShowExternalDocsAction extends SimpleViewAction + { + @Override + public ModelAndView getView(ShowExternalDocsForm form, BindException errors) throws Exception + { + Map props = OntologyManager.getPropertyObjects(getContainer(), form.getObjectURI()); + ObjectProperty prop = props.get(form.getPropertyURI()); + if (prop == null || !getContainer().equals(prop.getContainer())) + { + throw new NotFoundException(); + } + URI uri = new URI(prop.getStringValue()); + File f = new File(uri); + if (!f.exists()) + { + throw new NotFoundException(); + } + + PageFlowUtil.streamFile(getViewContext().getResponse(), new File(f.getAbsolutePath()), false); + return null; + } + + @Override + public void addNavTrail(NavTree root) + { + } + } + + + // TODO: DotGraph has been adding a "runId" parameter, but ShowGraphMoreListAction + public static ActionURL getShowGraphMoreListURL(Container c, @Nullable Long runId, @NotNull String objtype) + { + ActionURL url = new ActionURL(ShowGraphMoreListAction.class, c); + + if (null != runId) + url.addParameter("runId", runId); + + url.addParameter("objtype", objtype); + + return url; + } + + + @RequiresPermission(ReadPermission.class) + public static class ShowGraphMoreListAction extends SimpleViewAction + { + private ExperimentRunForm _form; + + @Override + public ModelAndView getView(ExperimentRunForm form, BindException errors) + { + _form = form; + return new GraphMoreGrid(getContainer(), errors, getViewContext().getActionURL()); + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild(new NavTree("Experiments", ExperimentUrlsImpl.get().getShowExperimentsURL(getContainer()))); + ExpRun run = ExperimentService.get().getExpRun(_form.getRowId()); + if (run != null) + { + root.addChild(new NavTree("Experiment Run", ExperimentUrlsImpl.get().getRunGraphURL(_form.lookupRun()))); + } + root.addChild(new NavTree("Selected Protocol Applications")); + } + } + + @RequiresPermission(DesignAssayPermission.class) + public class AssayXarFileAction extends MutatingApiAction + { + + @Override + public Object execute(Object o, BindException errors) throws Exception + { + ApiSimpleResponse response = new ApiSimpleResponse(); + + if (!(getViewContext().getRequest() instanceof MultipartHttpServletRequest)) + throw new BadRequestException("Expected MultipartHttpServletRequest when posting files."); + + if (!PipelineService.get().hasValidPipelineRoot(getContainer())) + { + return false; + } + + MultipartFile formFile = getFileMap().get("file"); + if (formFile == null) + { + errors.reject(ERROR_MSG, "No file was posted by the browser."); + return false; + } + + byte[] bytes = formFile.getBytes(); + if (bytes.length == 0) + { + errors.reject(ERROR_MSG, "No file was posted by the browser."); + return false; + } + + PipeRoot pipeRoot = PipelineService.get().findPipelineRoot(getContainer()); + FileLike systemDir = pipeRoot.ensureSystemFileLike(); + FileLike uploadDir = systemDir.resolveChild("UploadedXARs"); + FileUtil.createDirectories(uploadDir); + if (!uploadDir.isDirectory()) + { + errors.reject(ERROR_MSG, "Unable to create a 'system/UploadedXARs' directory under the pipeline root"); + return false; + } + String userDirName = getUser().getEmail(); + if (userDirName == null || userDirName.isEmpty()) + { + userDirName = GUEST_DIRECTORY_NAME; + } + FileLike userDir = uploadDir.resolveChild(userDirName); + FileUtil.createDirectories(userDir); + if (!userDir.isDirectory()) + { + errors.reject(ERROR_MSG, "Unable to create an 'UploadedXARs/" + userDirName + "' directory under the pipeline root"); + return false; + } + + FileLike xarFile = userDir.resolveChild(formFile.getOriginalFilename()); + + // As this is multi-part will need to use finally to close, to prevent a stream closure exception + try (OutputStream out = new BufferedOutputStream(xarFile.openOutputStream())) + { + out.write(bytes); + } + catch (IOException e) + { + errors.reject(ERROR_MSG, "Unable to write uploaded XAR file to " + xarFile); + return false; + } + //noinspection EmptyCatchBlock + + ExperimentPipelineJob job = new ExperimentPipelineJob(getViewBackgroundInfo(), xarFile, + "Uploaded file", true, pipeRoot); + PipelineService.get().queueJob(job); + + response.put("success", true); + return response; + } + } + + @RequiresPermission(InsertPermission.class) + public class ImportXarFileAction extends FormHandlerAction + { + @Override + public void validateCommand(ImportXarForm target, Errors errors) + { + } + + @Override + public boolean handlePost(ImportXarForm form, BindException errors) throws Exception + { + for (FileLike f : form.getValidatedFiles(getContainer())) + { + if (f.isFile()) + { + ExperimentPipelineJob job = new ExperimentPipelineJob(getViewBackgroundInfo(), f, "Experiment Import", false, form.getPipeRoot(getContainer())); + + // TODO: Configure module resources with the appropriate log location per container + if (form.getModule() != null) + { + FileLike logFile = form.getPipeRoot(getContainer()).getLogDirectoryFileLike(true).resolveChild("module-resource-xar.log"); + job.setLogFile(logFile); + } + + PipelineService.get().queueJob(job); + } + else + { + throw new NotFoundException("Expected a file but found a directory: " + f.getName()); + } + } + + return true; + } + + @Override + public URLHelper getSuccessURL(ImportXarForm importXarForm) + { + return getContainer().getStartURL(getUser()); + } + } + + + @RequiresPermission(InsertPermission.class) + public class ImportXarAction extends MutatingApiAction + { + @Override + public Object execute(ImportXarForm form, BindException errors) throws Exception + { + ApiSimpleResponse response = new ApiSimpleResponse(); + + List> archives = new ArrayList<>(); + for (FileLike f : form.getValidatedFiles(getContainer())) + { + Map archive = new HashMap<>(); + ExperimentPipelineJob job = new ExperimentPipelineJob(getViewBackgroundInfo(), f, "Experiment Import", false, form.getPipeRoot(getContainer())); + + // TODO: Configure module resources with the appropriate log location per container + if (form.getModule() != null) + { + FileLike logFile = form.getPipeRoot(getContainer()).getLogDirectoryFileLike(true).resolveChild("module-resource-xar.log"); + job.setLogFile(logFile); + } + + PipelineService.get().queueJob(job); + + archive.put("file", f.getName()); + archive.put("job", job.getJobGUID()); + archive.put("path", form.getPath()); // echo back the public path + + archives.add(archive); + } + + response.put("success", true); + response.put("archives", archives); + + return response; + } + } + + + /** + * User: jeckels + * Date: Jan 27, 2008 + */ + public static class ExperimentUrlsImpl implements ExperimentUrls + { + public ActionURL getOverviewURL(Container c) + { + return new ActionURL(BeginAction.class, c); + } + + @Override + public ActionURL getExperimentDetailsURL(Container c, ExpExperiment expExperiment) + { + return new ActionURL(DetailsAction.class, c).addParameter("rowId", expExperiment.getRowId()); + } + + public ActionURL getShowSampleURL(Container c, ExpMaterial material) + { + return getMaterialDetailsBaseURL(c, null).addParameter("rowId", material.getRowId()); + } + + @Override + public ActionURL getExportProtocolURL(Container container, ExpProtocol protocol) + { + return new ActionURL(ExperimentController.ExportProtocolsAction.class, container). + addParameter("protocolId", protocol.getRowId()). + addParameter("xarFileName", protocol.getName() + ".xar"); + } + + @Override + public ActionURL getMoveRunsLocationURL(Container container) + { + return new ActionURL(ExperimentController.MoveRunsLocationAction.class, container); + } + + @Override + public ActionURL getProtocolDetailsURL(ExpProtocol protocol) + { + return new ActionURL(ProtocolDetailsAction.class, protocol.getContainer()).addParameter("rowId", protocol.getRowId()); + } + + @Override + public ActionURL getProtocolApplicationDetailsURL(ExpProtocolApplication app) + { + return getShowApplicationURL(app.getContainer(), app.getRowId()); + } + + public ActionURL getProtocolGridURL(Container c) + { + return new ActionURL(ShowProtocolGridAction.class, c); + } + + public ActionURL getRunGraphDetailURL(ExpRun run) + { + return getShowRunGraphDetailURL(run.getContainer(), run.getRowId()); + } + + @Override + public ActionURL getRunGraphDetailURL(ExpRun run, @Nullable ExpData focus) + { + return getRunGraphDetailURL(run, focus, DotGraph.TYPECODE_DATA); + } + + public ActionURL getRunGraphDetailURL(ExpRun run, @Nullable ExpMaterial focus) + { + return getRunGraphDetailURL(run, focus, DotGraph.TYPECODE_MATERIAL); + } + + public ActionURL getRunGraphDetailURL(ExpRun run, @Nullable ExpProtocolApplication focus) + { + return getRunGraphDetailURL(run, focus, DotGraph.TYPECODE_PROT_APP); + } + + private ActionURL getRunGraphDetailURL(ExpRun run, @Nullable ExpObject focus, String typeCode) + { + ActionURL result = getShowRunGraphDetailURL(run.getContainer(), run.getRowId()); + result.addParameter("detail", "true"); + if (focus != null) + { + result.addParameter("focus", typeCode + focus.getRowId()); + } + return result; + } + + @Override + public ActionURL getRunGraphURL(Container container, long runId) + { + return ExperimentController.getRunGraphURL(container, runId); + } + + @Override + public ActionURL getRunGraphURL(ExpRun run) + { + return getRunGraphURL(run.getContainer(), run.getRowId()); + } + + @Override + public ActionURL getRunTextURL(Container c, long runId) + { + return new ActionURL(ShowRunTextAction.class, c).addParameter("rowId", runId); + } + + @Override + public ActionURL getRunTextURL(ExpRun run) + { + return getRunTextURL(run.getContainer(), run.getRowId()); + } + + @Override + public ActionURL getDeleteExperimentsURL(Container container, URLHelper returnUrl) + { + return new ActionURL(DeleteSelectedExperimentsAction.class, container).addReturnUrl(returnUrl); + } + + @Override + public ActionURL getDeleteProtocolURL(@NotNull ExpProtocol protocol, URLHelper returnUrl) + { + ActionURL result = new ActionURL(DeleteProtocolByRowIdsAction.class, protocol.getContainer()); + result.addParameter("singleObjectRowId", protocol.getRowId()); + if (returnUrl != null) + { + result.addReturnUrl(returnUrl); + } + return result; + } + + @Override + public ActionURL getAddRunsToExperimentURL(Container c, ExpExperiment exp) + { + return new ActionURL(AddRunsToExperimentAction.class, c).addParameter("expRowId", exp.getRowId()); + } + + @Override + public ActionURL getShowRunsURL(Container c, ExperimentRunType type) + { + ActionURL result = new ActionURL(ShowRunsAction.class, c); + result.addParameter("experimentRunFilter", type.getDescription()); + return result; + } + + public ActionURL getShowExperimentsURL(Container c) + { + return new ActionURL(ShowRunGroupsAction.class, c); + } + + @Override + public ActionURL getShowSampleTypeListURL(Container c) + { + return getShowSampleTypeListURL(c, null); + } + + @Override + public ActionURL getShowSampleTypeURL(ExpSampleType sampleType) + { + return getShowSampleTypeURL(sampleType, sampleType.getContainer()); + } + + @Override + public ActionURL getShowSampleTypeURL(ExpSampleType sampleType, Container container) + { + return new ActionURL(ShowSampleTypeAction.class, container).addParameter("rowId", sampleType.getRowId()); + } + + public ActionURL getExperimentListURL(Container container) + { + return new ActionURL(ShowRunGroupsAction.class, container); + } + + public ActionURL getShowSampleTypeListURL(Container c, String errorMessage) + { + ActionURL url = new ActionURL(ListSampleTypesAction.class, c); + if (errorMessage != null) + { + url.addParameter("errorMessage", errorMessage); + } + return url; + } + + @Override + public ActionURL getDataClassListURL(Container c) + { + return getDataClassListURL(c, null); + } + + public ActionURL getDataClassListURL(Container c, String errorMessage) + { + ActionURL url = new ActionURL(ListDataClassAction.class, c); + if (errorMessage != null) + { + url.addParameter("errorMessage", errorMessage); + } + return url; + } + + @Override + public ActionURL getDeleteDatasURL(Container c, URLHelper returnUrl) + { + ActionURL url = new ActionURL(DeleteSelectedDataAction.class, c); + if (returnUrl != null) + url.addReturnUrl(returnUrl); + return url; + } + + public ActionURL getDeleteSelectedExperimentsURL(Container c, URLHelper returnUrl) + { + ActionURL result = new ActionURL(DeleteSelectedExperimentsAction.class, c); + if (returnUrl != null) + result.addReturnUrl(returnUrl); + return result; + } + + @Override + public ActionURL getDeleteSelectedExpRunsURL(Container container, URLHelper returnUrl) + { + return new ActionURL(DeleteSelectedExpRunsAction.class, container).addReturnUrl(returnUrl); + } + + public ActionURL getShowUpdateURL(ExpExperiment experiment) + { + return new ActionURL(ShowUpdateAction.class, experiment.getContainer()).addParameter("rowId", experiment.getRowId()); + } + + @Override + public ActionURL getRemoveSelectedExpRunsURL(Container container, URLHelper returnUrl, ExpExperiment exp) + { + return new ActionURL(RemoveSelectedExpRunsAction.class, container).addReturnUrl(returnUrl).addParameter("expRowId", exp.getRowId()); + } + + @Override + public ActionURL getCreateRunGroupURL(Container container, URLHelper returnUrl, boolean addSelectedRuns) + { + ActionURL result = new ActionURL(CreateRunGroupAction.class, container); + if (returnUrl != null) + { + result.addReturnUrl(returnUrl); + } + if (addSelectedRuns) + { + result.addParameter("addSelectedRuns", "true"); + } + return result; + } + + + public static ExperimentUrlsImpl get() + { + return (ExperimentUrlsImpl) urlProvider(ExperimentUrls.class); + } + + public ActionURL getDownloadGraphURL(ExpRun run, boolean detail, String focus, String focusType) + { + ActionURL result = new ActionURL(DownloadGraphAction.class, run.getContainer()); + result.addParameter("rowId", run.getRowId()).addParameter("detail", detail); + if (focus != null) + { + result.addParameter("focus", focus); + } + if (focusType != null) + { + result.addParameter("focusType", focusType); + } + return result; + } + + public ActionURL getBeginURL(Container container) + { + return new ActionURL(BeginAction.class, container); + } + + @Override + public ActionURL getDomainEditorURL(Container container, String domainURI, boolean createOrEdit) + { + Domain domain = PropertyService.get().getDomain(container, domainURI); + if (domain != null) + return getDomainEditorURL(container, domain); + + ActionURL url = new ActionURL(PropertyController.EditDomainAction.class, container); + url.addParameter("domainURI", domainURI); + if (createOrEdit) + url.addParameter("createOrEdit", true); + return url; + } + + @Override + public ActionURL getDomainEditorURL(Container container, Domain domain) + { + ActionURL url = new ActionURL(PropertyController.EditDomainAction.class, container); + url.addParameter("domainId", domain.getTypeId()); + return url; + } + + @Override + public ActionURL getCreateDataClassURL(Container container) + { + return new ActionURL(EditDataClassAction.class, container); + } + + @Override + public ActionURL getShowDataClassURL(Container container, long rowId) + { + ActionURL url = new ActionURL(ShowDataClassAction.class, container); + url.addParameter("rowId", rowId); + return url; + } + + @Override + public ActionURL getShowFileURL(ExpData data, boolean inline) + { + ActionURL result = getShowFileURL(data.getContainer()).addParameter("rowId", data.getRowId()); + if (inline) + { + result.addParameter("inline", inline); + } + return result; + } + + @Override + public ActionURL getMaterialDetailsURL(ExpMaterial material) + { + return getMaterialDetailsURL(material.getContainer(), material.getRowId()); + } + + @Override + public ActionURL getMaterialDetailsURL(Container c, long materialRowId) + { + return getMaterialDetailsBaseURL(c, null).addParameter("rowId", materialRowId); + } + + @Override + public ActionURL getMaterialDetailsBaseURL(Container c, @Nullable String materialIdFieldKey) + { + return new ActionURL(ShowMaterialAction.class, c); + } + + @Override + public ActionURL getCreateSampleTypeURL(Container container) + { + return new ActionURL(EditSampleTypeAction.class, container); + } + + @Override + public ActionURL getImportSamplesURL(Container container, String sampleTypeName) + { + ActionURL url = new ActionURL(ImportSamplesAction.class, container); + url.addParameter("query.queryName", sampleTypeName); + url.addParameter("schemaName", "exp.materials"); + return url; + } + + @Override + public ActionURL getImportDataURL(Container container, String dataClassName) + { + ActionURL url = new ActionURL(ImportDataAction.class, container); + url.addParameter("query.queryName", dataClassName); + url.addParameter("schemaName", "exp.data"); + return url; + } + + @Override + public ActionURL getDataDetailsURL(ExpData data) + { + return new ActionURL(ShowDataAction.class, data.getContainer()).addParameter("rowId", data.getRowId()); + } + + @Override + public ActionURL getShowFileURL(Container c) + { + return new ActionURL(ShowFileAction.class, c); + } + + @Override + public ActionURL getSetFlagURL(Container container) + { + return new ActionURL(SetFlagAction.class, container); + } + + @Override + public ActionURL getShowRunGraphURL(ExpRun run) + { + return ExperimentController.getRunGraphURL(run.getContainer(), run.getRowId()); + } + + @Override + public ActionURL getRepairTypeURL(Container container) + { + return new ActionURL(TypesController.RepairAction.class, container); + } + + @Override + public ActionURL getUpdateMaterialQueryRowAction(Container c, TableInfo table) + { + ActionURL url = new ActionURL(UpdateMaterialQueryRowAction.class, c); + url.addParameter("schemaName", "samples"); + url.addParameter(QueryView.DATAREGIONNAME_DEFAULT + "." + QueryParam.queryName, table.getName()); + + return url; + } + + @Override + public ActionURL getInsertMaterialQueryRowAction(Container c, TableInfo table) + { + ActionURL url = new ActionURL(InsertMaterialQueryRowAction.class, c); + url.addParameter(QueryView.DATAREGIONNAME_DEFAULT + "." + QueryParam.queryName, table.getName()); + + return url; + } + + @Override + public ActionURL getDataClassAttachmentDownloadAction(Container c) + { + return new ActionURL(ExperimentController.DataClassAttachmentDownloadAction.class, c); + } + + } + + private static abstract class BaseResolveLsidApiAction extends ReadOnlyApiAction + { + protected Set _seeds; + + @Override + public void validateForm(F form, Errors errors) + { + if (null != form.getLsids()) + { + _seeds = new LinkedHashSet<>(form.getLsids().size()); + for (String lsid : form.getLsids()) + { + Identifiable id = LsidManager.get().getObject(lsid); + if (id == null) + throw new NotFoundException("Unable to resolve object: " + lsid); + + // ensure the user has read permission in the seed container + if (!getContainer().equals(id.getContainer())) + { + if (!id.getContainer().hasPermission(getUser(), ReadPermission.class)) + throw new UnauthorizedException("User does not have permission to read object: " + lsid); + } + + _seeds.add(id); + } + } + else + { + throw new ApiUsageException("Starting lsids required"); + } + } + } + + @RequiresPermission(ReadPermission.class) + public static class ResolveAction extends BaseResolveLsidApiAction + { + @Override + public Object execute(ResolveLsidsForm form, BindException errors) + { + var settings = new ExperimentJSONConverter.Settings(form.isIncludeProperties(), form.isIncludeInputsAndOutputs(), form.isIncludeRunSteps()); + var data = _seeds.stream().map(n -> ExperimentJSONConverter.serialize(n, getUser(), settings)).collect(toList()); + return new ApiSimpleResponse("data", data); + } + } + + @RequiresPermission(ReadPermission.class) + public static class LineageAction extends BaseResolveLsidApiAction + { + @Override + public Object execute(ExpLineageOptions options, BindException errors) throws Exception + { + ExpLineageServiceImpl.get().streamLineage(getContainer(), getUser(), getViewContext().getResponse(), _seeds, options); + return null; + } + } + + @Marshal(Marshaller.Jackson) + @RequiresPermission(AdminPermission.class) + public static class RebuildEdgesAction extends MutatingApiAction + { + @Override + public Object execute(ExperimentRunForm form, BindException errors) + { + if (form.getRowId() != 0 || form.getLsid() != null) + { + ExpRun run = form.lookupRun(); + if (!run.getContainer().hasPermission(getUser(), ReadPermission.class)) + throw new UnauthorizedException("Not permitted"); + + ExperimentServiceImpl.get().syncRunEdges(run); + } + else + { + // should this require site admin permissions? + ExperimentServiceImpl.get().rebuildAllRunEdges(); + } + return success(); + } + } + + private static class VerifyEdgesForm extends ExperimentRunForm + { + private Integer _limit; + + public Integer getLimit() + { + return _limit; + } + + public void setLimit(Integer limit) + { + _limit = limit; + } + } + + @Marshal(Marshaller.Jackson) + @RequiresPermission(AdminPermission.class) + public static class VerifyEdgesAction extends ReadOnlyApiAction + { + @Override + public Object execute(VerifyEdgesForm form, BindException errors) + { + if (form.getRowId() != 0 || form.getLsid() != null) + { + ExpRun run = form.lookupRun(); + if (!run.getContainer().hasPermission(getUser(), ReadPermission.class)) + throw new UnauthorizedException("Not permitted"); + + ExperimentServiceImpl.get().verifyRunEdges(run); + } + else + { + ExperimentServiceImpl.get().verifyAllEdges(getContainer(), form.getLimit()); + } + return success(); + } + } + + @Marshal(Marshaller.Jackson) + @RequiresPermission(AdminPermission.class) + public static class RebuildAncestorsAction extends MutatingApiAction + { + @Override + public Object execute(Object form, BindException errors) + { + ClosureQueryHelper.truncateAndRecreate(); + return success(); + } + } + + + @Marshal(Marshaller.Jackson) + @RequiresPermission(AdminPermission.class) + public static class CheckDataClassesIndexedAction extends ReadOnlyApiAction + { + @Override + public Object execute(Object o, BindException errors) throws Exception + { + List> notInIndex = new ArrayList<>(100); + + List list = ExperimentService.get().getDataClasses(getContainer(), getUser(), false); + for (ExpDataClass dc : list) + { + for (ExpData d : dc.getDatas()) + { + String docId = d.getDocumentId(); + if (docId != null) + { + SearchService.SearchHit hit = SearchService.get().find(docId); + if (hit == null) + { + JSONObject props = ExperimentJSONConverter.serializeData(d, getUser(), ExperimentJSONConverter.DEFAULT_SETTINGS); + props.put("docid", docId); + notInIndex.add(props.toMap()); + } + } + } + } + + return success(notInIndex); + } + } + + @Marshal(Marshaller.Jackson) + @RequiresPermission(AdminPermission.class) + public static class CheckEdgesAction extends ReadOnlyApiAction + { + @Override + public Object execute(Object o, BindException errors) throws Exception + { + List result; + DbSchema schema = ExperimentService.get().getSchema(); + TableInfo edgeTable = schema.getTable("Edge"); + + if (null != edgeTable.getColumn("fromObjectId")) + { + var edges = new SqlSelector(ExperimentService.get().getSchema(), "SELECT fromObjectId, toObjectId FROM exp.Edge") + .resultSetStream() + .map(r -> { try { return new Pair<>(r.getInt(1), r.getInt(2)); } catch (SQLException x) { throw new RuntimeException(x); } }) + .collect(toList()); + var cycles = (new GraphAlgorithms()).detectCycleInDirectedGraph(edges); + result = cycles.stream().map(e -> new Integer[]{e.first, e.second}).collect(toList()); + } + else + { + var edges = new SqlSelector(ExperimentService.get().getSchema(), "SELECT fromLsid, toLsid FROM exp.Edge") + .resultSetStream() + .map(r -> { try { return new Pair<>(r.getString(1), r.getString(2)); } catch (SQLException x) { throw new RuntimeException(x); } }) + .collect(toList()); + var cycles = (new GraphAlgorithms()).detectCycleInDirectedGraph(edges); + result = cycles.stream().map(e -> new String[]{e.first, e.second}).collect(toList()); + } + + JSONObject ret = new JSONObject(); + ret.put("result", result); + ret.put("success", true); + return ret; + } + } + + @RequiresPermission(UpdatePermission.class) + public static class UpdateMaterialQueryRowAction extends UserSchemaAction + { + @Override + protected QueryForm createQueryForm(ViewContext context) + { + QueryForm form = new QueryForm("samples", null); + form.setViewContext(getViewContext()); + form.bindParameters(getViewContext().getBindPropertyValues()); + return form; + } + + @Override + public BindException bindParameters(PropertyValues m) throws Exception + { + BindException bind = super.bindParameters(m); + + QueryUpdateForm tableForm = (QueryUpdateForm)bind.getTarget(); + + int sampleId; + try + { + sampleId = Integer.parseInt((String) tableForm.getPkVal()); + } + catch (NumberFormatException e) + { + throw new NotFoundException("Invalid RowId: " + tableForm.getPkVal()); + } + + ExpMaterial material = ExperimentService.get().getExpMaterial(sampleId); + if (material == null) + throw new NotFoundException("Invalid material: " + tableForm.getPkVal()); + + return bind; + } + + @Override + public ModelAndView getView(QueryUpdateForm tableForm, boolean reshow, BindException errors) + { + int sampleId = Integer.parseInt((String) tableForm.getPkVal()); + + ExpMaterial material = ExperimentService.get().getExpMaterial(sampleId); + if (material == null) + throw new NotFoundException("Invalid material: " + tableForm.getPkVal()); + + boolean isAliquot = !StringUtils.isEmpty(material.getAliquotedFromLSID()); + + TableInfo tableInfo = tableForm.getTable(); + Map scopedFields = new CaseInsensitiveHashMap<>(); + for (DomainProperty dp : tableInfo.getDomain().getProperties()) + { + if (!ExpSchema.DerivationDataScopeType.All.name().equalsIgnoreCase(dp.getDerivationDataScope())) + scopedFields.put(dp.getName(), ExpSchema.DerivationDataScopeType.ChildOnly.name().equalsIgnoreCase(dp.getDerivationDataScope())); + } + + for (var column : tableInfo.getColumns()) + { + String columnName = column.getName(); + if (scopedFields.containsKey(columnName)) + { + boolean isAliquotField = scopedFields.get(columnName); + boolean show = (isAliquot && isAliquotField) || (!isAliquot && !isAliquotField); + ((BaseColumnInfo)column).setUserEditable(show); + ((BaseColumnInfo)column).setHidden(!show); + } + } + + ButtonBar bb = createSubmitCancelButtonBar(tableForm); + UpdateView view = new UpdateView(tableForm, errors); + view.getDataRegion().setButtonBar(bb); + return view; + } + + @Override + public boolean handlePost(QueryUpdateForm tableForm, BindException errors) + { + doInsertUpdate(tableForm, errors, false); + return 0 == errors.getErrorCount(); + } + + @Override + public void addNavTrail(NavTree root) + { + super.addNavTrail(root); + root.addChild("Edit " + _form.getQueryName()); + } + } + + @RequiresPermission(InsertPermission.class) + public static class InsertMaterialQueryRowAction extends UserSchemaAction + { + @Override + protected QueryForm createQueryForm(ViewContext context) + { + QueryForm form = new QueryForm("samples", null); + form.setViewContext(getViewContext()); + form.bindParameters(getViewContext().getBindPropertyValues()); + + return form; + } + + @Override + public ModelAndView getView(QueryUpdateForm tableForm, boolean reshow, BindException errors) + { + TableInfo tableInfo = tableForm.getTable(); + Map propertyFields = new CaseInsensitiveHashMap<>(); + for (DomainProperty dp : tableInfo.getDomain().getProperties()) + { + propertyFields.put(dp.getName(), ExpSchema.DerivationDataScopeType.ChildOnly.name().equalsIgnoreCase(dp.getDerivationDataScope())); + } + + for (var column : tableInfo.getColumns()) + { + String columnName = column.getName(); + if (propertyFields.containsKey(columnName)) + { + boolean isAliquotField = propertyFields.get(columnName); + ((BaseColumnInfo)column).setUserEditable(!isAliquotField); + ((BaseColumnInfo)column).setHidden(isAliquotField); + } + } + + InsertView view = new InsertView(tableForm, errors); + view.getDataRegion().setButtonBar(createSubmitCancelButtonBar(tableForm)); + return view; + } + + @Override + public boolean handlePost(QueryUpdateForm tableForm, BindException errors) + { + doInsertUpdate(tableForm, errors, true); + return 0 == errors.getErrorCount(); + } + + @Override + public void addNavTrail(NavTree root) + { + super.addNavTrail(root); + root.addChild("Insert " + _form.getQueryName()); + } + } + + @Marshal(Marshaller.Jackson) + @RequiresPermission(ReadPermission.class) + public static class SaveFindIdsAction extends ReadOnlyApiAction + { + + public static final String FIND_BY_IDS_SESSION_KEY_PREFIX = "findByIds"; + + @Override + public Object execute(FindByIdsForm form, BindException errors) throws Exception + { + HttpServletRequest request = getViewContext().getRequest(); + String key = form.getSessionKey(); + boolean removePrevious = false; + + if (key == null) + { + removePrevious = true; + key = FIND_BY_IDS_SESSION_KEY_PREFIX + "_" + UniqueID.getServerSessionScopedUID(); + } + + if (request != null) + { + if (removePrevious) + SessionHelper.clearAttributesWithPrefix(request, FIND_BY_IDS_SESSION_KEY_PREFIX); + HttpSession session = request.getSession(false); + if (session != null) + { + @SuppressWarnings("unchecked") + List existingIds = (List) session.getAttribute(key); + + // deduplicate from existing ids + if (existingIds != null && form.getSessionKey() != null) + { + existingIds.addAll(form.getIds().stream().filter(id -> !existingIds.contains(id)).toList()); + session.setAttribute(key, existingIds); + } + else + { + session.setAttribute(key, form.getIds()); + } + return success("Saved ids to session key", key); + } + } + + return new SimpleResponse<>(false, "Unable to save to session. Session or request may be null."); + } + } + + @Marshal(Marshaller.Jackson) + @RequiresPermission(ReadPermission.class) + public static class SaveOrderedSamplesQueryAction extends ReadOnlyApiAction + { + private static final String SAMPLE_ID_PREFIX = "s:"; + private static final String UNIQUE_ID_PREFIX = "u:"; + + private List _ids; + private Map> _uniqueIdLsids; + + @Override + public void validateForm(FindByIdsForm form, Errors errors) + { + if (form.getSessionKey() == null) + errors.reject(ERROR_REQUIRED, "sessionKey must be provided"); + else + { + _ids = getFindIdsFromSession(form.getSessionKey()); + if (_ids == null || _ids.isEmpty()) + errors.reject(ERROR_REQUIRED, "No ids found corresponding to session key " + form.getSessionKey()); + } + } + + private void ensureUniqueIdLsids() + { + boolean hasUniqueId = _ids.stream().anyMatch(s -> s.startsWith(UNIQUE_ID_PREFIX)); + if (hasUniqueId && _uniqueIdLsids == null) + { + List uniqueIds = _ids.stream().map(s -> s.substring(UNIQUE_ID_PREFIX.length())).toList(); + _uniqueIdLsids = ExperimentService.get().getUniqueIdLsids(uniqueIds, getUser(), getContainer()); + } + } + + @Override + public Object execute(FindByIdsForm form, BindException errors) throws Exception + { + ensureUniqueIdLsids(); + + SQLFragment select = getOrderedRowsSql(); + // need to set the key field so selections are possible + // need the SampleTypeUnits so we will display using that unit + String metadata = + """ + + + + + true + true + + + true + + + true + + +
+
"""; + QueryDefinition def = QueryService.get().saveSessionQuery(getViewContext(), getContainer(), ExperimentServiceImpl.getExpSchema().getName(), select.getSQL(), metadata); + return success("Session query created", Map.of("queryName", def.getName(), "ids", _ids)); + } + + + private List getFindIdsFromSession(String sessionKey) + { + HttpServletRequest request = getViewContext().getRequest(); + List ids = new ArrayList<>(); + if (request != null) + { + HttpSession session = request.getSession(false); + if (session != null) + { + ids = (List) session.getAttribute(sessionKey); + } + } + return ids; + } + + private SQLFragment getOrderedRowsSql() + { + boolean isFMEnabled = InventoryService.isFreezerManagementEnabled(getContainer()); + String samplesTable = isFMEnabled ? "inventory.SampleItems" : "exp.materials"; + List orderedIdCols = new ArrayList<>(Arrays.asList("Id AS ProvidedID", "RowId", "Ordinal")); + List sampleColumns = new ArrayList<>(); + if (!isFMEnabled) + { + sampleColumns.addAll(Arrays.asList( + "S.Name AS SampleID", + "S.MaterialExpDate AS ExpirationDate", + "S.SampleSet as SampleType", + "S.SampleState", + "S.isAliquot", + "S.Created", + "S.CreatedBy" + )); + } + else + { + sampleColumns.addAll(Arrays.asList( + "S.Name AS SampleID", + "S.MaterialExpDate AS ExpirationDate", + "S.LabelColor", + "S.SampleSet", + "S.SampleState", + "S.StoredAmount", + "S.Units", + "S.SampleTypeUnits", + "S.FreezeThawCount", + "S.StorageStatus", + "S.CheckedOutBy", + "S.StorageLocation", + "S.StorageRow", + "S.StorageCol", + "S.StoragePositionNumber", + "S.IsAliquot", + "S.Created", + "S.CreatedBy" + )); + } + + + String sampleIdComma = ""; + String uniqueIdComma = ""; + int index = 1; + SQLFragment sampleIdValuesSql = new SQLFragment(); + SQLFragment uniqueIdValuesSql = new SQLFragment(); + for (String id : _ids) + { + if (id.startsWith(SAMPLE_ID_PREFIX)) + { + sampleIdValuesSql.append(sampleIdComma).append("\t(").appendValue(index); + sampleIdValuesSql.append(", "); + sampleIdValuesSql.append(LabKeySql.quoteString(id.substring(SAMPLE_ID_PREFIX.length()))); + sampleIdValuesSql.append(", "); + sampleIdValuesSql.append(LabKeySql.quoteString("null")); + sampleIdValuesSql.append(")"); + sampleIdComma = "\n,"; + } + else if (id.startsWith(UNIQUE_ID_PREFIX)) + { + String idClean = id.substring(UNIQUE_ID_PREFIX.length()); + + List lsids = _uniqueIdLsids.get(idClean); + if (lsids != null) + { + for (String lsid : lsids) + { + uniqueIdValuesSql.append(uniqueIdComma).append("\t(").appendValue(index); + uniqueIdValuesSql.append(", "); + uniqueIdValuesSql.append(LabKeySql.quoteString(idClean)); + uniqueIdValuesSql.append(", "); + uniqueIdValuesSql.append(LabKeySql.quoteString(lsid)); + uniqueIdValuesSql.append(")"); + uniqueIdComma = "\n,"; + } + } + } + index++; + } + + boolean haveData = !sampleIdValuesSql.isEmpty() || !_uniqueIdLsids.isEmpty(); + SQLFragment sql = new SQLFragment(); + if (!sampleIdValuesSql.isEmpty()) + { + sql.append("WITH _ordered_ids_ AS (\nSELECT * FROM (VALUES\n"); + sql.append(sampleIdValuesSql); + sql.append("\n) AS _values_ )\n"); // name of the alias here doesn't matter + } + if (!uniqueIdValuesSql.isEmpty()) + { + if (!sampleIdValuesSql.isEmpty()) + sql.append(",\n"); + else + sql.append("WITH "); + + sql.append("_ordered_unique_ids_ AS (\nSELECT * FROM (VALUES\n"); + sql.append(uniqueIdValuesSql); + sql.append("\n) AS _values_ )\n"); // name of the alias here doesn't matter + } + + sql.append("SELECT "); + sql.append("\n\tOID.").append(StringUtils.join(orderedIdCols, ",\n\tOID.")); + sql.append(",\n\t").append(StringUtils.join( sampleColumns, ",\n\t")); + sql.append("\nFROM\n("); + if (!sampleIdValuesSql.isEmpty()) + { + sql.append("SELECT\n\tM.RowId,\n\t_ordered_ids_.column1 as Ordinal,\n\t_ordered_ids_.column2 as Id,\n\t_ordered_ids_.column2 as lsid"); + sql.append("\nFROM _ordered_ids_\n"); + sql.append("INNER JOIN exp.materials M ON _ordered_ids_.column2 = M.Name"); + sql.append("\n"); + } + if (!uniqueIdValuesSql.isEmpty()) + { + if (!sampleIdValuesSql.isEmpty()) + sql.append("\nUNION ALL\n\n"); + + sql.append("SELECT\n\tM.RowId,\n\t_ordered_unique_ids_.column1 as Ordinal,\n\t_ordered_unique_ids_.column2 as Id,\n\t_ordered_unique_ids_.column3 as lsid"); + sql.append("\nFROM _ordered_unique_ids_\n"); + sql.append("INNER JOIN exp.materials M ON _ordered_unique_ids_.column3 = M.lsid"); + sql.append("\n"); + } + if (!haveData) // no data to return but return data in the expected shape. + { + sql = new SQLFragment("SELECT\n"); + sql.append(orderedIdCols.stream() + .map(col -> { + int asIndex = col.indexOf("AS"); + if (asIndex > 0) + return "NULL AS " + col.substring(asIndex+ 3); + else + return "NULL AS " + col; + }) + .collect(Collectors.joining(",\t\n"))); + sql.append(",\t\n").append(StringUtils.join(sampleColumns, ",\t\n")); + sql.append("\nFROM ").append(samplesTable).append(" S WHERE 1 = 2"); + return sql; + } + else + { + sql.append(") OID"); + if (isFMEnabled) + sql.append("\nLEFT JOIN inventory.SampleItems S on S.RowId = OID.RowId"); + else + sql.append("\nINNER JOIN exp.materials S on S.RowId = OID.RowId"); + sql.append("\n\nORDER BY Ordinal"); + return sql; + } + } + } + + public static class FindByIdsForm extends FindSessionKeyForm + { + List _ids; + + public List getIds() + { + return _ids; + } + + public void setIds(List ids) + { + _ids = ids; + } + } + + + public static class FindSessionKeyForm + { + private String _sessionKey; + + public String getSessionKey() + { + return _sessionKey; + } + + public void setSessionKey(String sessionKey) + { + _sessionKey = sessionKey; + } + } + + static void validateEntitySequenceForm(EntitySequenceForm form, Errors errors) + { + String kindName = form.getKindName(); + if (StringUtils.isEmpty(kindName) || form.getSeqType() == null) + { + errors.reject(ERROR_REQUIRED, "KindName and SeqType must be provided"); + return; + } + + if (form.getSeqType() == NameGenerator.EntityCounter.genId) + { + if (form.getRowId() == null) + errors.reject(ERROR_REQUIRED, "Data type RowId must be provided for genId"); + } + else if (!SampleTypeDomainKind.NAME.equalsIgnoreCase(kindName)) + { + errors.reject(ERROR_MSG, form.getSeqType() + " is not supported for " + kindName); + } + + if (!SampleTypeDomainKind.NAME.equalsIgnoreCase(kindName) && !DataClassDomainKind.NAME.equalsIgnoreCase(kindName)) + errors.reject(ERROR_MSG, "Invalid KindName. Should be either " + SampleTypeDomainKind.NAME + " or " + DataClassDomainKind.NAME + "."); + + } + + @RequiresPermission(ReadPermission.class) + public static class GetEntitySequenceAction extends ReadOnlyApiAction + { + @Override + public void validateForm(EntitySequenceForm form, Errors errors) + { + validateEntitySequenceForm(form, errors); + } + + @Override + public Object execute(EntitySequenceForm form, BindException errors) throws Exception + { + long value = -1; + if (SampleTypeDomainKind.NAME.equalsIgnoreCase(form.getKindName())) + { + if (form.getSeqType() == NameGenerator.EntityCounter.genId) + { + ExpSampleType sampleType = SampleTypeService.get().getSampleType(form.getRowId()); + if (sampleType != null) + value = sampleType.getCurrentGenId(); + } + else + { + value = SampleTypeService.get().getCurrentCount(form.getSeqType(), getContainer()); + } + + } + else if (DataClassDomainKind.NAME.equalsIgnoreCase(form.getKindName())) + { + ExpDataClass dataClass = ExperimentService.get().getDataClass(form.getRowId()); + if (dataClass != null) + value = dataClass.getCurrentGenId(); + } + + ApiSimpleResponse resp = new ApiSimpleResponse(); + resp.put("success", value > -1); + resp.put("value", value); + return resp; + } + } + + @RequiresPermission(ReadPermission.class) // actual permission checked later + public static class SetEntitySequenceAction extends MutatingApiAction + { + @Override + public void validateForm(EntitySequenceForm form, Errors errors) + { + validateEntitySequenceForm(form, errors); + + if (form.getNewValue() == null || form.getNewValue() < 0) + errors.reject(ERROR_MSG, "Invalid newValue."); + } + + @Override + public Object execute(EntitySequenceForm form, BindException errors) + { + ApiSimpleResponse resp = new ApiSimpleResponse(); + resp.put("success", true); + + try + { + Domain domain = null; + if (SampleTypeDomainKind.NAME.equalsIgnoreCase(form.getKindName())) + { + if (form.getSeqType() == NameGenerator.EntityCounter.genId) + { + if (!getContainer().hasPermission(getUser(), DesignSampleTypePermission.class)) + throw new UnauthorizedException("Insufficient permissions."); + + ExpSampleType sampleType = SampleTypeService.get().getSampleType(form.getRowId()); + if (sampleType != null) + { + sampleType.ensureMinGenId(form.getNewValue()); + domain = sampleType.getDomain(); + } + else + { + resp.put("success", false); + resp.put("error", "Sample type does not exist."); + } + } + else + { + if (!getContainer().hasPermission(getUser(), AdminPermission.class)) + throw new UnauthorizedException("Insufficient permissions."); + + SampleTypeService.get().ensureMinSampleCount(form.getNewValue(), form.getSeqType(), getContainer()); + } + } + else if (DataClassDomainKind.NAME.equalsIgnoreCase(form.getKindName())) + { + if (!getContainer().hasPermission(getUser(), DesignDataClassPermission.class)) + throw new BadRequestException("Insufficient permissions."); + + ExpDataClass dataClass = ExperimentService.get().getDataClass(form.getRowId()); + if (dataClass != null) + { + dataClass.ensureMinGenId(form.getNewValue(), getContainer()); + domain = dataClass.getDomain(); + } + else + { + resp.put("success", false); + resp.put("error", "DataClass does not exist."); + } + } + + if (domain != null) + { + DomainAuditProvider.DomainAuditEvent event = new DomainAuditProvider.DomainAuditEvent(getContainer(), "The genId for domain " + domain.getName() + " has been updated to " + form.getNewValue() + "."); + event.setDomainUri(domain.getTypeURI()); + event.setDomainName(domain.getName()); + AuditLogService.get().addEvent(getUser(), event); + } + } + catch (ExperimentException e) + { + resp.put("success", false); + resp.put("error", e.getMessage()); + } + + return resp; + } + } + + public static class EntitySequenceForm + { + private String _kindName; + private NameGenerator.EntityCounter _seqType; + private Integer _rowId; + private Long _newValue; + + public Integer getRowId() + { + return _rowId; + } + + public void setRowId(Integer rowId) + { + _rowId = rowId; + } + + public String getKindName() + { + return _kindName; + } + + public void setKindName(String kindName) + { + _kindName = kindName; + } + + public Long getNewValue() + { + return _newValue; + } + + public void setNewValue(Long newValue) + { + this._newValue = newValue; + } + + public NameGenerator.EntityCounter getSeqType() + { + return _seqType; + } + + public void setSeqType(String seqType) + { + _seqType = NameGenerator.EntityCounter.valueOf(seqType); + } + + } + + @Marshal(Marshaller.Jackson) + @RequiresPermission(ReadPermission.class) + public static class GetCrossFolderDataSelectionAction extends ReadOnlyApiAction + { + @Override + public void validateForm(CrossFolderSelectionForm form, Errors errors) + { + if (form.getDataRegionSelectionKey() == null && form.getRowIds() == null) + errors.reject(ERROR_REQUIRED, "You must provide either a set of rowIds or a dataRegionSelectionKey."); + if (!"samples".equalsIgnoreCase(form.getDataType()) && !"exp.data".equalsIgnoreCase(form.getDataType())&& !"assay".equalsIgnoreCase(form.getDataType())) + errors.reject(ERROR_REQUIRED, "Data type (sample, data or assayrun) must be specified."); + } + + @Override + public Object execute(CrossFolderSelectionForm form, BindException errors) + { + Pair result = ExperimentServiceImpl.getCurrentAndCrossFolderDataCount(form.getIds(false), form.getDataType(), getContainer()); + + ApiSimpleResponse resp = new ApiSimpleResponse(); + resp.put("success", true); + resp.put("currentFolderSelectionCount", result.first); + resp.put("crossFolderSelectionCount", result.second); + + return success(resp); + } + } + + public static class CrossFolderSelectionForm extends DataViewSnapshotSelectionForm + { + private String _dataType; + private String _picklistName; + + public String getDataType() + { + return _dataType; + } + + public void setDataType(String dataType) + { + _dataType = dataType; + } + + public String getPicklistName() + { + return _picklistName; + } + + public void setPicklistName(String picklistName) + { + _picklistName = picklistName; + } + + @Override + public Set getIds(boolean clear) + { + Set selectedIds; + + if (_rowIds != null) + selectedIds = _rowIds; + else if (isUseSnapshotSelection()) + selectedIds = new HashSet<>(DataRegionSelection.getSnapshotSelectedIntegers(getViewContext(), getDataRegionSelectionKey())); + else + selectedIds = DataRegionSelection.getSelectedIntegers(getViewContext(), getDataRegionSelectionKey(), clear); + + if (_picklistName != null) + { + User user = getViewContext().getUser(); + Container container = getViewContext().getContainer(); + UserSchema schema = ListService.get().getUserSchema(user, container); + TableInfo tInfo = schema.getTable(_picklistName); + if (tInfo != null) + { + SimpleFilter filter = SimpleFilter.createContainerFilter(container); + filter.addInClause(FieldKey.fromParts("id"), selectedIds); + TableSelector selector = new TableSelector(tInfo, Collections.singleton("SampleID"), filter, null); + return new HashSet<>(selector.getArrayList(Long.class)); + } + } + return selectedIds; + } + } + + @RequiresPermission(AdminPermission.class) + public static class RecomputeAliquotRollup extends SimpleViewAction + { + @Override + public void addNavTrail(NavTree root) + { + } + + @Override + public ModelAndView getView(Object o, BindException errors) throws SQLException + { + try (var ignore = SpringActionController.ignoreSqlUpdates()) + { + Container container = getContainer(); + User user = getUser(); + + List sampleTypes = SampleTypeService.get() + .getSampleTypes(container, user, true); + + HtmlStringBuilder builder = HtmlStringBuilder.of(); + builder.unsafeAppend(""); + + SampleTypeService service = SampleTypeService.get(); + for (ExpSampleType sampleType : sampleTypes) + { + int updatedCount; + updatedCount = service.recomputeSampleTypeRollup(sampleType, container); + // we could check "if (0 < updatedCount) refresh(rollup)", but since this is a "manual" usage lets just always refresh + SampleTypeServiceImpl.get().refreshSampleTypeMaterializedView(sampleType, update); + builder.unsafeAppend(""); + } + + builder.unsafeAppend("
Sample Type#Recomputed
") + .append(sampleType.getName()) + .unsafeAppend("") + .append(updatedCount) + .unsafeAppend("
"); + return new HtmlView("Aliquot Rollup Recalculation Result", builder); + } + } + } + + /* Also see API CheckEdgesAction */ + @RequiresPermission(TroubleshooterPermission.class) + public static class CycleCheckAction extends FormViewAction + { + List cycleObjectIds = null; + + @Override + public void validateCommand(Object target, Errors errors) + { + + } + + @Override + public ModelAndView getView(Object o, boolean reshow, BindException errors) + { + if (!reshow) + { + return new HtmlView( + DIV("This operation can use a lot of memory.", + LK.FORM(at(method,"POST"), + PageFlowUtil.button("Continue").submit(true))) + ); + } + + if (null == cycleObjectIds) + return new HtmlView(HtmlString.of("No cycles found")); + + Map map = new LongHashMap<>(); + var cf = new ContainerFilter.AllFolders(getUser()); + var materials = ExperimentServiceImpl.get().getExpMaterialsByObjectId(cf, cycleObjectIds); + materials.forEach( (m) -> map.put(m.getObjectId(), m)); + var datas = ExperimentServiceImpl.get().getExpDatasByObjectId(cf, cycleObjectIds); + datas.forEach( (d) -> map.put(d.getObjectId(), d)); + var runs = ExperimentServiceImpl.get().getRunsByObjectId(cf, cycleObjectIds); + runs.forEach( (r) -> map.put(r.getObjectId(), r)); + + ExperimentUrls urls = ExperimentUrls.get(); + return new HtmlView( + DIV("Cycle found involving these objects.", + UL(cycleObjectIds.stream().map((objectid) -> + { + ExpObject exp = map.get(objectid); + if (exp instanceof ExpMaterial mat) + return LI(A(at(target, "_blank", href, urls.getMaterialDetailsURL(mat)), objectid + " : material - " + mat.getName())); + else if (exp instanceof ExpRun run) + return LI(A(at(target, "_blank", href, urls.getRunTextURL(run)), objectid + " : run - " + run.getName())); + else if (exp instanceof ExpData data) + return LI(A(at(target, "_blank", href, urls.getDataDetailsURL(data)), objectid + " : run - " + data.getName())); + else + return LI(String.valueOf(objectid)); + })) + ) + ); + } + + @Override + public boolean handlePost(Object o, BindException errors) + { + var edges = new SqlSelector(ExperimentService.get().getSchema(), "SELECT fromObjectId, toObjectId FROM exp.Edge") + .resultSetStream() + .map(r -> { try { return new Pair<>(r.getInt(1), r.getInt(2)); } catch (SQLException x) { throw new RuntimeException(x); } }) + .collect(toList()); + var cyclesEdges = (new GraphAlgorithms()).detectCycleInDirectedGraph(edges); + + var set = new LinkedHashSet(); + cyclesEdges.forEach( (edge) -> { + set.add(edge.first); + set.add(edge.second); + }); + cycleObjectIds = set.stream().toList(); + return false; + } + + @Override + public URLHelper getSuccessURL(Object o) + { + return null; + } + + @Override + public void addNavTrail(NavTree root) + { + + } + } + + @RequiresPermission(AdminPermission.class) + public static class MissingFilesCheckAction extends ReadOnlyApiAction + { + @Override + public Object execute(Object o, BindException errors) throws Exception + { + Map> info = ExperimentServiceImpl.get().doMissingFilesCheck(getUser(), getContainer(), true); + JSONObject results = new JSONObject(); + for (String containerId : info.keySet()) + { + JSONObject containerResults = new JSONObject(); + for (String sourceName : info.get(containerId).keySet()) + containerResults.put(sourceName, info.get(containerId).get(sourceName).toJSON()); + results.put(containerId, containerResults); + } + + ApiSimpleResponse response = new ApiSimpleResponse(); + response.put("success", true); + response.put("result", results); + return response; + } + } + +} diff --git a/experiment/src/org/labkey/experiment/pipeline/ExperimentPipelineJob.java b/experiment/src/org/labkey/experiment/pipeline/ExperimentPipelineJob.java index 76f8614fa64..1571ec5d2f2 100644 --- a/experiment/src/org/labkey/experiment/pipeline/ExperimentPipelineJob.java +++ b/experiment/src/org/labkey/experiment/pipeline/ExperimentPipelineJob.java @@ -1,177 +1,177 @@ -/* - * Copyright (c) 2008-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.labkey.experiment.pipeline; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.labkey.experiment.xar.CompressedXarSource; -import org.labkey.api.exp.FileXarSource; -import org.labkey.api.exp.XarSource; -import org.labkey.api.exp.api.ExpRun; -import org.labkey.api.exp.api.ExperimentService; -import org.labkey.api.exp.api.ExperimentUrls; -import org.labkey.api.pipeline.PipeRoot; -import org.labkey.api.pipeline.PipelineJob; -import org.labkey.api.util.PageFlowUtil; -import org.labkey.api.view.ActionURL; -import org.labkey.api.view.ViewBackgroundInfo; -import org.labkey.vfs.FileLike; - -import java.io.IOException; -import java.sql.BatchUpdateException; -import java.util.List; - -/** - * User: jeckels - * Date: Oct 26, 2005 - */ -public class ExperimentPipelineJob extends PipelineJob -{ - private static final Logger _log = LogManager.getLogger(ExperimentPipelineJob.class); - - private static final Object _experimentLock = new Object(); - - private final FileLike _xarFile; - private final String _description; - private final boolean _deleteExistingRuns; - - private transient XarSource _xarSource; - - @JsonCreator - protected ExperimentPipelineJob(@JsonProperty("_xarFile") FileLike xarFile, - @JsonProperty("_description") String description, - @JsonProperty("_deleteExistingRuns") boolean deleteExistingRuns) - { - super(); - _xarFile = xarFile; - _description = description; - _deleteExistingRuns = deleteExistingRuns; - } - - public ExperimentPipelineJob(ViewBackgroundInfo info, FileLike file, String description, boolean deleteExistingRuns, PipeRoot root) throws IOException - { - super(ExperimentPipelineProvider.NAME, info, root); - _xarFile = file; - _description = description + " - " + file.getName(); - _deleteExistingRuns = deleteExistingRuns; - - XarSource xarSource = getXarSource(); - header("XAR Import from " + xarSource.toString()); - } - - protected XarSource createXarSource(FileLike file) - { - String name = file.getName().toLowerCase(); - if (name.endsWith(".xar") || name.endsWith(".zip")) - { - return new CompressedXarSource(file, this); - } - else - { - return new FileXarSource(file, this); - } - } - - private XarSource getXarSource() - { - if (_xarSource == null) - { - _xarSource = createXarSource(_xarFile); - - try - { - setLogFile(_xarSource.getLogFilePath()); - } - catch (IOException e) - { - _log.error("Failed to get log file for " + _xarFile, e); - } - } - return _xarSource; - } - - @Override - public ActionURL getStatusHref() - { - ExpRun run = getXarSource().getExperimentRun(); - if (run != null) - { - return PageFlowUtil.urlProvider(ExperimentUrls.class).getRunGraphURL(run); - } - return null; - } - - @Override - public String getDescription() - { - return _description; - } - - public static boolean loadExperiment(PipelineJob job, XarSource source, boolean deleteExistingRuns) - { - try - { - source.init(); - } - catch (Exception e) - { - job.getLogger().error("Failed to initialize XAR source", e); - return false; - } - - synchronized (_experimentLock) - { - job.getLogger().info("Starting to import XAR"); - - try - { - List runs = ExperimentService.get().importXar(source, job, deleteExistingRuns); - if (!runs.isEmpty()) - { - source.setExperimentRunRowId(runs.get(0).getRowId()); - } - - job.getLogger().info(""); - job.getLogger().info("XAR import completed successfully"); - } - catch (Throwable t) - { - job.getLogger().info(""); - job.getLogger().fatal("Exception during import", t); - job.getLogger().fatal("XAR import FAILED"); - if (t instanceof BatchUpdateException) - { - job.getLogger().fatal("Underlying exception", ((BatchUpdateException)t).getNextException()); - } - return false; - } - return true; - } - } - - @Override - public void run() - { - if (!setStatus("LOADING EXPERIMENT")) - return; - - if (loadExperiment(this, getXarSource(), _deleteExistingRuns)) - setStatus(TaskStatus.complete); - } -} +/* + * Copyright (c) 2008-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.labkey.experiment.pipeline; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.labkey.experiment.xar.CompressedXarSource; +import org.labkey.api.exp.FileXarSource; +import org.labkey.api.exp.XarSource; +import org.labkey.api.exp.api.ExpRun; +import org.labkey.api.exp.api.ExperimentService; +import org.labkey.api.exp.api.ExperimentUrls; +import org.labkey.api.pipeline.PipeRoot; +import org.labkey.api.pipeline.PipelineJob; +import org.labkey.api.util.PageFlowUtil; +import org.labkey.api.view.ActionURL; +import org.labkey.api.view.ViewBackgroundInfo; +import org.labkey.vfs.FileLike; + +import java.io.IOException; +import java.sql.BatchUpdateException; +import java.util.List; + +/** + * User: jeckels + * Date: Oct 26, 2005 + */ +public class ExperimentPipelineJob extends PipelineJob +{ + private static final Logger _log = LogManager.getLogger(ExperimentPipelineJob.class); + + private static final Object _experimentLock = new Object(); + + private final FileLike _xarFile; + private final String _description; + private final boolean _deleteExistingRuns; + + private transient XarSource _xarSource; + + @JsonCreator + protected ExperimentPipelineJob(@JsonProperty("_xarFile") FileLike xarFile, + @JsonProperty("_description") String description, + @JsonProperty("_deleteExistingRuns") boolean deleteExistingRuns) + { + super(); + _xarFile = xarFile; + _description = description; + _deleteExistingRuns = deleteExistingRuns; + } + + public ExperimentPipelineJob(ViewBackgroundInfo info, FileLike file, String description, boolean deleteExistingRuns, PipeRoot root) throws IOException + { + super(ExperimentPipelineProvider.NAME, info, root); + _xarFile = file; + _description = description + " - " + file.getName(); + _deleteExistingRuns = deleteExistingRuns; + + XarSource xarSource = getXarSource(); + header("XAR Import from " + xarSource.toString()); + } + + protected XarSource createXarSource(FileLike file) + { + String name = file.getName().toLowerCase(); + if (name.endsWith(".xar") || name.endsWith(".zip")) + { + return new CompressedXarSource(file, this); + } + else + { + return new FileXarSource(file, this); + } + } + + private XarSource getXarSource() + { + if (_xarSource == null) + { + _xarSource = createXarSource(_xarFile); + + try + { + setLogFile(_xarSource.getLogFilePath()); + } + catch (IOException e) + { + _log.error("Failed to get log file for " + _xarFile, e); + } + } + return _xarSource; + } + + @Override + public ActionURL getStatusHref() + { + ExpRun run = getXarSource().getExperimentRun(); + if (run != null) + { + return PageFlowUtil.urlProvider(ExperimentUrls.class).getRunGraphURL(run); + } + return null; + } + + @Override + public String getDescription() + { + return _description; + } + + public static boolean loadExperiment(PipelineJob job, XarSource source, boolean deleteExistingRuns) + { + try + { + source.init(); + } + catch (Exception e) + { + job.getLogger().error("Failed to initialize XAR source", e); + return false; + } + + synchronized (_experimentLock) + { + job.getLogger().info("Starting to import XAR"); + + try + { + List runs = ExperimentService.get().importXar(source, job, deleteExistingRuns); + if (!runs.isEmpty()) + { + source.setExperimentRunRowId(runs.get(0).getRowId()); + } + + job.getLogger().info(""); + job.getLogger().info("XAR import completed successfully"); + } + catch (Throwable t) + { + job.getLogger().info(""); + job.getLogger().fatal("Exception during import", t); + job.getLogger().fatal("XAR import FAILED"); + if (t instanceof BatchUpdateException) + { + job.getLogger().fatal("Underlying exception", ((BatchUpdateException)t).getNextException()); + } + return false; + } + return true; + } + } + + @Override + public void run() + { + if (!setStatus("LOADING EXPERIMENT")) + return; + + if (loadExperiment(this, getXarSource(), _deleteExistingRuns)) + setStatus(TaskStatus.complete); + } +} diff --git a/experiment/src/org/labkey/experiment/pipeline/MoveRunsTask.java b/experiment/src/org/labkey/experiment/pipeline/MoveRunsTask.java index 29fc75fb668..12d9ec8b57f 100644 --- a/experiment/src/org/labkey/experiment/pipeline/MoveRunsTask.java +++ b/experiment/src/org/labkey/experiment/pipeline/MoveRunsTask.java @@ -1,286 +1,286 @@ -/* - * Copyright (c) 2008-2018 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.experiment.pipeline; - -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.apache.xmlbeans.XmlException; -import org.fhcrc.cpas.exp.xml.ExperimentArchiveDocument; -import org.fhcrc.cpas.exp.xml.ExperimentArchiveType; -import org.jetbrains.annotations.NotNull; -import org.labkey.api.data.Container; -import org.labkey.api.data.DbScope; -import org.labkey.api.exp.ExperimentDataHandler; -import org.labkey.api.exp.ExperimentException; -import org.labkey.api.exp.XarSource; -import org.labkey.api.exp.api.ExpData; -import org.labkey.api.exp.api.ExperimentService; -import org.labkey.api.pipeline.PipelineJob; -import org.labkey.api.pipeline.RecordedActionSet; -import org.labkey.api.util.DateUtil; -import org.labkey.api.util.FileUtil; -import org.labkey.api.util.XmlBeansUtil; -import org.labkey.experiment.DataURLRelativizer; -import org.labkey.api.exp.xar.LSIDRelativizer; -import org.labkey.experiment.XarExporter; -import org.labkey.experiment.XarReader; -import org.labkey.experiment.api.ExpRunImpl; -import org.labkey.experiment.api.ExperimentServiceImpl; -import org.labkey.vfs.FileLike; -import org.labkey.vfs.FileSystemLike; - -import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.IOException; -import java.net.URI; -import java.nio.file.Path; -import java.sql.BatchUpdateException; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -/** - * User: jeckels - * Date: Sep 8, 2008 - */ -public class MoveRunsTask extends PipelineJob.Task -{ - public MoveRunsTask(MoveRunsTaskFactory factory, PipelineJob job) - { - super(factory, job); - } - - @Override - @NotNull - public RecordedActionSet run() - { - MoveRunsPipelineJob job = (MoveRunsPipelineJob)getJob(); - - try - { - for (long runId : job.getRunIds()) - { - ExpRunImpl experimentRun = ExperimentServiceImpl.get().getExpRun(runId); - if (experimentRun != null) - { - moveRun(job, experimentRun); - } - else - { - job.info("Run with id " + runId + " is no longer available in the system"); - } - } - } - catch (Throwable t) - { - job.error("Exception during move", t); - job.error("Move FAILED"); - if (t instanceof BatchUpdateException) - { - job.error("Underlying exception", ((BatchUpdateException)t).getNextException()); - } - } - return new RecordedActionSet(); - } - - private void moveRun(MoveRunsPipelineJob job, ExpRunImpl experimentRun) throws ExperimentException, IOException - { - XarExporter exporter = new XarExporter(LSIDRelativizer.PARTIAL_FOLDER_RELATIVE, DataURLRelativizer.ORIGINAL_FILE_LOCATION.createURLRewriter(), getJob().getUser(), getJob().getContainer()); - exporter.addExperimentRun(experimentRun); - - ByteArrayOutputStream bOut = new ByteArrayOutputStream(); - exporter.dumpXML(bOut); - - try (DbScope.Transaction transaction = ExperimentService.get().getSchema().getScope().ensureTransaction()) - { - Map dataFiles = new HashMap<>(); - - for (ExpData oldData : experimentRun.getAllDataUsedByRun()) - { - ExperimentDataHandler handler = oldData.findDataHandler(); - handler.beforeMove(oldData, experimentRun.getContainer(), job.getUser()); - } - - for (ExpData data : ExperimentService.get().deleteExperimentRunForMove(experimentRun.getRowId(), job.getUser())) - { - if (data.getDataFileUrl() != null) - { - dataFiles.put(data.getDataFileUrl(), data.getRowId()); - } - } - - MoveRunsXarSource xarSource = new MoveRunsXarSource(bOut.toString(), experimentRun.getFilePathRootPath(), job); - XarReader reader = new XarReader(xarSource, job); - reader.parseAndLoad(false, null); - - List runLSIDs = reader.getProcessedRunsLSIDs(); - assert runLSIDs.size() == 1 : "Expected a single run to be loaded"; - - for (String dataURL : dataFiles.keySet()) - { - ExpData newData = ExperimentService.get().getExpDataByURL(xarSource.getCanonicalDataFileURL(dataURL), job.getSourceContainer()); - if (newData != null) - { - ExperimentDataHandler handler = newData.findDataHandler(); - handler.runMoved(newData, experimentRun.getContainer(), job.getContainer(), experimentRun.getLSID(), runLSIDs.get(0), job.getUser(), dataFiles.get(dataURL)); - } - } - transaction.commit(); - } - } - - public static class MoveRunsXarSource extends XarSource - { - private static final Logger _log = LogManager.getLogger(MoveRunsXarSource.class); - - private final String _xml; - private File _logFile; - private File _logFileDir; - - private final String _uploadTime; - - private String _experimentName; - private final String _root; - private final Container _sourceContainer; - - public MoveRunsXarSource(String xml, Path root, MoveRunsPipelineJob job) throws ExperimentException - { - super(job); - _xml = xml; - if (null == root) - throw new ExperimentException("File path root is null"); - - _root = FileUtil.getAbsolutePath(job.getSourceContainer(), root); - if (null == _root) - throw new ExperimentException("Unable to create absolute file path for root"); - - _sourceContainer = job.getSourceContainer(); - - int retry = 0; - while (_logFileDir == null) - { - try - { - _logFileDir = FileUtil.createTempFile("xarupload", ""); - } - catch (IOException e) - { - if (++retry > 10) - { - throw new ExperimentException("Unable to create a log file", e); - } - _log.warn("Failed to create log file, retrying...", e); - } - } - - _logFileDir.delete(); - try - { - FileUtil.mkdir(_logFileDir); - } - catch (IOException e) - { - throw new ExperimentException("Unable to create log file directory", e); - } - _logFileDir.deleteOnExit(); - _logFile = FileUtil.appendName(_logFileDir, "upload.xar.log"); - _logFile.deleteOnExit(); - _uploadTime = DateUtil.formatDateTime(job.getContainer()); - } - - @Override - public ExperimentArchiveDocument getDocument() throws XmlException - { - ExperimentArchiveDocument doc = ExperimentArchiveDocument.Factory.parse(_xml, XmlBeansUtil.getDefaultParseOptions()); - ExperimentArchiveType ea = doc.getExperimentArchive(); - if (ea != null) - { - if (ea.getExperimentArray() != null && ea.getExperimentArray().length > 0) - { - _experimentName = ea.getExperimentArray()[0].getName(); - } - } - return doc; - } - - @Override - public Path getRootPath() - { - return FileUtil.stringToPath(_sourceContainer, _root); - } - - @Override - public Path getJobRootPath() - { - var pipelineJob = getXarContext().getJob(); - return pipelineJob != null - ? pipelineJob.getPipeRoot().getRootFileLike().toNioPathForRead() - : super.getJobRootPath(); - } - - @Override - public boolean shouldIgnoreDataFiles() - { - return true; - } - - @Override - public String canonicalizeDataFileURL(String dataFileURL) - { - URI uri = FileUtil.createUri(dataFileURL); - Path dataFilePath; - if (!uri.isAbsolute()) - { - dataFilePath = getRootPath().resolve(dataFileURL); - } - else - { - dataFilePath = FileUtil.getPath(_sourceContainer, uri); - } - return FileUtil.getAbsoluteCaseSensitivePathString(_sourceContainer, dataFilePath.toUri()); - } - - @Override - public FileLike getLogFilePath() - { - return FileSystemLike.wrapFile(_logFile); - } - - public String toString() - { - String result = "Uploaded file: " + _uploadTime; - if (_experimentName != null) - { - result += _experimentName; - } - return result; - } - - public void cleanup() - { - if (_logFile != null) - { - _logFile.delete(); - _logFile = null; - } - if (_logFileDir != null) - { - _logFileDir.delete(); - _logFileDir = null; - } - } - } -} +/* + * Copyright (c) 2008-2018 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.experiment.pipeline; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.xmlbeans.XmlException; +import org.fhcrc.cpas.exp.xml.ExperimentArchiveDocument; +import org.fhcrc.cpas.exp.xml.ExperimentArchiveType; +import org.jetbrains.annotations.NotNull; +import org.labkey.api.data.Container; +import org.labkey.api.data.DbScope; +import org.labkey.api.exp.ExperimentDataHandler; +import org.labkey.api.exp.ExperimentException; +import org.labkey.api.exp.XarSource; +import org.labkey.api.exp.api.ExpData; +import org.labkey.api.exp.api.ExperimentService; +import org.labkey.api.pipeline.PipelineJob; +import org.labkey.api.pipeline.RecordedActionSet; +import org.labkey.api.util.DateUtil; +import org.labkey.api.util.FileUtil; +import org.labkey.api.util.XmlBeansUtil; +import org.labkey.experiment.DataURLRelativizer; +import org.labkey.api.exp.xar.LSIDRelativizer; +import org.labkey.experiment.XarExporter; +import org.labkey.experiment.XarReader; +import org.labkey.experiment.api.ExpRunImpl; +import org.labkey.experiment.api.ExperimentServiceImpl; +import org.labkey.vfs.FileLike; +import org.labkey.vfs.FileSystemLike; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.nio.file.Path; +import java.sql.BatchUpdateException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * User: jeckels + * Date: Sep 8, 2008 + */ +public class MoveRunsTask extends PipelineJob.Task +{ + public MoveRunsTask(MoveRunsTaskFactory factory, PipelineJob job) + { + super(factory, job); + } + + @Override + @NotNull + public RecordedActionSet run() + { + MoveRunsPipelineJob job = (MoveRunsPipelineJob)getJob(); + + try + { + for (long runId : job.getRunIds()) + { + ExpRunImpl experimentRun = ExperimentServiceImpl.get().getExpRun(runId); + if (experimentRun != null) + { + moveRun(job, experimentRun); + } + else + { + job.info("Run with id " + runId + " is no longer available in the system"); + } + } + } + catch (Throwable t) + { + job.error("Exception during move", t); + job.error("Move FAILED"); + if (t instanceof BatchUpdateException) + { + job.error("Underlying exception", ((BatchUpdateException)t).getNextException()); + } + } + return new RecordedActionSet(); + } + + private void moveRun(MoveRunsPipelineJob job, ExpRunImpl experimentRun) throws ExperimentException, IOException + { + XarExporter exporter = new XarExporter(LSIDRelativizer.PARTIAL_FOLDER_RELATIVE, DataURLRelativizer.ORIGINAL_FILE_LOCATION.createURLRewriter(), getJob().getUser(), getJob().getContainer()); + exporter.addExperimentRun(experimentRun); + + ByteArrayOutputStream bOut = new ByteArrayOutputStream(); + exporter.dumpXML(bOut); + + try (DbScope.Transaction transaction = ExperimentService.get().getSchema().getScope().ensureTransaction()) + { + Map dataFiles = new HashMap<>(); + + for (ExpData oldData : experimentRun.getAllDataUsedByRun()) + { + ExperimentDataHandler handler = oldData.findDataHandler(); + handler.beforeMove(oldData, experimentRun.getContainer(), job.getUser()); + } + + for (ExpData data : ExperimentService.get().deleteExperimentRunForMove(experimentRun.getRowId(), job.getUser())) + { + if (data.getDataFileUrl() != null) + { + dataFiles.put(data.getDataFileUrl(), data.getRowId()); + } + } + + MoveRunsXarSource xarSource = new MoveRunsXarSource(bOut.toString(), experimentRun.getFilePathRootPath(), job); + XarReader reader = new XarReader(xarSource, job); + reader.parseAndLoad(false, null); + + List runLSIDs = reader.getProcessedRunsLSIDs(); + assert runLSIDs.size() == 1 : "Expected a single run to be loaded"; + + for (String dataURL : dataFiles.keySet()) + { + ExpData newData = ExperimentService.get().getExpDataByURL(xarSource.getCanonicalDataFileURL(dataURL), job.getSourceContainer()); + if (newData != null) + { + ExperimentDataHandler handler = newData.findDataHandler(); + handler.runMoved(newData, experimentRun.getContainer(), job.getContainer(), experimentRun.getLSID(), runLSIDs.get(0), job.getUser(), dataFiles.get(dataURL)); + } + } + transaction.commit(); + } + } + + public static class MoveRunsXarSource extends XarSource + { + private static final Logger _log = LogManager.getLogger(MoveRunsXarSource.class); + + private final String _xml; + private File _logFile; + private File _logFileDir; + + private final String _uploadTime; + + private String _experimentName; + private final String _root; + private final Container _sourceContainer; + + public MoveRunsXarSource(String xml, Path root, MoveRunsPipelineJob job) throws ExperimentException + { + super(job); + _xml = xml; + if (null == root) + throw new ExperimentException("File path root is null"); + + _root = FileUtil.getAbsolutePath(job.getSourceContainer(), root); + if (null == _root) + throw new ExperimentException("Unable to create absolute file path for root"); + + _sourceContainer = job.getSourceContainer(); + + int retry = 0; + while (_logFileDir == null) + { + try + { + _logFileDir = FileUtil.createTempFile("xarupload", ""); + } + catch (IOException e) + { + if (++retry > 10) + { + throw new ExperimentException("Unable to create a log file", e); + } + _log.warn("Failed to create log file, retrying...", e); + } + } + + _logFileDir.delete(); + try + { + FileUtil.mkdir(_logFileDir); + } + catch (IOException e) + { + throw new ExperimentException("Unable to create log file directory", e); + } + _logFileDir.deleteOnExit(); + _logFile = FileUtil.appendName(_logFileDir, "upload.xar.log"); + _logFile.deleteOnExit(); + _uploadTime = DateUtil.formatDateTime(job.getContainer()); + } + + @Override + public ExperimentArchiveDocument getDocument() throws XmlException + { + ExperimentArchiveDocument doc = ExperimentArchiveDocument.Factory.parse(_xml, XmlBeansUtil.getDefaultParseOptions()); + ExperimentArchiveType ea = doc.getExperimentArchive(); + if (ea != null) + { + if (ea.getExperimentArray() != null && ea.getExperimentArray().length > 0) + { + _experimentName = ea.getExperimentArray()[0].getName(); + } + } + return doc; + } + + @Override + public Path getRootPath() + { + return FileUtil.stringToPath(_sourceContainer, _root); + } + + @Override + public Path getJobRootPath() + { + var pipelineJob = getXarContext().getJob(); + return pipelineJob != null + ? pipelineJob.getPipeRoot().getRootFileLike().toNioPathForRead() + : super.getJobRootPath(); + } + + @Override + public boolean shouldIgnoreDataFiles() + { + return true; + } + + @Override + public String canonicalizeDataFileURL(String dataFileURL) + { + URI uri = FileUtil.createUri(dataFileURL); + Path dataFilePath; + if (!uri.isAbsolute()) + { + dataFilePath = getRootPath().resolve(dataFileURL); + } + else + { + dataFilePath = FileUtil.getPath(_sourceContainer, uri); + } + return FileUtil.getAbsoluteCaseSensitivePathString(_sourceContainer, dataFilePath.toUri()); + } + + @Override + public FileLike getLogFilePath() + { + return FileSystemLike.wrapFile(_logFile); + } + + public String toString() + { + String result = "Uploaded file: " + _uploadTime; + if (_experimentName != null) + { + result += _experimentName; + } + return result; + } + + public void cleanup() + { + if (_logFile != null) + { + _logFile.delete(); + _logFile = null; + } + if (_logFileDir != null) + { + _logFileDir.delete(); + _logFileDir = null; + } + } + } +} diff --git a/experiment/src/org/labkey/experiment/pipeline/XarGeneratorSource.java b/experiment/src/org/labkey/experiment/pipeline/XarGeneratorSource.java index f4af2612d61..be9b9cf220e 100644 --- a/experiment/src/org/labkey/experiment/pipeline/XarGeneratorSource.java +++ b/experiment/src/org/labkey/experiment/pipeline/XarGeneratorSource.java @@ -1,48 +1,48 @@ -/* - * Copyright (c) 2008-2018 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.experiment.pipeline; - -import org.fhcrc.cpas.exp.xml.ExperimentArchiveDocument; -import org.labkey.api.exp.AbstractFileXarSource; -import org.labkey.api.pipeline.PipelineJob; -import org.labkey.vfs.FileLike; - -import java.nio.file.Path; - -/* -* User: jeckels -* Date: Jul 30, 2008 -*/ -public class XarGeneratorSource extends AbstractFileXarSource -{ - public XarGeneratorSource(PipelineJob job, FileLike xarFile) - { - super(job); - _xmlFile = xarFile; - } - - @Override - public FileLike getLogFilePath() - { - throw new UnsupportedOperationException(); - } - - @Override - public ExperimentArchiveDocument getDocument() - { - throw new UnsupportedOperationException(); - } -} +/* + * Copyright (c) 2008-2018 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.experiment.pipeline; + +import org.fhcrc.cpas.exp.xml.ExperimentArchiveDocument; +import org.labkey.api.exp.AbstractFileXarSource; +import org.labkey.api.pipeline.PipelineJob; +import org.labkey.vfs.FileLike; + +import java.nio.file.Path; + +/* +* User: jeckels +* Date: Jul 30, 2008 +*/ +public class XarGeneratorSource extends AbstractFileXarSource +{ + public XarGeneratorSource(PipelineJob job, FileLike xarFile) + { + super(job); + _xmlFile = xarFile; + } + + @Override + public FileLike getLogFilePath() + { + throw new UnsupportedOperationException(); + } + + @Override + public ExperimentArchiveDocument getDocument() + { + throw new UnsupportedOperationException(); + } +} diff --git a/experiment/src/org/labkey/experiment/pipeline/XarGeneratorTask.java b/experiment/src/org/labkey/experiment/pipeline/XarGeneratorTask.java index 3816282d7e5..f0e60638a03 100644 --- a/experiment/src/org/labkey/experiment/pipeline/XarGeneratorTask.java +++ b/experiment/src/org/labkey/experiment/pipeline/XarGeneratorTask.java @@ -1,253 +1,253 @@ -/* - * Copyright (c) 2008-2018 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.experiment.pipeline; - -import org.jetbrains.annotations.NotNull; -import org.labkey.api.data.RuntimeSQLException; -import org.labkey.api.exp.ExperimentException; -import org.labkey.api.exp.FileXarSource; -import org.labkey.api.exp.PropertyDescriptor; -import org.labkey.api.exp.XarSource; -import org.labkey.api.exp.api.ExpRun; -import org.labkey.api.exp.api.ExperimentService; -import org.labkey.api.exp.pipeline.XarGeneratorFactorySettings; -import org.labkey.api.exp.pipeline.XarGeneratorId; -import org.labkey.api.pipeline.AbstractTaskFactory; -import org.labkey.api.pipeline.PipelineJob; -import org.labkey.api.pipeline.PipelineJobException; -import org.labkey.api.pipeline.PipelineService; -import org.labkey.api.pipeline.PipelineStatusFile; -import org.labkey.api.pipeline.PropertiesJobSupport; -import org.labkey.api.pipeline.RecordedActionSet; -import org.labkey.api.pipeline.file.FileAnalysisJobSupport; -import org.labkey.api.query.ValidationException; -import org.labkey.api.util.FileType; -import org.labkey.api.util.NetworkDrive; -import org.labkey.experiment.DataURLRelativizer; -import org.labkey.api.exp.xar.LSIDRelativizer; -import org.labkey.experiment.XarExporter; -import org.labkey.vfs.FileLike; - -import java.io.BufferedOutputStream; -import java.io.IOException; -import java.io.OutputStream; -import java.nio.file.Files; -import java.nio.file.StandardCopyOption; -import java.util.Collections; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; - -/** - * Creates an experiment run to represent the work that the task's job has done so far. - * User: jeckels - * Date: Jul 25, 2008 -*/ -public class XarGeneratorTask extends PipelineJob.Task implements XarWriter -{ - public static class Factory extends AbstractTaskFactory - { - private FileType _outputType = XarGeneratorId.FT_PIPE_XAR_XML; - private boolean _loadFiles = true; - private String _statusName = "IMPORT RESULTS"; - - public Factory() - { - super(XarGeneratorId.class); - } - - @Override - public PipelineJob.Task createTask(PipelineJob job) - { - return new XarGeneratorTask(this, job); - } - - @Override - public List getInputTypes() - { - return Collections.emptyList(); - } - - public FileType getOutputType() - { - return _outputType; - } - - public boolean isLoadFiles() - { - return _loadFiles; - } - - @Override - public String getStatusName() - { - return _statusName; - } - - @Override - public List getProtocolActionNames() - { - return Collections.emptyList(); - } - - protected FileLike getXarFile(PipelineJob job) - { - FileAnalysisJobSupport jobSupport = job.getJobSupport(FileAnalysisJobSupport.class); - return getOutputType().newFile(jobSupport.getAnalysisDirectoryFileLike(), jobSupport.getBaseName()); - } - - @Override - public boolean isJobComplete(PipelineJob job) - { - // We can use an existing XAR file from disk if it's been generated, but we need to load it because - // there's no simple way to tell if it's already been imported or not, or if it's been subsequently deleted - return false; - } - - @Override - public void configure(XarGeneratorFactorySettings settings) - { - super.configure(settings); - - if (settings.getOutputExt() != null) - _outputType = new FileType(settings.getOutputExt()); - _loadFiles = settings.isLoadFiles(); - if (!_loadFiles) - _statusName = "GENERATING EXPERIMENT"; - } - } - - public XarGeneratorTask(Factory factory, PipelineJob job) - { - super(factory, job); - } - - /** - * The basic steps are: - * 1. Start a transaction. - * 2. Create a protocol and a run and insert them into the database, not loading the data files. - * 3. Export the run and protocol to a temporary XAR on the file system. - * 4. Commit the transaction. - * 5. Import the temporary XAR (not reloading the runs it references), which causes its referenced data files to load. - * 6. Rename the temporary XAR to its permanent name. - * - * This allows us to quickly tell if the task is already complete by checking for the XAR file. If it exists, we - * can simply reimport it. If the temporary file exists, we can skip directly to step 5 above. - */ - @Override - @NotNull - public RecordedActionSet run() throws PipelineJobException - { - try - { - // Keep track of all of the runs that have been created by this task - Set importedRuns = new HashSet<>(); - if (_factory.isLoadFiles()) - { - FileLike permanentXAR = _factory.getXarFile(getJob()); - if (NetworkDrive.exists(permanentXAR)) - { - // Be sure that it's been imported (and not already deleted from the database) - importedRuns.addAll(ExperimentService.get().importXar(new FileXarSource(permanentXAR, getJob()), getJob(), false)); - } - else - { - if (!NetworkDrive.exists(getLoadingXarFile())) - { - XarSource source = new XarGeneratorSource(getJob(), _factory.getXarFile(getJob())); - importedRuns.add(ExpGeneratorHelper.insertRun(getJob(), source, this)); - } - - // Load the data files for this run - importedRuns.addAll(ExperimentService.get().importXar(new FileXarSource(getLoadingXarFile(), getJob()), getJob(), false)); - - Files.move(getLoadingXarFile().toNioPathForWrite(), permanentXAR.toNioPathForWrite()); - } - } - else - { - importedRuns.add(ExpGeneratorHelper.insertRun(getJob(), null, null)); - } - - // save any job-level custom properties from the run - if (getJob() instanceof PropertiesJobSupport) - { - PropertiesJobSupport jobSupport = getJob().getJobSupport(PropertiesJobSupport.class); - for (Map.Entry prop : jobSupport.getProps().entrySet()) - { - for (ExpRun importedRun : importedRuns) - importedRun.setProperty(getJob().getUser(), prop.getKey(), prop.getValue()); - } - } - - // Check if we've been cancelled. If so, delete any newly created runs from the database - PipelineStatusFile statusFile = PipelineService.get().getStatusFile(getJob().getLogFile()); - if (statusFile != null && (PipelineJob.TaskStatus.cancelled.matches(statusFile.getStatus()) || PipelineJob.TaskStatus.cancelling.matches(statusFile.getStatus()))) - { - for (ExpRun importedRun : importedRuns) - { - getJob().info("Deleting run " + importedRun.getName() + " due to cancellation request"); - importedRun.delete(getJob().getUser()); - } - } - } - catch (RuntimeSQLException | ValidationException e) - { - throw new PipelineJobException("Failed to save experiment run in the database", e); - } - catch (ExperimentException e) - { - throw new PipelineJobException("Failed to import data files", e); - } - catch (IOException e) - { - throw new PipelineJobException("Unable to read data files", e); - } - return new RecordedActionSet(); - } - - // XarWriter interface - @Override - public void writeToDisk(ExpRun run) throws PipelineJobException - { - FileLike f = getLoadingXarFile(); - FileLike tempFile = f.getParent().resolveChild(f.getName() + ".temp"); - - try - { - XarExporter exporter = new XarExporter(LSIDRelativizer.FOLDER_RELATIVE, DataURLRelativizer.RUN_RELATIVE_LOCATION.createURLRewriter(), getJob().getUser(), getJob().getContainer()); - exporter.addExperimentRun(run); - - try (OutputStream fOut = new BufferedOutputStream(tempFile.openOutputStream())) - { - exporter.dumpXML(fOut); - fOut.close(); - Files.move(tempFile.toNioPathForWrite(), f.toNioPathForWrite(), StandardCopyOption.ATOMIC_MOVE); - } - } - catch (ExperimentException | IOException e) - { - throw new PipelineJobException("Failed to write XAR to disk", e); - } - } - - private FileLike getLoadingXarFile() - { - FileLike xarPath = _factory.getXarFile(getJob()); - return xarPath.getParent().resolveChild(xarPath.getName() + ".loading"); - } -} +/* + * Copyright (c) 2008-2018 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.experiment.pipeline; + +import org.jetbrains.annotations.NotNull; +import org.labkey.api.data.RuntimeSQLException; +import org.labkey.api.exp.ExperimentException; +import org.labkey.api.exp.FileXarSource; +import org.labkey.api.exp.PropertyDescriptor; +import org.labkey.api.exp.XarSource; +import org.labkey.api.exp.api.ExpRun; +import org.labkey.api.exp.api.ExperimentService; +import org.labkey.api.exp.pipeline.XarGeneratorFactorySettings; +import org.labkey.api.exp.pipeline.XarGeneratorId; +import org.labkey.api.pipeline.AbstractTaskFactory; +import org.labkey.api.pipeline.PipelineJob; +import org.labkey.api.pipeline.PipelineJobException; +import org.labkey.api.pipeline.PipelineService; +import org.labkey.api.pipeline.PipelineStatusFile; +import org.labkey.api.pipeline.PropertiesJobSupport; +import org.labkey.api.pipeline.RecordedActionSet; +import org.labkey.api.pipeline.file.FileAnalysisJobSupport; +import org.labkey.api.query.ValidationException; +import org.labkey.api.util.FileType; +import org.labkey.api.util.NetworkDrive; +import org.labkey.experiment.DataURLRelativizer; +import org.labkey.api.exp.xar.LSIDRelativizer; +import org.labkey.experiment.XarExporter; +import org.labkey.vfs.FileLike; + +import java.io.BufferedOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Creates an experiment run to represent the work that the task's job has done so far. + * User: jeckels + * Date: Jul 25, 2008 +*/ +public class XarGeneratorTask extends PipelineJob.Task implements XarWriter +{ + public static class Factory extends AbstractTaskFactory + { + private FileType _outputType = XarGeneratorId.FT_PIPE_XAR_XML; + private boolean _loadFiles = true; + private String _statusName = "IMPORT RESULTS"; + + public Factory() + { + super(XarGeneratorId.class); + } + + @Override + public PipelineJob.Task createTask(PipelineJob job) + { + return new XarGeneratorTask(this, job); + } + + @Override + public List getInputTypes() + { + return Collections.emptyList(); + } + + public FileType getOutputType() + { + return _outputType; + } + + public boolean isLoadFiles() + { + return _loadFiles; + } + + @Override + public String getStatusName() + { + return _statusName; + } + + @Override + public List getProtocolActionNames() + { + return Collections.emptyList(); + } + + protected FileLike getXarFile(PipelineJob job) + { + FileAnalysisJobSupport jobSupport = job.getJobSupport(FileAnalysisJobSupport.class); + return getOutputType().newFile(jobSupport.getAnalysisDirectoryFileLike(), jobSupport.getBaseName()); + } + + @Override + public boolean isJobComplete(PipelineJob job) + { + // We can use an existing XAR file from disk if it's been generated, but we need to load it because + // there's no simple way to tell if it's already been imported or not, or if it's been subsequently deleted + return false; + } + + @Override + public void configure(XarGeneratorFactorySettings settings) + { + super.configure(settings); + + if (settings.getOutputExt() != null) + _outputType = new FileType(settings.getOutputExt()); + _loadFiles = settings.isLoadFiles(); + if (!_loadFiles) + _statusName = "GENERATING EXPERIMENT"; + } + } + + public XarGeneratorTask(Factory factory, PipelineJob job) + { + super(factory, job); + } + + /** + * The basic steps are: + * 1. Start a transaction. + * 2. Create a protocol and a run and insert them into the database, not loading the data files. + * 3. Export the run and protocol to a temporary XAR on the file system. + * 4. Commit the transaction. + * 5. Import the temporary XAR (not reloading the runs it references), which causes its referenced data files to load. + * 6. Rename the temporary XAR to its permanent name. + * + * This allows us to quickly tell if the task is already complete by checking for the XAR file. If it exists, we + * can simply reimport it. If the temporary file exists, we can skip directly to step 5 above. + */ + @Override + @NotNull + public RecordedActionSet run() throws PipelineJobException + { + try + { + // Keep track of all of the runs that have been created by this task + Set importedRuns = new HashSet<>(); + if (_factory.isLoadFiles()) + { + FileLike permanentXAR = _factory.getXarFile(getJob()); + if (NetworkDrive.exists(permanentXAR)) + { + // Be sure that it's been imported (and not already deleted from the database) + importedRuns.addAll(ExperimentService.get().importXar(new FileXarSource(permanentXAR, getJob()), getJob(), false)); + } + else + { + if (!NetworkDrive.exists(getLoadingXarFile())) + { + XarSource source = new XarGeneratorSource(getJob(), _factory.getXarFile(getJob())); + importedRuns.add(ExpGeneratorHelper.insertRun(getJob(), source, this)); + } + + // Load the data files for this run + importedRuns.addAll(ExperimentService.get().importXar(new FileXarSource(getLoadingXarFile(), getJob()), getJob(), false)); + + Files.move(getLoadingXarFile().toNioPathForWrite(), permanentXAR.toNioPathForWrite()); + } + } + else + { + importedRuns.add(ExpGeneratorHelper.insertRun(getJob(), null, null)); + } + + // save any job-level custom properties from the run + if (getJob() instanceof PropertiesJobSupport) + { + PropertiesJobSupport jobSupport = getJob().getJobSupport(PropertiesJobSupport.class); + for (Map.Entry prop : jobSupport.getProps().entrySet()) + { + for (ExpRun importedRun : importedRuns) + importedRun.setProperty(getJob().getUser(), prop.getKey(), prop.getValue()); + } + } + + // Check if we've been cancelled. If so, delete any newly created runs from the database + PipelineStatusFile statusFile = PipelineService.get().getStatusFile(getJob().getLogFile()); + if (statusFile != null && (PipelineJob.TaskStatus.cancelled.matches(statusFile.getStatus()) || PipelineJob.TaskStatus.cancelling.matches(statusFile.getStatus()))) + { + for (ExpRun importedRun : importedRuns) + { + getJob().info("Deleting run " + importedRun.getName() + " due to cancellation request"); + importedRun.delete(getJob().getUser()); + } + } + } + catch (RuntimeSQLException | ValidationException e) + { + throw new PipelineJobException("Failed to save experiment run in the database", e); + } + catch (ExperimentException e) + { + throw new PipelineJobException("Failed to import data files", e); + } + catch (IOException e) + { + throw new PipelineJobException("Unable to read data files", e); + } + return new RecordedActionSet(); + } + + // XarWriter interface + @Override + public void writeToDisk(ExpRun run) throws PipelineJobException + { + FileLike f = getLoadingXarFile(); + FileLike tempFile = f.getParent().resolveChild(f.getName() + ".temp"); + + try + { + XarExporter exporter = new XarExporter(LSIDRelativizer.FOLDER_RELATIVE, DataURLRelativizer.RUN_RELATIVE_LOCATION.createURLRewriter(), getJob().getUser(), getJob().getContainer()); + exporter.addExperimentRun(run); + + try (OutputStream fOut = new BufferedOutputStream(tempFile.openOutputStream())) + { + exporter.dumpXML(fOut); + fOut.close(); + Files.move(tempFile.toNioPathForWrite(), f.toNioPathForWrite(), StandardCopyOption.ATOMIC_MOVE); + } + } + catch (ExperimentException | IOException e) + { + throw new PipelineJobException("Failed to write XAR to disk", e); + } + } + + private FileLike getLoadingXarFile() + { + FileLike xarPath = _factory.getXarFile(getJob()); + return xarPath.getParent().resolveChild(xarPath.getName() + ".loading"); + } +} diff --git a/experiment/src/org/labkey/experiment/samples/AbstractExpFolderImporter.java b/experiment/src/org/labkey/experiment/samples/AbstractExpFolderImporter.java index 433d240354a..5147ae170f4 100644 --- a/experiment/src/org/labkey/experiment/samples/AbstractExpFolderImporter.java +++ b/experiment/src/org/labkey/experiment/samples/AbstractExpFolderImporter.java @@ -91,7 +91,7 @@ public void process(@Nullable PipelineJob job, FolderImportContext ctx, VirtualF if (xarDir != null) { // #44384 Generate a relative Path object for the folder's VirtualFile - FileLike xarDirPath = FileSystemLike.wrapFile(Path.of(xarDir.getLocation())); + FileLike xarDirPath = FileSystemLike.wrapFile(Path.of(xarDir.getLocation()).toAbsolutePath()); FileLike typesXarFile = null; FileLike runsXarFile = null; Logger log = ctx.getLogger(); diff --git a/experiment/src/org/labkey/experiment/samples/SampleStatusFolderImporter.java b/experiment/src/org/labkey/experiment/samples/SampleStatusFolderImporter.java index 3b28543aebc..7d3e441d744 100644 --- a/experiment/src/org/labkey/experiment/samples/SampleStatusFolderImporter.java +++ b/experiment/src/org/labkey/experiment/samples/SampleStatusFolderImporter.java @@ -54,7 +54,7 @@ public void process(@Nullable PipelineJob job, FolderImportContext ctx, VirtualF if (xarDir != null) { // #44384 Generate a relative Path object for the folder's VirtualFile - FileLike xarDirPath = FileSystemLike.wrapFile(Path.of(xarDir.getLocation())); + FileLike xarDirPath = FileSystemLike.wrapFile(Path.of(xarDir.getLocation()).toAbsolutePath()); FileLike typesXarFile = null; Map sampleStatusDataFiles = new HashMap<>(); Logger log = ctx.getLogger(); diff --git a/experiment/src/org/labkey/experiment/xar/FolderXarImporterFactory.java b/experiment/src/org/labkey/experiment/xar/FolderXarImporterFactory.java index 86e804d7c0a..18326f029fd 100644 --- a/experiment/src/org/labkey/experiment/xar/FolderXarImporterFactory.java +++ b/experiment/src/org/labkey/experiment/xar/FolderXarImporterFactory.java @@ -1,249 +1,249 @@ -/* - * Copyright (c) 2014-2018 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.experiment.xar; - -import org.labkey.api.admin.AbstractFolderImportFactory; -import org.labkey.api.admin.FolderArchiveDataTypes; -import org.labkey.api.admin.FolderImportContext; -import org.labkey.api.admin.FolderImporter; -import org.labkey.api.admin.ImportException; -import org.labkey.api.data.Container; -import org.labkey.api.exp.FileXarSource; -import org.labkey.api.exp.XarSource; -import org.labkey.api.pipeline.PipeRoot; -import org.labkey.api.pipeline.PipelineJob; -import org.labkey.api.pipeline.PipelineService; -import org.labkey.api.util.FileUtil; -import org.labkey.api.view.NotFoundException; -import org.labkey.api.view.ViewBackgroundInfo; -import org.labkey.api.writer.VirtualFile; -import org.labkey.experiment.XarReader; -import org.labkey.experiment.pipeline.ExperimentPipelineJob; -import org.labkey.vfs.FileLike; -import org.labkey.vfs.FileSystemLike; - -/** - * User: vsharma - * Date: 6/4/14 - * Time: 9:52 AM - */ -public class FolderXarImporterFactory extends AbstractFolderImportFactory -{ - @Override - public FolderImporter create() - { - return new FolderXarImporter(); - } - - @Override - public int getPriority() - { - return 70; - } - - public static class FolderXarImporter implements FolderImporter - { - @Override - public String getDataType() - { - return FolderArchiveDataTypes.EXPERIMENTS_AND_RUNS; - } - - @Override - public String getDescription() - { - return "xar"; - } - - @Override - public void process(PipelineJob job, FolderImportContext ctx, VirtualFile root) throws Exception - { - if (!isValidForImportArchive(ctx)) - { - ctx.getLogger().info("xar directory not found in folder " + ctx.getContainer().getPath()); - return; - } - - VirtualFile xarDir = ctx.getDir(FolderXarWriterFactory.XAR_DIRECTORY); - - if (job != null) - job.setStatus("IMPORT " + getDescription()); - ctx.getLogger().info("Loading " + getDescription()); - - PipeRoot pipeRoot = PipelineService.get().findPipelineRoot(ctx.getContainer()); - if (pipeRoot == null) - { - throw new NotFoundException("PipelineRoot not found for container " + ctx.getContainer().getPath()); - } - - final FolderExportXarSourceWrapper xarSourceWrapper = new FolderExportXarSourceWrapper(xarDir, ctx); - try - { - xarSourceWrapper.init(); - } - catch (Exception e) - { - ctx.getLogger().info("Failed to initialize xar source.", e); - throw e; - } - - FileLike xarFile = xarSourceWrapper.getXarFile(); - if (xarFile == null) - { - ctx.getLogger().error("Could not find a xar file in the xar directory."); - throw new NotFoundException("Could not find a xar file in the xar directory."); - } - - if (job == null) - { - // Create a new job, if we were not given one. This will happen if we are creating a new folder - // from a template folder. - ViewBackgroundInfo bgInfo = new ViewBackgroundInfo(ctx.getContainer(), ctx.getUser(), null); - - // This will create a new job in the folder. - // If subfolders are being imported, a job will be created in each subfolder that has a xar file. - // TODO: Is there a way to create a single job that will import all the files to - // their respective folders? - job = new ExperimentPipelineJob(bgInfo, xarFile, "Xar import", false, pipeRoot) - { - @Override - protected XarSource createXarSource(FileLike file) - { - // Assume this is a .xar or a .zip file - return xarSourceWrapper.getXarSource(this); - } - }; - PipelineService.get().queueJob(job); - } - else - { - XarSource xarSource = xarSourceWrapper.getXarSource(job); - try - { - xarSource.init(); - } - catch (Exception e) - { - ctx.getLogger().error("Failed to initialize XAR source", e); - throw(e); - } - - FolderExportXarReader reader = new FolderExportXarReader(xarSource, job); - XarImportContext xarCtx = ctx.getContext(XarImportContext.class); - if (xarCtx != null) - { - reader.setStrictValidateExistingSampleType(xarCtx.isStrictValidateExistingSampleType()); - } - reader.parseAndLoad(false, ctx.getAuditBehaviorType()); - } - - ctx.getLogger().info("Done importing " + getDescription()); - } - - @Override - public boolean isValidForImportArchive(FolderImportContext ctx) throws ImportException - { - return ctx.getDir(FolderXarWriterFactory.XAR_DIRECTORY) != null; - } - } - - private static class FolderExportXarSourceWrapper - { - private final VirtualFile _xarDir; - private final FolderImportContext _importContext; - - private FileLike _xarFile; - private XarSource _xarSource; - - public FolderExportXarSourceWrapper(VirtualFile xarDir, FolderImportContext ctx) - { - _xarDir = xarDir; - _importContext = ctx; - } - - public void init() - { - if (_xarDir == null) - { - throw new IllegalStateException("Xar directory is null"); - } - - for (String file: _xarDir.list()) - { - if (file.toLowerCase().endsWith(".xar") || file.toLowerCase().endsWith(".xar.xml")) - { - _xarFile = FileSystemLike.wrapFile(FileUtil.getPath(_importContext.getContainer(), FileUtil.createUri(_xarDir.getLocation())).resolve(file)); - break; - } - } - } - - public FileLike getXarFile() - { - return _xarFile; - } - - public XarSource getXarSource(PipelineJob job) - { - if (_xarSource == null) - { - if (getXarFile().getName().toLowerCase().endsWith(".xar.xml")) - { - _xarSource = new FileXarSource( - getXarFile(), - job, - // Initialize the XarSource with the container from the ImportContext instead of the job - // so that a XarContext with the correct folder gets created, and runs imported to subfolders - // get assigned to the subfolder instead of the parent container. - // If we were given a non-null job in FolderXarImporter.process(), job.getContainer() will - // return the parent container. - _importContext.getContainer(), - _importContext.getXarJobIdContext()); - } - else - { - _xarSource = new CompressedXarSource( - getXarFile(), - job, - // Initialize the XarSource with the container from the ImportContext instead of the job - // so that a XarContext with the correct folder gets created, and runs imported to subfolders - // get assigned to the subfolder instead of the parent container. - // If we were given a non-null job in FolderXarImporter.process(), job.getContainer() will - // return the parent container. - _importContext.getContainer(), - _importContext.getXarJobIdContext()); - } - } - return _xarSource; - } - } - - public static class FolderExportXarReader extends XarReader - { - public FolderExportXarReader(XarSource source, PipelineJob job) - { - super(source, job); - } - - @Override - protected Container getContainer() - { - // XarReader.getContainer() returns job.getContainer(). - // We want to return the container from the XarContext instead. - return _xarSource.getXarContext().getContainer(); - } - } -} +/* + * Copyright (c) 2014-2018 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.experiment.xar; + +import org.labkey.api.admin.AbstractFolderImportFactory; +import org.labkey.api.admin.FolderArchiveDataTypes; +import org.labkey.api.admin.FolderImportContext; +import org.labkey.api.admin.FolderImporter; +import org.labkey.api.admin.ImportException; +import org.labkey.api.data.Container; +import org.labkey.api.exp.FileXarSource; +import org.labkey.api.exp.XarSource; +import org.labkey.api.pipeline.PipeRoot; +import org.labkey.api.pipeline.PipelineJob; +import org.labkey.api.pipeline.PipelineService; +import org.labkey.api.util.FileUtil; +import org.labkey.api.view.NotFoundException; +import org.labkey.api.view.ViewBackgroundInfo; +import org.labkey.api.writer.VirtualFile; +import org.labkey.experiment.XarReader; +import org.labkey.experiment.pipeline.ExperimentPipelineJob; +import org.labkey.vfs.FileLike; +import org.labkey.vfs.FileSystemLike; + +/** + * User: vsharma + * Date: 6/4/14 + * Time: 9:52 AM + */ +public class FolderXarImporterFactory extends AbstractFolderImportFactory +{ + @Override + public FolderImporter create() + { + return new FolderXarImporter(); + } + + @Override + public int getPriority() + { + return 70; + } + + public static class FolderXarImporter implements FolderImporter + { + @Override + public String getDataType() + { + return FolderArchiveDataTypes.EXPERIMENTS_AND_RUNS; + } + + @Override + public String getDescription() + { + return "xar"; + } + + @Override + public void process(PipelineJob job, FolderImportContext ctx, VirtualFile root) throws Exception + { + if (!isValidForImportArchive(ctx)) + { + ctx.getLogger().info("xar directory not found in folder " + ctx.getContainer().getPath()); + return; + } + + VirtualFile xarDir = ctx.getDir(FolderXarWriterFactory.XAR_DIRECTORY); + + if (job != null) + job.setStatus("IMPORT " + getDescription()); + ctx.getLogger().info("Loading " + getDescription()); + + PipeRoot pipeRoot = PipelineService.get().findPipelineRoot(ctx.getContainer()); + if (pipeRoot == null) + { + throw new NotFoundException("PipelineRoot not found for container " + ctx.getContainer().getPath()); + } + + final FolderExportXarSourceWrapper xarSourceWrapper = new FolderExportXarSourceWrapper(xarDir, ctx); + try + { + xarSourceWrapper.init(); + } + catch (Exception e) + { + ctx.getLogger().info("Failed to initialize xar source.", e); + throw e; + } + + FileLike xarFile = xarSourceWrapper.getXarFile(); + if (xarFile == null) + { + ctx.getLogger().error("Could not find a xar file in the xar directory."); + throw new NotFoundException("Could not find a xar file in the xar directory."); + } + + if (job == null) + { + // Create a new job, if we were not given one. This will happen if we are creating a new folder + // from a template folder. + ViewBackgroundInfo bgInfo = new ViewBackgroundInfo(ctx.getContainer(), ctx.getUser(), null); + + // This will create a new job in the folder. + // If subfolders are being imported, a job will be created in each subfolder that has a xar file. + // TODO: Is there a way to create a single job that will import all the files to + // their respective folders? + job = new ExperimentPipelineJob(bgInfo, xarFile, "Xar import", false, pipeRoot) + { + @Override + protected XarSource createXarSource(FileLike file) + { + // Assume this is a .xar or a .zip file + return xarSourceWrapper.getXarSource(this); + } + }; + PipelineService.get().queueJob(job); + } + else + { + XarSource xarSource = xarSourceWrapper.getXarSource(job); + try + { + xarSource.init(); + } + catch (Exception e) + { + ctx.getLogger().error("Failed to initialize XAR source", e); + throw(e); + } + + FolderExportXarReader reader = new FolderExportXarReader(xarSource, job); + XarImportContext xarCtx = ctx.getContext(XarImportContext.class); + if (xarCtx != null) + { + reader.setStrictValidateExistingSampleType(xarCtx.isStrictValidateExistingSampleType()); + } + reader.parseAndLoad(false, ctx.getAuditBehaviorType()); + } + + ctx.getLogger().info("Done importing " + getDescription()); + } + + @Override + public boolean isValidForImportArchive(FolderImportContext ctx) throws ImportException + { + return ctx.getDir(FolderXarWriterFactory.XAR_DIRECTORY) != null; + } + } + + private static class FolderExportXarSourceWrapper + { + private final VirtualFile _xarDir; + private final FolderImportContext _importContext; + + private FileLike _xarFile; + private XarSource _xarSource; + + public FolderExportXarSourceWrapper(VirtualFile xarDir, FolderImportContext ctx) + { + _xarDir = xarDir; + _importContext = ctx; + } + + public void init() + { + if (_xarDir == null) + { + throw new IllegalStateException("Xar directory is null"); + } + + for (String file: _xarDir.list()) + { + if (file.toLowerCase().endsWith(".xar") || file.toLowerCase().endsWith(".xar.xml")) + { + _xarFile = FileSystemLike.wrapFile(FileUtil.getPath(_importContext.getContainer(), FileUtil.createUri(_xarDir.getLocation())).resolve(file)); + break; + } + } + } + + public FileLike getXarFile() + { + return _xarFile; + } + + public XarSource getXarSource(PipelineJob job) + { + if (_xarSource == null) + { + if (getXarFile().getName().toLowerCase().endsWith(".xar.xml")) + { + _xarSource = new FileXarSource( + getXarFile(), + job, + // Initialize the XarSource with the container from the ImportContext instead of the job + // so that a XarContext with the correct folder gets created, and runs imported to subfolders + // get assigned to the subfolder instead of the parent container. + // If we were given a non-null job in FolderXarImporter.process(), job.getContainer() will + // return the parent container. + _importContext.getContainer(), + _importContext.getXarJobIdContext()); + } + else + { + _xarSource = new CompressedXarSource( + getXarFile(), + job, + // Initialize the XarSource with the container from the ImportContext instead of the job + // so that a XarContext with the correct folder gets created, and runs imported to subfolders + // get assigned to the subfolder instead of the parent container. + // If we were given a non-null job in FolderXarImporter.process(), job.getContainer() will + // return the parent container. + _importContext.getContainer(), + _importContext.getXarJobIdContext()); + } + } + return _xarSource; + } + } + + public static class FolderExportXarReader extends XarReader + { + public FolderExportXarReader(XarSource source, PipelineJob job) + { + super(source, job); + } + + @Override + protected Container getContainer() + { + // XarReader.getContainer() returns job.getContainer(). + // We want to return the container from the XarContext instead. + return _xarSource.getXarContext().getContainer(); + } + } +} diff --git a/filecontent/src/org/labkey/filecontent/FileContentServiceImpl.java b/filecontent/src/org/labkey/filecontent/FileContentServiceImpl.java index 1b3c35ee263..2836818c539 100644 --- a/filecontent/src/org/labkey/filecontent/FileContentServiceImpl.java +++ b/filecontent/src/org/labkey/filecontent/FileContentServiceImpl.java @@ -1,1968 +1,1968 @@ -/* - * Copyright (c) 2009-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.labkey.filecontent; - -import org.apache.commons.io.FilenameUtils; -import org.apache.commons.lang3.StringUtils; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.junit.After; -import org.junit.Assert; -import org.junit.Test; -import org.labkey.api.action.SpringActionController; -import org.labkey.api.admin.AdminUrls; -import org.labkey.api.attachments.AttachmentDirectory; -import org.labkey.api.cache.Cache; -import org.labkey.api.cache.CacheManager; -import org.labkey.api.cloud.CloudStoreService; -import org.labkey.api.collections.CaseInsensitiveHashMap; -import org.labkey.api.data.CompareType; -import org.labkey.api.data.Container; -import org.labkey.api.data.ContainerManager; -import org.labkey.api.data.ContainerManager.ContainerListener; -import org.labkey.api.data.ContainerType; -import org.labkey.api.data.CoreSchema; -import org.labkey.api.data.SQLFragment; -import org.labkey.api.data.SimpleFilter; -import org.labkey.api.data.TabContainerType; -import org.labkey.api.data.Table; -import org.labkey.api.data.TableInfo; -import org.labkey.api.data.TableSelector; -import org.labkey.api.data.WorkbookContainerType; -import org.labkey.api.exp.Lsid; -import org.labkey.api.exp.api.ExpData; -import org.labkey.api.exp.api.ExpProtocol; -import org.labkey.api.exp.api.ExpRun; -import org.labkey.api.exp.api.ExperimentService; -import org.labkey.api.exp.query.ExpDataTable; -import org.labkey.api.files.DirectoryPattern; -import org.labkey.api.files.FileContentService; -import org.labkey.api.files.FileListener; -import org.labkey.api.files.FileRoot; -import org.labkey.api.files.FilesAdminOptions; -import org.labkey.api.files.MissingRootDirectoryException; -import org.labkey.api.files.UnsetRootDirectoryException; -import org.labkey.api.files.view.FilesWebPart; -import org.labkey.api.module.Module; -import org.labkey.api.module.ModuleLoader; -import org.labkey.api.pipeline.PipeRoot; -import org.labkey.api.pipeline.PipelineService; -import org.labkey.api.pipeline.PipelineUrls; -import org.labkey.api.query.BatchValidationException; -import org.labkey.api.query.FieldKey; -import org.labkey.api.query.QueryUpdateService; -import org.labkey.api.security.User; -import org.labkey.api.security.permissions.AdminOperationsPermission; -import org.labkey.api.settings.AppProps; -import org.labkey.api.settings.RandomSiteSettingsPropertyHandler; -import org.labkey.api.settings.StartupPropertyEntry; -import org.labkey.api.settings.WriteableAppProps; -import org.labkey.api.test.TestWhen; -import org.labkey.api.util.ContainerUtil; -import org.labkey.api.util.DOM; -import org.labkey.api.util.FileUtil; -import org.labkey.api.util.GUID; -import org.labkey.api.util.HtmlString; -import org.labkey.api.util.NetworkDrive; -import org.labkey.api.util.PageFlowUtil; -import org.labkey.api.util.Path; -import org.labkey.api.util.TestContext; -import org.labkey.api.util.URIUtil; -import org.labkey.api.view.ActionURL; -import org.labkey.api.view.HttpView; -import org.labkey.api.view.ViewBackgroundInfo; -import org.labkey.api.view.ViewContext; -import org.labkey.api.view.template.WarningProvider; -import org.labkey.api.view.template.WarningService; -import org.labkey.api.view.template.Warnings; -import org.labkey.api.webdav.WebdavResource; -import org.labkey.api.webdav.WebdavService; -import org.labkey.vfs.FileLike; - -import java.beans.PropertyChangeEvent; -import java.io.BufferedWriter; -import java.io.File; -import java.io.IOException; -import java.net.URI; -import java.net.URISyntaxException; -import java.nio.file.Files; -import java.nio.file.InvalidPathException; -import java.nio.file.StandardOpenOption; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.Date; -import java.util.HashMap; -import java.util.HashSet; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.regex.Pattern; -import java.util.stream.Stream; - -import static org.labkey.api.settings.AppProps.SCOPE_SITE_SETTINGS; -import static org.labkey.api.util.DOM.Attribute.href; -import static org.labkey.api.util.DOM.at; - -public class FileContentServiceImpl implements FileContentService, WarningProvider -{ - private static final Logger _log = LogManager.getLogger(FileContentServiceImpl.class); - private static final String UPLOAD_LOG = ".upload.log"; - private static final FileContentServiceImpl INSTANCE = new FileContentServiceImpl(); - - private final ContainerListener _containerListener = new FileContentServiceContainerListener(); - private final List _fileListeners = new CopyOnWriteArrayList<>(); - - private final List _ziploaderPattern = new CopyOnWriteArrayList<>(); - - private volatile boolean _fileRootSetViaStartupProperty = false; - private String _problematicFileRootMessage; - - enum FileAction - { - UPLOAD, - DELETE - } - - static FileContentServiceImpl getInstance() - { - return INSTANCE; - } - - private FileContentServiceImpl() - { - WarningService.get().register(this); - } - - @Override - @NotNull - public List getContainersForFilePath(java.nio.file.Path path) - { - // Ignore cloud files for now - if (FileUtil.hasCloudScheme(path)) - return Collections.emptyList(); - - // If the path is under the default root, do optimistic simple match for containers under the default root - File defaultRoot = getSiteDefaultRoot(); - java.nio.file.Path defaultRootPath = defaultRoot.toPath(); - if (path.startsWith(defaultRootPath)) - { - java.nio.file.Path rel = defaultRootPath.relativize(path); - if (rel.getNameCount() > 0) - { - Container root = ContainerManager.getRoot(); - Container next = root; - while (rel.getNameCount() > 0) - { - // check if there exists a child container that matches the next path segment - java.nio.file.Path top = rel.subpath(0, 1); - assert top != null; - Container child = next.getChild(top.getFileName().toString()); - if (child == null) - break; - - next = child; - - if(rel.getNameCount() > 1) - { - rel = rel.subpath(1, rel.getNameCount()); - } - else - { - break; - } - } - - if (next != null && !next.equals(root)) - { - // verify our naive file path is correct for the container -- it may have a file root other than the default - java.nio.file.Path fileRoot = getFileRootPath(next); - if (fileRoot != null && path.startsWith(fileRoot)) - return Collections.singletonList(next); - } - } - } - - // TODO: Create cache of file root and pipeline root paths -> list of containers - - return Collections.emptyList(); - } - - @Override - public @Nullable File getFileRoot(@NotNull Container c, @NotNull ContentType type) - { - switch (type) - { - case files: - case assayfiles: - String folderName = getFolderName(type); - if (folderName == null) - folderName = ""; - - java.nio.file.Path dir = getFileRootPath(c); - return dir != null ? dir.resolve(folderName).toFile() : null; - - case pipeline: - PipeRoot root = PipelineService.get().findPipelineRoot(c); - return root != null ? root.getRootPath() : null; - } - return null; - } - - @Override - public @Nullable java.nio.file.Path getFileRootPath(@NotNull Container c, @NotNull ContentType type) - { - switch (type) - { - case files: - case assayfiles: - java.nio.file.Path fileRootPath = getFileRootPath(c); - if (null != fileRootPath && !FileUtil.hasCloudScheme(fileRootPath)) // Don't add @files when we're in the cloud - fileRootPath = fileRootPath.resolve(getFolderName(type)); - return fileRootPath; - - case pipeline: - PipeRoot root = PipelineService.get().findPipelineRoot(c); - return root != null ? root.getRootNioPath() : null; - } - return null; - } - - // Returns full uri to file root for this container. filePath is optional relative path to a file under the file root - @Override - public @Nullable URI getFileRootUri(@NotNull Container c, @NotNull ContentType type, @Nullable String filePath) - { - java.nio.file.Path root = FileContentService.get().getFileRootPath(c, FileContentService.ContentType.files); - if (root != null) - { - String path = root.toString(); - if (filePath != null) { - path += filePath; - } - - // non-unix needs a leading slash - if (!path.startsWith("/") && !path.startsWith("\\")) - { - path = "/" + path; - } - return FileUtil.createUri(path); - } - - return null; - } - - @Override - public @Nullable File getFileRoot(@NotNull Container c) - { - java.nio.file.Path path = getFileRootPath(c); - throwIfPathNotFile(path, c); - return path.toFile(); - } - - @Override - public @Nullable java.nio.file.Path getFileRootPath(@NotNull Container c) - { - if (c == null) - return null; - - if (c.isRoot()) - { - return getSiteDefaultRootPath(); - } - - if (!isFileRootDisabled(c)) - { - FileRoot root = FileRootManager.get().getFileRoot(c); - - // check if there is a site wide file root - if (root.getPath() == null || isUseDefaultRoot(c)) - { - return getDefaultRootPath(c, true); - } - else - return getNioPath(c, root.getPath()); - } - return null; - } - - @Override - public File getDefaultRoot(Container c, boolean createDir) - { - return getDefaultRootPath(c, createDir).toFile(); - } - - @Override - public java.nio.file.Path getDefaultRootPath(@NotNull Container c, boolean createDir) - { - Container firstOverride = getFirstAncestorWithOverride(c); - - java.nio.file.Path parentRoot; - if (firstOverride == null) - { - parentRoot = getSiteDefaultRoot().toPath(); - firstOverride = ContainerManager.getRoot(); - } - else - { - parentRoot = getFileRootPath(firstOverride); - } - - if (parentRoot != null && firstOverride != null) - { - java.nio.file.Path fileRootPath; - if (FileUtil.hasCloudScheme(parentRoot)) - { - // For cloud root, we don't have to create directories for this path - fileRootPath = CloudStoreService.get().getPathForOtherContainer(firstOverride, c, FileUtil.pathToString(parentRoot), new Path("")); - } - else - { - // For local, the path may be several directories deep (since it matches the LK folder path), so we should create the directories for that path - fileRootPath = FileUtil.appendPath(parentRoot.toFile(), Path.parse(getRelativePath(c, firstOverride))).toPath(); - - try - { - if (createDir && !NetworkDrive.exists(fileRootPath)) - FileUtil.createDirectories(fileRootPath); - } - catch (IOException e) - { - return null; // throw new RuntimeException(e); TODO: does returning null make certain tests, like TargetedMSQCGuideSetTest pass on Windows? - } - } - - return fileRootPath; - } - return null; - } - - // Return pretty path string for defaultFileRoot and boolean true if defaultFileRoot is cloud - @Override - public DefaultRootInfo getDefaultRootInfo(Container container) - { - String defaultRoot = ""; - boolean isDefaultRootCloud = false; - java.nio.file.Path defaultRootPath = getDefaultRootPath(container, false); - String cloudName = null; - if (defaultRootPath != null) - { - isDefaultRootCloud = FileUtil.hasCloudScheme(defaultRootPath); - if (isDefaultRootCloud && !container.isProject()) - { - FileRoot fileRoot = getDefaultFileRoot(container); - if (null != fileRoot) - defaultRoot = fileRoot.getPath(); - if (null != defaultRoot) - cloudName = getCloudRootName(defaultRoot); - } - else - { - defaultRoot = FileUtil.getAbsolutePath(container, defaultRootPath.toUri()); - } - } - return new DefaultRootInfo(defaultRootPath, defaultRoot, isDefaultRootCloud, cloudName); - } - - @Nullable - // Get FileRoot associated with path returned form getDefaultRootPath() - public FileRoot getDefaultFileRoot(Container c) - { - Container firstOverride = getFirstAncestorWithOverride(c); - - if (firstOverride == null) - firstOverride = ContainerManager.getRoot(); - - if (null != firstOverride) - return FileRootManager.get().getFileRoot(firstOverride); - return null; - } - - private @NotNull String getRelativePath(Container c, Container ancestor) - { - return c.getPath().replaceAll("^" + Pattern.quote(ancestor.getPath()), ""); - } - - //returns the first parent container that has a custom file root, or NULL if none have overrides - private Container getFirstAncestorWithOverride(Container c) - { - Container toTest = c.getParent(); - if (toTest == null) - return null; - - while (isUseDefaultRoot(toTest)) - { - if (toTest == null || toTest.equals(ContainerManager.getRoot())) - return null; - - toTest = toTest.getParent(); - } - - return toTest; - } - - private java.nio.file.Path getNioPath(Container c, @NotNull String fileRootPath) - { - if (isCloudFileRoot(fileRootPath)) - return CloudStoreService.get().getPath(c, getCloudRootName(fileRootPath), new org.labkey.api.util.Path("")); - - return FileUtil.stringToPath(c, fileRootPath, false); // fileRootPath is unencoded - } - - private boolean isCloudFileRoot(String fileRootPseudoPath) - { - return StringUtils.startsWith(fileRootPseudoPath, FileContentService.CLOUD_ROOT_PREFIX); - } - - private String getCloudRootName(@NotNull String fileRootPseudoPath) - { - return fileRootPseudoPath.substring(fileRootPseudoPath.indexOf(FileContentService.CLOUD_ROOT_PREFIX) + FileContentService.CLOUD_ROOT_PREFIX.length() + 1); - } - - @Override - public boolean isCloudRoot(Container c) - { - if (null != c) - { - java.nio.file.Path fileRootPath = getFileRootPath(c); - return null != fileRootPath && FileUtil.hasCloudScheme(fileRootPath); - } - return false; - } - - @Override - @NotNull - public String getCloudRootName(Container c) - { - if (null != c) - { - if (isCloudRoot(c)) - { - FileRoot root = FileRootManager.get().getFileRoot(c); - if (null == root.getPath() || isUseDefaultRoot(c)) - { - Container firstOverride = getFirstAncestorWithOverride(c); - if (null == firstOverride) - firstOverride = ContainerManager.getRoot(); - root = FileRootManager.get().getFileRoot(firstOverride); - if (null == root.getPath()) - return ""; - } - return getCloudRootName(root.getPath()); - } - } - return ""; - } - - @Override - public void setCloudRoot(@NotNull Container c, String cloudRootName) - { - _setFileRoot(c, FileContentService.CLOUD_ROOT_PREFIX + "/" + cloudRootName); - } - - @Override - public void setFileRoot(@NotNull Container c, @Nullable File path) - { - _setFileRoot(c, (null != path ? FileUtil.getAbsoluteCaseSensitiveFile(path).getAbsolutePath() : null)); - } - - @Override - public void setFileRootPath(@NotNull Container c, @Nullable String strPath) - { - String absolutePath = null; - if (strPath != null) - { - URI uri = FileUtil.createUri(strPath, false); // strPath is unencoded - if (FileUtil.hasCloudScheme(uri)) - absolutePath = FileUtil.getAbsolutePath(c, uri); - else - absolutePath = FileUtil.getAbsoluteCaseSensitiveFile(new File(uri)).getAbsolutePath(); - } - _setFileRoot(c, absolutePath); - } - - private void _setFileRoot(@NotNull Container c, @Nullable String absolutePath) - { - if (!c.isContainerFor(ContainerType.DataType.fileRoot)) - throw new IllegalArgumentException("File roots cannot be set for containers of type " + c.getContainerType().getName()); - - FileRoot root = FileRootManager.get().getFileRoot(c); - root.setEnabled(true); - - String oldValue = root.getPath(); - String newValue = null; - - // clear out the root - if (absolutePath == null) - root.setPath(null); - else - { - root.setPath(absolutePath); - newValue = root.getPath(); - } - - FileRootManager.get().saveFileRoot(null, root); - ContainerManager.ContainerPropertyChangeEvent evt = new ContainerManager.ContainerPropertyChangeEvent( - c, ContainerManager.Property.WebRoot, oldValue, newValue); - ContainerManager.firePropertyChangeEvent(evt); - } - - @Override - public void disableFileRoot(Container container) - { - if (container == null || container.isRoot()) - throw new IllegalArgumentException("Disabling either a null project or the root project is not allowed."); - - Container effective = container.getContainerFor(ContainerType.DataType.fileRoot); - if (effective != null) - { - FileRoot root = FileRootManager.get().getFileRoot(effective); - String oldValue = root.getPath(); - root.setEnabled(false); - FileRootManager.get().saveFileRoot(null, root); - - ContainerManager.ContainerPropertyChangeEvent evt = new ContainerManager.ContainerPropertyChangeEvent( - container, ContainerManager.Property.WebRoot, oldValue, null); - ContainerManager.firePropertyChangeEvent(evt); - } - } - - @Override - public boolean isFileRootDisabled(Container c) - { - Container effective = c.getContainerFor(ContainerType.DataType.fileRoot); - if (null == effective) - return false; - - FileRoot root = FileRootManager.get().getFileRoot(effective); - return !root.isEnabled(); - } - - @Override - public boolean isUseDefaultRoot(Container c) - { - if (c == null) - return true; - - Container effective = c.getContainerFor(ContainerType.DataType.fileRoot); - if (null == effective) - return true; - - FileRoot root = FileRootManager.get().getFileRoot(effective); - return root.isUseDefault() || StringUtils.isEmpty(root.getPath()); - } - - @Override - public void setIsUseDefaultRoot(Container c, boolean useDefaultRoot) - { - Container effective = c.getContainerFor(ContainerType.DataType.fileRoot); - if (effective != null) - { - FileRoot root = FileRootManager.get().getFileRoot(effective); - String oldValue = root.getPath(); - root.setEnabled(true); - root.setUseDefault(useDefaultRoot); - if (useDefaultRoot) - root.setPath(null); - FileRootManager.get().saveFileRoot(null, root); - - ContainerManager.ContainerPropertyChangeEvent evt = new ContainerManager.ContainerPropertyChangeEvent( - effective, ContainerManager.Property.WebRoot, oldValue, null); - ContainerManager.firePropertyChangeEvent(evt); - } - } - - @Override - public @NotNull java.nio.file.Path getSiteDefaultRootPath() - { - return getSiteDefaultRoot().toPath(); - } - - @Override - public @NotNull File getSiteDefaultRoot() - { - // Site default is always on file system - File root = AppProps.getInstance().getFileSystemRoot(); - - try - { - if (!NetworkDrive.exists(root)) - { - File configuredRoot = root; - root = getDefaultRoot(); - if (configuredRoot != null && !configuredRoot.equals(root)) - { - String message = "The configured site-wide file root " + configuredRoot + " does not exist. Falling back to " + root; - if (!message.equals(_problematicFileRootMessage)) - { - _problematicFileRootMessage = message; - _log.error(_problematicFileRootMessage); - } - } - } - else - { - _problematicFileRootMessage = null; - } - - if (!NetworkDrive.exists(root)) - { - if (FileUtil.mkdirs(root)) - { - _log.info("Created site-wide file root " + root); - } - else - { - _log.error("Failed when attempting to create site-wide file root " + root); - } - } - } - catch (IOException e) - { - throw new RuntimeException("Unable to create file root directory", e); - } - - return root; - } - - @Override - public String getProblematicFileRootMessage() - { - return _problematicFileRootMessage; - } - - private @NotNull File getDefaultRoot() throws IOException - { - File explodedPath = ModuleLoader.getInstance().getCoreModule().getExplodedPath(); - - File root = explodedPath.getParentFile(); - if (root != null) - { - if (root.getParentFile() != null) - root = root.getParentFile(); - } - File defaultRoot = new File(root, "files"); - if (!NetworkDrive.exists(defaultRoot)) - FileUtil.mkdirs(defaultRoot); - - return defaultRoot; - } - - @Override - public void setSiteDefaultRoot(File root, User user) - { - if (root == null) - throw new IllegalArgumentException("Invalid site root: specified root is null"); - - if (!NetworkDrive.exists(root)) - throw new IllegalArgumentException("Invalid site root: " + root.getAbsolutePath() + " does not exist"); - - File prevRoot = getSiteDefaultRoot(); - WriteableAppProps props = AppProps.getWriteableInstance(); - - props.setFileSystemRoot(root.getAbsolutePath()); - props.save(user); - - FileRootManager.get().clearCache(); - ContainerManager.ContainerPropertyChangeEvent evt = new ContainerManager.ContainerPropertyChangeEvent( - ContainerManager.getRoot(), ContainerManager.Property.SiteRoot, prevRoot, root); - ContainerManager.firePropertyChangeEvent(evt); - } - - @Override - public void setWebfilesEnabled(boolean enabled, User user) - { - WriteableAppProps props = AppProps.getWriteableInstance(); - props.setWebfilesEnabled(enabled); - props.save(user); - } - - @Override - public FileSystemAttachmentParent registerDirectory(Container c, String name, String path, boolean relative) - { - FileSystemAttachmentParent parent = new FileSystemAttachmentParent(); - parent.setContainer(c); - if (null == name) - name = path; - parent.setName(name); - parent.setPath(path); - parent.setRelative(relative); - //We do this because insert does not return new fields - parent.setEntityid(GUID.makeGUID()); - - FileSystemAttachmentParent ret = Table.insert(HttpView.currentContext().getUser(), CoreSchema.getInstance().getMappedDirectories(), parent); - ContainerManager.ContainerPropertyChangeEvent evt = new ContainerManager.ContainerPropertyChangeEvent( - c, ContainerManager.Property.AttachmentDirectory, null, ret); - ContainerManager.firePropertyChangeEvent(evt); - return ret; - } - - @Override - public void unregisterDirectory(Container c, String name) - { - FileSystemAttachmentParent parent = getRegisteredDirectory(c, name); - SimpleFilter filter = SimpleFilter.createContainerFilter(c); - filter.addCondition(FieldKey.fromParts("Name"), name); - Table.delete(CoreSchema.getInstance().getMappedDirectories(), filter); - ContainerManager.ContainerPropertyChangeEvent evt = new ContainerManager.ContainerPropertyChangeEvent( - c, ContainerManager.Property.AttachmentDirectory, parent, null); - ContainerManager.firePropertyChangeEvent(evt); - } - - @Override - public @Nullable AttachmentDirectory getMappedAttachmentDirectory(Container c, boolean createDir) throws UnsetRootDirectoryException, MissingRootDirectoryException - { - return getMappedAttachmentDirectory(c, ContentType.files, createDir); - } - - @Override - @Nullable - public AttachmentDirectory getMappedAttachmentDirectory(Container c, ContentType contentType, boolean createDir) throws UnsetRootDirectoryException - { - try - { - if (createDir) //force create - getMappedDirectory(c, true); - else if (null == getMappedDirectory(c, false)) - return null; - - return new FileSystemAttachmentParent(c, contentType); - } - catch (IOException e) - { - _log.error("Cannot get mapped directory for " + c.getPath(), e); - return null; - } - } - - public java.nio.file.Path getMappedDirectory(Container c, boolean create) throws UnsetRootDirectoryException, IOException - { - java.nio.file.Path root = getFileRootPath(c); - if (!FileUtil.hasCloudScheme(root)) - { - if (null == root) - { - if (create) - throw new UnsetRootDirectoryException(c.isRoot() ? c : c.getProject()); - else - return null; - } - - if (!NetworkDrive.exists(root)) - { - if (create) - throw new MissingRootDirectoryException(c.isRoot() ? c : c.getProject(), root); - else - return null; - - } - } - return root; - } - - @Override - public FileSystemAttachmentParent getRegisteredDirectory(Container c, String name) - { - SimpleFilter filter = SimpleFilter.createContainerFilter(c); - filter.addCondition(FieldKey.fromParts("Name"), name); - - return new TableSelector(CoreSchema.getInstance().getMappedDirectories(), filter, null).getObject(FileSystemAttachmentParent.class); - } - - @Override - public FileSystemAttachmentParent getRegisteredDirectoryFromEntityId(Container c, String entityId) - { - SimpleFilter filter = SimpleFilter.createContainerFilter(c); - filter.addCondition(FieldKey.fromParts("EntityId"), entityId); - - return new TableSelector(CoreSchema.getInstance().getMappedDirectories(), filter, null).getObject(FileSystemAttachmentParent.class); - } - - @Override - public @NotNull Collection getRegisteredDirectories(Container c) - { - SimpleFilter filter = SimpleFilter.createContainerFilter(c); - - return Collections.unmodifiableCollection(new TableSelector(CoreSchema.getInstance().getMappedDirectories(), filter, null).getCollection(FileSystemAttachmentParent.class)); - } - - private class FileContentServiceContainerListener implements ContainerListener - { - @Override - public void containerCreated(Container c, User user) - { - try - { - // Will create directory if it's a default dir - getMappedDirectory(c, false); - } - catch (IOException ex) - { - /* */ - } - } - - @Override - public void containerDeleted(Container c, User user) - { - java.nio.file.Path dir = null; - try - { - // don't delete the file contents if they have a project override - if (isUseDefaultRoot(c) && !isCloudRoot(c)) // Don't do anything for cloud root here. CloudContainerListener will handle - dir = getMappedDirectory(c, false); - - if (null != dir) - { - FileUtil.deleteDir(dir); - } - } - catch (Exception e) - { - _log.error("containerDeleted", e); - } - - ContainerUtil.purgeTable(CoreSchema.getInstance().getMappedDirectories(), c, null); - } - - @Override - public void containerMoved(Container c, Container oldParent, User user) - { - /* **** Cases: - SRC DEST - specific local path same -- no work - specific cloud path same -- no work - local default local default -- move tree - local default cloud default -- move tree - cloud default local default -- move tree - cloud default cloud default -- if change bucket, move tree - *************************************************************/ - if (isUseDefaultRoot(c)) - { - java.nio.file.Path srcParent = getFileRootPath(oldParent); - java.nio.file.Path dest = getFileRootPath(c); - if (null != srcParent && null != dest) - { - if (!FileUtil.hasCloudScheme(srcParent)) - { - File src = new File(srcParent.toFile(), c.getName()); - if (NetworkDrive.exists(src)) - { - if (!FileUtil.hasCloudScheme(dest)) - { - // local -> local - moveFileRoot(src, dest.toFile(), user, c); - } - else - { - // local -> cloud; source starts under @files - File filesSrc = FileUtil.appendName(src, FILES_LINK); - if (NetworkDrive.exists(filesSrc)) - moveFileRoot(filesSrc.toPath(), dest, user, c); - FileUtil.deleteDir(src); // moveFileRoot will delete @files, but we need to delete its parent - } - } - } - else - { - // Get source path using moving container and parent's config (cloudRoot), because that config must be the source config - java.nio.file.Path src = CloudStoreService.get().getPath(c, getCloudRootName(oldParent), new Path("")); - if (!FileUtil.hasCloudScheme(dest)) - { - // cloud -> local; destination is under @files - dest = dest.resolve(FILES_LINK); - moveFileRoot(src, dest, user, c); - } - else - { - // cloud -> cloud - if (!getCloudRootName(oldParent).equals(getCloudRootName(c))) - { - // Different configs - moveFileRoot(src, dest, user, c); - } - } - } - } - } - } - - @NotNull - @Override - public Collection canMove(Container c, Container newParent, User user) - { - return Collections.emptyList(); - } - - @Override - public void propertyChange(PropertyChangeEvent propertyChangeEvent) - { - ContainerManager.ContainerPropertyChangeEvent evt = (ContainerManager.ContainerPropertyChangeEvent)propertyChangeEvent; - Container c = evt.container; - - switch (evt.property) - { - case Name: // container rename event - { - String oldValue = (String) propertyChangeEvent.getOldValue(); - String newValue = (String) propertyChangeEvent.getNewValue(); - - java.nio.file.Path location; - try - { - location = getMappedDirectory(c, false); - if (location != null && !FileUtil.hasCloudScheme(location)) // If cloud, folder name for container not dependent on Name - { - //Don't rely on container object. Seems not to point to the - //new location even AFTER rename. Just construct new file paths - File locationFile = location.toFile(); - File parentDir = locationFile.getParentFile(); - File oldLocation = new File(parentDir, oldValue); - File newLocation = new File(parentDir, newValue); - if (NetworkDrive.exists(newLocation)) - moveToDeleted(newLocation); - - if (NetworkDrive.exists(oldLocation)) - { - oldLocation.renameTo(newLocation); - fireFileMoveEvent(oldLocation, newLocation, evt.user, evt.container); - } - } - } - catch (IOException ex) - { - _log.error(ex); - } - - break; - } - } - } - } - - - @Override - public @Nullable String getFolderName(FileContentService.ContentType type) - { - if (type != null) - return "@" + type.name(); - return null; - } - - - /** - * Move the file or directory into a ".deleted" directory under the parent directory. - * @return True if successfully moved. - */ - private static boolean moveToDeleted(File fileToMove) throws IOException - { - if (!NetworkDrive.exists(fileToMove)) - return false; - - File parent = fileToMove.getParentFile(); - - File deletedDir = new File(parent, ".deleted"); - if (!NetworkDrive.exists(deletedDir)) - if (!FileUtil.mkdir(deletedDir)) - return false; - - File newLocation = new File(deletedDir, fileToMove.getName()); - if (NetworkDrive.exists(newLocation)) - FileUtil.deleteDir(newLocation); - - return fileToMove.renameTo(newLocation); - } - - static void logFileAction(java.nio.file.Path directory, String fileName, FileAction action, User user) - { - try (BufferedWriter fw = Files.newBufferedWriter(directory.resolve(UPLOAD_LOG), StandardOpenOption.APPEND, StandardOpenOption.CREATE)) - { - fw.write(action.toString() + "\t" + fileName + "\t" + new Date() + "\t" + (user == null ? "(unknown)" : user.getEmail()) + "\n"); - } - catch (Exception x) - { - //Just log it. - _log.error(x); - } - } - - @Override - public FilesAdminOptions getAdminOptions(Container c) - { - FileRoot root = FileRootManager.get().getFileRoot(c); - String xml = null; - - if (!StringUtils.isBlank(root.getProperties())) - { - xml = root.getProperties(); - } - return new FilesAdminOptions(c, xml); - } - - @Override - public void setAdminOptions(Container c, FilesAdminOptions options) - { - if (options != null) - { - setAdminOptions(c, options.serialize()); - } - } - - @Override - public void setAdminOptions(Container c, String properties) - { - FileRoot root = FileRootManager.get().getFileRoot(c); - - root.setProperties(properties); - FileRootManager.get().saveFileRoot(null, root); - } - - public static final String NAMESPACE_PREFIX = "FileProperties"; - public static final String PROPERTIES_DOMAIN = "File Properties"; - public static final String TYPE_PROPERTIES = "FileProperties"; - - @Override - public String getDomainURI(Container container) - { - return getDomainURI(container, getAdminOptions(container).getFileConfig()); - } - - @Override - public String getDomainURI(Container container, FilesAdminOptions.fileConfig config) - { - while (config == FilesAdminOptions.fileConfig.useParent && container != container.getParent()) - { - container = container.getParent(); - config = getAdminOptions(container).getFileConfig(); - } - - //String typeURI = "urn:lsid:" + AppProps.getInstance().getDefaultLsidAuthority() + ":List" + ".Folder-" + container.getRowId() + ":" + name; - - return new Lsid("urn:lsid:labkey.com:" + NAMESPACE_PREFIX + ".Folder-" + container.getRowId() + ':' + TYPE_PROPERTIES).toString(); - } - - @Override @Nullable - public ExpData getDataObject(WebdavResource resource, Container c) - { - return getDataObject(resource, c, null, false); - } - - @Nullable - private static ExpData getDataObject(WebdavResource resource, Container c, User user, boolean create) - { - // TODO: S3: seems to only be called from Search and currently we're not searching in cloud. SaveCustomPropsAction seems unused - if (resource != null) - { - File file = resource.getFile(); - if (file != null) - { - ExpData data = ExperimentService.get().getExpDataByURL(file, c); - - if (data == null && create) - { - data = ExperimentService.get().createData(c, FileContentService.UPLOADED_FILE); - data.setName(file.getName()); - data.setDataFileURI(file.toURI()); - data.save(user); - } - return data; - } - } - return null; - } - - @Override - public QueryUpdateService getFilePropsUpdateService(TableInfo tinfo, Container container) - { - return new FileQueryUpdateService(tinfo, container); - } - - @Override - public boolean isValidProjectRoot(String root) - { - File f = new File(root); - return NetworkDrive.exists(f) && f.isDirectory(); - } - - @Override - public void moveFileRoot(java.nio.file.Path prev, java.nio.file.Path dest, @Nullable User user, @Nullable Container container) - { - if (!FileUtil.hasCloudScheme(prev) && !FileUtil.hasCloudScheme(dest)) - { - moveFileRoot(prev.toFile(), dest.toFile(), user, container); // Both files; try rename - } - else - { - try - { - // At least one is in the cloud - FileUtil.copyDirectory(prev, dest); - FileUtil.deleteDir(prev); // TODO use more efficient delete - fireFileMoveEvent(prev, dest, user, container); - } - catch (IOException e) - { - _log.error("error occurred moving the file root", e); - } - } - } - - @Override - public void moveFileRoot(File prev, File dest, @Nullable User user, @Nullable Container container) - { - try - { - _log.info("moving " + prev.getPath() + " to " + dest.getPath()); - boolean doRename = true; - - // Our best bet for perf is to do a rename, which doesn't require creating an actual copy. - // If it exists, try deleting the target directory, which will only succeed if it's empty, but would - // enable using renameTo() method. Don't delete if it's a symbolic link, since it wouldn't be recreated - // in the same way. - if (NetworkDrive.exists(dest) && !Files.isSymbolicLink(dest.toPath())) - doRename = dest.delete(); - - if (doRename && !prev.renameTo(dest)) - { - _log.info("rename failed, attempting to copy"); - - //listFiles can return null, which could cause a NPE - File[] children = prev.listFiles(); - if (children != null) - { - for (File file : children) - FileUtil.copyBranch(file, dest); - } - FileUtil.deleteDir(prev); - } - fireFileMoveEvent(prev, dest, user, container); - } - catch (IOException e) - { - _log.error("error occurred moving the file root", e); - } - } - - @Override - public void fireFileCreateEvent(@NotNull File created, @Nullable User user, @Nullable Container container) - { - fireFileCreateEvent(created.toPath(), user, container); - } - - @Override - public void fireFileCreateEvent(@NotNull java.nio.file.Path created, @Nullable User user, @Nullable Container container) - { - java.nio.file.Path absPath = FileUtil.getAbsoluteCaseSensitivePath(container, created); - for (FileListener fileListener : _fileListeners) - { - fileListener.fileCreated(absPath, user, container); - } - } - - @Override - public void fireFileReplacedEvent(@NotNull java.nio.file.Path replaced, @Nullable User user, @Nullable Container container) - { - java.nio.file.Path absPath = FileUtil.getAbsoluteCaseSensitivePath(container, replaced); - for (FileListener fileListener : _fileListeners) - { - fileListener.fileReplaced(absPath, user, container); - } - } - - @Override - public void fireFileDeletedEvent(@NotNull java.nio.file.Path deleted, @Nullable User user, @Nullable Container container) - { - java.nio.file.Path absPath = FileUtil.getAbsoluteCaseSensitivePath(container, deleted); - for (FileListener fileListener : _fileListeners) - { - fileListener.fileDeleted(absPath, user, container); - } - } - - @Override - public int fireFileMoveEvent(@NotNull File src, @NotNull File dest, @Nullable User user, @Nullable Container container) - { - return fireFileMoveEvent(src.toPath(), dest.toPath(), user, container); - } - - @Override - public int fireFileMoveEvent(@NotNull java.nio.file.Path src, @NotNull java.nio.file.Path dest, @Nullable User user, @Nullable Container container) - { - return fireFileMoveEvent(src, dest, user, container, null); - } - - @Override - public int fireFileMoveEvent(@NotNull java.nio.file.Path src, @NotNull java.nio.file.Path dest, @Nullable User user, @Nullable Container sourceContainer, @Nullable Container targetContainer) - { - // Make sure that we've got the best representation of the file that we can - java.nio.file.Path absSrc = FileUtil.getAbsoluteCaseSensitivePath(sourceContainer, src); - java.nio.file.Path absDest = FileUtil.getAbsoluteCaseSensitivePath(targetContainer != null ? targetContainer : sourceContainer, dest); - int result = 0; - for (FileListener fileListener : _fileListeners) - { - result += fileListener.fileMoved(absSrc, absDest, user, sourceContainer, targetContainer); - } - return result; - } - - @Override - public void addFileListener(FileListener listener) - { - _fileListeners.add(listener); - } - - @Override - public Map> listFiles(@NotNull Container container) - { - Map> files = new LinkedHashMap<>(); - for (FileListener fileListener : _fileListeners) - { - files.put(fileListener.getSourceName(), new HashSet<>(fileListener.listFiles(container))); - } - return files; - } - - @Override - public SQLFragment listFilesQuery(@NotNull User currentUser) - { - SQLFragment frag = new SQLFragment(); - if (currentUser == null || !currentUser.hasSiteAdminPermission()) - { - frag.append("SELECT\n"); - frag.append(" CAST(NULL AS VARCHAR) AS Container,\n"); - frag.append(" NULL AS Created,\n"); - frag.append(" NULL AS CreatedBy,\n"); - frag.append(" NULL AS Modified,\n"); - frag.append(" NULL AS ModifiedBy,\n"); - frag.append(" NULL AS FilePath,\n"); - frag.append(" NULL AS SourceKey,\n"); - frag.append(" NULL AS SourceName\n"); - frag.append("WHERE 1 = 0"); - } - else - { - String union = ""; - frag.append("("); - for (FileListener fileListener : _fileListeners) - { - SQLFragment subselect = fileListener.listFilesQuery(); - if (subselect != null) - { - frag.append(union); - frag.append(subselect); - union = "UNION\n"; - } - } - frag.append(")"); - } - return frag; - } - - @Override - public void setFileRootSetViaStartupProperty(boolean fileRootSetViaStartupProperty) - { - _fileRootSetViaStartupProperty = fileRootSetViaStartupProperty; - } - - @Override - public boolean isFileRootSetViaStartupProperty() - { - return _fileRootSetViaStartupProperty; - } - - public ContainerListener getContainerListener() - { - return _containerListener; - } - - public Set> getNodes(boolean isShowOverridesOnly, @Nullable String browseUrl, Container c) - { - Set> children = new LinkedHashSet<>(); - - try { - java.nio.file.Path assayFilesRoot = getFileRootPath(c, ContentType.assayfiles); - if (NetworkDrive.exists(assayFilesRoot)) - { - Map node = createFileSetNode(c, ASSAY_FILES, assayFilesRoot); - node.put("default", false); - node.put("webdavURL", FilesWebPart.getRootPath(c, ASSAY_FILES).toString()); - children.add(node); - } - - AttachmentDirectory root = getMappedAttachmentDirectory(c, false); - if (root != null) - { - boolean isDefault = isUseDefaultRoot(c); - if (!isDefault || !isShowOverridesOnly) - { - ActionURL config = PageFlowUtil.urlProvider(AdminUrls.class).getProjectSettingsFileURL(c); - Map node = createFileSetNode(c, FILES_LINK, root.getFileSystemDirectoryPath()); - node.put("default", isUseDefaultRoot(c)); - node.put("configureURL", config.getEncodedLocalURIString()); - node.put("browseURL", browseUrl); - node.put("webdavURL", FilesWebPart.getRootPath(c, FILES_LINK).toString()); - - children.add(node); - } - } - - for (AttachmentDirectory fileSet : getRegisteredDirectories(c)) - { - ActionURL config = new ActionURL(FileContentController.ShowAdminAction.class, c); - Map node = createFileSetNode(c, fileSet.getName(), fileSet.getFileSystemDirectoryPath()); - node.put("configureURL", config.getEncodedLocalURIString()); - node.put("browseURL", browseUrl); - node.put("webdavURL", FilesWebPart.getRootPath(c, FILE_SETS_LINK, fileSet.getName()).toString()); - node.put("rootType", "fileset"); - - children.add(node); - } - - PipeRoot pipeRoot = PipelineService.get().findPipelineRoot(c); - if (pipeRoot != null) - { - boolean isDefault = PipelineService.get().hasSiteDefaultRoot(c); - if (!isDefault || !isShowOverridesOnly) - { - ActionURL config = PageFlowUtil.urlProvider(PipelineUrls.class).urlSetup(c); - ActionURL pipelineBrowse = PageFlowUtil.urlProvider(PipelineUrls.class).urlBrowse(c, null); - Map node = createFileSetNode(c, PIPELINE_LINK, pipeRoot.getRootNioPath()); - node.put("default", isDefault ); - node.put("configureURL", config.getEncodedLocalURIString()); - node.put("browseURL", pipelineBrowse.getEncodedLocalURIString()); - node.put("webdavURL", FilesWebPart.getRootPath(c, PIPELINE_LINK).toString()); - - children.add(node); - } - } - } - catch (IOException | UnsetRootDirectoryException ignored) {} - return children; - } - - protected Map createFileSetNode(Container container, String name, java.nio.file.Path dir) - { - Map node = new HashMap<>(); - if (dir != null) - { - node.put("name", name); - node.put("path", FileUtil.getAbsolutePath(container, dir)); - node.put("leaf", true); - } - return node; - } - - public String getAbsolutePathFromDataFileUrl(String dataFileUrl, Container container) - { - return FileUtil.getAbsolutePath(container, FileUtil.createUri(dataFileUrl)); - } - - @Nullable - @Override - public URI getWebDavUrl(@NotNull FileLike path, @NotNull Container container, @NotNull PathType type) - { - return getWebDavUrl(path.toNioPathForRead(), container, type); - } - - @Nullable - @Override - public URI getWebDavUrl(@NotNull java.nio.file.Path path, @NotNull Container container, @NotNull PathType type) - { - PipeRoot root = PipelineService.get().getPipelineRootSetting(container); - java.nio.file.Path assayFilesPath = getFileRootPath(container, ContentType.assayfiles); - path = path.toAbsolutePath(); - String relPath = null; - URI rootWebDavUrl = null; - - try - { - // currently, only report if the file is under the parent container - if (root != null && root.isUnderRoot(path)) - { - relPath = root.relativePath(path); - rootWebDavUrl = root.getWebdavURL(); - } - else if (assayFilesPath != null && URIUtil.isDescendant(assayFilesPath.toUri(), path.toUri())) - { - relPath = assayFilesPath.relativize(path).toString(); - rootWebDavUrl = FilesWebPart.getRootPath(container, ASSAY_FILES); - } - - if (relPath != null) - { - relPath = Path.parse(FilenameUtils.separatorsToUnix(relPath)).encode(); - - return switch (type) - { - case folderRelative -> new URI(relPath); - case serverRelative -> new URI(rootWebDavUrl + (rootWebDavUrl.getPath().endsWith("/") ? "" : "/") + relPath); - case full -> new URI(AppProps.getInstance().getBaseServerUrl() + rootWebDavUrl + (rootWebDavUrl.getPath().endsWith("/") ? "" : "/") + relPath); - }; - } - } - catch (InvalidPathException | URISyntaxException e) - { - _log.error("Invalid WebDav URL from: " + path, e); - } - - return null; - } - - @Override - public String getDataFileRelativeFileRootPath(@NotNull String dataFileUrl, Container container) - { - Set> children = getNodes(false, null, container); - String filesRoot = null; // the path for @files - for (Map child : children) - { - String rootName = (String) child.get("name"); - String rootPath = (String) child.get("path"); - - // skip default @pipeline, which is the same as @files - if (PIPELINE_LINK.equals(rootName)) - { - if((boolean) child.get("default") || rootPath.equals(filesRoot)) - continue; - } - - if (FILES_LINK.equals(rootName)) - filesRoot = rootPath; - - String absoluteFilePath = getAbsolutePathFromDataFileUrl(dataFileUrl, container); - if (StringUtils.startsWith(absoluteFilePath, rootPath)) - { - String offset = absoluteFilePath.replace(rootPath, "").replace("\\", "/"); - int lastSlash = offset.lastIndexOf("/"); - if (lastSlash <= 0) - return "/"; - else - return offset.substring(0, lastSlash); - } - } - return null; - } - - @Override - public void ensureFileData(@NotNull ExpDataTable table) - { - Container container = table.getUserSchema().getContainer(); - // The current user may not have insert permission, and they didn't necessarily upload the files anyway - User user = User.getAdminServiceUser(); - QueryUpdateService qus = table.getUpdateService(); - if (qus == null) - { - throw new IllegalArgumentException("getUpdateServer() returned null from " + table); - } - - synchronized (_fileDataUpToDateCache) - { - if (_fileDataUpToDateCache.get(container) != null) // already synced in the past 5 minutes, skip - return; - - _fileDataUpToDateCache.put(container, true); - } - - List existingDataFileUrls = getDataFileUrls(container); - Collection filesets = getRegisteredDirectories(container); - Set> children = getNodes(false, null, container); - String filesRoot = null; // the path for @files - for (Map child : children) - { - String rootName = (String) child.get("name"); - String rootPathVal = (String) child.get("path"); - - // skip default @pipeline, which is the same as @files - if (PIPELINE_LINK.equals(rootName)) - { - if((boolean) child.get("default") || rootPathVal.equals(filesRoot)) - continue; - } - - if (FILES_LINK.equals(rootName)) - filesRoot = rootPathVal; - - String rootDavUrl = (String) child.get("webdavURL"); - - WebdavResource resource = getResource(rootDavUrl); - if (resource == null) - continue; - - List> rows = new ArrayList<>(); - BatchValidationException errors = new BatchValidationException(); - File file = resource.getFile(); - - if (file == null) - { - String rootType = (String) child.get("rootType"); - if ("fileset".equals(rootType)) - { - for (AttachmentDirectory fileset : filesets) - { - if (fileset.getName().equals(rootName)) - { - try - { - file = fileset.getFileSystemDirectory(); - } - catch (MissingRootDirectoryException e) - { - _log.error("Unable to list files for fileset: " + rootName, e); - } - break; - } - } - } - } - - if (file == null) - return; - - try (var ignore = SpringActionController.ignoreSqlUpdates()) - { - java.nio.file.Path rootPath = file.toPath(); - - try (Stream pathStream = Files.walk(rootPath, 100)) // prevent symlink loop - { - pathStream - .filter(path -> !Files.isSymbolicLink(path) && path.compareTo(rootPath) != 0) // exclude symlink & root - .forEach(path -> { - if (!containsUrlOrVariation(existingDataFileUrls, path)) - rows.add(new CaseInsensitiveHashMap<>(Collections.singletonMap("DataFileUrl", path.toUri().toString()))); - }); - } - - qus.insertRows(user, container, rows, errors, null, null); - } - catch (Exception e) - { - _log.error("Error listing content of directory: " + file.getAbsolutePath(), e); - } - } - } - - - @Override - public void addZiploaderPattern(DirectoryPattern directoryPattern) - { - _ziploaderPattern.add(directoryPattern); - } - - @Override - public List getZiploaderPatterns(Container container) - { - List registeredPatterns = new ArrayList<>(); - for(Module module : container.getActiveModules()) - { - _ziploaderPattern.forEach(p -> { - if(p.getModule().getName().equalsIgnoreCase(module.getName())) - registeredPatterns.add(p); - }); - } - return registeredPatterns; - } - - public List getDataFileUrls(Container container) - { - SimpleFilter filter = SimpleFilter.createContainerFilter(container); - filter.addCondition(FieldKey.fromParts("DataFileUrl"), null, CompareType.NONBLANK); - TableSelector selector = new TableSelector(ExperimentService.get().getTinfoData(), Collections.singleton("DataFileUrl"), filter, null); - return selector.getArrayList(String.class); - } - - public Path getPath(String uri) - { - Path path = Path.decode(uri); - - if (!path.startsWith(WebdavService.getPath()) && path.contains(WebdavService.getPath().getName())) - { - String newPath = path.toString(); - int idx = newPath.indexOf(WebdavService.getPath().toString()); - - if (idx != -1) - { - newPath = newPath.substring(idx); - path = Path.parse(newPath); - } - } - return path; - } - - @Nullable - public WebdavResource getResource(String uri) - { - Path path = getPath(uri); - return WebdavService.get().getResolver().lookup(path); - } - - public static void throwIfPathNotFile(java.nio.file.Path path, Container container) - { - if (null == path) - { - throw new RuntimeException("No path to evaluate in " + container.getPath()); - } - if (FileUtil.hasCloudScheme(path)) - { - throw new RuntimeException("Cannot get File object from Cloud File Root in " + container.getPath()); - } - } - - private boolean containsUrlOrVariation(List existingUrls, java.nio.file.Path path) - { - String url = path.toUri().toString(); - if (existingUrls.contains(url)) - return true; - - boolean urlHasTrailingSlash = (Files.isDirectory(path) && (url.endsWith("/") || url.endsWith(File.pathSeparator))); - if (urlHasTrailingSlash && existingUrls.contains(url.substring(0, url.length() - 1))) - return true; - - if (!FileUtil.hasCloudScheme(path)) - { - File file = path.toFile(); - String legacyUrl = file.toURI().toString(); - if (existingUrls.contains(legacyUrl)) // Legacy URI format (file:/users/...) - return true; - - return existingUrls.contains(file.getPath()); - } - return false; - } - - @Override - public File getMoveTargetFile(String absoluteFilePath, @NotNull Container sourceContainer, @NotNull Container targetContainer) - { - if (absoluteFilePath == null) - return null; - - File file = new File(absoluteFilePath); - if (!NetworkDrive.exists(file)) - { - _log.warn("File '" + absoluteFilePath + "' not found and cannot be moved"); - return null; - } - - File sourceFileRoot = getFileRoot(sourceContainer); - if (sourceFileRoot == null) - return null; - - String sourceRootPath = sourceFileRoot.getAbsolutePath(); - if (!absoluteFilePath.startsWith(sourceRootPath)) - { - _log.warn("File '" + absoluteFilePath + "' not currently located in source folder '" + sourceRootPath + "'. Not moving."); - return null; - } - File targetFileRoot = getFileRoot(targetContainer); - if (targetFileRoot == null) - return null; - - String targetPath = absoluteFilePath.replace(sourceRootPath, targetFileRoot.getAbsolutePath()); - File targetFile = new File(targetPath); - return FileUtil.findUniqueFileName(file.getName(), targetFile.getParentFile()); - } - - @Override - public void addDynamicWarnings(@NotNull Warnings warnings, @Nullable ViewContext context, boolean showAllWarnings) - { - if (_problematicFileRootMessage != null && context != null && ContainerManager.getRoot().hasPermission(context.getUser(), AdminOperationsPermission.class)) - { - warnings.add(DOM.createHtmlFragment(_problematicFileRootMessage, " ", DOM.A(at(href, PageFlowUtil.urlProvider(AdminUrls.class).getFilesSiteSettingsURL()), "Configure File System Access"))); - } - else if (showAllWarnings) - { - try - { - warnings.add(HtmlString.of("Configured site-wide file root " + getDefaultRoot() + " does not exist. Falling back to " + getDefaultRoot())); - } - catch (IOException ignored) {} - } - } - - // Cache with short-lived entries so that exp.files can perform reasonably - private static final Cache _fileDataUpToDateCache = CacheManager.getCache(CacheManager.UNLIMITED, 5 * CacheManager.MINUTE, "Files"); - - @TestWhen(TestWhen.When.BVT) - public static class TestCase extends AssertionError - { - private static final String TRICKY_CHARACTERS_FOR_PROJECT_NAMES = "\u2603~!@$&()_+{}-=[],.#\u00E4\u00F6\u00FC"; - - private static final String PROJECT1 = "FileRootTestProject1" + TRICKY_CHARACTERS_FOR_PROJECT_NAMES; - private static final String PROJECT1_SUBFOLDER1 = "Subfolder1"; - private static final String PROJECT1_SUBFOLDER2 = "Subfolder2" + TRICKY_CHARACTERS_FOR_PROJECT_NAMES; - private static final String PROJECT1_SUBSUBFOLDER = "SubSubfolder"; - private static final String PROJECT1_SUBSUBFOLDER_SIBLING = "SubSubfolderSibling"; - private static final String PROJECT2 = "FileRootTestProject2"; - - private static final String FILE_ROOT_SUFFIX = "_FileRootTest"; - private static final String TXT_FILE = "FileContentTestFile.txt"; - - private Map _expectedPaths; - - @Test - public void fileRootsTest() - { - //pre-clean - cleanup(); - - _expectedPaths = new HashMap<>(); - - FileContentService svc = FileContentService.get(); - Assert.assertNotNull(svc); - - Container project1 = ContainerManager.createContainer(ContainerManager.getRoot(), PROJECT1, TestContext.get().getUser()); - _expectedPaths.put(project1, null); - - Container project2 = ContainerManager.createContainer(ContainerManager.getRoot(), PROJECT2, TestContext.get().getUser()); - _expectedPaths.put(project2, null); - - Container subfolder1 = ContainerManager.createContainer(project1, PROJECT1_SUBFOLDER1, TestContext.get().getUser()); - _expectedPaths.put(subfolder1, null); - - Container subfolder2 = ContainerManager.createContainer(project1, PROJECT1_SUBFOLDER2, TestContext.get().getUser()); - _expectedPaths.put(subfolder2, null); - - Container subsubfolder = ContainerManager.createContainer(subfolder1, PROJECT1_SUBSUBFOLDER, TestContext.get().getUser()); - _expectedPaths.put(subsubfolder, null); - - //set custom root on project, then expect children to inherit - File testRoot = getTestRoot(); - - svc.setFileRoot(project1, testRoot); - _expectedPaths.put(project1, testRoot); - - //the subfolder should inherit from the parent - _expectedPaths.put(subfolder1, new File(testRoot, subfolder1.getName())); - assertPathsEqual("Incorrect values returned by getDefaultRoot", _expectedPaths.get(subfolder1), svc.getDefaultRoot(subfolder1, false)); - assertPathsEqual("Subfolder1 has incorrect root", _expectedPaths.get(subfolder1), svc.getFileRoot(subfolder1)); - - _expectedPaths.put(subfolder2, new File(testRoot, subfolder2.getName())); - assertPathsEqual("Incorrect values returned by getDefaultRoot", _expectedPaths.get(subfolder2), svc.getDefaultRoot(subfolder2, false)); - assertPathsEqual("Subfolder2 has incorrect root", _expectedPaths.get(subfolder2), svc.getFileRoot(subfolder2)); - - _expectedPaths.put(subsubfolder, new File(_expectedPaths.get(subfolder1), subsubfolder.getName())); - assertPathsEqual("Incorrect values returned by getDefaultRoot", _expectedPaths.get(subsubfolder), svc.getDefaultRoot(subsubfolder, false)); - assertPathsEqual("SubSubfolder has incorrect root", _expectedPaths.get(subsubfolder), svc.getFileRoot(subsubfolder)); - - //override root on 1st child, expect children of that folder to inherit - _expectedPaths.put(subfolder1, new File(testRoot, "CustomSubfolder")); - _expectedPaths.get(subfolder1).mkdirs(); - svc.setFileRoot(subfolder1, _expectedPaths.get(subfolder1)); - assertPathsEqual("SubSubfolder has incorrect root", new File(_expectedPaths.get(subfolder1), subsubfolder.getName()), svc.getFileRoot(subsubfolder)); - - //reset project, we assume overridden child roots to remain the same - svc.setFileRoot(project1, null); - assertPathsEqual("Subfolder1 has incorrect root", _expectedPaths.get(subfolder1), svc.getFileRoot(subfolder1)); - assertPathsEqual("SubSubfolder has incorrect root", new File(_expectedPaths.get(subfolder1), subsubfolder.getName()), svc.getFileRoot(subsubfolder)); - - } - - private void assertPathsEqual(String msg, File expected, File actual) - { - String expectedPath = FileUtil.getAbsoluteCaseSensitiveFile(expected).getPath(); - String actualPath = FileUtil.getAbsoluteCaseSensitiveFile(actual).getPath(); - Assert.assertEquals(msg, expectedPath, actualPath); - } - - private File getTestRoot() - { - FileContentService svc = FileContentService.get(); - File siteRoot = svc.getSiteDefaultRoot(); - File testRoot = new File(siteRoot, FILE_ROOT_SUFFIX); - testRoot.mkdirs(); - Assert.assertTrue("Unable to create test file root", NetworkDrive.exists(testRoot)); - - return testRoot; - } - - @Test - //when we move a folder, we expect child files to follow, and expect - // any file paths stored in the DB to also get updated - public void testFolderMove() throws Exception - { - //pre-clean - cleanup(); - - _expectedPaths = new HashMap<>(); - - FileContentService svc = FileContentService.get(); - Assert.assertNotNull(svc); - - Container project1 = ContainerManager.createContainer(ContainerManager.getRoot(), PROJECT1, TestContext.get().getUser()); - _expectedPaths.put(project1, null); - - Container project2 = ContainerManager.createContainer(ContainerManager.getRoot(), PROJECT2, TestContext.get().getUser()); - _expectedPaths.put(project2, null); - - Container subfolder1 = ContainerManager.createContainer(project1, PROJECT1_SUBFOLDER1, TestContext.get().getUser()); - _expectedPaths.put(subfolder1, null); - - Container subfolder2 = ContainerManager.createContainer(project1, PROJECT1_SUBFOLDER2, TestContext.get().getUser()); - _expectedPaths.put(subfolder2, null); - - Container subsubfolder = ContainerManager.createContainer(subfolder1, PROJECT1_SUBSUBFOLDER, TestContext.get().getUser()); - Container subsubfolderSibling = ContainerManager.createContainer(subfolder1, PROJECT1_SUBSUBFOLDER_SIBLING, TestContext.get().getUser()); - _expectedPaths.put(subsubfolder, null); - - //create a test file that we will follow - File fileRoot = svc.getFileRoot(subsubfolder, ContentType.files); - fileRoot.mkdirs(); - - File childFile = new File(fileRoot, TXT_FILE); - childFile.createNewFile(); - - ExpData data = ExperimentService.get().createData(subsubfolder, UPLOADED_FILE); - data.setDataFileURI(childFile.toPath().toUri()); - data.save(TestContext.get().getUser()); - - ExpProtocol protocol = ExperimentService.get().createExpProtocol(subsubfolder, ExpProtocol.ApplicationType.ProtocolApplication, "DummyProtocol"); - protocol = ExperimentService.get().insertSimpleProtocol(protocol, TestContext.get().getUser()); - - ExpRun expRun = ExperimentService.get().createExperimentRun(subsubfolder, "DummyRun"); - expRun.setProtocol(protocol); - expRun.setFilePathRootPath(childFile.getParentFile().toPath()); - - ViewBackgroundInfo info = new ViewBackgroundInfo(subsubfolder, TestContext.get().getUser(), null); - ExpRun run = ExperimentService.get().saveSimpleExperimentRun( - expRun, - Collections.emptyMap(), - Collections.singletonMap(data, "Data"), - Collections.emptyMap(), - Collections.emptyMap(), - Collections.emptyMap(), - info, - _log, - false); - - Assert.assertTrue("File not found: " + childFile.getPath(), NetworkDrive.exists(childFile)); - ContainerManager.move(subsubfolder, subfolder2, TestContext.get().getUser()); - Container movedSubfolder = ContainerManager.getChild(subfolder2, subsubfolder.getName()); - - _expectedPaths.put(movedSubfolder, new File(svc.getFileRoot(subfolder2), movedSubfolder.getName())); - assertPathsEqual("Incorrect values returned by getDefaultRoot", _expectedPaths.get(movedSubfolder), svc.getDefaultRoot(movedSubfolder, false)); - assertPathsEqual("SubSubfolder has incorrect root", _expectedPaths.get(movedSubfolder), svc.getFileRoot(movedSubfolder)); - - File expectedFile = new File(svc.getFileRoot(movedSubfolder, ContentType.files), TXT_FILE); - Assert.assertTrue("File was not moved, expected: " + expectedFile.getPath(), NetworkDrive.exists(expectedFile)); - - ExpData movedData = ExperimentService.get().getExpData(data.getRowId()); - Assert.assertNotNull(movedData); - - // Reload the run after it's path has hopefully been updated - expRun = ExperimentService.get().getExpRun(expRun.getRowId()); - - assertPathsEqual("Incorrect data file path", expectedFile, FileUtil.stringToPath(movedSubfolder, movedData.getDataFileUrl()).toFile()); - assertPathsEqual("Incorrect run root path", expectedFile.getParentFile(), expRun.getFilePathRoot()); - - // Issue 38206 - file paths get mangled with multiple folder moves - ContainerManager.move(subsubfolderSibling, subfolder2, TestContext.get().getUser()); - - // Reload the run after it's path has hopefully NOT been updated - expRun = ExperimentService.get().getExpRun(expRun.getRowId()); - assertPathsEqual("Incorrect run root path", expectedFile.getParentFile(), expRun.getFilePathRoot()); - } - - @Test - public void testWorkbooksAndTabs() - { - //pre-clean - cleanup(); - - FileContentService svc = FileContentService.get(); - Assert.assertNotNull(svc); - - Container project1 = ContainerManager.createContainer(ContainerManager.getRoot(), PROJECT1, TestContext.get().getUser()); - - Container workbook = ContainerManager.createContainer(project1, null, null, null, WorkbookContainerType.NAME, TestContext.get().getUser()); - File expectedWorkbookRoot = new File(svc.getFileRoot(project1), workbook.getName()); - assertPathsEqual("Workbook has incorrect file root", expectedWorkbookRoot, svc.getFileRoot(workbook)); - - Container tab = ContainerManager.createContainer(project1, "tab", null, null, TabContainerType.NAME, TestContext.get().getUser()); - File expectedTabRoot = new File(svc.getFileRoot(project1), tab.getName()); - assertPathsEqual("Folder tab has incorrect file root", expectedTabRoot, svc.getFileRoot(tab)); - } - - /** - * Test that the Site Settings can be configured from startup properties - */ - @Test - public void testStartupPropertiesForSiteRootSettings() throws IOException - { - // save the original Site Root File settings so that we can restore them when this test is done - File originalSiteRootFile = FileContentService.get().getSiteDefaultRoot(); - - // create the new site root file to test with as a child of the current site root file so that we know it is in a dir that exist - String originalSiteRootFilePath = originalSiteRootFile.getAbsolutePath(); - File testSiteRootFile = new File(originalSiteRootFilePath, "testSiteRootFile"); - testSiteRootFile.createNewFile(); - - ModuleLoader.getInstance().handleStartupProperties(new RandomSiteSettingsPropertyHandler(){ - @Override - public @NotNull Collection getStartupPropertyEntries() - { - return List.of(new StartupPropertyEntry("siteFileRoot", testSiteRootFile.getAbsolutePath(), "startup", SCOPE_SITE_SETTINGS)); - } - - @Override - public boolean performChecks() - { - return false; - } - }); - - // now check that the expected changes occurred to the Site Root File settings on the server - File newSiteRootFile = FileContentService.get().getSiteDefaultRoot(); - Assert.assertEquals("The expected change in Site Root File was not found", testSiteRootFile.getAbsolutePath(), newSiteRootFile.getAbsolutePath()); - - // restore the Site Root File server settings to how they were originally - FileContentService.get().setSiteDefaultRoot(originalSiteRootFile, null); - testSiteRootFile.delete(); - } - - @After - public void cleanup() - { - FileContentService svc = FileContentService.get(); - Assert.assertNotNull(svc); - - deleteContainerAndFiles(svc, ContainerManager.getForPath(PROJECT1)); - deleteContainerAndFiles(svc, ContainerManager.getForPath(PROJECT2)); - - File testRoot = getTestRoot(); - if (NetworkDrive.exists(testRoot)) - { - FileUtil.deleteDir(testRoot); - } - } - - private void deleteContainerAndFiles(FileContentService svc, @Nullable Container c) - { - if (c != null) - { - ContainerManager.deleteAll(c, TestContext.get().getUser()); - - File file1 = svc.getFileRoot(c); - if (NetworkDrive.exists(file1)) - { - FileUtil.deleteDir(file1); - } - } - } - } -} +/* + * Copyright (c) 2009-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.labkey.filecontent; + +import org.apache.commons.io.FilenameUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.junit.After; +import org.junit.Assert; +import org.junit.Test; +import org.labkey.api.action.SpringActionController; +import org.labkey.api.admin.AdminUrls; +import org.labkey.api.attachments.AttachmentDirectory; +import org.labkey.api.cache.Cache; +import org.labkey.api.cache.CacheManager; +import org.labkey.api.cloud.CloudStoreService; +import org.labkey.api.collections.CaseInsensitiveHashMap; +import org.labkey.api.data.CompareType; +import org.labkey.api.data.Container; +import org.labkey.api.data.ContainerManager; +import org.labkey.api.data.ContainerManager.ContainerListener; +import org.labkey.api.data.ContainerType; +import org.labkey.api.data.CoreSchema; +import org.labkey.api.data.SQLFragment; +import org.labkey.api.data.SimpleFilter; +import org.labkey.api.data.TabContainerType; +import org.labkey.api.data.Table; +import org.labkey.api.data.TableInfo; +import org.labkey.api.data.TableSelector; +import org.labkey.api.data.WorkbookContainerType; +import org.labkey.api.exp.Lsid; +import org.labkey.api.exp.api.ExpData; +import org.labkey.api.exp.api.ExpProtocol; +import org.labkey.api.exp.api.ExpRun; +import org.labkey.api.exp.api.ExperimentService; +import org.labkey.api.exp.query.ExpDataTable; +import org.labkey.api.files.DirectoryPattern; +import org.labkey.api.files.FileContentService; +import org.labkey.api.files.FileListener; +import org.labkey.api.files.FileRoot; +import org.labkey.api.files.FilesAdminOptions; +import org.labkey.api.files.MissingRootDirectoryException; +import org.labkey.api.files.UnsetRootDirectoryException; +import org.labkey.api.files.view.FilesWebPart; +import org.labkey.api.module.Module; +import org.labkey.api.module.ModuleLoader; +import org.labkey.api.pipeline.PipeRoot; +import org.labkey.api.pipeline.PipelineService; +import org.labkey.api.pipeline.PipelineUrls; +import org.labkey.api.query.BatchValidationException; +import org.labkey.api.query.FieldKey; +import org.labkey.api.query.QueryUpdateService; +import org.labkey.api.security.User; +import org.labkey.api.security.permissions.AdminOperationsPermission; +import org.labkey.api.settings.AppProps; +import org.labkey.api.settings.RandomSiteSettingsPropertyHandler; +import org.labkey.api.settings.StartupPropertyEntry; +import org.labkey.api.settings.WriteableAppProps; +import org.labkey.api.test.TestWhen; +import org.labkey.api.util.ContainerUtil; +import org.labkey.api.util.DOM; +import org.labkey.api.util.FileUtil; +import org.labkey.api.util.GUID; +import org.labkey.api.util.HtmlString; +import org.labkey.api.util.NetworkDrive; +import org.labkey.api.util.PageFlowUtil; +import org.labkey.api.util.Path; +import org.labkey.api.util.TestContext; +import org.labkey.api.util.URIUtil; +import org.labkey.api.view.ActionURL; +import org.labkey.api.view.HttpView; +import org.labkey.api.view.ViewBackgroundInfo; +import org.labkey.api.view.ViewContext; +import org.labkey.api.view.template.WarningProvider; +import org.labkey.api.view.template.WarningService; +import org.labkey.api.view.template.Warnings; +import org.labkey.api.webdav.WebdavResource; +import org.labkey.api.webdav.WebdavService; +import org.labkey.vfs.FileLike; + +import java.beans.PropertyChangeEvent; +import java.io.BufferedWriter; +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import static org.labkey.api.settings.AppProps.SCOPE_SITE_SETTINGS; +import static org.labkey.api.util.DOM.Attribute.href; +import static org.labkey.api.util.DOM.at; + +public class FileContentServiceImpl implements FileContentService, WarningProvider +{ + private static final Logger _log = LogManager.getLogger(FileContentServiceImpl.class); + private static final String UPLOAD_LOG = ".upload.log"; + private static final FileContentServiceImpl INSTANCE = new FileContentServiceImpl(); + + private final ContainerListener _containerListener = new FileContentServiceContainerListener(); + private final List _fileListeners = new CopyOnWriteArrayList<>(); + + private final List _ziploaderPattern = new CopyOnWriteArrayList<>(); + + private volatile boolean _fileRootSetViaStartupProperty = false; + private String _problematicFileRootMessage; + + enum FileAction + { + UPLOAD, + DELETE + } + + static FileContentServiceImpl getInstance() + { + return INSTANCE; + } + + private FileContentServiceImpl() + { + WarningService.get().register(this); + } + + @Override + @NotNull + public List getContainersForFilePath(java.nio.file.Path path) + { + // Ignore cloud files for now + if (FileUtil.hasCloudScheme(path)) + return Collections.emptyList(); + + // If the path is under the default root, do optimistic simple match for containers under the default root + File defaultRoot = getSiteDefaultRoot(); + java.nio.file.Path defaultRootPath = defaultRoot.toPath(); + if (path.startsWith(defaultRootPath)) + { + java.nio.file.Path rel = defaultRootPath.relativize(path); + if (rel.getNameCount() > 0) + { + Container root = ContainerManager.getRoot(); + Container next = root; + while (rel.getNameCount() > 0) + { + // check if there exists a child container that matches the next path segment + java.nio.file.Path top = rel.subpath(0, 1); + assert top != null; + Container child = next.getChild(top.getFileName().toString()); + if (child == null) + break; + + next = child; + + if(rel.getNameCount() > 1) + { + rel = rel.subpath(1, rel.getNameCount()); + } + else + { + break; + } + } + + if (next != null && !next.equals(root)) + { + // verify our naive file path is correct for the container -- it may have a file root other than the default + java.nio.file.Path fileRoot = getFileRootPath(next); + if (fileRoot != null && path.startsWith(fileRoot)) + return Collections.singletonList(next); + } + } + } + + // TODO: Create cache of file root and pipeline root paths -> list of containers + + return Collections.emptyList(); + } + + @Override + public @Nullable File getFileRoot(@NotNull Container c, @NotNull ContentType type) + { + switch (type) + { + case files: + case assayfiles: + String folderName = getFolderName(type); + if (folderName == null) + folderName = ""; + + java.nio.file.Path dir = getFileRootPath(c); + return dir != null ? dir.resolve(folderName).toFile() : null; + + case pipeline: + PipeRoot root = PipelineService.get().findPipelineRoot(c); + return root != null ? root.getRootPath() : null; + } + return null; + } + + @Override + public @Nullable java.nio.file.Path getFileRootPath(@NotNull Container c, @NotNull ContentType type) + { + switch (type) + { + case files: + case assayfiles: + java.nio.file.Path fileRootPath = getFileRootPath(c); + if (null != fileRootPath && !FileUtil.hasCloudScheme(fileRootPath)) // Don't add @files when we're in the cloud + fileRootPath = fileRootPath.resolve(getFolderName(type)); + return fileRootPath; + + case pipeline: + PipeRoot root = PipelineService.get().findPipelineRoot(c); + return root != null ? root.getRootNioPath() : null; + } + return null; + } + + // Returns full uri to file root for this container. filePath is optional relative path to a file under the file root + @Override + public @Nullable URI getFileRootUri(@NotNull Container c, @NotNull ContentType type, @Nullable String filePath) + { + java.nio.file.Path root = FileContentService.get().getFileRootPath(c, FileContentService.ContentType.files); + if (root != null) + { + String path = root.toString(); + if (filePath != null) { + path += filePath; + } + + // non-unix needs a leading slash + if (!path.startsWith("/") && !path.startsWith("\\")) + { + path = "/" + path; + } + return FileUtil.createUri(path); + } + + return null; + } + + @Override + public @Nullable File getFileRoot(@NotNull Container c) + { + java.nio.file.Path path = getFileRootPath(c); + throwIfPathNotFile(path, c); + return path.toFile(); + } + + @Override + public @Nullable java.nio.file.Path getFileRootPath(@NotNull Container c) + { + if (c == null) + return null; + + if (c.isRoot()) + { + return getSiteDefaultRootPath(); + } + + if (!isFileRootDisabled(c)) + { + FileRoot root = FileRootManager.get().getFileRoot(c); + + // check if there is a site wide file root + if (root.getPath() == null || isUseDefaultRoot(c)) + { + return getDefaultRootPath(c, true); + } + else + return getNioPath(c, root.getPath()); + } + return null; + } + + @Override + public File getDefaultRoot(Container c, boolean createDir) + { + return getDefaultRootPath(c, createDir).toFile(); + } + + @Override + public java.nio.file.Path getDefaultRootPath(@NotNull Container c, boolean createDir) + { + Container firstOverride = getFirstAncestorWithOverride(c); + + java.nio.file.Path parentRoot; + if (firstOverride == null) + { + parentRoot = getSiteDefaultRoot().toPath(); + firstOverride = ContainerManager.getRoot(); + } + else + { + parentRoot = getFileRootPath(firstOverride); + } + + if (parentRoot != null && firstOverride != null) + { + java.nio.file.Path fileRootPath; + if (FileUtil.hasCloudScheme(parentRoot)) + { + // For cloud root, we don't have to create directories for this path + fileRootPath = CloudStoreService.get().getPathForOtherContainer(firstOverride, c, FileUtil.pathToString(parentRoot), new Path("")); + } + else + { + // For local, the path may be several directories deep (since it matches the LK folder path), so we should create the directories for that path + fileRootPath = FileUtil.appendPath(parentRoot.toFile(), Path.parse(getRelativePath(c, firstOverride))).toPath(); + + try + { + if (createDir && !NetworkDrive.exists(fileRootPath)) + FileUtil.createDirectories(fileRootPath); + } + catch (IOException e) + { + return null; // throw new RuntimeException(e); TODO: does returning null make certain tests, like TargetedMSQCGuideSetTest pass on Windows? + } + } + + return fileRootPath; + } + return null; + } + + // Return pretty path string for defaultFileRoot and boolean true if defaultFileRoot is cloud + @Override + public DefaultRootInfo getDefaultRootInfo(Container container) + { + String defaultRoot = ""; + boolean isDefaultRootCloud = false; + java.nio.file.Path defaultRootPath = getDefaultRootPath(container, false); + String cloudName = null; + if (defaultRootPath != null) + { + isDefaultRootCloud = FileUtil.hasCloudScheme(defaultRootPath); + if (isDefaultRootCloud && !container.isProject()) + { + FileRoot fileRoot = getDefaultFileRoot(container); + if (null != fileRoot) + defaultRoot = fileRoot.getPath(); + if (null != defaultRoot) + cloudName = getCloudRootName(defaultRoot); + } + else + { + defaultRoot = FileUtil.getAbsolutePath(container, defaultRootPath.toUri()); + } + } + return new DefaultRootInfo(defaultRootPath, defaultRoot, isDefaultRootCloud, cloudName); + } + + @Nullable + // Get FileRoot associated with path returned form getDefaultRootPath() + public FileRoot getDefaultFileRoot(Container c) + { + Container firstOverride = getFirstAncestorWithOverride(c); + + if (firstOverride == null) + firstOverride = ContainerManager.getRoot(); + + if (null != firstOverride) + return FileRootManager.get().getFileRoot(firstOverride); + return null; + } + + private @NotNull String getRelativePath(Container c, Container ancestor) + { + return c.getPath().replaceAll("^" + Pattern.quote(ancestor.getPath()), ""); + } + + //returns the first parent container that has a custom file root, or NULL if none have overrides + private Container getFirstAncestorWithOverride(Container c) + { + Container toTest = c.getParent(); + if (toTest == null) + return null; + + while (isUseDefaultRoot(toTest)) + { + if (toTest == null || toTest.equals(ContainerManager.getRoot())) + return null; + + toTest = toTest.getParent(); + } + + return toTest; + } + + private java.nio.file.Path getNioPath(Container c, @NotNull String fileRootPath) + { + if (isCloudFileRoot(fileRootPath)) + return CloudStoreService.get().getPath(c, getCloudRootName(fileRootPath), new org.labkey.api.util.Path("")); + + return FileUtil.stringToPath(c, fileRootPath, false); // fileRootPath is unencoded + } + + private boolean isCloudFileRoot(String fileRootPseudoPath) + { + return StringUtils.startsWith(fileRootPseudoPath, FileContentService.CLOUD_ROOT_PREFIX); + } + + private String getCloudRootName(@NotNull String fileRootPseudoPath) + { + return fileRootPseudoPath.substring(fileRootPseudoPath.indexOf(FileContentService.CLOUD_ROOT_PREFIX) + FileContentService.CLOUD_ROOT_PREFIX.length() + 1); + } + + @Override + public boolean isCloudRoot(Container c) + { + if (null != c) + { + java.nio.file.Path fileRootPath = getFileRootPath(c); + return null != fileRootPath && FileUtil.hasCloudScheme(fileRootPath); + } + return false; + } + + @Override + @NotNull + public String getCloudRootName(Container c) + { + if (null != c) + { + if (isCloudRoot(c)) + { + FileRoot root = FileRootManager.get().getFileRoot(c); + if (null == root.getPath() || isUseDefaultRoot(c)) + { + Container firstOverride = getFirstAncestorWithOverride(c); + if (null == firstOverride) + firstOverride = ContainerManager.getRoot(); + root = FileRootManager.get().getFileRoot(firstOverride); + if (null == root.getPath()) + return ""; + } + return getCloudRootName(root.getPath()); + } + } + return ""; + } + + @Override + public void setCloudRoot(@NotNull Container c, String cloudRootName) + { + _setFileRoot(c, FileContentService.CLOUD_ROOT_PREFIX + "/" + cloudRootName); + } + + @Override + public void setFileRoot(@NotNull Container c, @Nullable File path) + { + _setFileRoot(c, (null != path ? FileUtil.getAbsoluteCaseSensitiveFile(path).getAbsolutePath() : null)); + } + + @Override + public void setFileRootPath(@NotNull Container c, @Nullable String strPath) + { + String absolutePath = null; + if (strPath != null) + { + URI uri = FileUtil.createUri(strPath, false); // strPath is unencoded + if (FileUtil.hasCloudScheme(uri)) + absolutePath = FileUtil.getAbsolutePath(c, uri); + else + absolutePath = FileUtil.getAbsoluteCaseSensitiveFile(new File(uri)).getAbsolutePath(); + } + _setFileRoot(c, absolutePath); + } + + private void _setFileRoot(@NotNull Container c, @Nullable String absolutePath) + { + if (!c.isContainerFor(ContainerType.DataType.fileRoot)) + throw new IllegalArgumentException("File roots cannot be set for containers of type " + c.getContainerType().getName()); + + FileRoot root = FileRootManager.get().getFileRoot(c); + root.setEnabled(true); + + String oldValue = root.getPath(); + String newValue = null; + + // clear out the root + if (absolutePath == null) + root.setPath(null); + else + { + root.setPath(absolutePath); + newValue = root.getPath(); + } + + FileRootManager.get().saveFileRoot(null, root); + ContainerManager.ContainerPropertyChangeEvent evt = new ContainerManager.ContainerPropertyChangeEvent( + c, ContainerManager.Property.WebRoot, oldValue, newValue); + ContainerManager.firePropertyChangeEvent(evt); + } + + @Override + public void disableFileRoot(Container container) + { + if (container == null || container.isRoot()) + throw new IllegalArgumentException("Disabling either a null project or the root project is not allowed."); + + Container effective = container.getContainerFor(ContainerType.DataType.fileRoot); + if (effective != null) + { + FileRoot root = FileRootManager.get().getFileRoot(effective); + String oldValue = root.getPath(); + root.setEnabled(false); + FileRootManager.get().saveFileRoot(null, root); + + ContainerManager.ContainerPropertyChangeEvent evt = new ContainerManager.ContainerPropertyChangeEvent( + container, ContainerManager.Property.WebRoot, oldValue, null); + ContainerManager.firePropertyChangeEvent(evt); + } + } + + @Override + public boolean isFileRootDisabled(Container c) + { + Container effective = c.getContainerFor(ContainerType.DataType.fileRoot); + if (null == effective) + return false; + + FileRoot root = FileRootManager.get().getFileRoot(effective); + return !root.isEnabled(); + } + + @Override + public boolean isUseDefaultRoot(Container c) + { + if (c == null) + return true; + + Container effective = c.getContainerFor(ContainerType.DataType.fileRoot); + if (null == effective) + return true; + + FileRoot root = FileRootManager.get().getFileRoot(effective); + return root.isUseDefault() || StringUtils.isEmpty(root.getPath()); + } + + @Override + public void setIsUseDefaultRoot(Container c, boolean useDefaultRoot) + { + Container effective = c.getContainerFor(ContainerType.DataType.fileRoot); + if (effective != null) + { + FileRoot root = FileRootManager.get().getFileRoot(effective); + String oldValue = root.getPath(); + root.setEnabled(true); + root.setUseDefault(useDefaultRoot); + if (useDefaultRoot) + root.setPath(null); + FileRootManager.get().saveFileRoot(null, root); + + ContainerManager.ContainerPropertyChangeEvent evt = new ContainerManager.ContainerPropertyChangeEvent( + effective, ContainerManager.Property.WebRoot, oldValue, null); + ContainerManager.firePropertyChangeEvent(evt); + } + } + + @Override + public @NotNull java.nio.file.Path getSiteDefaultRootPath() + { + return getSiteDefaultRoot().toPath(); + } + + @Override + public @NotNull File getSiteDefaultRoot() + { + // Site default is always on file system + File root = AppProps.getInstance().getFileSystemRoot(); + + try + { + if (!NetworkDrive.exists(root)) + { + File configuredRoot = root; + root = getDefaultRoot(); + if (configuredRoot != null && !configuredRoot.equals(root)) + { + String message = "The configured site-wide file root " + configuredRoot + " does not exist. Falling back to " + root; + if (!message.equals(_problematicFileRootMessage)) + { + _problematicFileRootMessage = message; + _log.error(_problematicFileRootMessage); + } + } + } + else + { + _problematicFileRootMessage = null; + } + + if (!NetworkDrive.exists(root)) + { + if (FileUtil.mkdirs(root)) + { + _log.info("Created site-wide file root " + root); + } + else + { + _log.error("Failed when attempting to create site-wide file root " + root); + } + } + } + catch (IOException e) + { + throw new RuntimeException("Unable to create file root directory", e); + } + + return root; + } + + @Override + public String getProblematicFileRootMessage() + { + return _problematicFileRootMessage; + } + + private @NotNull File getDefaultRoot() throws IOException + { + File explodedPath = ModuleLoader.getInstance().getCoreModule().getExplodedPath(); + + File root = explodedPath.getParentFile(); + if (root != null) + { + if (root.getParentFile() != null) + root = root.getParentFile(); + } + File defaultRoot = new File(root, "files"); + if (!NetworkDrive.exists(defaultRoot)) + FileUtil.mkdirs(defaultRoot); + + return defaultRoot; + } + + @Override + public void setSiteDefaultRoot(File root, User user) + { + if (root == null) + throw new IllegalArgumentException("Invalid site root: specified root is null"); + + if (!NetworkDrive.exists(root)) + throw new IllegalArgumentException("Invalid site root: " + root.getAbsolutePath() + " does not exist"); + + File prevRoot = getSiteDefaultRoot(); + WriteableAppProps props = AppProps.getWriteableInstance(); + + props.setFileSystemRoot(root.getAbsolutePath()); + props.save(user); + + FileRootManager.get().clearCache(); + ContainerManager.ContainerPropertyChangeEvent evt = new ContainerManager.ContainerPropertyChangeEvent( + ContainerManager.getRoot(), ContainerManager.Property.SiteRoot, prevRoot, root); + ContainerManager.firePropertyChangeEvent(evt); + } + + @Override + public void setWebfilesEnabled(boolean enabled, User user) + { + WriteableAppProps props = AppProps.getWriteableInstance(); + props.setWebfilesEnabled(enabled); + props.save(user); + } + + @Override + public FileSystemAttachmentParent registerDirectory(Container c, String name, String path, boolean relative) + { + FileSystemAttachmentParent parent = new FileSystemAttachmentParent(); + parent.setContainer(c); + if (null == name) + name = path; + parent.setName(name); + parent.setPath(path); + parent.setRelative(relative); + //We do this because insert does not return new fields + parent.setEntityid(GUID.makeGUID()); + + FileSystemAttachmentParent ret = Table.insert(HttpView.currentContext().getUser(), CoreSchema.getInstance().getMappedDirectories(), parent); + ContainerManager.ContainerPropertyChangeEvent evt = new ContainerManager.ContainerPropertyChangeEvent( + c, ContainerManager.Property.AttachmentDirectory, null, ret); + ContainerManager.firePropertyChangeEvent(evt); + return ret; + } + + @Override + public void unregisterDirectory(Container c, String name) + { + FileSystemAttachmentParent parent = getRegisteredDirectory(c, name); + SimpleFilter filter = SimpleFilter.createContainerFilter(c); + filter.addCondition(FieldKey.fromParts("Name"), name); + Table.delete(CoreSchema.getInstance().getMappedDirectories(), filter); + ContainerManager.ContainerPropertyChangeEvent evt = new ContainerManager.ContainerPropertyChangeEvent( + c, ContainerManager.Property.AttachmentDirectory, parent, null); + ContainerManager.firePropertyChangeEvent(evt); + } + + @Override + public @Nullable AttachmentDirectory getMappedAttachmentDirectory(Container c, boolean createDir) throws UnsetRootDirectoryException, MissingRootDirectoryException + { + return getMappedAttachmentDirectory(c, ContentType.files, createDir); + } + + @Override + @Nullable + public AttachmentDirectory getMappedAttachmentDirectory(Container c, ContentType contentType, boolean createDir) throws UnsetRootDirectoryException + { + try + { + if (createDir) //force create + getMappedDirectory(c, true); + else if (null == getMappedDirectory(c, false)) + return null; + + return new FileSystemAttachmentParent(c, contentType); + } + catch (IOException e) + { + _log.error("Cannot get mapped directory for " + c.getPath(), e); + return null; + } + } + + public java.nio.file.Path getMappedDirectory(Container c, boolean create) throws UnsetRootDirectoryException, IOException + { + java.nio.file.Path root = getFileRootPath(c); + if (!FileUtil.hasCloudScheme(root)) + { + if (null == root) + { + if (create) + throw new UnsetRootDirectoryException(c.isRoot() ? c : c.getProject()); + else + return null; + } + + if (!NetworkDrive.exists(root)) + { + if (create) + throw new MissingRootDirectoryException(c.isRoot() ? c : c.getProject(), root); + else + return null; + + } + } + return root; + } + + @Override + public FileSystemAttachmentParent getRegisteredDirectory(Container c, String name) + { + SimpleFilter filter = SimpleFilter.createContainerFilter(c); + filter.addCondition(FieldKey.fromParts("Name"), name); + + return new TableSelector(CoreSchema.getInstance().getMappedDirectories(), filter, null).getObject(FileSystemAttachmentParent.class); + } + + @Override + public FileSystemAttachmentParent getRegisteredDirectoryFromEntityId(Container c, String entityId) + { + SimpleFilter filter = SimpleFilter.createContainerFilter(c); + filter.addCondition(FieldKey.fromParts("EntityId"), entityId); + + return new TableSelector(CoreSchema.getInstance().getMappedDirectories(), filter, null).getObject(FileSystemAttachmentParent.class); + } + + @Override + public @NotNull Collection getRegisteredDirectories(Container c) + { + SimpleFilter filter = SimpleFilter.createContainerFilter(c); + + return Collections.unmodifiableCollection(new TableSelector(CoreSchema.getInstance().getMappedDirectories(), filter, null).getCollection(FileSystemAttachmentParent.class)); + } + + private class FileContentServiceContainerListener implements ContainerListener + { + @Override + public void containerCreated(Container c, User user) + { + try + { + // Will create directory if it's a default dir + getMappedDirectory(c, false); + } + catch (IOException ex) + { + /* */ + } + } + + @Override + public void containerDeleted(Container c, User user) + { + java.nio.file.Path dir = null; + try + { + // don't delete the file contents if they have a project override + if (isUseDefaultRoot(c) && !isCloudRoot(c)) // Don't do anything for cloud root here. CloudContainerListener will handle + dir = getMappedDirectory(c, false); + + if (null != dir) + { + FileUtil.deleteDir(dir); + } + } + catch (Exception e) + { + _log.error("containerDeleted", e); + } + + ContainerUtil.purgeTable(CoreSchema.getInstance().getMappedDirectories(), c, null); + } + + @Override + public void containerMoved(Container c, Container oldParent, User user) + { + /* **** Cases: + SRC DEST + specific local path same -- no work + specific cloud path same -- no work + local default local default -- move tree + local default cloud default -- move tree + cloud default local default -- move tree + cloud default cloud default -- if change bucket, move tree + *************************************************************/ + if (isUseDefaultRoot(c)) + { + java.nio.file.Path srcParent = getFileRootPath(oldParent); + java.nio.file.Path dest = getFileRootPath(c); + if (null != srcParent && null != dest) + { + if (!FileUtil.hasCloudScheme(srcParent)) + { + File src = new File(srcParent.toFile(), c.getName()); + if (NetworkDrive.exists(src)) + { + if (!FileUtil.hasCloudScheme(dest)) + { + // local -> local + moveFileRoot(src, dest.toFile(), user, c); + } + else + { + // local -> cloud; source starts under @files + File filesSrc = FileUtil.appendName(src, FILES_LINK); + if (NetworkDrive.exists(filesSrc)) + moveFileRoot(filesSrc.toPath(), dest, user, c); + FileUtil.deleteDir(src); // moveFileRoot will delete @files, but we need to delete its parent + } + } + } + else + { + // Get source path using moving container and parent's config (cloudRoot), because that config must be the source config + java.nio.file.Path src = CloudStoreService.get().getPath(c, getCloudRootName(oldParent), new Path("")); + if (!FileUtil.hasCloudScheme(dest)) + { + // cloud -> local; destination is under @files + dest = dest.resolve(FILES_LINK); + moveFileRoot(src, dest, user, c); + } + else + { + // cloud -> cloud + if (!getCloudRootName(oldParent).equals(getCloudRootName(c))) + { + // Different configs + moveFileRoot(src, dest, user, c); + } + } + } + } + } + } + + @NotNull + @Override + public Collection canMove(Container c, Container newParent, User user) + { + return Collections.emptyList(); + } + + @Override + public void propertyChange(PropertyChangeEvent propertyChangeEvent) + { + ContainerManager.ContainerPropertyChangeEvent evt = (ContainerManager.ContainerPropertyChangeEvent)propertyChangeEvent; + Container c = evt.container; + + switch (evt.property) + { + case Name: // container rename event + { + String oldValue = (String) propertyChangeEvent.getOldValue(); + String newValue = (String) propertyChangeEvent.getNewValue(); + + java.nio.file.Path location; + try + { + location = getMappedDirectory(c, false); + if (location != null && !FileUtil.hasCloudScheme(location)) // If cloud, folder name for container not dependent on Name + { + //Don't rely on container object. Seems not to point to the + //new location even AFTER rename. Just construct new file paths + File locationFile = location.toFile(); + File parentDir = locationFile.getParentFile(); + File oldLocation = new File(parentDir, oldValue); + File newLocation = new File(parentDir, newValue); + if (NetworkDrive.exists(newLocation)) + moveToDeleted(newLocation); + + if (NetworkDrive.exists(oldLocation)) + { + oldLocation.renameTo(newLocation); + fireFileMoveEvent(oldLocation, newLocation, evt.user, evt.container); + } + } + } + catch (IOException ex) + { + _log.error(ex); + } + + break; + } + } + } + } + + + @Override + public @Nullable String getFolderName(FileContentService.ContentType type) + { + if (type != null) + return "@" + type.name(); + return null; + } + + + /** + * Move the file or directory into a ".deleted" directory under the parent directory. + * @return True if successfully moved. + */ + private static boolean moveToDeleted(File fileToMove) throws IOException + { + if (!NetworkDrive.exists(fileToMove)) + return false; + + File parent = fileToMove.getParentFile(); + + File deletedDir = new File(parent, ".deleted"); + if (!NetworkDrive.exists(deletedDir)) + if (!FileUtil.mkdir(deletedDir)) + return false; + + File newLocation = new File(deletedDir, fileToMove.getName()); + if (NetworkDrive.exists(newLocation)) + FileUtil.deleteDir(newLocation); + + return fileToMove.renameTo(newLocation); + } + + static void logFileAction(java.nio.file.Path directory, String fileName, FileAction action, User user) + { + try (BufferedWriter fw = Files.newBufferedWriter(directory.resolve(UPLOAD_LOG), StandardOpenOption.APPEND, StandardOpenOption.CREATE)) + { + fw.write(action.toString() + "\t" + fileName + "\t" + new Date() + "\t" + (user == null ? "(unknown)" : user.getEmail()) + "\n"); + } + catch (Exception x) + { + //Just log it. + _log.error(x); + } + } + + @Override + public FilesAdminOptions getAdminOptions(Container c) + { + FileRoot root = FileRootManager.get().getFileRoot(c); + String xml = null; + + if (!StringUtils.isBlank(root.getProperties())) + { + xml = root.getProperties(); + } + return new FilesAdminOptions(c, xml); + } + + @Override + public void setAdminOptions(Container c, FilesAdminOptions options) + { + if (options != null) + { + setAdminOptions(c, options.serialize()); + } + } + + @Override + public void setAdminOptions(Container c, String properties) + { + FileRoot root = FileRootManager.get().getFileRoot(c); + + root.setProperties(properties); + FileRootManager.get().saveFileRoot(null, root); + } + + public static final String NAMESPACE_PREFIX = "FileProperties"; + public static final String PROPERTIES_DOMAIN = "File Properties"; + public static final String TYPE_PROPERTIES = "FileProperties"; + + @Override + public String getDomainURI(Container container) + { + return getDomainURI(container, getAdminOptions(container).getFileConfig()); + } + + @Override + public String getDomainURI(Container container, FilesAdminOptions.fileConfig config) + { + while (config == FilesAdminOptions.fileConfig.useParent && container != container.getParent()) + { + container = container.getParent(); + config = getAdminOptions(container).getFileConfig(); + } + + //String typeURI = "urn:lsid:" + AppProps.getInstance().getDefaultLsidAuthority() + ":List" + ".Folder-" + container.getRowId() + ":" + name; + + return new Lsid("urn:lsid:labkey.com:" + NAMESPACE_PREFIX + ".Folder-" + container.getRowId() + ':' + TYPE_PROPERTIES).toString(); + } + + @Override @Nullable + public ExpData getDataObject(WebdavResource resource, Container c) + { + return getDataObject(resource, c, null, false); + } + + @Nullable + private static ExpData getDataObject(WebdavResource resource, Container c, User user, boolean create) + { + // TODO: S3: seems to only be called from Search and currently we're not searching in cloud. SaveCustomPropsAction seems unused + if (resource != null) + { + File file = resource.getFile(); + if (file != null) + { + ExpData data = ExperimentService.get().getExpDataByURL(file, c); + + if (data == null && create) + { + data = ExperimentService.get().createData(c, FileContentService.UPLOADED_FILE); + data.setName(file.getName()); + data.setDataFileURI(file.toURI()); + data.save(user); + } + return data; + } + } + return null; + } + + @Override + public QueryUpdateService getFilePropsUpdateService(TableInfo tinfo, Container container) + { + return new FileQueryUpdateService(tinfo, container); + } + + @Override + public boolean isValidProjectRoot(String root) + { + File f = new File(root); + return NetworkDrive.exists(f) && f.isDirectory(); + } + + @Override + public void moveFileRoot(java.nio.file.Path prev, java.nio.file.Path dest, @Nullable User user, @Nullable Container container) + { + if (!FileUtil.hasCloudScheme(prev) && !FileUtil.hasCloudScheme(dest)) + { + moveFileRoot(prev.toFile(), dest.toFile(), user, container); // Both files; try rename + } + else + { + try + { + // At least one is in the cloud + FileUtil.copyDirectory(prev, dest); + FileUtil.deleteDir(prev); // TODO use more efficient delete + fireFileMoveEvent(prev, dest, user, container); + } + catch (IOException e) + { + _log.error("error occurred moving the file root", e); + } + } + } + + @Override + public void moveFileRoot(File prev, File dest, @Nullable User user, @Nullable Container container) + { + try + { + _log.info("moving " + prev.getPath() + " to " + dest.getPath()); + boolean doRename = true; + + // Our best bet for perf is to do a rename, which doesn't require creating an actual copy. + // If it exists, try deleting the target directory, which will only succeed if it's empty, but would + // enable using renameTo() method. Don't delete if it's a symbolic link, since it wouldn't be recreated + // in the same way. + if (NetworkDrive.exists(dest) && !Files.isSymbolicLink(dest.toPath())) + doRename = dest.delete(); + + if (doRename && !prev.renameTo(dest)) + { + _log.info("rename failed, attempting to copy"); + + //listFiles can return null, which could cause a NPE + File[] children = prev.listFiles(); + if (children != null) + { + for (File file : children) + FileUtil.copyBranch(file, dest); + } + FileUtil.deleteDir(prev); + } + fireFileMoveEvent(prev, dest, user, container); + } + catch (IOException e) + { + _log.error("error occurred moving the file root", e); + } + } + + @Override + public void fireFileCreateEvent(@NotNull File created, @Nullable User user, @Nullable Container container) + { + fireFileCreateEvent(created.toPath(), user, container); + } + + @Override + public void fireFileCreateEvent(@NotNull java.nio.file.Path created, @Nullable User user, @Nullable Container container) + { + java.nio.file.Path absPath = FileUtil.getAbsoluteCaseSensitivePath(container, created); + for (FileListener fileListener : _fileListeners) + { + fileListener.fileCreated(absPath, user, container); + } + } + + @Override + public void fireFileReplacedEvent(@NotNull java.nio.file.Path replaced, @Nullable User user, @Nullable Container container) + { + java.nio.file.Path absPath = FileUtil.getAbsoluteCaseSensitivePath(container, replaced); + for (FileListener fileListener : _fileListeners) + { + fileListener.fileReplaced(absPath, user, container); + } + } + + @Override + public void fireFileDeletedEvent(@NotNull java.nio.file.Path deleted, @Nullable User user, @Nullable Container container) + { + java.nio.file.Path absPath = FileUtil.getAbsoluteCaseSensitivePath(container, deleted); + for (FileListener fileListener : _fileListeners) + { + fileListener.fileDeleted(absPath, user, container); + } + } + + @Override + public int fireFileMoveEvent(@NotNull File src, @NotNull File dest, @Nullable User user, @Nullable Container container) + { + return fireFileMoveEvent(src.toPath(), dest.toPath(), user, container); + } + + @Override + public int fireFileMoveEvent(@NotNull java.nio.file.Path src, @NotNull java.nio.file.Path dest, @Nullable User user, @Nullable Container container) + { + return fireFileMoveEvent(src, dest, user, container, null); + } + + @Override + public int fireFileMoveEvent(@NotNull java.nio.file.Path src, @NotNull java.nio.file.Path dest, @Nullable User user, @Nullable Container sourceContainer, @Nullable Container targetContainer) + { + // Make sure that we've got the best representation of the file that we can + java.nio.file.Path absSrc = FileUtil.getAbsoluteCaseSensitivePath(sourceContainer, src); + java.nio.file.Path absDest = FileUtil.getAbsoluteCaseSensitivePath(targetContainer != null ? targetContainer : sourceContainer, dest); + int result = 0; + for (FileListener fileListener : _fileListeners) + { + result += fileListener.fileMoved(absSrc, absDest, user, sourceContainer, targetContainer); + } + return result; + } + + @Override + public void addFileListener(FileListener listener) + { + _fileListeners.add(listener); + } + + @Override + public Map> listFiles(@NotNull Container container) + { + Map> files = new LinkedHashMap<>(); + for (FileListener fileListener : _fileListeners) + { + files.put(fileListener.getSourceName(), new HashSet<>(fileListener.listFiles(container))); + } + return files; + } + + @Override + public SQLFragment listFilesQuery(@NotNull User currentUser) + { + SQLFragment frag = new SQLFragment(); + if (currentUser == null || !currentUser.hasSiteAdminPermission()) + { + frag.append("SELECT\n"); + frag.append(" CAST(NULL AS VARCHAR) AS Container,\n"); + frag.append(" NULL AS Created,\n"); + frag.append(" NULL AS CreatedBy,\n"); + frag.append(" NULL AS Modified,\n"); + frag.append(" NULL AS ModifiedBy,\n"); + frag.append(" NULL AS FilePath,\n"); + frag.append(" NULL AS SourceKey,\n"); + frag.append(" NULL AS SourceName\n"); + frag.append("WHERE 1 = 0"); + } + else + { + String union = ""; + frag.append("("); + for (FileListener fileListener : _fileListeners) + { + SQLFragment subselect = fileListener.listFilesQuery(); + if (subselect != null) + { + frag.append(union); + frag.append(subselect); + union = "UNION\n"; + } + } + frag.append(")"); + } + return frag; + } + + @Override + public void setFileRootSetViaStartupProperty(boolean fileRootSetViaStartupProperty) + { + _fileRootSetViaStartupProperty = fileRootSetViaStartupProperty; + } + + @Override + public boolean isFileRootSetViaStartupProperty() + { + return _fileRootSetViaStartupProperty; + } + + public ContainerListener getContainerListener() + { + return _containerListener; + } + + public Set> getNodes(boolean isShowOverridesOnly, @Nullable String browseUrl, Container c) + { + Set> children = new LinkedHashSet<>(); + + try { + java.nio.file.Path assayFilesRoot = getFileRootPath(c, ContentType.assayfiles); + if (NetworkDrive.exists(assayFilesRoot)) + { + Map node = createFileSetNode(c, ASSAY_FILES, assayFilesRoot); + node.put("default", false); + node.put("webdavURL", FilesWebPart.getRootPath(c, ASSAY_FILES).toString()); + children.add(node); + } + + AttachmentDirectory root = getMappedAttachmentDirectory(c, false); + if (root != null) + { + boolean isDefault = isUseDefaultRoot(c); + if (!isDefault || !isShowOverridesOnly) + { + ActionURL config = PageFlowUtil.urlProvider(AdminUrls.class).getProjectSettingsFileURL(c); + Map node = createFileSetNode(c, FILES_LINK, root.getFileSystemDirectoryPath()); + node.put("default", isUseDefaultRoot(c)); + node.put("configureURL", config.getEncodedLocalURIString()); + node.put("browseURL", browseUrl); + node.put("webdavURL", FilesWebPart.getRootPath(c, FILES_LINK).toString()); + + children.add(node); + } + } + + for (AttachmentDirectory fileSet : getRegisteredDirectories(c)) + { + ActionURL config = new ActionURL(FileContentController.ShowAdminAction.class, c); + Map node = createFileSetNode(c, fileSet.getName(), fileSet.getFileSystemDirectoryPath()); + node.put("configureURL", config.getEncodedLocalURIString()); + node.put("browseURL", browseUrl); + node.put("webdavURL", FilesWebPart.getRootPath(c, FILE_SETS_LINK, fileSet.getName()).toString()); + node.put("rootType", "fileset"); + + children.add(node); + } + + PipeRoot pipeRoot = PipelineService.get().findPipelineRoot(c); + if (pipeRoot != null) + { + boolean isDefault = PipelineService.get().hasSiteDefaultRoot(c); + if (!isDefault || !isShowOverridesOnly) + { + ActionURL config = PageFlowUtil.urlProvider(PipelineUrls.class).urlSetup(c); + ActionURL pipelineBrowse = PageFlowUtil.urlProvider(PipelineUrls.class).urlBrowse(c, null); + Map node = createFileSetNode(c, PIPELINE_LINK, pipeRoot.getRootNioPath()); + node.put("default", isDefault ); + node.put("configureURL", config.getEncodedLocalURIString()); + node.put("browseURL", pipelineBrowse.getEncodedLocalURIString()); + node.put("webdavURL", FilesWebPart.getRootPath(c, PIPELINE_LINK).toString()); + + children.add(node); + } + } + } + catch (IOException | UnsetRootDirectoryException ignored) {} + return children; + } + + protected Map createFileSetNode(Container container, String name, java.nio.file.Path dir) + { + Map node = new HashMap<>(); + if (dir != null) + { + node.put("name", name); + node.put("path", FileUtil.getAbsolutePath(container, dir)); + node.put("leaf", true); + } + return node; + } + + public String getAbsolutePathFromDataFileUrl(String dataFileUrl, Container container) + { + return FileUtil.getAbsolutePath(container, FileUtil.createUri(dataFileUrl)); + } + + @Nullable + @Override + public URI getWebDavUrl(@NotNull FileLike path, @NotNull Container container, @NotNull PathType type) + { + return getWebDavUrl(path.toNioPathForRead(), container, type); + } + + @Nullable + @Override + public URI getWebDavUrl(@NotNull java.nio.file.Path path, @NotNull Container container, @NotNull PathType type) + { + PipeRoot root = PipelineService.get().getPipelineRootSetting(container); + java.nio.file.Path assayFilesPath = getFileRootPath(container, ContentType.assayfiles); + path = path.toAbsolutePath(); + String relPath = null; + URI rootWebDavUrl = null; + + try + { + // currently, only report if the file is under the parent container + if (root != null && root.isUnderRoot(path)) + { + relPath = root.relativePath(path); + rootWebDavUrl = root.getWebdavURL(); + } + else if (assayFilesPath != null && URIUtil.isDescendant(assayFilesPath.toUri(), path.toUri())) + { + relPath = assayFilesPath.relativize(path).toString(); + rootWebDavUrl = FilesWebPart.getRootPath(container, ASSAY_FILES); + } + + if (relPath != null) + { + relPath = Path.parse(FilenameUtils.separatorsToUnix(relPath)).encode(); + + return switch (type) + { + case folderRelative -> new URI(relPath); + case serverRelative -> new URI(rootWebDavUrl + (rootWebDavUrl.getPath().endsWith("/") ? "" : "/") + relPath); + case full -> new URI(AppProps.getInstance().getBaseServerUrl() + rootWebDavUrl + (rootWebDavUrl.getPath().endsWith("/") ? "" : "/") + relPath); + }; + } + } + catch (InvalidPathException | URISyntaxException e) + { + _log.error("Invalid WebDav URL from: " + path, e); + } + + return null; + } + + @Override + public String getDataFileRelativeFileRootPath(@NotNull String dataFileUrl, Container container) + { + Set> children = getNodes(false, null, container); + String filesRoot = null; // the path for @files + for (Map child : children) + { + String rootName = (String) child.get("name"); + String rootPath = (String) child.get("path"); + + // skip default @pipeline, which is the same as @files + if (PIPELINE_LINK.equals(rootName)) + { + if((boolean) child.get("default") || rootPath.equals(filesRoot)) + continue; + } + + if (FILES_LINK.equals(rootName)) + filesRoot = rootPath; + + String absoluteFilePath = getAbsolutePathFromDataFileUrl(dataFileUrl, container); + if (StringUtils.startsWith(absoluteFilePath, rootPath)) + { + String offset = absoluteFilePath.replace(rootPath, "").replace("\\", "/"); + int lastSlash = offset.lastIndexOf("/"); + if (lastSlash <= 0) + return "/"; + else + return offset.substring(0, lastSlash); + } + } + return null; + } + + @Override + public void ensureFileData(@NotNull ExpDataTable table) + { + Container container = table.getUserSchema().getContainer(); + // The current user may not have insert permission, and they didn't necessarily upload the files anyway + User user = User.getAdminServiceUser(); + QueryUpdateService qus = table.getUpdateService(); + if (qus == null) + { + throw new IllegalArgumentException("getUpdateServer() returned null from " + table); + } + + synchronized (_fileDataUpToDateCache) + { + if (_fileDataUpToDateCache.get(container) != null) // already synced in the past 5 minutes, skip + return; + + _fileDataUpToDateCache.put(container, true); + } + + List existingDataFileUrls = getDataFileUrls(container); + Collection filesets = getRegisteredDirectories(container); + Set> children = getNodes(false, null, container); + String filesRoot = null; // the path for @files + for (Map child : children) + { + String rootName = (String) child.get("name"); + String rootPathVal = (String) child.get("path"); + + // skip default @pipeline, which is the same as @files + if (PIPELINE_LINK.equals(rootName)) + { + if((boolean) child.get("default") || rootPathVal.equals(filesRoot)) + continue; + } + + if (FILES_LINK.equals(rootName)) + filesRoot = rootPathVal; + + String rootDavUrl = (String) child.get("webdavURL"); + + WebdavResource resource = getResource(rootDavUrl); + if (resource == null) + continue; + + List> rows = new ArrayList<>(); + BatchValidationException errors = new BatchValidationException(); + File file = resource.getFile(); + + if (file == null) + { + String rootType = (String) child.get("rootType"); + if ("fileset".equals(rootType)) + { + for (AttachmentDirectory fileset : filesets) + { + if (fileset.getName().equals(rootName)) + { + try + { + file = fileset.getFileSystemDirectory(); + } + catch (MissingRootDirectoryException e) + { + _log.error("Unable to list files for fileset: " + rootName, e); + } + break; + } + } + } + } + + if (file == null) + return; + + try (var ignore = SpringActionController.ignoreSqlUpdates()) + { + java.nio.file.Path rootPath = file.toPath(); + + try (Stream pathStream = Files.walk(rootPath, 100)) // prevent symlink loop + { + pathStream + .filter(path -> !Files.isSymbolicLink(path) && path.compareTo(rootPath) != 0) // exclude symlink & root + .forEach(path -> { + if (!containsUrlOrVariation(existingDataFileUrls, path)) + rows.add(new CaseInsensitiveHashMap<>(Collections.singletonMap("DataFileUrl", path.toUri().toString()))); + }); + } + + qus.insertRows(user, container, rows, errors, null, null); + } + catch (Exception e) + { + _log.error("Error listing content of directory: " + file.getAbsolutePath(), e); + } + } + } + + + @Override + public void addZiploaderPattern(DirectoryPattern directoryPattern) + { + _ziploaderPattern.add(directoryPattern); + } + + @Override + public List getZiploaderPatterns(Container container) + { + List registeredPatterns = new ArrayList<>(); + for(Module module : container.getActiveModules()) + { + _ziploaderPattern.forEach(p -> { + if(p.getModule().getName().equalsIgnoreCase(module.getName())) + registeredPatterns.add(p); + }); + } + return registeredPatterns; + } + + public List getDataFileUrls(Container container) + { + SimpleFilter filter = SimpleFilter.createContainerFilter(container); + filter.addCondition(FieldKey.fromParts("DataFileUrl"), null, CompareType.NONBLANK); + TableSelector selector = new TableSelector(ExperimentService.get().getTinfoData(), Collections.singleton("DataFileUrl"), filter, null); + return selector.getArrayList(String.class); + } + + public Path getPath(String uri) + { + Path path = Path.decode(uri); + + if (!path.startsWith(WebdavService.getPath()) && path.contains(WebdavService.getPath().getName())) + { + String newPath = path.toString(); + int idx = newPath.indexOf(WebdavService.getPath().toString()); + + if (idx != -1) + { + newPath = newPath.substring(idx); + path = Path.parse(newPath); + } + } + return path; + } + + @Nullable + public WebdavResource getResource(String uri) + { + Path path = getPath(uri); + return WebdavService.get().getResolver().lookup(path); + } + + public static void throwIfPathNotFile(java.nio.file.Path path, Container container) + { + if (null == path) + { + throw new RuntimeException("No path to evaluate in " + container.getPath()); + } + if (FileUtil.hasCloudScheme(path)) + { + throw new RuntimeException("Cannot get File object from Cloud File Root in " + container.getPath()); + } + } + + private boolean containsUrlOrVariation(List existingUrls, java.nio.file.Path path) + { + String url = path.toUri().toString(); + if (existingUrls.contains(url)) + return true; + + boolean urlHasTrailingSlash = (Files.isDirectory(path) && (url.endsWith("/") || url.endsWith(File.pathSeparator))); + if (urlHasTrailingSlash && existingUrls.contains(url.substring(0, url.length() - 1))) + return true; + + if (!FileUtil.hasCloudScheme(path)) + { + File file = path.toFile(); + String legacyUrl = file.toURI().toString(); + if (existingUrls.contains(legacyUrl)) // Legacy URI format (file:/users/...) + return true; + + return existingUrls.contains(file.getPath()); + } + return false; + } + + @Override + public File getMoveTargetFile(String absoluteFilePath, @NotNull Container sourceContainer, @NotNull Container targetContainer) + { + if (absoluteFilePath == null) + return null; + + File file = new File(absoluteFilePath); + if (!NetworkDrive.exists(file)) + { + _log.warn("File '" + absoluteFilePath + "' not found and cannot be moved"); + return null; + } + + File sourceFileRoot = getFileRoot(sourceContainer); + if (sourceFileRoot == null) + return null; + + String sourceRootPath = sourceFileRoot.getAbsolutePath(); + if (!absoluteFilePath.startsWith(sourceRootPath)) + { + _log.warn("File '" + absoluteFilePath + "' not currently located in source folder '" + sourceRootPath + "'. Not moving."); + return null; + } + File targetFileRoot = getFileRoot(targetContainer); + if (targetFileRoot == null) + return null; + + String targetPath = absoluteFilePath.replace(sourceRootPath, targetFileRoot.getAbsolutePath()); + File targetFile = new File(targetPath); + return FileUtil.findUniqueFileName(file.getName(), targetFile.getParentFile()); + } + + @Override + public void addDynamicWarnings(@NotNull Warnings warnings, @Nullable ViewContext context, boolean showAllWarnings) + { + if (_problematicFileRootMessage != null && context != null && ContainerManager.getRoot().hasPermission(context.getUser(), AdminOperationsPermission.class)) + { + warnings.add(DOM.createHtmlFragment(_problematicFileRootMessage, " ", DOM.A(at(href, PageFlowUtil.urlProvider(AdminUrls.class).getFilesSiteSettingsURL()), "Configure File System Access"))); + } + else if (showAllWarnings) + { + try + { + warnings.add(HtmlString.of("Configured site-wide file root " + getDefaultRoot() + " does not exist. Falling back to " + getDefaultRoot())); + } + catch (IOException ignored) {} + } + } + + // Cache with short-lived entries so that exp.files can perform reasonably + private static final Cache _fileDataUpToDateCache = CacheManager.getCache(CacheManager.UNLIMITED, 5 * CacheManager.MINUTE, "Files"); + + @TestWhen(TestWhen.When.BVT) + public static class TestCase extends AssertionError + { + private static final String TRICKY_CHARACTERS_FOR_PROJECT_NAMES = "\u2603~!@$&()_+{}-=[],.#\u00E4\u00F6\u00FC"; + + private static final String PROJECT1 = "FileRootTestProject1" + TRICKY_CHARACTERS_FOR_PROJECT_NAMES; + private static final String PROJECT1_SUBFOLDER1 = "Subfolder1"; + private static final String PROJECT1_SUBFOLDER2 = "Subfolder2" + TRICKY_CHARACTERS_FOR_PROJECT_NAMES; + private static final String PROJECT1_SUBSUBFOLDER = "SubSubfolder"; + private static final String PROJECT1_SUBSUBFOLDER_SIBLING = "SubSubfolderSibling"; + private static final String PROJECT2 = "FileRootTestProject2"; + + private static final String FILE_ROOT_SUFFIX = "_FileRootTest"; + private static final String TXT_FILE = "FileContentTestFile.txt"; + + private Map _expectedPaths; + + @Test + public void fileRootsTest() + { + //pre-clean + cleanup(); + + _expectedPaths = new HashMap<>(); + + FileContentService svc = FileContentService.get(); + Assert.assertNotNull(svc); + + Container project1 = ContainerManager.createContainer(ContainerManager.getRoot(), PROJECT1, TestContext.get().getUser()); + _expectedPaths.put(project1, null); + + Container project2 = ContainerManager.createContainer(ContainerManager.getRoot(), PROJECT2, TestContext.get().getUser()); + _expectedPaths.put(project2, null); + + Container subfolder1 = ContainerManager.createContainer(project1, PROJECT1_SUBFOLDER1, TestContext.get().getUser()); + _expectedPaths.put(subfolder1, null); + + Container subfolder2 = ContainerManager.createContainer(project1, PROJECT1_SUBFOLDER2, TestContext.get().getUser()); + _expectedPaths.put(subfolder2, null); + + Container subsubfolder = ContainerManager.createContainer(subfolder1, PROJECT1_SUBSUBFOLDER, TestContext.get().getUser()); + _expectedPaths.put(subsubfolder, null); + + //set custom root on project, then expect children to inherit + File testRoot = getTestRoot(); + + svc.setFileRoot(project1, testRoot); + _expectedPaths.put(project1, testRoot); + + //the subfolder should inherit from the parent + _expectedPaths.put(subfolder1, new File(testRoot, subfolder1.getName())); + assertPathsEqual("Incorrect values returned by getDefaultRoot", _expectedPaths.get(subfolder1), svc.getDefaultRoot(subfolder1, false)); + assertPathsEqual("Subfolder1 has incorrect root", _expectedPaths.get(subfolder1), svc.getFileRoot(subfolder1)); + + _expectedPaths.put(subfolder2, new File(testRoot, subfolder2.getName())); + assertPathsEqual("Incorrect values returned by getDefaultRoot", _expectedPaths.get(subfolder2), svc.getDefaultRoot(subfolder2, false)); + assertPathsEqual("Subfolder2 has incorrect root", _expectedPaths.get(subfolder2), svc.getFileRoot(subfolder2)); + + _expectedPaths.put(subsubfolder, new File(_expectedPaths.get(subfolder1), subsubfolder.getName())); + assertPathsEqual("Incorrect values returned by getDefaultRoot", _expectedPaths.get(subsubfolder), svc.getDefaultRoot(subsubfolder, false)); + assertPathsEqual("SubSubfolder has incorrect root", _expectedPaths.get(subsubfolder), svc.getFileRoot(subsubfolder)); + + //override root on 1st child, expect children of that folder to inherit + _expectedPaths.put(subfolder1, new File(testRoot, "CustomSubfolder")); + _expectedPaths.get(subfolder1).mkdirs(); + svc.setFileRoot(subfolder1, _expectedPaths.get(subfolder1)); + assertPathsEqual("SubSubfolder has incorrect root", new File(_expectedPaths.get(subfolder1), subsubfolder.getName()), svc.getFileRoot(subsubfolder)); + + //reset project, we assume overridden child roots to remain the same + svc.setFileRoot(project1, null); + assertPathsEqual("Subfolder1 has incorrect root", _expectedPaths.get(subfolder1), svc.getFileRoot(subfolder1)); + assertPathsEqual("SubSubfolder has incorrect root", new File(_expectedPaths.get(subfolder1), subsubfolder.getName()), svc.getFileRoot(subsubfolder)); + + } + + private void assertPathsEqual(String msg, File expected, File actual) + { + String expectedPath = FileUtil.getAbsoluteCaseSensitiveFile(expected).getPath(); + String actualPath = FileUtil.getAbsoluteCaseSensitiveFile(actual).getPath(); + Assert.assertEquals(msg, expectedPath, actualPath); + } + + private File getTestRoot() + { + FileContentService svc = FileContentService.get(); + File siteRoot = svc.getSiteDefaultRoot(); + File testRoot = new File(siteRoot, FILE_ROOT_SUFFIX); + testRoot.mkdirs(); + Assert.assertTrue("Unable to create test file root", NetworkDrive.exists(testRoot)); + + return testRoot; + } + + @Test + //when we move a folder, we expect child files to follow, and expect + // any file paths stored in the DB to also get updated + public void testFolderMove() throws Exception + { + //pre-clean + cleanup(); + + _expectedPaths = new HashMap<>(); + + FileContentService svc = FileContentService.get(); + Assert.assertNotNull(svc); + + Container project1 = ContainerManager.createContainer(ContainerManager.getRoot(), PROJECT1, TestContext.get().getUser()); + _expectedPaths.put(project1, null); + + Container project2 = ContainerManager.createContainer(ContainerManager.getRoot(), PROJECT2, TestContext.get().getUser()); + _expectedPaths.put(project2, null); + + Container subfolder1 = ContainerManager.createContainer(project1, PROJECT1_SUBFOLDER1, TestContext.get().getUser()); + _expectedPaths.put(subfolder1, null); + + Container subfolder2 = ContainerManager.createContainer(project1, PROJECT1_SUBFOLDER2, TestContext.get().getUser()); + _expectedPaths.put(subfolder2, null); + + Container subsubfolder = ContainerManager.createContainer(subfolder1, PROJECT1_SUBSUBFOLDER, TestContext.get().getUser()); + Container subsubfolderSibling = ContainerManager.createContainer(subfolder1, PROJECT1_SUBSUBFOLDER_SIBLING, TestContext.get().getUser()); + _expectedPaths.put(subsubfolder, null); + + //create a test file that we will follow + File fileRoot = svc.getFileRoot(subsubfolder, ContentType.files); + fileRoot.mkdirs(); + + File childFile = new File(fileRoot, TXT_FILE); + childFile.createNewFile(); + + ExpData data = ExperimentService.get().createData(subsubfolder, UPLOADED_FILE); + data.setDataFileURI(childFile.toPath().toUri()); + data.save(TestContext.get().getUser()); + + ExpProtocol protocol = ExperimentService.get().createExpProtocol(subsubfolder, ExpProtocol.ApplicationType.ProtocolApplication, "DummyProtocol"); + protocol = ExperimentService.get().insertSimpleProtocol(protocol, TestContext.get().getUser()); + + ExpRun expRun = ExperimentService.get().createExperimentRun(subsubfolder, "DummyRun"); + expRun.setProtocol(protocol); + expRun.setFilePathRootPath(childFile.getParentFile().toPath()); + + ViewBackgroundInfo info = new ViewBackgroundInfo(subsubfolder, TestContext.get().getUser(), null); + ExpRun run = ExperimentService.get().saveSimpleExperimentRun( + expRun, + Collections.emptyMap(), + Collections.singletonMap(data, "Data"), + Collections.emptyMap(), + Collections.emptyMap(), + Collections.emptyMap(), + info, + _log, + false); + + Assert.assertTrue("File not found: " + childFile.getPath(), NetworkDrive.exists(childFile)); + ContainerManager.move(subsubfolder, subfolder2, TestContext.get().getUser()); + Container movedSubfolder = ContainerManager.getChild(subfolder2, subsubfolder.getName()); + + _expectedPaths.put(movedSubfolder, new File(svc.getFileRoot(subfolder2), movedSubfolder.getName())); + assertPathsEqual("Incorrect values returned by getDefaultRoot", _expectedPaths.get(movedSubfolder), svc.getDefaultRoot(movedSubfolder, false)); + assertPathsEqual("SubSubfolder has incorrect root", _expectedPaths.get(movedSubfolder), svc.getFileRoot(movedSubfolder)); + + File expectedFile = new File(svc.getFileRoot(movedSubfolder, ContentType.files), TXT_FILE); + Assert.assertTrue("File was not moved, expected: " + expectedFile.getPath(), NetworkDrive.exists(expectedFile)); + + ExpData movedData = ExperimentService.get().getExpData(data.getRowId()); + Assert.assertNotNull(movedData); + + // Reload the run after it's path has hopefully been updated + expRun = ExperimentService.get().getExpRun(expRun.getRowId()); + + assertPathsEqual("Incorrect data file path", expectedFile, FileUtil.stringToPath(movedSubfolder, movedData.getDataFileUrl()).toFile()); + assertPathsEqual("Incorrect run root path", expectedFile.getParentFile(), expRun.getFilePathRoot()); + + // Issue 38206 - file paths get mangled with multiple folder moves + ContainerManager.move(subsubfolderSibling, subfolder2, TestContext.get().getUser()); + + // Reload the run after it's path has hopefully NOT been updated + expRun = ExperimentService.get().getExpRun(expRun.getRowId()); + assertPathsEqual("Incorrect run root path", expectedFile.getParentFile(), expRun.getFilePathRoot()); + } + + @Test + public void testWorkbooksAndTabs() + { + //pre-clean + cleanup(); + + FileContentService svc = FileContentService.get(); + Assert.assertNotNull(svc); + + Container project1 = ContainerManager.createContainer(ContainerManager.getRoot(), PROJECT1, TestContext.get().getUser()); + + Container workbook = ContainerManager.createContainer(project1, null, null, null, WorkbookContainerType.NAME, TestContext.get().getUser()); + File expectedWorkbookRoot = new File(svc.getFileRoot(project1), workbook.getName()); + assertPathsEqual("Workbook has incorrect file root", expectedWorkbookRoot, svc.getFileRoot(workbook)); + + Container tab = ContainerManager.createContainer(project1, "tab", null, null, TabContainerType.NAME, TestContext.get().getUser()); + File expectedTabRoot = new File(svc.getFileRoot(project1), tab.getName()); + assertPathsEqual("Folder tab has incorrect file root", expectedTabRoot, svc.getFileRoot(tab)); + } + + /** + * Test that the Site Settings can be configured from startup properties + */ + @Test + public void testStartupPropertiesForSiteRootSettings() throws IOException + { + // save the original Site Root File settings so that we can restore them when this test is done + File originalSiteRootFile = FileContentService.get().getSiteDefaultRoot(); + + // create the new site root file to test with as a child of the current site root file so that we know it is in a dir that exist + String originalSiteRootFilePath = originalSiteRootFile.getAbsolutePath(); + File testSiteRootFile = new File(originalSiteRootFilePath, "testSiteRootFile"); + testSiteRootFile.createNewFile(); + + ModuleLoader.getInstance().handleStartupProperties(new RandomSiteSettingsPropertyHandler(){ + @Override + public @NotNull Collection getStartupPropertyEntries() + { + return List.of(new StartupPropertyEntry("siteFileRoot", testSiteRootFile.getAbsolutePath(), "startup", SCOPE_SITE_SETTINGS)); + } + + @Override + public boolean performChecks() + { + return false; + } + }); + + // now check that the expected changes occurred to the Site Root File settings on the server + File newSiteRootFile = FileContentService.get().getSiteDefaultRoot(); + Assert.assertEquals("The expected change in Site Root File was not found", testSiteRootFile.getAbsolutePath(), newSiteRootFile.getAbsolutePath()); + + // restore the Site Root File server settings to how they were originally + FileContentService.get().setSiteDefaultRoot(originalSiteRootFile, null); + testSiteRootFile.delete(); + } + + @After + public void cleanup() + { + FileContentService svc = FileContentService.get(); + Assert.assertNotNull(svc); + + deleteContainerAndFiles(svc, ContainerManager.getForPath(PROJECT1)); + deleteContainerAndFiles(svc, ContainerManager.getForPath(PROJECT2)); + + File testRoot = getTestRoot(); + if (NetworkDrive.exists(testRoot)) + { + FileUtil.deleteDir(testRoot); + } + } + + private void deleteContainerAndFiles(FileContentService svc, @Nullable Container c) + { + if (c != null) + { + ContainerManager.deleteAll(c, TestContext.get().getUser()); + + File file1 = svc.getFileRoot(c); + if (NetworkDrive.exists(file1)) + { + FileUtil.deleteDir(file1); + } + } + } + } +} diff --git a/query/package-lock.json b/query/package-lock.json deleted file mode 100644 index 001e6eec59f..00000000000 --- a/query/package-lock.json +++ /dev/null @@ -1,10541 +0,0 @@ -{ - "name": "puppeteer", - "version": "0.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "puppeteer", - "version": "0.0.0", - "dependencies": { - "@labkey/components": "6.64.0" - }, - "devDependencies": { - "@labkey/build": "8.6.0", - "@types/jest": "30.0.0", - "@types/react": "18.3.23", - "@types/react-dom": "18.3.7" - } - }, - "node_modules/@adobe/css-tools": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.3.tgz", - "integrity": "sha512-VQKMkwriZbaOgVCby1UDY/LDk5fIjhQicCvVPFqfe+69fWaPWydbWJ3wRt59/YzIwda1I81loas3oCoHxnqvdA==", - "license": "MIT" - }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", - "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz", - "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.0", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.27.3", - "@babel/helpers": "^7.27.6", - "@babel/parser": "^7.28.0", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.0", - "@babel/types": "^7.28.0", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/generator": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz", - "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==", - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.28.0", - "@babel/types": "^7.28.0", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", - "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.27.3" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.27.2", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.1.tgz", - "integrity": "sha512-QwGAmuvM17btKU5VqXfb+Giw4JcN0hjuufz3DYnpeVDvZLAObloM77bhMXiqry3Iio+Ai4phVRDwl6WU10+r5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-member-expression-to-functions": "^7.27.1", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/helper-replace-supers": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/traverse": "^7.27.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-create-regexp-features-plugin": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.27.1.tgz", - "integrity": "sha512-uVDC72XVf8UbrH5qQTc18Agb8emwjTiZrQE11Nv3CuBEZmVvTwwE9CBUEvHku06gQCAyYf8Nv6ja1IN+6LMbxQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "regexpu-core": "^6.2.0", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.5.tgz", - "integrity": "sha512-uJnGFcPsWQK8fvjgGP5LZUZZsYGIoPeRjSF5PGwrelYgq7Q15/Ft9NGFp1zglwgIv//W0uG4BevRuSJRyylZPg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-plugin-utils": "^7.27.1", - "debug": "^4.4.1", - "lodash.debounce": "^4.0.8", - "resolve": "^1.22.10" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz", - "integrity": "sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz", - "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.27.3" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", - "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-remap-async-to-generator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", - "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-wrap-function": "^7.27.1", - "@babel/traverse": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-replace-supers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.27.1.tgz", - "integrity": "sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.27.1", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/traverse": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", - "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-wrap-function": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.27.1.tgz", - "integrity": "sha512-NFJK2sHUvrjo8wAU/nQTWU890/zB2jj0qBcCbZbbf+005cAsv6tMjXz31fBign6M5ov1o0Bllu+9nbqkfsjjJQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.27.1", - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.27.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz", - "integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.27.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", - "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.27.1.tgz", - "integrity": "sha512-QPG3C9cCVRQLxAVwmefEmwdTanECuUBMQZ/ym5kiw3XKCGA7qkuQLcjWWHcrD/GKbn/WmJwaezfuuAOcyKlRPA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz", - "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz", - "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz", - "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/plugin-transform-optional-chaining": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.13.0" - } - }, - "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.27.1.tgz", - "integrity": "sha512-6BpaYGDavZqkI6yT+KSPdpZFfpnd68UKXbcjI9pJ13pvHhPrCKWOOLp+ysvMeA+DxnhuPpgIaRpxRxo5A9t5jw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-proposal-private-property-in-object": { - "version": "7.21.0-placeholder-for-preset-env.2", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", - "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-import-assertions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.27.1.tgz", - "integrity": "sha512-UT/Jrhw57xg4ILHLFnzFpPDlMbcdEicaAtjPQpbj9wa8T4r5KVWCimHcL/460g8Ht0DMxDyjsLgiWSkVjnwPFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", - "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", - "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", - "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-unicode-sets-regex": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", - "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.18.6", - "@babel/helper-plugin-utils": "^7.18.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-transform-arrow-functions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", - "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-async-generator-functions": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.28.0.tgz", - "integrity": "sha512-BEOdvX4+M765icNPZeidyADIvQ1m1gmunXufXxvRESy/jNNyfovIqUyE7MVgGBjWktCoJlzvFA1To2O4ymIO3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-remap-async-to-generator": "^7.27.1", - "@babel/traverse": "^7.28.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-async-to-generator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.27.1.tgz", - "integrity": "sha512-NREkZsZVJS4xmTr8qzE5y8AfIPqsdQfRuUiLRTEzb7Qii8iFWCyDKaUV2c0rCuh4ljDZ98ALHP/PetiBV2nddA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-remap-async-to-generator": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-block-scoped-functions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz", - "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.0.tgz", - "integrity": "sha512-gKKnwjpdx5sER/wl0WN0efUBFzF/56YZO0RJrSYP4CljXnP31ByY7fol89AzomdlLNzI36AvOTmYHsnZTCkq8Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-class-properties": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.27.1.tgz", - "integrity": "sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-class-static-block": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.27.1.tgz", - "integrity": "sha512-s734HmYU78MVzZ++joYM+NkJusItbdRcbm+AGRgJCt3iA+yux0QpD9cBVdz3tKyrjVYWRl7j0mHSmv4lhV0aoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.12.0" - } - }, - "node_modules/@babel/plugin-transform-classes": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.0.tgz", - "integrity": "sha512-IjM1IoJNw72AZFlj33Cu8X0q2XK/6AaVC3jQu+cgQ5lThWD5ajnuUAml80dqRmOhmPkTH8uAwnpMu9Rvj0LTRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-globals": "^7.28.0", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-replace-supers": "^7.27.1", - "@babel/traverse": "^7.28.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-computed-properties": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.27.1.tgz", - "integrity": "sha512-lj9PGWvMTVksbWiDT2tW68zGS/cyo4AkZ/QTp0sQT0mjPopCmrSkzxeXkznjqBxzDI6TclZhOJbBmbBLjuOZUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/template": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.0.tgz", - "integrity": "sha512-v1nrSMBiKcodhsyJ4Gf+Z0U/yawmJDBOTpEB3mcQY52r9RIyPneGyAS/yM6seP/8I+mWI3elOMtT5dB8GJVs+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.28.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-dotall-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.27.1.tgz", - "integrity": "sha512-gEbkDVGRvjj7+T1ivxrfgygpT7GUd4vmODtYpbs0gZATdkX8/iSnOtZSxiZnsgm1YjTgjI6VKBGSJJevkrclzw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-duplicate-keys": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz", - "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.27.1.tgz", - "integrity": "sha512-hkGcueTEzuhB30B3eJCbCYeCaaEQOmQR0AdvzpD4LoN0GXMWzzGSuRrxR2xTnCrvNbVwK9N6/jQ92GSLfiZWoQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-transform-dynamic-import": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz", - "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-explicit-resource-management": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.0.tgz", - "integrity": "sha512-K8nhUcn3f6iB+P3gwCv/no7OdzOZQcKchW6N389V6PD8NUWKZHzndOd9sPDVbMoBsbmjMqlB4L9fm+fEFNVlwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/plugin-transform-destructuring": "^7.28.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-exponentiation-operator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.27.1.tgz", - "integrity": "sha512-uspvXnhHvGKf2r4VVtBpeFnuDWsJLQ6MF6lGJLC89jBR1uoVeqM416AZtTuhTezOfgHicpJQmoD5YUakO/YmXQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-export-namespace-from": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", - "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-for-of": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", - "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-function-name": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz", - "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-compilation-targets": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-json-strings": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.27.1.tgz", - "integrity": "sha512-6WVLVJiTjqcQauBhn1LkICsR2H+zm62I3h9faTDKt1qP4jn2o72tSvqMwtGFKGTpojce0gJs+76eZ2uCHRZh0Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", - "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-logical-assignment-operators": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.27.1.tgz", - "integrity": "sha512-SJvDs5dXxiae4FbSL1aBJlG4wvl594N6YEVVn9e3JGulwioy6z3oPjx/sQBO3Y4NwUu5HNix6KJ3wBZoewcdbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-member-expression-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz", - "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-amd": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz", - "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.27.1.tgz", - "integrity": "sha512-OJguuwlTYlN0gBZFRPqwOGNWssZjfIUdS7HMYtN8c1KmwpwHFBwTeFZrg9XZa+DFTitWOW5iTAG7tyCUPsCCyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.27.1.tgz", - "integrity": "sha512-w5N1XzsRbc0PQStASMksmUeqECuzKuTJer7kFagK8AXgpCMkeDMO5S+aaFb7A51ZYDF7XI34qsTX+fkHiIm5yA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-umd": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz", - "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.27.1.tgz", - "integrity": "sha512-SstR5JYy8ddZvD6MhV0tM/j16Qds4mIpJTOd1Yu9J9pJjH93bxHECF7pgtc28XvkzTD6Pxcm/0Z73Hvk7kb3Ng==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-transform-new-target": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz", - "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.27.1.tgz", - "integrity": "sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-numeric-separator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.27.1.tgz", - "integrity": "sha512-fdPKAcujuvEChxDBJ5c+0BTaS6revLV7CJL08e4m3de8qJfNIuCc2nc7XJYOjBoTMJeqSmwXJ0ypE14RCjLwaw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-object-rest-spread": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.0.tgz", - "integrity": "sha512-9VNGikXxzu5eCiQjdE4IZn8sb9q7Xsk5EXLDBKUYg1e/Tve8/05+KJEtcxGxAgCY5t/BpKQM+JEL/yT4tvgiUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/plugin-transform-destructuring": "^7.28.0", - "@babel/plugin-transform-parameters": "^7.27.7", - "@babel/traverse": "^7.28.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-object-super": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz", - "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-replace-supers": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-optional-catch-binding": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.27.1.tgz", - "integrity": "sha512-txEAEKzYrHEX4xSZN4kJ+OfKXFVSWKB2ZxM9dpcE3wT7smwkNmXo5ORRlVzMVdJbD+Q8ILTgSD7959uj+3Dm3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-optional-chaining": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.27.1.tgz", - "integrity": "sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-parameters": { - "version": "7.27.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz", - "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-private-methods": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.27.1.tgz", - "integrity": "sha512-10FVt+X55AjRAYI9BrdISN9/AQWHqldOeZDUoLyif1Kn05a56xVBXb8ZouL8pZ9jem8QpXaOt8TS7RHUIS+GPA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-private-property-in-object": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.27.1.tgz", - "integrity": "sha512-5J+IhqTi1XPa0DXF83jYOaARrX+41gOewWbkPyjMNRDqgOCqdffGh8L3f/Ek5utaEBZExjSAzcyjmV9SSAWObQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-create-class-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-property-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz", - "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-display-name": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.28.0.tgz", - "integrity": "sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.27.1.tgz", - "integrity": "sha512-2KH4LWGSrJIkVf5tSiBFYuXDAoWRq2MMwgivCf+93dd0GQi8RXLjKA/0EvRnVV5G0hrHczsquXuD01L8s6dmBw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/plugin-syntax-jsx": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-development": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.27.1.tgz", - "integrity": "sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/plugin-transform-react-jsx": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-pure-annotations": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.27.1.tgz", - "integrity": "sha512-JfuinvDOsD9FVMTHpzA/pBLisxpv1aSf+OIV8lgH3MuWrks19R27e6a6DipIg4aX1Zm9Wpb04p8wljfKrVSnPA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-regenerator": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.28.0.tgz", - "integrity": "sha512-LOAozRVbqxEVjSKfhGnuLoE4Kz4Oc5UJzuvFUhSsQzdCdaAQu06mG8zDv2GFSerM62nImUZ7K92vxnQcLSDlCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-regexp-modifiers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.27.1.tgz", - "integrity": "sha512-TtEciroaiODtXvLZv4rmfMhkCv8jx3wgKpL68PuiPh2M4fvz5jhsA7697N1gMvkvr/JTF13DrFYyEbY9U7cVPA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-transform-reserved-words": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz", - "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-shorthand-properties": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", - "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-spread": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.27.1.tgz", - "integrity": "sha512-kpb3HUqaILBJcRFVhFUs6Trdd4mkrzcGXss+6/mxUd273PfbWqSDHRzMT2234gIg2QYfAjvXLSquP1xECSg09Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-sticky-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz", - "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-template-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", - "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-typeof-symbol": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz", - "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-typescript": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.0.tgz", - "integrity": "sha512-4AEiDEBPIZvLQaWlc9liCavE0xRM0dNca41WtBeM3jgFptfUOSG9z0uteLhq6+3rq+WB6jIvUwKDTpXEHPJ2Vg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-create-class-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/plugin-syntax-typescript": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-unicode-escapes": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", - "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-unicode-property-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.27.1.tgz", - "integrity": "sha512-uW20S39PnaTImxp39O5qFlHLS9LJEmANjMG7SxIhap8rCHqu0Ik+tLEPX5DKmHn6CsWQ7j3lix2tFOa5YtL12Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-unicode-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", - "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-unicode-sets-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.27.1.tgz", - "integrity": "sha512-EtkOujbc4cgvb0mlpQefi4NTPBzhSIevblFevACNLUspmrALgmEBdL/XfnyyITfd8fKBZrZys92zOWcik7j9Tw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/preset-env": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.28.0.tgz", - "integrity": "sha512-VmaxeGOwuDqzLl5JUkIRM1X2Qu2uKGxHEQWh+cvvbl7JuJRgKGJSfsEF/bUaxFhJl/XAyxBe7q7qSuTbKFuCyg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.28.0", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-validator-option": "^7.27.1", - "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.27.1", - "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1", - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1", - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1", - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.27.1", - "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", - "@babel/plugin-syntax-import-assertions": "^7.27.1", - "@babel/plugin-syntax-import-attributes": "^7.27.1", - "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", - "@babel/plugin-transform-arrow-functions": "^7.27.1", - "@babel/plugin-transform-async-generator-functions": "^7.28.0", - "@babel/plugin-transform-async-to-generator": "^7.27.1", - "@babel/plugin-transform-block-scoped-functions": "^7.27.1", - "@babel/plugin-transform-block-scoping": "^7.28.0", - "@babel/plugin-transform-class-properties": "^7.27.1", - "@babel/plugin-transform-class-static-block": "^7.27.1", - "@babel/plugin-transform-classes": "^7.28.0", - "@babel/plugin-transform-computed-properties": "^7.27.1", - "@babel/plugin-transform-destructuring": "^7.28.0", - "@babel/plugin-transform-dotall-regex": "^7.27.1", - "@babel/plugin-transform-duplicate-keys": "^7.27.1", - "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.27.1", - "@babel/plugin-transform-dynamic-import": "^7.27.1", - "@babel/plugin-transform-explicit-resource-management": "^7.28.0", - "@babel/plugin-transform-exponentiation-operator": "^7.27.1", - "@babel/plugin-transform-export-namespace-from": "^7.27.1", - "@babel/plugin-transform-for-of": "^7.27.1", - "@babel/plugin-transform-function-name": "^7.27.1", - "@babel/plugin-transform-json-strings": "^7.27.1", - "@babel/plugin-transform-literals": "^7.27.1", - "@babel/plugin-transform-logical-assignment-operators": "^7.27.1", - "@babel/plugin-transform-member-expression-literals": "^7.27.1", - "@babel/plugin-transform-modules-amd": "^7.27.1", - "@babel/plugin-transform-modules-commonjs": "^7.27.1", - "@babel/plugin-transform-modules-systemjs": "^7.27.1", - "@babel/plugin-transform-modules-umd": "^7.27.1", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.27.1", - "@babel/plugin-transform-new-target": "^7.27.1", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1", - "@babel/plugin-transform-numeric-separator": "^7.27.1", - "@babel/plugin-transform-object-rest-spread": "^7.28.0", - "@babel/plugin-transform-object-super": "^7.27.1", - "@babel/plugin-transform-optional-catch-binding": "^7.27.1", - "@babel/plugin-transform-optional-chaining": "^7.27.1", - "@babel/plugin-transform-parameters": "^7.27.7", - "@babel/plugin-transform-private-methods": "^7.27.1", - "@babel/plugin-transform-private-property-in-object": "^7.27.1", - "@babel/plugin-transform-property-literals": "^7.27.1", - "@babel/plugin-transform-regenerator": "^7.28.0", - "@babel/plugin-transform-regexp-modifiers": "^7.27.1", - "@babel/plugin-transform-reserved-words": "^7.27.1", - "@babel/plugin-transform-shorthand-properties": "^7.27.1", - "@babel/plugin-transform-spread": "^7.27.1", - "@babel/plugin-transform-sticky-regex": "^7.27.1", - "@babel/plugin-transform-template-literals": "^7.27.1", - "@babel/plugin-transform-typeof-symbol": "^7.27.1", - "@babel/plugin-transform-unicode-escapes": "^7.27.1", - "@babel/plugin-transform-unicode-property-regex": "^7.27.1", - "@babel/plugin-transform-unicode-regex": "^7.27.1", - "@babel/plugin-transform-unicode-sets-regex": "^7.27.1", - "@babel/preset-modules": "0.1.6-no-external-plugins", - "babel-plugin-polyfill-corejs2": "^0.4.14", - "babel-plugin-polyfill-corejs3": "^0.13.0", - "babel-plugin-polyfill-regenerator": "^0.6.5", - "core-js-compat": "^3.43.0", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/preset-modules": { - "version": "0.1.6-no-external-plugins", - "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", - "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.0.0", - "@babel/types": "^7.4.4", - "esutils": "^2.0.2" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/@babel/preset-react": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.27.1.tgz", - "integrity": "sha512-oJHWh2gLhU9dW9HHr42q0cI0/iHHXTLGe39qvpAZZzagHy0MzYLCnCVV0symeRvzmjHyVU7mw2K06E6u/JwbhA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-validator-option": "^7.27.1", - "@babel/plugin-transform-react-display-name": "^7.27.1", - "@babel/plugin-transform-react-jsx": "^7.27.1", - "@babel/plugin-transform-react-jsx-development": "^7.27.1", - "@babel/plugin-transform-react-pure-annotations": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/preset-typescript": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.1.tgz", - "integrity": "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-validator-option": "^7.27.1", - "@babel/plugin-syntax-jsx": "^7.27.1", - "@babel/plugin-transform-modules-commonjs": "^7.27.1", - "@babel/plugin-transform-typescript": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/runtime": { - "version": "7.27.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", - "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz", - "integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.0", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.0", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.0.tgz", - "integrity": "sha512-jYnje+JyZG5YThjHiF28oT4SIZLnYOcSBb6+SDaFIyzDVSkXQmQQYclJ2R+YxcdmK0AX6x1E5OQNtuh3jHDrUg==", - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@discoveryjs/json-ext": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", - "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/@emotion/babel-plugin": { - "version": "11.13.5", - "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", - "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.16.7", - "@babel/runtime": "^7.18.3", - "@emotion/hash": "^0.9.2", - "@emotion/memoize": "^0.9.0", - "@emotion/serialize": "^1.3.3", - "babel-plugin-macros": "^3.1.0", - "convert-source-map": "^1.5.0", - "escape-string-regexp": "^4.0.0", - "find-root": "^1.1.0", - "source-map": "^0.5.7", - "stylis": "4.2.0" - } - }, - "node_modules/@emotion/babel-plugin/node_modules/convert-source-map": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", - "license": "MIT" - }, - "node_modules/@emotion/babel-plugin/node_modules/source-map": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/@emotion/cache": { - "version": "11.14.0", - "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", - "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", - "license": "MIT", - "dependencies": { - "@emotion/memoize": "^0.9.0", - "@emotion/sheet": "^1.4.0", - "@emotion/utils": "^1.4.2", - "@emotion/weak-memoize": "^0.4.0", - "stylis": "4.2.0" - } - }, - "node_modules/@emotion/core": { - "version": "10.3.1", - "resolved": "https://registry.npmjs.org/@emotion/core/-/core-10.3.1.tgz", - "integrity": "sha512-447aUEjPIm0MnE6QYIaFz9VQOHSXf4Iu6EWOIqq11EAPqinkSZmfymPTmlOE3QjLv846lH4JVZBUOtwGbuQoww==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.5.5", - "@emotion/cache": "^10.0.27", - "@emotion/css": "^10.0.27", - "@emotion/serialize": "^0.11.15", - "@emotion/sheet": "0.9.4", - "@emotion/utils": "0.11.3" - }, - "peerDependencies": { - "react": ">=16.3.0" - } - }, - "node_modules/@emotion/core/node_modules/@emotion/cache": { - "version": "10.0.29", - "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-10.0.29.tgz", - "integrity": "sha512-fU2VtSVlHiF27empSbxi1O2JFdNWZO+2NFHfwO0pxgTep6Xa3uGb+3pVKfLww2l/IBGLNEZl5Xf/++A4wAYDYQ==", - "license": "MIT", - "dependencies": { - "@emotion/sheet": "0.9.4", - "@emotion/stylis": "0.8.5", - "@emotion/utils": "0.11.3", - "@emotion/weak-memoize": "0.2.5" - } - }, - "node_modules/@emotion/core/node_modules/@emotion/hash": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", - "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", - "license": "MIT" - }, - "node_modules/@emotion/core/node_modules/@emotion/memoize": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz", - "integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==", - "license": "MIT" - }, - "node_modules/@emotion/core/node_modules/@emotion/serialize": { - "version": "0.11.16", - "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-0.11.16.tgz", - "integrity": "sha512-G3J4o8by0VRrO+PFeSc3js2myYNOXVJ3Ya+RGVxnshRYgsvErfAOglKAiy1Eo1vhzxqtUvjCyS5gtewzkmvSSg==", - "license": "MIT", - "dependencies": { - "@emotion/hash": "0.8.0", - "@emotion/memoize": "0.7.4", - "@emotion/unitless": "0.7.5", - "@emotion/utils": "0.11.3", - "csstype": "^2.5.7" - } - }, - "node_modules/@emotion/core/node_modules/@emotion/sheet": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-0.9.4.tgz", - "integrity": "sha512-zM9PFmgVSqBw4zL101Q0HrBVTGmpAxFZH/pYx/cjJT5advXguvcgjHFTCaIO3enL/xr89vK2bh0Mfyj9aa0ANA==", - "license": "MIT" - }, - "node_modules/@emotion/core/node_modules/@emotion/unitless": { - "version": "0.7.5", - "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", - "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", - "license": "MIT" - }, - "node_modules/@emotion/core/node_modules/@emotion/utils": { - "version": "0.11.3", - "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-0.11.3.tgz", - "integrity": "sha512-0o4l6pZC+hI88+bzuaX/6BgOvQVhbt2PfmxauVaYOGgbsAw14wdKyvMCZXnsnsHys94iadcF+RG/wZyx6+ZZBw==", - "license": "MIT" - }, - "node_modules/@emotion/core/node_modules/@emotion/weak-memoize": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.2.5.tgz", - "integrity": "sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA==", - "license": "MIT" - }, - "node_modules/@emotion/core/node_modules/csstype": { - "version": "2.6.21", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.21.tgz", - "integrity": "sha512-Z1PhmomIfypOpoMjRQB70jfvy/wxT50qW08YXO5lMIJkrdq4yOTR+AW7FqutScmB9NkLwxo+jU+kZLbofZZq/w==", - "license": "MIT" - }, - "node_modules/@emotion/css": { - "version": "10.0.27", - "resolved": "https://registry.npmjs.org/@emotion/css/-/css-10.0.27.tgz", - "integrity": "sha512-6wZjsvYeBhyZQYNrGoR5yPMYbMBNEnanDrqmsqS1mzDm1cOTu12shvl2j4QHNS36UaTE0USIJawCH9C8oW34Zw==", - "license": "MIT", - "dependencies": { - "@emotion/serialize": "^0.11.15", - "@emotion/utils": "0.11.3", - "babel-plugin-emotion": "^10.0.27" - } - }, - "node_modules/@emotion/css/node_modules/@emotion/hash": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", - "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", - "license": "MIT" - }, - "node_modules/@emotion/css/node_modules/@emotion/memoize": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz", - "integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==", - "license": "MIT" - }, - "node_modules/@emotion/css/node_modules/@emotion/serialize": { - "version": "0.11.16", - "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-0.11.16.tgz", - "integrity": "sha512-G3J4o8by0VRrO+PFeSc3js2myYNOXVJ3Ya+RGVxnshRYgsvErfAOglKAiy1Eo1vhzxqtUvjCyS5gtewzkmvSSg==", - "license": "MIT", - "dependencies": { - "@emotion/hash": "0.8.0", - "@emotion/memoize": "0.7.4", - "@emotion/unitless": "0.7.5", - "@emotion/utils": "0.11.3", - "csstype": "^2.5.7" - } - }, - "node_modules/@emotion/css/node_modules/@emotion/unitless": { - "version": "0.7.5", - "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", - "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", - "license": "MIT" - }, - "node_modules/@emotion/css/node_modules/@emotion/utils": { - "version": "0.11.3", - "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-0.11.3.tgz", - "integrity": "sha512-0o4l6pZC+hI88+bzuaX/6BgOvQVhbt2PfmxauVaYOGgbsAw14wdKyvMCZXnsnsHys94iadcF+RG/wZyx6+ZZBw==", - "license": "MIT" - }, - "node_modules/@emotion/css/node_modules/csstype": { - "version": "2.6.21", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.21.tgz", - "integrity": "sha512-Z1PhmomIfypOpoMjRQB70jfvy/wxT50qW08YXO5lMIJkrdq4yOTR+AW7FqutScmB9NkLwxo+jU+kZLbofZZq/w==", - "license": "MIT" - }, - "node_modules/@emotion/hash": { - "version": "0.9.2", - "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", - "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", - "license": "MIT" - }, - "node_modules/@emotion/is-prop-valid": { - "version": "0.8.8", - "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz", - "integrity": "sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==", - "license": "MIT", - "dependencies": { - "@emotion/memoize": "0.7.4" - } - }, - "node_modules/@emotion/is-prop-valid/node_modules/@emotion/memoize": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz", - "integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==", - "license": "MIT" - }, - "node_modules/@emotion/memoize": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", - "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", - "license": "MIT" - }, - "node_modules/@emotion/react": { - "version": "11.14.0", - "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", - "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.3", - "@emotion/babel-plugin": "^11.13.5", - "@emotion/cache": "^11.14.0", - "@emotion/serialize": "^1.3.3", - "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", - "@emotion/utils": "^1.4.2", - "@emotion/weak-memoize": "^0.4.0", - "hoist-non-react-statics": "^3.3.1" - }, - "peerDependencies": { - "react": ">=16.8.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@emotion/serialize": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", - "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", - "license": "MIT", - "dependencies": { - "@emotion/hash": "^0.9.2", - "@emotion/memoize": "^0.9.0", - "@emotion/unitless": "^0.10.0", - "@emotion/utils": "^1.4.2", - "csstype": "^3.0.2" - } - }, - "node_modules/@emotion/sheet": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", - "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", - "license": "MIT" - }, - "node_modules/@emotion/styled": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-10.3.0.tgz", - "integrity": "sha512-GgcUpXBBEU5ido+/p/mCT2/Xx+Oqmp9JzQRuC+a4lYM4i4LBBn/dWvc0rQ19N9ObA8/T4NWMrPNe79kMBDJqoQ==", - "license": "MIT", - "dependencies": { - "@emotion/styled-base": "^10.3.0", - "babel-plugin-emotion": "^10.0.27" - }, - "peerDependencies": { - "@emotion/core": "^10.0.27", - "react": ">=16.3.0" - } - }, - "node_modules/@emotion/styled-base": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/@emotion/styled-base/-/styled-base-10.3.0.tgz", - "integrity": "sha512-PBRqsVKR7QRNkmfH78hTSSwHWcwDpecH9W6heujWAcyp2wdz/64PP73s7fWS1dIPm8/Exc8JAzYS8dEWXjv60w==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.5.5", - "@emotion/is-prop-valid": "0.8.8", - "@emotion/serialize": "^0.11.15", - "@emotion/utils": "0.11.3" - }, - "peerDependencies": { - "@emotion/core": "^10.0.28", - "react": ">=16.3.0" - } - }, - "node_modules/@emotion/styled-base/node_modules/@emotion/hash": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", - "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", - "license": "MIT" - }, - "node_modules/@emotion/styled-base/node_modules/@emotion/memoize": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz", - "integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==", - "license": "MIT" - }, - "node_modules/@emotion/styled-base/node_modules/@emotion/serialize": { - "version": "0.11.16", - "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-0.11.16.tgz", - "integrity": "sha512-G3J4o8by0VRrO+PFeSc3js2myYNOXVJ3Ya+RGVxnshRYgsvErfAOglKAiy1Eo1vhzxqtUvjCyS5gtewzkmvSSg==", - "license": "MIT", - "dependencies": { - "@emotion/hash": "0.8.0", - "@emotion/memoize": "0.7.4", - "@emotion/unitless": "0.7.5", - "@emotion/utils": "0.11.3", - "csstype": "^2.5.7" - } - }, - "node_modules/@emotion/styled-base/node_modules/@emotion/unitless": { - "version": "0.7.5", - "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", - "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", - "license": "MIT" - }, - "node_modules/@emotion/styled-base/node_modules/@emotion/utils": { - "version": "0.11.3", - "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-0.11.3.tgz", - "integrity": "sha512-0o4l6pZC+hI88+bzuaX/6BgOvQVhbt2PfmxauVaYOGgbsAw14wdKyvMCZXnsnsHys94iadcF+RG/wZyx6+ZZBw==", - "license": "MIT" - }, - "node_modules/@emotion/styled-base/node_modules/csstype": { - "version": "2.6.21", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.21.tgz", - "integrity": "sha512-Z1PhmomIfypOpoMjRQB70jfvy/wxT50qW08YXO5lMIJkrdq4yOTR+AW7FqutScmB9NkLwxo+jU+kZLbofZZq/w==", - "license": "MIT" - }, - "node_modules/@emotion/stylis": { - "version": "0.8.5", - "resolved": "https://registry.npmjs.org/@emotion/stylis/-/stylis-0.8.5.tgz", - "integrity": "sha512-h6KtPihKFn3T9fuIrwvXXUOwlx3rfUvfZIcP5a6rh8Y7zjE3O06hT5Ss4S/YI1AYhuZ1kjaE/5EaOOI2NqSylQ==", - "license": "MIT" - }, - "node_modules/@emotion/unitless": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", - "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", - "license": "MIT" - }, - "node_modules/@emotion/use-insertion-effect-with-fallbacks": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", - "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", - "license": "MIT", - "peerDependencies": { - "react": ">=16.8.0" - } - }, - "node_modules/@emotion/utils": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", - "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", - "license": "MIT" - }, - "node_modules/@emotion/weak-memoize": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", - "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", - "license": "MIT" - }, - "node_modules/@floating-ui/core": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", - "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", - "license": "MIT", - "dependencies": { - "@floating-ui/utils": "^0.2.10" - } - }, - "node_modules/@floating-ui/dom": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", - "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", - "license": "MIT", - "dependencies": { - "@floating-ui/core": "^1.7.3", - "@floating-ui/utils": "^0.2.10" - } - }, - "node_modules/@floating-ui/react": { - "version": "0.26.28", - "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz", - "integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==", - "license": "MIT", - "dependencies": { - "@floating-ui/react-dom": "^2.1.2", - "@floating-ui/utils": "^0.2.8", - "tabbable": "^6.0.0" - }, - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, - "node_modules/@floating-ui/react-dom": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", - "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", - "license": "MIT", - "dependencies": { - "@floating-ui/dom": "^1.7.4" - }, - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, - "node_modules/@floating-ui/utils": { - "version": "0.2.10", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", - "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", - "license": "MIT" - }, - "node_modules/@hello-pangea/dnd": { - "version": "18.0.1", - "resolved": "https://registry.npmjs.org/@hello-pangea/dnd/-/dnd-18.0.1.tgz", - "integrity": "sha512-xojVWG8s/TGrKT1fC8K2tIWeejJYTAeJuj36zM//yEm/ZrnZUSFGS15BpO+jGZT1ybWvyXmeDJwPYb4dhWlbZQ==", - "license": "Apache-2.0", - "dependencies": { - "@babel/runtime": "^7.26.7", - "css-box-model": "^1.2.1", - "raf-schd": "^4.0.3", - "react-redux": "^9.2.0", - "redux": "^5.0.1" - }, - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, - "node_modules/@icons/material": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@icons/material/-/material-0.2.4.tgz", - "integrity": "sha512-QPcGmICAPbGLGb6F/yNf/KzKqvFx8z5qx3D1yFqVAjoFmXK35EgyW+cJ57Te3CNsmzblwtzakLGFqHPqrfb4Tw==", - "license": "MIT", - "peerDependencies": { - "react": "*" - } - }, - "node_modules/@isaacs/balanced-match": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@isaacs/balanced-match": "^4.0.1" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/@jest/diff-sequences": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz", - "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/expect-utils": { - "version": "30.0.4", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.0.4.tgz", - "integrity": "sha512-EgXecHDNfANeqOkcak0DxsoVI4qkDUsR7n/Lr2vtmTBjwLPBnnPOF71S11Q8IObWzxm2QgQoY6f9hzrRD3gHRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/get-type": "30.0.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/get-type": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.0.1.tgz", - "integrity": "sha512-AyYdemXCptSRFirI5EPazNxyPwAL0jXt3zceFjaj8NFiKP9pOi0bfXonf6qkf82z2t3QWPeLCWWw4stPBzctLw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/pattern": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", - "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "jest-regex-util": "30.0.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/schemas": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.1.tgz", - "integrity": "sha512-+g/1TKjFuGrf1Hh0QPCv0gISwBxJ+MQSNXmG9zjHy7BmFhtoJ9fdNhWJp3qUKRi93AOZHXtdxZgJ1vAtz6z65w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sinclair/typebox": "^0.34.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/types": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.0.1.tgz", - "integrity": "sha512-HGwoYRVF0QSKJu1ZQX0o5ZrUrrhj0aOOFA8hXrumD7SIzjouevhawbTjmXdwOmURdGluU9DM/XvGm3NyFoiQjw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/pattern": "30.0.1", - "@jest/schemas": "30.0.1", - "@types/istanbul-lib-coverage": "^2.0.6", - "@types/istanbul-reports": "^3.0.4", - "@types/node": "*", - "@types/yargs": "^17.0.33", - "chalk": "^4.1.2" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.12", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", - "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.10", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.10.tgz", - "integrity": "sha512-0pPkgz9dY+bijgistcTTJ5mR+ocqRXLuhXHYdzoMmmoJ2C9S46RCm2GMUbatPEUK9Yjy26IrAy8D/M00lLkv+Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", - "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.29", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", - "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@jsonjoy.com/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/json-pack": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.2.0.tgz", - "integrity": "sha512-io1zEbbYcElht3tdlqEOFxZ0dMTYrHz9iMf0gqn1pPjZFTCgM5R4R5IMA20Chb2UPYYsxjzs8CgZ7Nb5n2K2rA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jsonjoy.com/base64": "^1.1.1", - "@jsonjoy.com/util": "^1.1.2", - "hyperdyperid": "^1.2.0", - "thingies": "^1.20.0" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/util": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.6.0.tgz", - "integrity": "sha512-sw/RMbehRhN68WRtcKCpQOPfnH6lLP4GJfqzi3iYej8tnzpZUDr6UkZYJjcjjC0FWEJOJbyM3PTIwxucUmDG2A==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@labkey/api": { - "version": "1.43.0", - "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/api/-/@labkey/api-1.43.0.tgz", - "integrity": "sha512-4hOQz+pM/QaCey6ooJEmEbElnR9+TDEzWG+8caFfeIX1iAg1335NXW3+/Xzs6a+L9ysRKds8bNgFPu2sxjPzfg==", - "license": "Apache-2.0" - }, - "node_modules/@labkey/build": { - "version": "8.6.0", - "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/build/-/@labkey/build-8.6.0.tgz", - "integrity": "sha512-rAb/cQomhlL4GamJkI8/V2lHUGwUNRh7n1uXFMeHfUmuiXP/adp9uqhCC+yTAcaAK0kdq6Z3vU/n7VvCEdjtMQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@babel/core": "~7.28.0", - "@babel/plugin-transform-class-properties": "~7.27.1", - "@babel/plugin-transform-object-rest-spread": "~7.28.0", - "@babel/preset-env": "~7.28.0", - "@babel/preset-react": "~7.27.1", - "@babel/preset-typescript": "~7.27.1", - "@pmmmwh/react-refresh-webpack-plugin": "~0.6.1", - "ajv": "~8.17.1", - "babel-loader": "~10.0.0", - "bootstrap-sass": "~3.4.3", - "copy-webpack-plugin": "~13.0.0", - "cross-env": "~7.0.3", - "css-loader": "~7.1.2", - "fork-ts-checker-webpack-plugin": "~9.1.0", - "html-webpack-plugin": "~5.6.3", - "mini-css-extract-plugin": "~2.9.2", - "react-refresh": "~0.17.0", - "resolve-url-loader": "~5.0.0", - "rimraf": "~6.0.1", - "sass": "~1.79.6", - "sass-loader": "~16.0.5", - "source-map-loader": "~5.0.0", - "style-loader": "~4.0.0", - "typescript": "~5.8.3", - "webpack": "~5.100.0", - "webpack-bundle-analyzer": "~4.10.2", - "webpack-cli": "~6.0.1", - "webpack-dev-server": "~5.2.2" - } - }, - "node_modules/@labkey/components": { - "version": "6.64.0", - "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-6.64.0.tgz", - "integrity": "sha512-PvaxxI03mJ64L/F0FFWrtHDFwrFiyYm+/w/uyCTjFv/RZ/A+CIjkb5+v4iaQyAzJPUr1HzVPUfDlNxWkd3r2OQ==", - "license": "SEE LICENSE IN LICENSE.txt", - "dependencies": { - "@hello-pangea/dnd": "18.0.1", - "@labkey/api": "1.43.0", - "@testing-library/dom": "~10.4.0", - "@testing-library/jest-dom": "~6.6.3", - "@testing-library/react": "~16.3.0", - "@testing-library/user-event": "~14.6.1", - "bootstrap": "~3.4.1", - "classnames": "~2.5.1", - "date-fns": "~3.6.0", - "date-fns-tz": "~3.2.0", - "font-awesome": "~4.7.0", - "immer": "~10.1.1", - "immutable": "~3.8.2", - "normalizr": "~3.6.2", - "numeral": "~2.0.6", - "react": "~18.3.1", - "react-color": "~2.19.3", - "react-datepicker": "~7.5.0", - "react-dom": "~18.3.1", - "react-router-dom": "~6.30.1", - "react-select": "~5.10.1", - "react-treebeard": "~3.2.4", - "vis-data": "~7.1.10", - "vis-network": "~9.1.13" - } - }, - "node_modules/@leichtgewicht/ip-codec": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", - "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@parcel/watcher": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", - "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "detect-libc": "^1.0.3", - "is-glob": "^4.0.3", - "micromatch": "^4.0.5", - "node-addon-api": "^7.0.0" - }, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "@parcel/watcher-android-arm64": "2.5.1", - "@parcel/watcher-darwin-arm64": "2.5.1", - "@parcel/watcher-darwin-x64": "2.5.1", - "@parcel/watcher-freebsd-x64": "2.5.1", - "@parcel/watcher-linux-arm-glibc": "2.5.1", - "@parcel/watcher-linux-arm-musl": "2.5.1", - "@parcel/watcher-linux-arm64-glibc": "2.5.1", - "@parcel/watcher-linux-arm64-musl": "2.5.1", - "@parcel/watcher-linux-x64-glibc": "2.5.1", - "@parcel/watcher-linux-x64-musl": "2.5.1", - "@parcel/watcher-win32-arm64": "2.5.1", - "@parcel/watcher-win32-ia32": "2.5.1", - "@parcel/watcher-win32-x64": "2.5.1" - } - }, - "node_modules/@parcel/watcher-android-arm64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", - "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-darwin-arm64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", - "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-darwin-x64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", - "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-freebsd-x64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", - "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm-glibc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", - "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm-musl": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", - "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm64-glibc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", - "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm64-musl": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", - "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-x64-glibc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", - "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-x64-musl": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", - "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-arm64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", - "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-ia32": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", - "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-x64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", - "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@pmmmwh/react-refresh-webpack-plugin": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.6.1.tgz", - "integrity": "sha512-95DXXJxNkpYu+sqmpDp7vbw9JCyiNpHuCsvuMuOgVFrKQlwEIn9Y1+NNIQJq+zFL+eWyxw6htthB5CtdwJupNA==", - "dev": true, - "license": "MIT", - "dependencies": { - "anser": "^2.1.1", - "core-js-pure": "^3.23.3", - "error-stack-parser": "^2.0.6", - "html-entities": "^2.1.0", - "schema-utils": "^4.2.0", - "source-map": "^0.7.3" - }, - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "@types/webpack": "5.x", - "react-refresh": ">=0.10.0 <1.0.0", - "sockjs-client": "^1.4.0", - "type-fest": ">=0.17.0 <5.0.0", - "webpack": "^5.0.0", - "webpack-dev-server": "^4.8.0 || 5.x", - "webpack-hot-middleware": "2.x", - "webpack-plugin-serve": "1.x" - }, - "peerDependenciesMeta": { - "@types/webpack": { - "optional": true - }, - "sockjs-client": { - "optional": true - }, - "type-fest": { - "optional": true - }, - "webpack-dev-server": { - "optional": true - }, - "webpack-hot-middleware": { - "optional": true - }, - "webpack-plugin-serve": { - "optional": true - } - } - }, - "node_modules/@polka/url": { - "version": "1.0.0-next.29", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", - "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", - "dev": true, - "license": "MIT" - }, - "node_modules/@remix-run/router": { - "version": "1.23.0", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz", - "integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@sinclair/typebox": { - "version": "0.34.37", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.37.tgz", - "integrity": "sha512-2TRuQVgQYfy+EzHRTIvkhv2ADEouJ2xNS/Vq+W5EuuewBdOrvATvljZTxHWZSTYr2sTjTHpGvucaGAt67S2akw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@testing-library/dom": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", - "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", - "chalk": "^4.1.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@testing-library/jest-dom": { - "version": "6.6.3", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.6.3.tgz", - "integrity": "sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==", - "license": "MIT", - "dependencies": { - "@adobe/css-tools": "^4.4.0", - "aria-query": "^5.0.0", - "chalk": "^3.0.0", - "css.escape": "^1.5.1", - "dom-accessibility-api": "^0.6.3", - "lodash": "^4.17.21", - "redent": "^3.0.0" - }, - "engines": { - "node": ">=14", - "npm": ">=6", - "yarn": ">=1" - } - }, - "node_modules/@testing-library/jest-dom/node_modules/chalk": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", - "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", - "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", - "license": "MIT" - }, - "node_modules/@testing-library/react": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz", - "integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.5" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@testing-library/dom": "^10.0.0", - "@types/react": "^18.0.0 || ^19.0.0", - "@types/react-dom": "^18.0.0 || ^19.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@testing-library/user-event": { - "version": "14.6.1", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", - "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", - "license": "MIT", - "engines": { - "node": ">=12", - "npm": ">=6" - }, - "peerDependencies": { - "@testing-library/dom": ">=7.21.4" - } - }, - "node_modules/@types/aria-query": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", - "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "license": "MIT" - }, - "node_modules/@types/body-parser": { - "version": "1.19.6", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", - "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/connect": "*", - "@types/node": "*" - } - }, - "node_modules/@types/bonjour": { - "version": "3.5.13", - "resolved": "https://registry.npmjs.org/@types/bonjour/-/bonjour-3.5.13.tgz", - "integrity": "sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/connect-history-api-fallback": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.5.4.tgz", - "integrity": "sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/express-serve-static-core": "*", - "@types/node": "*" - } - }, - "node_modules/@types/eslint": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", - "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, - "node_modules/@types/eslint-scope": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", - "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/eslint": "*", - "@types/estree": "*" - } - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/express": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz", - "integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.33", - "@types/qs": "*", - "@types/serve-static": "*" - } - }, - "node_modules/@types/express-serve-static-core": { - "version": "4.19.6", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", - "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, - "node_modules/@types/html-minifier-terser": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", - "integrity": "sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/http-errors": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", - "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/http-proxy": { - "version": "1.17.16", - "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.16.tgz", - "integrity": "sha512-sdWoUajOB1cd0A8cRRQ1cfyWNbmFKLAqBB89Y8x5iYyG/mkJHc0YUH8pdWBy2omi9qtCpiIgGjuwO0dQST2l5w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/istanbul-lib-coverage": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", - "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/istanbul-lib-report": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", - "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/istanbul-lib-coverage": "*" - } - }, - "node_modules/@types/istanbul-reports": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", - "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/istanbul-lib-report": "*" - } - }, - "node_modules/@types/jest": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", - "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", - "dev": true, - "license": "MIT", - "dependencies": { - "expect": "^30.0.0", - "pretty-format": "^30.0.0" - } - }, - "node_modules/@types/jest/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@types/jest/node_modules/pretty-format": { - "version": "30.0.2", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.2.tgz", - "integrity": "sha512-yC5/EBSOrTtqhCKfLHqoUIAXVRZnukHPwWBJWR7h84Q3Be1DRQZLncwcfLoPA5RPQ65qfiCMqgYwdUuQ//eVpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "30.0.1", - "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@types/jest/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "24.0.12", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.12.tgz", - "integrity": "sha512-LtOrbvDf5ndC9Xi+4QZjVL0woFymF/xSTKZKPgrrl7H7XoeDvnD+E2IclKVDyaK9UM756W/3BXqSU+JEHopA9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.8.0" - } - }, - "node_modules/@types/node-forge": { - "version": "1.3.12", - "resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.12.tgz", - "integrity": "sha512-a0ToKlRVnUw3aXKQq2F+krxZKq7B8LEQijzPn5RdFAMatARD2JX9o8FBpMXOOrjob0uc13aN+V/AXniOXW4d9A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/parse-json": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", - "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", - "license": "MIT" - }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/range-parser": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/react": { - "version": "18.3.23", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz", - "integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/prop-types": "*", - "csstype": "^3.0.2" - } - }, - "node_modules/@types/react-dom": { - "version": "18.3.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", - "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^18.0.0" - } - }, - "node_modules/@types/react-transition-group": { - "version": "4.4.12", - "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", - "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*" - } - }, - "node_modules/@types/retry": { - "version": "0.12.2", - "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz", - "integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/send": { - "version": "0.17.5", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", - "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/mime": "^1", - "@types/node": "*" - } - }, - "node_modules/@types/serve-index": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@types/serve-index/-/serve-index-1.9.4.tgz", - "integrity": "sha512-qLpGZ/c2fhSs5gnYsQxtDEq3Oy8SXPClIXkW5ghvAvsNuVSA8k+gCONcUCS/UjLEYvYps+e8uBtfgXgvhwfNug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/express": "*" - } - }, - "node_modules/@types/serve-static": { - "version": "1.15.8", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.8.tgz", - "integrity": "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/http-errors": "*", - "@types/node": "*", - "@types/send": "*" - } - }, - "node_modules/@types/sockjs": { - "version": "0.3.36", - "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.36.tgz", - "integrity": "sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/stack-utils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", - "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/use-sync-external-store": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", - "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", - "license": "MIT" - }, - "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/yargs": { - "version": "17.0.33", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", - "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/yargs-parser": "*" - } - }, - "node_modules/@types/yargs-parser": { - "version": "21.0.3", - "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", - "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@webassemblyjs/ast": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", - "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@webassemblyjs/helper-numbers": "1.13.2", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2" - } - }, - "node_modules/@webassemblyjs/floating-point-hex-parser": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", - "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@webassemblyjs/helper-api-error": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", - "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@webassemblyjs/helper-buffer": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", - "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@webassemblyjs/helper-numbers": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", - "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@webassemblyjs/floating-point-hex-parser": "1.13.2", - "@webassemblyjs/helper-api-error": "1.13.2", - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webassemblyjs/helper-wasm-bytecode": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", - "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@webassemblyjs/helper-wasm-section": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", - "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/wasm-gen": "1.14.1" - } - }, - "node_modules/@webassemblyjs/ieee754": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", - "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@xtuc/ieee754": "^1.2.0" - } - }, - "node_modules/@webassemblyjs/leb128": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", - "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webassemblyjs/utf8": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", - "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@webassemblyjs/wasm-edit": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", - "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/helper-wasm-section": "1.14.1", - "@webassemblyjs/wasm-gen": "1.14.1", - "@webassemblyjs/wasm-opt": "1.14.1", - "@webassemblyjs/wasm-parser": "1.14.1", - "@webassemblyjs/wast-printer": "1.14.1" - } - }, - "node_modules/@webassemblyjs/wasm-gen": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", - "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/ieee754": "1.13.2", - "@webassemblyjs/leb128": "1.13.2", - "@webassemblyjs/utf8": "1.13.2" - } - }, - "node_modules/@webassemblyjs/wasm-opt": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", - "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/wasm-gen": "1.14.1", - "@webassemblyjs/wasm-parser": "1.14.1" - } - }, - "node_modules/@webassemblyjs/wasm-parser": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", - "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-api-error": "1.13.2", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/ieee754": "1.13.2", - "@webassemblyjs/leb128": "1.13.2", - "@webassemblyjs/utf8": "1.13.2" - } - }, - "node_modules/@webassemblyjs/wast-printer": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", - "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webpack-cli/configtest": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-3.0.1.tgz", - "integrity": "sha512-u8d0pJ5YFgneF/GuvEiDA61Tf1VDomHHYMjv/wc9XzYj7nopltpG96nXN5dJRstxZhcNpV1g+nT6CydO7pHbjA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12.0" - }, - "peerDependencies": { - "webpack": "^5.82.0", - "webpack-cli": "6.x.x" - } - }, - "node_modules/@webpack-cli/info": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@webpack-cli/info/-/info-3.0.1.tgz", - "integrity": "sha512-coEmDzc2u/ffMvuW9aCjoRzNSPDl/XLuhPdlFRpT9tZHmJ/039az33CE7uH+8s0uL1j5ZNtfdv0HkfaKRBGJsQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12.0" - }, - "peerDependencies": { - "webpack": "^5.82.0", - "webpack-cli": "6.x.x" - } - }, - "node_modules/@webpack-cli/serve": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-3.0.1.tgz", - "integrity": "sha512-sbgw03xQaCLiT6gcY/6u3qBDn01CWw/nbaXl3gTdTFuJJ75Gffv3E3DBpgvY2fkkrdS1fpjaXNOmJlnbtKauKg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12.0" - }, - "peerDependencies": { - "webpack": "^5.82.0", - "webpack-cli": "6.x.x" - }, - "peerDependenciesMeta": { - "webpack-dev-server": { - "optional": true - } - } - }, - "node_modules/@xtuc/ieee754": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", - "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@xtuc/long": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", - "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/accepts/node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-import-phases": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.3.tgz", - "integrity": "sha512-jtKLnfoOzm28PazuQ4dVBcE9Jeo6ha1GAJvq3N0LlNOszmTfx+wSycBehn+FN0RnyeR77IBxN/qVYMw0Rlj0Xw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.13.0" - }, - "peerDependencies": { - "acorn": "^8.14.0" - } - }, - "node_modules/acorn-walk": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", - "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/adjust-sourcemap-loader": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/adjust-sourcemap-loader/-/adjust-sourcemap-loader-4.0.0.tgz", - "integrity": "sha512-OXwN5b9pCUXNQHJpwwD2qP40byEmSgzj8B4ydSN0uMNYWiFmJ6x6KwUllMmfk8Rwu/HJDFR7U8ubsWBoN0Xp0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "loader-utils": "^2.0.0", - "regex-parser": "^2.2.11" - }, - "engines": { - "node": ">=8.9" - } - }, - "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" - } - }, - "node_modules/anser": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/anser/-/anser-2.3.2.tgz", - "integrity": "sha512-PMqBCBvrOVDRqLGooQb+z+t1Q0PiPyurUQeZRR5uHBOVZcW8B04KMmnT12USnhpNX2wCPagWzLVppQMUG3u0Dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/ansi-html-community": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz", - "integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==", - "dev": true, - "engines": [ - "node >= 0.8.0" - ], - "license": "Apache-2.0", - "bin": { - "ansi-html": "bin/ansi-html" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "license": "Apache-2.0", - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/babel-loader": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-10.0.0.tgz", - "integrity": "sha512-z8jt+EdS61AMw22nSfoNJAZ0vrtmhPRVi6ghL3rCeRZI8cdNYFiV5xeV3HbE7rlZZNmGH8BVccwWt8/ED0QOHA==", - "dev": true, - "license": "MIT", - "dependencies": { - "find-up": "^5.0.0" - }, - "engines": { - "node": "^18.20.0 || ^20.10.0 || >=22.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.12.0", - "webpack": ">=5.61.0" - } - }, - "node_modules/babel-plugin-emotion": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/babel-plugin-emotion/-/babel-plugin-emotion-10.2.2.tgz", - "integrity": "sha512-SMSkGoqTbTyUTDeuVuPIWifPdUGkTk1Kf9BWRiXIOIcuyMfsdp2EjeiiFvOzX8NOBvEh/ypKYvUh2rkgAJMCLA==", - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.0.0", - "@emotion/hash": "0.8.0", - "@emotion/memoize": "0.7.4", - "@emotion/serialize": "^0.11.16", - "babel-plugin-macros": "^2.0.0", - "babel-plugin-syntax-jsx": "^6.18.0", - "convert-source-map": "^1.5.0", - "escape-string-regexp": "^1.0.5", - "find-root": "^1.1.0", - "source-map": "^0.5.7" - } - }, - "node_modules/babel-plugin-emotion/node_modules/@emotion/hash": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", - "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", - "license": "MIT" - }, - "node_modules/babel-plugin-emotion/node_modules/@emotion/memoize": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz", - "integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==", - "license": "MIT" - }, - "node_modules/babel-plugin-emotion/node_modules/@emotion/serialize": { - "version": "0.11.16", - "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-0.11.16.tgz", - "integrity": "sha512-G3J4o8by0VRrO+PFeSc3js2myYNOXVJ3Ya+RGVxnshRYgsvErfAOglKAiy1Eo1vhzxqtUvjCyS5gtewzkmvSSg==", - "license": "MIT", - "dependencies": { - "@emotion/hash": "0.8.0", - "@emotion/memoize": "0.7.4", - "@emotion/unitless": "0.7.5", - "@emotion/utils": "0.11.3", - "csstype": "^2.5.7" - } - }, - "node_modules/babel-plugin-emotion/node_modules/@emotion/unitless": { - "version": "0.7.5", - "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", - "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", - "license": "MIT" - }, - "node_modules/babel-plugin-emotion/node_modules/@emotion/utils": { - "version": "0.11.3", - "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-0.11.3.tgz", - "integrity": "sha512-0o4l6pZC+hI88+bzuaX/6BgOvQVhbt2PfmxauVaYOGgbsAw14wdKyvMCZXnsnsHys94iadcF+RG/wZyx6+ZZBw==", - "license": "MIT" - }, - "node_modules/babel-plugin-emotion/node_modules/babel-plugin-macros": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-2.8.0.tgz", - "integrity": "sha512-SEP5kJpfGYqYKpBrj5XU3ahw5p5GOHJ0U5ssOSQ/WBVdwkD2Dzlce95exQTs3jOVWPPKLBN2rlEWkCK7dSmLvg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.7.2", - "cosmiconfig": "^6.0.0", - "resolve": "^1.12.0" - } - }, - "node_modules/babel-plugin-emotion/node_modules/convert-source-map": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", - "license": "MIT" - }, - "node_modules/babel-plugin-emotion/node_modules/cosmiconfig": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-6.0.0.tgz", - "integrity": "sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg==", - "license": "MIT", - "dependencies": { - "@types/parse-json": "^4.0.0", - "import-fresh": "^3.1.0", - "parse-json": "^5.0.0", - "path-type": "^4.0.0", - "yaml": "^1.7.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/babel-plugin-emotion/node_modules/csstype": { - "version": "2.6.21", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.21.tgz", - "integrity": "sha512-Z1PhmomIfypOpoMjRQB70jfvy/wxT50qW08YXO5lMIJkrdq4yOTR+AW7FqutScmB9NkLwxo+jU+kZLbofZZq/w==", - "license": "MIT" - }, - "node_modules/babel-plugin-emotion/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/babel-plugin-emotion/node_modules/source-map": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/babel-plugin-macros": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", - "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.5", - "cosmiconfig": "^7.0.0", - "resolve": "^1.19.0" - }, - "engines": { - "node": ">=10", - "npm": ">=6" - } - }, - "node_modules/babel-plugin-macros/node_modules/cosmiconfig": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", - "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", - "license": "MIT", - "dependencies": { - "@types/parse-json": "^4.0.0", - "import-fresh": "^3.2.1", - "parse-json": "^5.0.0", - "path-type": "^4.0.0", - "yaml": "^1.10.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/babel-plugin-polyfill-corejs2": { - "version": "0.4.14", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz", - "integrity": "sha512-Co2Y9wX854ts6U8gAAPXfn0GmAyctHuK8n0Yhfjd6t30g7yvKjspvvOo9yG+z52PZRgFErt7Ka2pYnXCjLKEpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.27.7", - "@babel/helper-define-polyfill-provider": "^0.6.5", - "semver": "^6.3.1" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz", - "integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.5", - "core-js-compat": "^3.43.0" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/babel-plugin-polyfill-regenerator": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.5.tgz", - "integrity": "sha512-ISqQ2frbiNU9vIJkzg7dlPpznPZ4jOiUQ1uSmB0fEHeowtN3COYRsXr/xexn64NpU13P06jc/L5TgiJXOgrbEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.5" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/babel-plugin-syntax-jsx": { - "version": "6.18.0", - "resolved": "https://registry.npmjs.org/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz", - "integrity": "sha512-qrPaCSo9c8RHNRHIotaufGbuOBN8rtdC4QrrFFc43vyWCCz7Kl7GL1PGaXtMGQZUXrkCjNEgxDfmAuAabr/rlw==", - "license": "MIT" - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/batch": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", - "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==", - "dev": true, - "license": "MIT" - }, - "node_modules/big.js": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", - "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/body-parser": { - "version": "1.20.3", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", - "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.13.0", - "raw-body": "2.5.2", - "type-is": "~1.6.18", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/body-parser/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/body-parser/node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/body-parser/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, - "node_modules/bonjour-service": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.3.0.tgz", - "integrity": "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "multicast-dns": "^7.2.5" - } - }, - "node_modules/boolbase": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", - "dev": true, - "license": "ISC" - }, - "node_modules/bootstrap": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-3.4.1.tgz", - "integrity": "sha512-yN5oZVmRCwe5aKwzRj6736nSmKDX7pLYwsXiCj/EYmo16hODaBiT4En5btW/jhBF/seV+XMx3aYwukYC3A49DA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/bootstrap-sass": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/bootstrap-sass/-/bootstrap-sass-3.4.3.tgz", - "integrity": "sha512-vPgFnGMp1jWZZupOND65WS6mkR8rxhJxndT/AcMbqcq1hHMdkcH4sMPhznLzzoHOHkSCrd6J9F8pWBriPCKP2Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.25.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", - "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "caniuse-lite": "^1.0.30001726", - "electron-to-chromium": "^1.5.173", - "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/bundle-name": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", - "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "run-applescript": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/camel-case": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", - "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "pascal-case": "^3.1.2", - "tslib": "^2.0.3" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001727", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz", - "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/chrome-trace-event": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", - "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0" - } - }, - "node_modules/ci-info": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.0.tgz", - "integrity": "sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/classnames": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", - "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", - "license": "MIT" - }, - "node_modules/clean-css": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz", - "integrity": "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "source-map": "~0.6.0" - }, - "engines": { - "node": ">= 10.0" - } - }, - "node_modules/clean-css/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/clone-deep": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", - "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-plain-object": "^2.0.4", - "kind-of": "^6.0.2", - "shallow-clone": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/colorette": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "dev": true, - "license": "MIT" - }, - "node_modules/commander": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", - "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/compressible": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", - "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": ">= 1.43.0 < 2" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/compression": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.0.tgz", - "integrity": "sha512-k6WLKfunuqCYD3t6AsuPGvQWaKwuLLh2/xHNcX4qE+vIfDNXpSqnrhwA7O53R7WVQUnt8dVAIW+YHr7xTgOgGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "3.1.2", - "compressible": "~2.0.18", - "debug": "2.6.9", - "negotiator": "~0.6.4", - "on-headers": "~1.0.2", - "safe-buffer": "5.2.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/compression/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/compression/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/connect-history-api-fallback": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz", - "integrity": "sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8" - } - }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cookie": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", - "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/copy-webpack-plugin": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-13.0.0.tgz", - "integrity": "sha512-FgR/h5a6hzJqATDGd9YG41SeDViH+0bkHn6WNXCi5zKAZkeESeSxLySSsFLHqLEVCh0E+rITmCf0dusXWYukeQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "glob-parent": "^6.0.1", - "normalize-path": "^3.0.0", - "schema-utils": "^4.2.0", - "serialize-javascript": "^6.0.2", - "tinyglobby": "^0.2.12" - }, - "engines": { - "node": ">= 18.12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.1.0" - } - }, - "node_modules/core-js-compat": { - "version": "3.44.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.44.0.tgz", - "integrity": "sha512-JepmAj2zfl6ogy34qfWtcE7nHKAJnKsQFRn++scjVS2bZFllwptzw61BZcZFYBPpUznLfAvh0LGhxKppk04ClA==", - "dev": true, - "license": "MIT", - "dependencies": { - "browserslist": "^4.25.1" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, - "node_modules/core-js-pure": { - "version": "3.44.0", - "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.44.0.tgz", - "integrity": "sha512-gvMQAGB4dfVUxpYD0k3Fq8J+n5bB6Ytl15lqlZrOIXFzxOhtPaObfkQGHtMRdyjIf7z2IeNULwi1jEwyS+ltKQ==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/cosmiconfig": { - "version": "8.3.6", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", - "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", - "dev": true, - "license": "MIT", - "dependencies": { - "import-fresh": "^3.3.0", - "js-yaml": "^4.1.0", - "parse-json": "^5.2.0", - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" - }, - "peerDependencies": { - "typescript": ">=4.9.5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/cross-env": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", - "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.1" - }, - "bin": { - "cross-env": "src/bin/cross-env.js", - "cross-env-shell": "src/bin/cross-env-shell.js" - }, - "engines": { - "node": ">=10.14", - "npm": ">=6", - "yarn": ">=1" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/css-box-model": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz", - "integrity": "sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==", - "license": "MIT", - "dependencies": { - "tiny-invariant": "^1.0.6" - } - }, - "node_modules/css-loader": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-7.1.2.tgz", - "integrity": "sha512-6WvYYn7l/XEGN8Xu2vWFt9nVzrCn39vKyTEFf/ExEyoksJjjSZV/0/35XPlMbpnr6VGhZIUg5yJrL8tGfes/FA==", - "dev": true, - "license": "MIT", - "dependencies": { - "icss-utils": "^5.1.0", - "postcss": "^8.4.33", - "postcss-modules-extract-imports": "^3.1.0", - "postcss-modules-local-by-default": "^4.0.5", - "postcss-modules-scope": "^3.2.0", - "postcss-modules-values": "^4.0.0", - "postcss-value-parser": "^4.2.0", - "semver": "^7.5.4" - }, - "engines": { - "node": ">= 18.12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "@rspack/core": "0.x || 1.x", - "webpack": "^5.27.0" - }, - "peerDependenciesMeta": { - "@rspack/core": { - "optional": true - }, - "webpack": { - "optional": true - } - } - }, - "node_modules/css-loader/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/css-select": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz", - "integrity": "sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0", - "css-what": "^6.0.1", - "domhandler": "^4.3.1", - "domutils": "^2.8.0", - "nth-check": "^2.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/css-what": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", - "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">= 6" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/css.escape": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", - "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", - "license": "MIT" - }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "license": "MIT" - }, - "node_modules/date-fns": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", - "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/kossnocorp" - } - }, - "node_modules/date-fns-tz": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-3.2.0.tgz", - "integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==", - "license": "MIT", - "peerDependencies": { - "date-fns": "^3.0.0 || ^4.0.0" - } - }, - "node_modules/debounce": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", - "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==", - "dev": true, - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-equal": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz", - "integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==", - "license": "MIT", - "dependencies": { - "is-arguments": "^1.1.1", - "is-date-object": "^1.0.5", - "is-regex": "^1.1.4", - "object-is": "^1.1.5", - "object-keys": "^1.1.1", - "regexp.prototype.flags": "^1.5.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/default-browser": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz", - "integrity": "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==", - "dev": true, - "license": "MIT", - "dependencies": { - "bundle-name": "^4.1.0", - "default-browser-id": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/default-browser-id": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.0.tgz", - "integrity": "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/define-lazy-prop": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", - "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "license": "MIT", - "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/detect-libc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", - "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "detect-libc": "bin/detect-libc.js" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/detect-node": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", - "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", - "dev": true, - "license": "MIT" - }, - "node_modules/dns-packet": { - "version": "5.6.1", - "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", - "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@leichtgewicht/ip-codec": "^2.0.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/dom-accessibility-api": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "license": "MIT" - }, - "node_modules/dom-converter": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz", - "integrity": "sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==", - "dev": true, - "license": "MIT", - "dependencies": { - "utila": "~0.4" - } - }, - "node_modules/dom-helpers": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", - "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.8.7", - "csstype": "^3.0.2" - } - }, - "node_modules/dom-serializer": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", - "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", - "dev": true, - "license": "MIT", - "dependencies": { - "domelementtype": "^2.0.1", - "domhandler": "^4.2.0", - "entities": "^2.0.0" - }, - "funding": { - "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" - } - }, - "node_modules/domelementtype": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "BSD-2-Clause" - }, - "node_modules/domhandler": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", - "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "domelementtype": "^2.2.0" - }, - "engines": { - "node": ">= 4" - }, - "funding": { - "url": "https://github.com/fb55/domhandler?sponsor=1" - } - }, - "node_modules/domutils": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", - "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "dom-serializer": "^1.0.1", - "domelementtype": "^2.2.0", - "domhandler": "^4.2.0" - }, - "funding": { - "url": "https://github.com/fb55/domutils?sponsor=1" - } - }, - "node_modules/dot-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", - "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", - "dev": true, - "license": "MIT", - "dependencies": { - "no-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/duplexer": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", - "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", - "dev": true, - "license": "MIT" - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "dev": true, - "license": "MIT" - }, - "node_modules/electron-to-chromium": { - "version": "1.5.181", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.181.tgz", - "integrity": "sha512-+ISMj8OIQ+0qEeDj14Rt8WwcTOiqHyAB+5bnK1K7xNNLjBJ4hRCQfUkw8RWtcLbfBzDwc15ZnKH0c7SNOfwiyA==", - "dev": true, - "license": "ISC" - }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/emojis-list": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", - "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/enhanced-resolve": { - "version": "5.18.2", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.2.tgz", - "integrity": "sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/entities": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", - "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", - "dev": true, - "license": "BSD-2-Clause", - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/envinfo": { - "version": "7.14.0", - "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.14.0.tgz", - "integrity": "sha512-CO40UI41xDQzhLB1hWyqUKgFhs250pNcGbyGKe1l/e4FSaI/+YE4IMG76GDt0In67WLPACIITC+sOi08x4wIvg==", - "dev": true, - "license": "MIT", - "bin": { - "envinfo": "dist/cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, - "node_modules/error-stack-parser": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", - "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "stackframe": "^1.3.4" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "dev": true, - "license": "MIT" - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esrecurse/node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/eventemitter3": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", - "dev": true, - "license": "MIT" - }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.x" - } - }, - "node_modules/expect": { - "version": "30.0.4", - "resolved": "https://registry.npmjs.org/expect/-/expect-30.0.4.tgz", - "integrity": "sha512-dDLGjnP2cKbEppxVICxI/Uf4YemmGMPNy0QytCbfafbpYk9AFQsxb8Uyrxii0RPK7FWgLGlSem+07WirwS3cFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/expect-utils": "30.0.4", - "@jest/get-type": "30.0.1", - "jest-matcher-utils": "30.0.4", - "jest-message-util": "30.0.2", - "jest-mock": "30.0.2", - "jest-util": "30.0.2" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/express": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", - "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "1.20.3", - "content-disposition": "0.5.4", - "content-type": "~1.0.4", - "cookie": "0.7.1", - "cookie-signature": "1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "1.3.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "0.1.12", - "proxy-addr": "~2.0.7", - "qs": "6.13.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "0.19.0", - "serve-static": "1.16.2", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/express/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-uri": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", - "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/fastest-levenshtein": { - "version": "1.0.16", - "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", - "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.9.1" - } - }, - "node_modules/faye-websocket": { - "version": "0.11.4", - "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", - "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "websocket-driver": ">=0.5.1" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/fdir": { - "version": "6.4.6", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", - "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/finalhandler": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", - "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "statuses": "2.0.1", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/finalhandler/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/finalhandler/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, - "node_modules/find-root": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", - "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", - "license": "MIT" - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", - "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", - "dev": true, - "license": "BSD-3-Clause", - "bin": { - "flat": "cli.js" - } - }, - "node_modules/follow-redirects": { - "version": "1.15.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", - "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/font-awesome": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/font-awesome/-/font-awesome-4.7.0.tgz", - "integrity": "sha512-U6kGnykA/6bFmg1M/oT9EkFeIYv7JlX3bozwQJWiiLz6L0w3F5vBVPxHlwyX/vtNq1ckcpRKOB9f2Qal/VtFpg==", - "license": "(OFL-1.1 AND MIT)", - "engines": { - "node": ">=0.10.3" - } - }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/fork-ts-checker-webpack-plugin": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-9.1.0.tgz", - "integrity": "sha512-mpafl89VFPJmhnJ1ssH+8wmM2b50n+Rew5x42NeI2U78aRWgtkEtGmctp7iT16UjquJTjorEmIfESj3DxdW84Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.16.7", - "chalk": "^4.1.2", - "chokidar": "^4.0.1", - "cosmiconfig": "^8.2.0", - "deepmerge": "^4.2.2", - "fs-extra": "^10.0.0", - "memfs": "^3.4.1", - "minimatch": "^3.0.4", - "node-abort-controller": "^3.0.1", - "schema-utils": "^3.1.1", - "semver": "^7.3.5", - "tapable": "^2.2.1" - }, - "engines": { - "node": ">=14.21.3" - }, - "peerDependencies": { - "typescript": ">3.6.0", - "webpack": "^5.11.0" - } - }, - "node_modules/fork-ts-checker-webpack-plugin/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/fork-ts-checker-webpack-plugin/node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "ajv": "^6.9.1" - } - }, - "node_modules/fork-ts-checker-webpack-plugin/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/fork-ts-checker-webpack-plugin/node_modules/schema-utils": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", - "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/fork-ts-checker-webpack-plugin/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/fs-monkey": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.6.tgz", - "integrity": "sha512-b1FMfwetIKymC0eioW7mTywihSQE4oLzQn1dB6rZB5fx/3NpNEdAWeCSMB+60/AeT0TCXsxzAlcYVEFCTAksWg==", - "dev": true, - "license": "Unlicense" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/functions-have-names": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/glob": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz", - "integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.3.1", - "jackspeak": "^4.1.1", - "minimatch": "^10.0.3", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^2.0.0" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/glob-to-regexp": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/glob/node_modules/minimatch": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz", - "integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==", - "dev": true, - "license": "ISC", - "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/gzip-size": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", - "integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "duplexer": "^0.1.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/handle-thing": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", - "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==", - "dev": true, - "license": "MIT" - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/he": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", - "dev": true, - "license": "MIT", - "bin": { - "he": "bin/he" - } - }, - "node_modules/hoist-non-react-statics": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", - "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", - "license": "BSD-3-Clause", - "dependencies": { - "react-is": "^16.7.0" - } - }, - "node_modules/hoist-non-react-statics/node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" - }, - "node_modules/hpack.js": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", - "integrity": "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.1", - "obuf": "^1.0.0", - "readable-stream": "^2.0.1", - "wbuf": "^1.1.0" - } - }, - "node_modules/hpack.js/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/hpack.js/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, - "license": "MIT" - }, - "node_modules/hpack.js/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/html-entities": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", - "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/mdevils" - }, - { - "type": "patreon", - "url": "https://patreon.com/mdevils" - } - ], - "license": "MIT" - }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true, - "license": "MIT" - }, - "node_modules/html-minifier-terser": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", - "integrity": "sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw==", - "dev": true, - "license": "MIT", - "dependencies": { - "camel-case": "^4.1.2", - "clean-css": "^5.2.2", - "commander": "^8.3.0", - "he": "^1.2.0", - "param-case": "^3.0.4", - "relateurl": "^0.2.7", - "terser": "^5.10.0" - }, - "bin": { - "html-minifier-terser": "cli.js" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/html-webpack-plugin": { - "version": "5.6.3", - "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.6.3.tgz", - "integrity": "sha512-QSf1yjtSAsmf7rYBV7XX86uua4W/vkhIt0xNXKbsi2foEeW7vjJQz4bhnpL3xH+l1ryl1680uNv968Z+X6jSYg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/html-minifier-terser": "^6.0.0", - "html-minifier-terser": "^6.0.2", - "lodash": "^4.17.21", - "pretty-error": "^4.0.0", - "tapable": "^2.0.0" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/html-webpack-plugin" - }, - "peerDependencies": { - "@rspack/core": "0.x || 1.x", - "webpack": "^5.20.0" - }, - "peerDependenciesMeta": { - "@rspack/core": { - "optional": true - }, - "webpack": { - "optional": true - } - } - }, - "node_modules/htmlparser2": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz", - "integrity": "sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==", - "dev": true, - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.0.1", - "domhandler": "^4.0.0", - "domutils": "^2.5.2", - "entities": "^2.0.0" - } - }, - "node_modules/http-deceiver": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", - "integrity": "sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==", - "dev": true, - "license": "MIT" - }, - "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/http-parser-js": { - "version": "0.5.10", - "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", - "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", - "dev": true, - "license": "MIT" - }, - "node_modules/http-proxy": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", - "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "eventemitter3": "^4.0.0", - "follow-redirects": "^1.0.0", - "requires-port": "^1.0.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/http-proxy-middleware": { - "version": "2.0.9", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz", - "integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/http-proxy": "^1.17.8", - "http-proxy": "^1.18.1", - "is-glob": "^4.0.1", - "is-plain-obj": "^3.0.0", - "micromatch": "^4.0.2" - }, - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "@types/express": "^4.17.13" - }, - "peerDependenciesMeta": { - "@types/express": { - "optional": true - } - } - }, - "node_modules/hyperdyperid": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", - "integrity": "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.18" - } - }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/icss-utils": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", - "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/immer": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz", - "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, - "node_modules/immutable": { - "version": "3.8.2", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-3.8.2.tgz", - "integrity": "sha512-15gZoQ38eYjEjxkorfbcgBKBL6R7T459OuK+CpcWt7O3KF4uPCx2tD0uFETlUDIyo+1789crbMhTvQBSR5yBMg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/import-local": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", - "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pkg-dir": "^4.2.0", - "resolve-cwd": "^3.0.0" - }, - "bin": { - "import-local-fixture": "fixtures/cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/interpret": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz", - "integrity": "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/ipaddr.js": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz", - "integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, - "node_modules/is-arguments": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", - "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "license": "MIT" - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-date-object": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", - "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-docker": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", - "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", - "dev": true, - "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-inside-container": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", - "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-docker": "^3.0.0" - }, - "bin": { - "is-inside-container": "cli.js" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-network-error": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.1.0.tgz", - "integrity": "sha512-tUdRRAnhT+OtCZR/LxZelH/C7QtjtFrTu5tXCA8pl55eTUElUHT+GPYV8MBMBvea/j+NxQqVt3LbWMRir7Gx9g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-plain-obj": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz", - "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "dev": true, - "license": "MIT", - "dependencies": { - "isobject": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-wsl": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", - "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-inside-container": "^1.0.0" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/jackspeak": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", - "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/jest-diff": { - "version": "30.0.4", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.0.4.tgz", - "integrity": "sha512-TSjceIf6797jyd+R64NXqicttROD+Qf98fex7CowmlSn7f8+En0da1Dglwr1AXxDtVizoxXYZBlUQwNhoOXkNw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/diff-sequences": "30.0.1", - "@jest/get-type": "30.0.1", - "chalk": "^4.1.2", - "pretty-format": "30.0.2" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-diff/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-diff/node_modules/pretty-format": { - "version": "30.0.2", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.2.tgz", - "integrity": "sha512-yC5/EBSOrTtqhCKfLHqoUIAXVRZnukHPwWBJWR7h84Q3Be1DRQZLncwcfLoPA5RPQ65qfiCMqgYwdUuQ//eVpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "30.0.1", - "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-diff/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, - "node_modules/jest-matcher-utils": { - "version": "30.0.4", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.0.4.tgz", - "integrity": "sha512-ubCewJ54YzeAZ2JeHHGVoU+eDIpQFsfPQs0xURPWoNiO42LGJ+QGgfSf+hFIRplkZDkhH5MOvuxHKXRTUU3dUQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/get-type": "30.0.1", - "chalk": "^4.1.2", - "jest-diff": "30.0.4", - "pretty-format": "30.0.2" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-matcher-utils/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-matcher-utils/node_modules/pretty-format": { - "version": "30.0.2", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.2.tgz", - "integrity": "sha512-yC5/EBSOrTtqhCKfLHqoUIAXVRZnukHPwWBJWR7h84Q3Be1DRQZLncwcfLoPA5RPQ65qfiCMqgYwdUuQ//eVpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "30.0.1", - "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-matcher-utils/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, - "node_modules/jest-message-util": { - "version": "30.0.2", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.0.2.tgz", - "integrity": "sha512-vXywcxmr0SsKXF/bAD7t7nMamRvPuJkras00gqYeB1V0WllxZrbZ0paRr3XqpFU2sYYjD0qAaG2fRyn/CGZ0aw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@jest/types": "30.0.1", - "@types/stack-utils": "^2.0.3", - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "micromatch": "^4.0.8", - "pretty-format": "30.0.2", - "slash": "^3.0.0", - "stack-utils": "^2.0.6" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-message-util/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-message-util/node_modules/pretty-format": { - "version": "30.0.2", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.2.tgz", - "integrity": "sha512-yC5/EBSOrTtqhCKfLHqoUIAXVRZnukHPwWBJWR7h84Q3Be1DRQZLncwcfLoPA5RPQ65qfiCMqgYwdUuQ//eVpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "30.0.1", - "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-message-util/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, - "node_modules/jest-mock": { - "version": "30.0.2", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.0.2.tgz", - "integrity": "sha512-PnZOHmqup/9cT/y+pXIVbbi8ID6U1XHRmbvR7MvUy4SLqhCbwpkmXhLbsWbGewHrV5x/1bF7YDjs+x24/QSvFA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "30.0.1", - "@types/node": "*", - "jest-util": "30.0.2" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-regex-util": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", - "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-util": { - "version": "30.0.2", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.2.tgz", - "integrity": "sha512-8IyqfKS4MqprBuUpZNlFB5l+WFehc8bfCe1HSZFHzft2mOuND8Cvi9r1musli+u6F3TqanCZ/Ik4H4pXUolZIg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "30.0.1", - "@types/node": "*", - "chalk": "^4.1.2", - "ci-info": "^4.2.0", - "graceful-fs": "^4.2.11", - "picomatch": "^4.0.2" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-worker": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", - "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, - "engines": { - "node": ">= 10.13.0" - } - }, - "node_modules/jest-worker/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/launch-editor": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.10.0.tgz", - "integrity": "sha512-D7dBRJo/qcGX9xlvt/6wUYzQxjh5G1RvZPgPv8vi4KRU99DVQL/oW7tnVOCCTm2HGeo3C5HvGE5Yrh6UBoZ0vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "picocolors": "^1.0.0", - "shell-quote": "^1.8.1" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "license": "MIT" - }, - "node_modules/loader-runner": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", - "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.11.5" - } - }, - "node_modules/loader-utils": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", - "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "big.js": "^5.2.2", - "emojis-list": "^3.0.0", - "json5": "^2.1.2" - }, - "engines": { - "node": ">=8.9.0" - } - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "license": "MIT" - }, - "node_modules/lodash-es": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", - "license": "MIT" - }, - "node_modules/lodash.debounce": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", - "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", - "dev": true, - "license": "MIT" - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/lower-case": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", - "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.3" - } - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/lz-string": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "license": "MIT", - "bin": { - "lz-string": "bin/bin.js" - } - }, - "node_modules/material-colors": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/material-colors/-/material-colors-1.2.6.tgz", - "integrity": "sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==", - "license": "ISC" - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/memfs": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.3.tgz", - "integrity": "sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==", - "dev": true, - "license": "Unlicense", - "dependencies": { - "fs-monkey": "^1.0.4" - }, - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/memoize-one": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", - "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", - "license": "MIT" - }, - "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true, - "license": "MIT" - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "dev": true, - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/min-indent": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/mini-css-extract-plugin": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.9.2.tgz", - "integrity": "sha512-GJuACcS//jtq4kCtd5ii/M0SZf7OZRH+BxdqXZHaJfb8TJiVl+NgQRPwiYt2EuqeSkNydn/7vP+bcE27C5mb9w==", - "dev": true, - "license": "MIT", - "dependencies": { - "schema-utils": "^4.0.0", - "tapable": "^2.2.1" - }, - "engines": { - "node": ">= 12.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.0.0" - } - }, - "node_modules/minimalistic-assert": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", - "dev": true, - "license": "ISC" - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/mrmime": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", - "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/multicast-dns": { - "version": "7.2.5", - "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", - "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", - "dev": true, - "license": "MIT", - "dependencies": { - "dns-packet": "^5.2.2", - "thunky": "^1.0.2" - }, - "bin": { - "multicast-dns": "cli.js" - } - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/negotiator": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", - "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/neo-async": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "dev": true, - "license": "MIT" - }, - "node_modules/no-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", - "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", - "dev": true, - "license": "MIT", - "dependencies": { - "lower-case": "^2.0.2", - "tslib": "^2.0.3" - } - }, - "node_modules/node-abort-controller": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", - "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-addon-api": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", - "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-forge": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", - "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", - "dev": true, - "license": "(BSD-3-Clause OR GPL-2.0)", - "engines": { - "node": ">= 6.13.0" - } - }, - "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", - "dev": true, - "license": "MIT" - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/normalizr": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/normalizr/-/normalizr-3.6.2.tgz", - "integrity": "sha512-30qCybsBaCBciotorvuOZTCGEg2AXrJfADMT2Kk/lvpIAcipHdK0zc33nNtwKzyfQAqIJXAcqET6YgflYUgsoQ==", - "license": "MIT" - }, - "node_modules/nth-check": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", - "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0" - }, - "funding": { - "url": "https://github.com/fb55/nth-check?sponsor=1" - } - }, - "node_modules/numeral": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/numeral/-/numeral-2.0.6.tgz", - "integrity": "sha512-qaKRmtYPZ5qdw4jWJD6bxEf1FJEqllJrwxCLIm0sQU/A7v2/czigzOb+C2uSiFsa9lBUzeH7M1oK+Q+OLxL3kA==", - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-is": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", - "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/obuf": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", - "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", - "dev": true, - "license": "MIT" - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/on-headers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", - "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/open": { - "version": "10.1.2", - "resolved": "https://registry.npmjs.org/open/-/open-10.1.2.tgz", - "integrity": "sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "default-browser": "^5.2.1", - "define-lazy-prop": "^3.0.0", - "is-inside-container": "^1.0.0", - "is-wsl": "^3.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/opener": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", - "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==", - "dev": true, - "license": "(WTFPL OR MIT)", - "bin": { - "opener": "bin/opener-bin.js" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-retry": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-6.2.1.tgz", - "integrity": "sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/retry": "0.12.2", - "is-network-error": "^1.0.0", - "retry": "^0.13.1" - }, - "engines": { - "node": ">=16.17" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, - "node_modules/param-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", - "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==", - "dev": true, - "license": "MIT", - "dependencies": { - "dot-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/pascal-case": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", - "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", - "dev": true, - "license": "MIT", - "dependencies": { - "no-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "license": "MIT" - }, - "node_modules/path-scurry": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz", - "integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz", - "integrity": "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==", - "dev": true, - "license": "ISC", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/path-to-regexp": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pkg-dir": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", - "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "find-up": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-dir/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-dir/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-dir/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pkg-dir/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-modules-extract-imports": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", - "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/postcss-modules-local-by-default": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.2.0.tgz", - "integrity": "sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==", - "dev": true, - "license": "MIT", - "dependencies": { - "icss-utils": "^5.0.0", - "postcss-selector-parser": "^7.0.0", - "postcss-value-parser": "^4.1.0" - }, - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/postcss-modules-scope": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz", - "integrity": "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==", - "dev": true, - "license": "ISC", - "dependencies": { - "postcss-selector-parser": "^7.0.0" - }, - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/postcss-modules-values": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", - "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "icss-utils": "^5.0.0" - }, - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/postcss-selector-parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", - "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", - "dev": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/pretty-error": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-4.0.0.tgz", - "integrity": "sha512-AoJ5YMAcXKYxKhuJGdcvse+Voc6v1RgnsR3nWcYU7q4t6z0Q6T86sv5Zq8VIRbOWWFpvdGE83LtdSMNd+6Y0xw==", - "dev": true, - "license": "MIT", - "dependencies": { - "lodash": "^4.17.20", - "renderkid": "^3.0.0" - } - }, - "node_modules/pretty-format": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", - "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true, - "license": "MIT" - }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "node_modules/prop-types/node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/proxy-addr/node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/qs": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", - "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.0.6" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/raf-schd": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz", - "integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==", - "license": "MIT" - }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", - "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/raw-body/node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-color": { - "version": "2.19.3", - "resolved": "https://registry.npmjs.org/react-color/-/react-color-2.19.3.tgz", - "integrity": "sha512-LEeGE/ZzNLIsFWa1TMe8y5VYqr7bibneWmvJwm1pCn/eNmrabWDh659JSPn9BuaMpEfU83WTOJfnCcjDZwNQTA==", - "license": "MIT", - "dependencies": { - "@icons/material": "^0.2.4", - "lodash": "^4.17.15", - "lodash-es": "^4.17.15", - "material-colors": "^1.2.1", - "prop-types": "^15.5.10", - "reactcss": "^1.2.0", - "tinycolor2": "^1.4.1" - }, - "peerDependencies": { - "react": "*" - } - }, - "node_modules/react-datepicker": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/react-datepicker/-/react-datepicker-7.5.0.tgz", - "integrity": "sha512-6MzeamV8cWSOcduwePHfGqY40acuGlS1cG//ePHT6bVbLxWyqngaStenfH03n1wbzOibFggF66kWaBTb1SbTtQ==", - "license": "MIT", - "dependencies": { - "@floating-ui/react": "^0.26.23", - "clsx": "^2.1.1", - "date-fns": "^3.6.0", - "prop-types": "^15.8.1" - }, - "peerDependencies": { - "react": "^16.9.0 || ^17 || ^18", - "react-dom": "^16.9.0 || ^17 || ^18" - } - }, - "node_modules/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" - }, - "peerDependencies": { - "react": "^18.3.1" - } - }, - "node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "license": "MIT" - }, - "node_modules/react-lifecycles-compat": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", - "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==", - "license": "MIT" - }, - "node_modules/react-redux": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", - "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", - "license": "MIT", - "dependencies": { - "@types/use-sync-external-store": "^0.0.6", - "use-sync-external-store": "^1.4.0" - }, - "peerDependencies": { - "@types/react": "^18.2.25 || ^19", - "react": "^18.0 || ^19", - "redux": "^5.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "redux": { - "optional": true - } - } - }, - "node_modules/react-refresh": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", - "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-router": { - "version": "6.30.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz", - "integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==", - "license": "MIT", - "dependencies": { - "@remix-run/router": "1.23.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "react": ">=16.8" - } - }, - "node_modules/react-router-dom": { - "version": "6.30.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz", - "integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==", - "license": "MIT", - "dependencies": { - "@remix-run/router": "1.23.0", - "react-router": "6.30.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "react": ">=16.8", - "react-dom": ">=16.8" - } - }, - "node_modules/react-select": { - "version": "5.10.1", - "resolved": "https://registry.npmjs.org/react-select/-/react-select-5.10.1.tgz", - "integrity": "sha512-roPEZUL4aRZDx6DcsD+ZNreVl+fM8VsKn0Wtex1v4IazH60ILp5xhdlp464IsEAlJdXeD+BhDAFsBVMfvLQueA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.0", - "@emotion/cache": "^11.4.0", - "@emotion/react": "^11.8.1", - "@floating-ui/dom": "^1.0.1", - "@types/react-transition-group": "^4.4.0", - "memoize-one": "^6.0.0", - "prop-types": "^15.6.0", - "react-transition-group": "^4.3.0", - "use-isomorphic-layout-effect": "^1.2.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/react-transition-group": { - "version": "4.4.5", - "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", - "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", - "license": "BSD-3-Clause", - "dependencies": { - "@babel/runtime": "^7.5.5", - "dom-helpers": "^5.0.1", - "loose-envify": "^1.4.0", - "prop-types": "^15.6.2" - }, - "peerDependencies": { - "react": ">=16.6.0", - "react-dom": ">=16.6.0" - } - }, - "node_modules/react-treebeard": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/react-treebeard/-/react-treebeard-3.2.4.tgz", - "integrity": "sha512-TsvdUq2kbLavRXa8k4mmqfPse8HmSA9G9s1SZUtIpiYSccSwa0Tm6miMgx7DZ5gpKofQ+j/3Ua0rjsahM3/FQg==", - "license": "MIT", - "dependencies": { - "@emotion/core": "^10.0.10", - "@emotion/styled": "^10.0.10", - "deep-equal": "^1.0.1", - "shallowequal": "^1.1.0", - "velocity-react": "^1.4.1" - }, - "peerDependencies": { - "@babel/runtime": ">=7.0.0", - "@emotion/styled": "^10.0.10", - "prop-types": ">=15.7.2", - "react": ">=16.7.0", - "react-dom": ">=16.7.0" - } - }, - "node_modules/reactcss": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz", - "integrity": "sha512-KiwVUcFu1RErkI97ywr8nvx8dNOpT03rbnma0SSalTYjkrPYaEajR4a/MRt6DZ46K6arDRbWMNHF+xH7G7n/8A==", - "license": "MIT", - "dependencies": { - "lodash": "^4.0.1" - } - }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/rechoir": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", - "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve": "^1.20.0" - }, - "engines": { - "node": ">= 10.13.0" - } - }, - "node_modules/redent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", - "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", - "license": "MIT", - "dependencies": { - "indent-string": "^4.0.0", - "strip-indent": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/redux": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", - "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" - }, - "node_modules/regenerate": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", - "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", - "dev": true, - "license": "MIT" - }, - "node_modules/regenerate-unicode-properties": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.0.tgz", - "integrity": "sha512-DqHn3DwbmmPVzeKj9woBadqmXxLvQoQIwu7nopMc72ztvxVmVk2SBhSnx67zuye5TP+lJsb/TBQsjLKhnDf3MA==", - "dev": true, - "license": "MIT", - "dependencies": { - "regenerate": "^1.4.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/regex-parser": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/regex-parser/-/regex-parser-2.3.1.tgz", - "integrity": "sha512-yXLRqatcCuKtVHsWrNg0JL3l1zGfdXeEvDa0bdu4tCDQw0RpMDZsqbkyRTUnKMR0tXF627V2oEWjBEaEdqTwtQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/regexp.prototype.flags": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", - "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-errors": "^1.3.0", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "set-function-name": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/regexpu-core": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.2.0.tgz", - "integrity": "sha512-H66BPQMrv+V16t8xtmq+UC0CBpiTBA60V8ibS1QVReIp8T1z8hwFxqcGzm9K6lgsN7sB5edVH8a+ze6Fqm4weA==", - "dev": true, - "license": "MIT", - "dependencies": { - "regenerate": "^1.4.2", - "regenerate-unicode-properties": "^10.2.0", - "regjsgen": "^0.8.0", - "regjsparser": "^0.12.0", - "unicode-match-property-ecmascript": "^2.0.0", - "unicode-match-property-value-ecmascript": "^2.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/regjsgen": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", - "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/regjsparser": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.12.0.tgz", - "integrity": "sha512-cnE+y8bz4NhMjISKbgeVJtqNbtf5QpjZP+Bslo+UqkIt9QPnX9q095eiRRASJG1/tz6dlNr6Z5NsBiWYokp6EQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "jsesc": "~3.0.2" - }, - "bin": { - "regjsparser": "bin/parser" - } - }, - "node_modules/regjsparser/node_modules/jsesc": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", - "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/relateurl": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", - "integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/renderkid": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz", - "integrity": "sha512-q/7VIQA8lmM1hF+jn+sFSPWGlMkSAeNYcPLmDQx2zzuiDfaLrOmumR8iaUKlenFgh0XRPIUeSPlH3A+AW3Z5pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "css-select": "^4.1.3", - "dom-converter": "^0.2.0", - "htmlparser2": "^6.1.0", - "lodash": "^4.17.21", - "strip-ansi": "^6.0.1" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/resolve": { - "version": "1.22.10", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", - "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", - "license": "MIT", - "dependencies": { - "is-core-module": "^2.16.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-cwd": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", - "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/resolve-cwd/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/resolve-url-loader": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-url-loader/-/resolve-url-loader-5.0.0.tgz", - "integrity": "sha512-uZtduh8/8srhBoMx//5bwqjQ+rfYOUq8zC9NrMUGtjBiGTtFJM42s58/36+hTqeqINcnYe08Nj3LkK9lW4N8Xg==", - "dev": true, - "license": "MIT", - "dependencies": { - "adjust-sourcemap-loader": "^4.0.0", - "convert-source-map": "^1.7.0", - "loader-utils": "^2.0.0", - "postcss": "^8.2.14", - "source-map": "0.6.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/resolve-url-loader/node_modules/convert-source-map": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", - "dev": true, - "license": "MIT" - }, - "node_modules/resolve-url-loader/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/retry": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", - "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/rimraf": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.0.1.tgz", - "integrity": "sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A==", - "dev": true, - "license": "ISC", - "dependencies": { - "glob": "^11.0.0", - "package-json-from-dist": "^1.0.0" - }, - "bin": { - "rimraf": "dist/esm/bin.mjs" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/run-applescript": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.0.0.tgz", - "integrity": "sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, - "license": "MIT" - }, - "node_modules/sass": { - "version": "1.79.6", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.79.6.tgz", - "integrity": "sha512-PVVjeeiUGx6Nj4PtEE/ecwu8ltwfPKzHxbbVmmLj4l1FYHhOyfA0scuVF8sVaa+b+VY4z7BVKjKq0cPUQdUU3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@parcel/watcher": "^2.4.1", - "chokidar": "^4.0.0", - "immutable": "^4.0.0", - "source-map-js": ">=0.6.2 <2.0.0" - }, - "bin": { - "sass": "sass.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-loader": { - "version": "16.0.5", - "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-16.0.5.tgz", - "integrity": "sha512-oL+CMBXrj6BZ/zOq4os+UECPL+bWqt6OAC6DWS8Ln8GZRcMDjlJ4JC3FBDuHJdYaFWIdKNIBYmtZtK2MaMkNIw==", - "dev": true, - "license": "MIT", - "dependencies": { - "neo-async": "^2.6.2" - }, - "engines": { - "node": ">= 18.12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "@rspack/core": "0.x || 1.x", - "node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0", - "sass": "^1.3.0", - "sass-embedded": "*", - "webpack": "^5.0.0" - }, - "peerDependenciesMeta": { - "@rspack/core": { - "optional": true - }, - "node-sass": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "webpack": { - "optional": true - } - } - }, - "node_modules/sass/node_modules/immutable": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz", - "integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==", - "dev": true, - "license": "MIT" - }, - "node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } - }, - "node_modules/schema-utils": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz", - "integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/select-hose": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", - "integrity": "sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==", - "dev": true, - "license": "MIT" - }, - "node_modules/selfsigned": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.4.1.tgz", - "integrity": "sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node-forge": "^1.3.0", - "node-forge": "^1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/send": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", - "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "2.4.1", - "range-parser": "~1.2.1", - "statuses": "2.0.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/send/node_modules/debug/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, - "node_modules/send/node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "randombytes": "^2.1.0" - } - }, - "node_modules/serve-index": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.1.tgz", - "integrity": "sha512-pXHfKNP4qujrtteMrSBb0rc8HJ9Ms/GrXwcUtUtD5s4ewDJI8bT3Cz2zTVRMKtri49pLx2e0Ya8ziP5Ya2pZZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "accepts": "~1.3.4", - "batch": "0.6.1", - "debug": "2.6.9", - "escape-html": "~1.0.3", - "http-errors": "~1.6.2", - "mime-types": "~2.1.17", - "parseurl": "~1.3.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/serve-index/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/serve-index/node_modules/depd": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", - "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/serve-index/node_modules/http-errors": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", - "integrity": "sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "depd": "~1.1.2", - "inherits": "2.0.3", - "setprototypeof": "1.1.0", - "statuses": ">= 1.4.0 < 2" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/serve-index/node_modules/inherits": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", - "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", - "dev": true, - "license": "ISC" - }, - "node_modules/serve-index/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, - "node_modules/serve-index/node_modules/setprototypeof": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", - "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/serve-index/node_modules/statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/serve-static": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", - "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", - "dev": true, - "license": "MIT", - "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "0.19.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-function-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", - "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "dev": true, - "license": "ISC" - }, - "node_modules/shallow-clone": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", - "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", - "dev": true, - "license": "MIT", - "dependencies": { - "kind-of": "^6.0.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shallowequal": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", - "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==", - "license": "MIT" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/sirv": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", - "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@polka/url": "^1.0.0-next.24", - "mrmime": "^2.0.0", - "totalist": "^3.0.0" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/sockjs": { - "version": "0.3.24", - "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", - "integrity": "sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "faye-websocket": "^0.11.3", - "uuid": "^8.3.2", - "websocket-driver": "^0.7.4" - } - }, - "node_modules/source-map": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", - "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">= 8" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-loader": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/source-map-loader/-/source-map-loader-5.0.0.tgz", - "integrity": "sha512-k2Dur7CbSLcAH73sBcIkV5xjPV4SzqO1NJ7+XaQl8if3VODDUj3FNchNGpqgJSKbvUfJuhVdv8K2Eu8/TNl2eA==", - "dev": true, - "license": "MIT", - "dependencies": { - "iconv-lite": "^0.6.3", - "source-map-js": "^1.0.2" - }, - "engines": { - "node": ">= 18.12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.72.1" - } - }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/source-map-support/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/spdy": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/spdy/-/spdy-4.0.2.tgz", - "integrity": "sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.1.0", - "handle-thing": "^2.0.0", - "http-deceiver": "^1.2.7", - "select-hose": "^2.0.0", - "spdy-transport": "^3.0.0" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/spdy-transport": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/spdy-transport/-/spdy-transport-3.0.0.tgz", - "integrity": "sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.1.0", - "detect-node": "^2.0.4", - "hpack.js": "^2.1.6", - "obuf": "^1.1.2", - "readable-stream": "^3.0.6", - "wbuf": "^1.7.3" - } - }, - "node_modules/stack-utils": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", - "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "escape-string-regexp": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/stack-utils/node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/stackframe": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", - "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", - "dev": true, - "license": "MIT" - }, - "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/string-width/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/string-width/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-indent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", - "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", - "license": "MIT", - "dependencies": { - "min-indent": "^1.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/style-loader": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-4.0.0.tgz", - "integrity": "sha512-1V4WqhhZZgjVAVJyt7TdDPZoPBPNHbekX4fWnCJL1yQukhCeZhJySUL+gL9y6sNdN95uEOS83Y55SqHcP7MzLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 18.12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.27.0" - } - }, - "node_modules/stylis": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", - "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", - "license": "MIT" - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/tabbable": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", - "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", - "license": "MIT" - }, - "node_modules/tapable": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz", - "integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/terser": { - "version": "5.43.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.43.1.tgz", - "integrity": "sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.14.0", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "bin": { - "terser": "bin/terser" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/terser-webpack-plugin": { - "version": "5.3.14", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz", - "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.25", - "jest-worker": "^27.4.5", - "schema-utils": "^4.3.0", - "serialize-javascript": "^6.0.2", - "terser": "^5.31.1" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.1.0" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "uglify-js": { - "optional": true - } - } - }, - "node_modules/terser/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/thingies": { - "version": "1.21.0", - "resolved": "https://registry.npmjs.org/thingies/-/thingies-1.21.0.tgz", - "integrity": "sha512-hsqsJsFMsV+aD4s3CWKk85ep/3I9XzYV/IXaSouJMYIoDlgyi11cBhsqYe9/geRfB0YIikBQg6raRaM+nIMP9g==", - "dev": true, - "license": "Unlicense", - "engines": { - "node": ">=10.18" - }, - "peerDependencies": { - "tslib": "^2" - } - }, - "node_modules/thunky": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", - "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tiny-invariant": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", - "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", - "license": "MIT" - }, - "node_modules/tinycolor2": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", - "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", - "license": "MIT" - }, - "node_modules/tinyglobby": { - "version": "0.2.14", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", - "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.4.4", - "picomatch": "^4.0.2" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/totalist": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", - "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/tree-dump": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.0.3.tgz", - "integrity": "sha512-il+Cv80yVHFBwokQSfd4bldvr1Md951DpgAGfmhydt04L+YzHgubm2tQ7zueWDcGENKHq0ZvGFR/hjvNXilHEg==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD" - }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", - "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", - "dev": true, - "license": "MIT" - }, - "node_modules/unicode-canonical-property-names-ecmascript": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", - "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/unicode-match-property-ecmascript": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", - "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "unicode-canonical-property-names-ecmascript": "^2.0.0", - "unicode-property-aliases-ecmascript": "^2.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/unicode-match-property-value-ecmascript": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.0.tgz", - "integrity": "sha512-4IehN3V/+kkr5YeSSDDQG8QLqO26XpL2XP3GQtqwlT/QYSECAwFztxVHjlbh0+gjJ3XmNLS0zDsbgs9jWKExLg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/unicode-property-aliases-ecmascript": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", - "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/use-isomorphic-layout-effect": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz", - "integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/use-sync-external-store": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", - "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, - "license": "MIT" - }, - "node_modules/utila": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/utila/-/utila-0.4.0.tgz", - "integrity": "sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA==", - "dev": true, - "license": "MIT" - }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "dev": true, - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/velocity-animate": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/velocity-animate/-/velocity-animate-1.5.2.tgz", - "integrity": "sha512-m6EXlCAMetKztO1ppBhGU1/1MR3IiEevO6ESq6rcrSQ3Q77xYSW13jkfXW88o4xMrkXJhy/U7j4wFR/twMB0Eg==", - "license": "MIT" - }, - "node_modules/velocity-react": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/velocity-react/-/velocity-react-1.4.3.tgz", - "integrity": "sha512-zvefGm85A88S3KdF9/dz5vqyFLAiwKYlXGYkHH2EbXl+CZUD1OT0a0aS1tkX/WXWTa/FUYqjBaAzAEFYuSobBQ==", - "license": "MIT", - "dependencies": { - "lodash": "^4.17.5", - "prop-types": "^15.5.8", - "react-transition-group": "^2.0.0", - "velocity-animate": "^1.4.0" - }, - "peerDependencies": { - "react": "^15.3.0 || ^16.0.0", - "react-dom": "^15.3.0 || ^16.0.0" - } - }, - "node_modules/velocity-react/node_modules/dom-helpers": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-3.4.0.tgz", - "integrity": "sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.1.2" - } - }, - "node_modules/velocity-react/node_modules/react-transition-group": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.9.0.tgz", - "integrity": "sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg==", - "license": "BSD-3-Clause", - "dependencies": { - "dom-helpers": "^3.4.0", - "loose-envify": "^1.4.0", - "prop-types": "^15.6.2", - "react-lifecycles-compat": "^3.0.4" - }, - "peerDependencies": { - "react": ">=15.0.0", - "react-dom": ">=15.0.0" - } - }, - "node_modules/vis-data": { - "version": "7.1.10", - "resolved": "https://registry.npmjs.org/vis-data/-/vis-data-7.1.10.tgz", - "integrity": "sha512-23juM9tdCaHTX5vyIQ7XBzsfZU0Hny+gSTwniLrfFcmw9DOm7pi3+h9iEBsoZMp5rX6KNqWwc1MF0fkAmWVuoQ==", - "license": "(Apache-2.0 OR MIT)", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/visjs" - }, - "peerDependencies": { - "uuid": "^3.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", - "vis-util": "^5.0.1" - } - }, - "node_modules/vis-network": { - "version": "9.1.13", - "resolved": "https://registry.npmjs.org/vis-network/-/vis-network-9.1.13.tgz", - "integrity": "sha512-HLeHd5KZS92qzO1kC59qMh1/FWAZxMUEwUWBwDMoj6RKj/Ajkrgy/heEYo0Zc8SZNQ2J+u6omvK2+a28GX1QuQ==", - "license": "(Apache-2.0 OR MIT)", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/visjs" - }, - "peerDependencies": { - "@egjs/hammerjs": "^2.0.0", - "component-emitter": "^1.3.0 || ^2.0.0", - "keycharm": "^0.2.0 || ^0.3.0 || ^0.4.0", - "uuid": "^3.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", - "vis-data": "^6.3.0 || ^7.0.0", - "vis-util": "^5.0.1" - } - }, - "node_modules/watchpack": { - "version": "2.4.4", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz", - "integrity": "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.1.2" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/wbuf": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/wbuf/-/wbuf-1.7.3.tgz", - "integrity": "sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimalistic-assert": "^1.0.0" - } - }, - "node_modules/webpack": { - "version": "5.100.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.100.0.tgz", - "integrity": "sha512-H8yBSBTk+BqxrINJnnRzaxU94SVP2bjd7WmA+PfCphoIdDpeQMJ77pq9/4I7xjLq38cB1bNKfzYPZu8pB3zKtg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/eslint-scope": "^3.7.7", - "@types/estree": "^1.0.8", - "@types/json-schema": "^7.0.15", - "@webassemblyjs/ast": "^1.14.1", - "@webassemblyjs/wasm-edit": "^1.14.1", - "@webassemblyjs/wasm-parser": "^1.14.1", - "acorn": "^8.15.0", - "acorn-import-phases": "^1.0.3", - "browserslist": "^4.24.0", - "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.2", - "es-module-lexer": "^1.2.1", - "eslint-scope": "5.1.1", - "events": "^3.2.0", - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.2.11", - "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.2.0", - "mime-types": "^2.1.27", - "neo-async": "^2.6.2", - "schema-utils": "^4.3.2", - "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.3.11", - "watchpack": "^2.4.1", - "webpack-sources": "^3.3.3" - }, - "bin": { - "webpack": "bin/webpack.js" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependenciesMeta": { - "webpack-cli": { - "optional": true - } - } - }, - "node_modules/webpack-bundle-analyzer": { - "version": "4.10.2", - "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.2.tgz", - "integrity": "sha512-vJptkMm9pk5si4Bv922ZbKLV8UTT4zib4FPgXMhgzUny0bfDDkLXAVQs3ly3fS4/TN9ROFtb0NFrm04UXFE/Vw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@discoveryjs/json-ext": "0.5.7", - "acorn": "^8.0.4", - "acorn-walk": "^8.0.0", - "commander": "^7.2.0", - "debounce": "^1.2.1", - "escape-string-regexp": "^4.0.0", - "gzip-size": "^6.0.0", - "html-escaper": "^2.0.2", - "opener": "^1.5.2", - "picocolors": "^1.0.0", - "sirv": "^2.0.3", - "ws": "^7.3.1" - }, - "bin": { - "webpack-bundle-analyzer": "lib/bin/analyzer.js" - }, - "engines": { - "node": ">= 10.13.0" - } - }, - "node_modules/webpack-bundle-analyzer/node_modules/commander": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", - "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, - "node_modules/webpack-cli": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-6.0.1.tgz", - "integrity": "sha512-MfwFQ6SfwinsUVi0rNJm7rHZ31GyTcpVE5pgVA3hwFRb7COD4TzjUUwhGWKfO50+xdc2MQPuEBBJoqIMGt3JDw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@discoveryjs/json-ext": "^0.6.1", - "@webpack-cli/configtest": "^3.0.1", - "@webpack-cli/info": "^3.0.1", - "@webpack-cli/serve": "^3.0.1", - "colorette": "^2.0.14", - "commander": "^12.1.0", - "cross-spawn": "^7.0.3", - "envinfo": "^7.14.0", - "fastest-levenshtein": "^1.0.12", - "import-local": "^3.0.2", - "interpret": "^3.1.1", - "rechoir": "^0.8.0", - "webpack-merge": "^6.0.1" - }, - "bin": { - "webpack-cli": "bin/cli.js" - }, - "engines": { - "node": ">=18.12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.82.0" - }, - "peerDependenciesMeta": { - "webpack-bundle-analyzer": { - "optional": true - }, - "webpack-dev-server": { - "optional": true - } - } - }, - "node_modules/webpack-cli/node_modules/@discoveryjs/json-ext": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.6.3.tgz", - "integrity": "sha512-4B4OijXeVNOPZlYA2oEwWOTkzyltLao+xbotHQeqN++Rv27Y6s818+n2Qkp8q+Fxhn0t/5lA5X1Mxktud8eayQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.17.0" - } - }, - "node_modules/webpack-cli/node_modules/commander": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", - "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/webpack-dev-middleware": { - "version": "7.4.2", - "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-7.4.2.tgz", - "integrity": "sha512-xOO8n6eggxnwYpy1NlzUKpvrjfJTvae5/D6WOK0S2LSo7vjmo5gCM1DbLUmFqrMTJP+W/0YZNctm7jasWvLuBA==", - "dev": true, - "license": "MIT", - "dependencies": { - "colorette": "^2.0.10", - "memfs": "^4.6.0", - "mime-types": "^2.1.31", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "schema-utils": "^4.0.0" - }, - "engines": { - "node": ">= 18.12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.0.0" - }, - "peerDependenciesMeta": { - "webpack": { - "optional": true - } - } - }, - "node_modules/webpack-dev-middleware/node_modules/memfs": { - "version": "4.17.2", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.17.2.tgz", - "integrity": "sha512-NgYhCOWgovOXSzvYgUW0LQ7Qy72rWQMGGFJDoWg4G30RHd3z77VbYdtJ4fembJXBy8pMIUA31XNAupobOQlwdg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jsonjoy.com/json-pack": "^1.0.3", - "@jsonjoy.com/util": "^1.3.0", - "tree-dump": "^1.0.1", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">= 4.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - } - }, - "node_modules/webpack-dev-server": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.2.tgz", - "integrity": "sha512-QcQ72gh8a+7JO63TAx/6XZf/CWhgMzu5m0QirvPfGvptOusAxG12w2+aua1Jkjr7hzaWDnJ2n6JFeexMHI+Zjg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/bonjour": "^3.5.13", - "@types/connect-history-api-fallback": "^1.5.4", - "@types/express": "^4.17.21", - "@types/express-serve-static-core": "^4.17.21", - "@types/serve-index": "^1.9.4", - "@types/serve-static": "^1.15.5", - "@types/sockjs": "^0.3.36", - "@types/ws": "^8.5.10", - "ansi-html-community": "^0.0.8", - "bonjour-service": "^1.2.1", - "chokidar": "^3.6.0", - "colorette": "^2.0.10", - "compression": "^1.7.4", - "connect-history-api-fallback": "^2.0.0", - "express": "^4.21.2", - "graceful-fs": "^4.2.6", - "http-proxy-middleware": "^2.0.9", - "ipaddr.js": "^2.1.0", - "launch-editor": "^2.6.1", - "open": "^10.0.3", - "p-retry": "^6.2.0", - "schema-utils": "^4.2.0", - "selfsigned": "^2.4.1", - "serve-index": "^1.9.1", - "sockjs": "^0.3.24", - "spdy": "^4.0.2", - "webpack-dev-middleware": "^7.4.2", - "ws": "^8.18.0" - }, - "bin": { - "webpack-dev-server": "bin/webpack-dev-server.js" - }, - "engines": { - "node": ">= 18.12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.0.0" - }, - "peerDependenciesMeta": { - "webpack": { - "optional": true - }, - "webpack-cli": { - "optional": true - } - } - }, - "node_modules/webpack-dev-server/node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/webpack-dev-server/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/webpack-dev-server/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/webpack-dev-server/node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/webpack-dev-server/node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/webpack-merge": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-6.0.1.tgz", - "integrity": "sha512-hXXvrjtx2PLYx4qruKl+kyRSLc52V+cCvMxRjmKwoA+CBbbF5GfIBtR6kCvl0fYGqTUPKB+1ktVmTHqMOzgCBg==", - "dev": true, - "license": "MIT", - "dependencies": { - "clone-deep": "^4.0.1", - "flat": "^5.0.2", - "wildcard": "^2.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/webpack-sources": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.3.tgz", - "integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/websocket-driver": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", - "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "http-parser-js": ">=0.5.1", - "safe-buffer": ">=5.1.0", - "websocket-extensions": ">=0.1.1" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/websocket-extensions": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", - "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/wildcard": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", - "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.3.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" - }, - "node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", - "license": "ISC", - "engines": { - "node": ">= 6" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - } - } -} diff --git a/query/src/client/Hello/BrowserApp.tsx b/query/src/client/Hello/BrowserApp.tsx deleted file mode 100644 index 6f3caa4d5a7..00000000000 --- a/query/src/client/Hello/BrowserApp.tsx +++ /dev/null @@ -1,601 +0,0 @@ -import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; - -// Rely on the global LABKEY object to avoid introducing new dependencies here. -// Types are intentionally loose to minimize coupling to specific API shapes. -declare const LABKEY: any; - -interface SchemaItem { - name: string; - displayName?: string; -} - -interface QueryItem { - name: string; - schemaName: string; - isUserDefined?: boolean; -} - -const buildExecuteUrl = (schemaName: string, queryName: string) => { - try { - return LABKEY.ActionURL.buildURL('query', 'executeQuery', undefined, { schemaName, queryName }); - } catch (e) { - return '#'; - } -}; - -const buildNewQueryUrl = (schemaName: string, baseTableName?: string) => { - try { - const params: any = { schemaName }; - if (baseTableName) params.ff_baseTableName = baseTableName; - return LABKEY.ActionURL.buildURL('query', 'newQuery', undefined, params); - } catch (e) { - return '#'; - } -}; - -const buildEditQueryUrl = (schemaName: string, queryName: string) => { - try { - return LABKEY.ActionURL.buildURL('query', 'sourceQuery', undefined, { schemaName, queryName }); - } catch (e) { - return '#'; - } -}; - -const getContainerPath = () => { - try { - return LABKEY.ActionURL.getContainer(); - } catch { - return undefined; - } -}; - -const useHashRoute = () => { - const [hash, setHash] = useState(() => window.location.hash || ''); - - useEffect(() => { - const onHashChange = () => setHash(window.location.hash || ''); - window.addEventListener('hashchange', onHashChange); - return () => window.removeEventListener('hashchange', onHashChange); - }, []); - - const route = useMemo(() => { - // Supported formats: - // #/schema/SchemaName - // #/schema/SchemaName/query/QueryName - const trimmed = hash.startsWith('#') ? hash.substring(1) : hash; - const parts = trimmed.split('/').filter(Boolean); - if (parts.length >= 2 && parts[0] === 'schema') { - const schemaName = decodeURIComponent(parts[1]); - if (parts.length >= 4 && parts[2] === 'query') { - const queryName = decodeURIComponent(parts[3]); - return { schemaName, queryName }; - } - return { schemaName }; - } - return {} as { schemaName?: string; queryName?: string }; - }, [hash]); - - const setRoute = useCallback((schemaName?: string, queryName?: string) => { - if (!schemaName) { - window.location.hash = ''; - } else if (!queryName) { - window.location.hash = `#/schema/${encodeURIComponent(schemaName)}`; - } else { - window.location.hash = `#/schema/${encodeURIComponent(schemaName)}/query/${encodeURIComponent(queryName)}`; - } - }, []); - - return { route, setRoute } as const; -}; - -const Toolbar: FC<{ - schemaName?: string; - queryName?: string; -}> = ({ schemaName, queryName }) => { - const mc = LABKEY?.moduleContext || {}; - const currentUser = LABKEY?.Security?.currentUser || {}; - - const canCreate = mc?.query?.hasEditQueriesPermission && currentUser?.canUpdate && !!schemaName; - - return ( -
- Schema Browser - - {currentUser?.isSystemAdmin && mc?.query?.hasQueryAnalysisService && ( - - )} - {currentUser?.isAdmin && ( - - )} - {canCreate && ( - - )} - {currentUser?.isAdmin && mc?.dataintegration && ( - - )} -
- ); -}; - -interface LookupInfo { - schemaName?: string; - queryName?: string; - containerPath?: string; -} - -interface ColumnInfo { - name: string; - caption?: string; - type?: string; - nullable?: boolean; - lookup?: LookupInfo; -} - -const displayType = (col: ColumnInfo | any): string => { - const t = col?.type || col?.jsonType || col?.jdbcType || ''; - if (t) return String(t); - const rangeURI: string | undefined = col?.rangeURI || col?.rangeUri; - if (rangeURI) { - const hash = rangeURI.lastIndexOf('#'); - if (hash > -1) return rangeURI.substring(hash + 1); - const slash = rangeURI.lastIndexOf('/'); - if (slash > -1) return rangeURI.substring(slash + 1); - return rangeURI; - } - return ''; -}; - -const toBool = (v: any): boolean | undefined => (v === undefined ? undefined : !!v); - -const normalizeLookup = (c: any): LookupInfo | undefined => { - const lk = c?.lookup || c?.lookupJSON || c?.fk || c?.foreignKey || c?.displayFieldFK; - if (!lk) return undefined; - const schema = lk.schemaName || lk.schema || lk.schemaNameFull || lk.schemaPath || lk.schemaDisplay || lk.schemaQueryName; - const query = lk.queryName || lk.table || lk.query || lk.tableName; - const containerPath = lk.containerPath || lk.container || lk.publicContainer; - if (!schema || !query) return undefined; - return { schemaName: schema, queryName: query, containerPath }; -}; - -const normalizeColumns = (input: any[]): ColumnInfo[] => { - const cols: ColumnInfo[] = []; - input?.forEach((c: any) => { - const name = c?.name || c?.fieldKey || c?.columnName; - if (!name) return; - const caption = c?.caption || c?.label || c?.displayName || name; - const type = displayType(c); - const required = toBool(c?.required); - const allowNull = c?.allowNull ?? c?.nullable ?? c?.allowMissingValue; - cols.push({ - name, - caption, - type, - nullable: allowNull !== undefined ? !!allowNull : required !== undefined ? !required : undefined, - lookup: normalizeLookup(c), - }); - }); - return cols.sort((a, b) => a.name.localeCompare(b.name)); -}; - -const extractColumnsFromGetQueryDetails = (result: any): ColumnInfo[] => { - const cols = result?.columns || result?.queryDetail?.columns || result?.metaData?.columns || []; - return normalizeColumns(cols); -}; - -const extractColumnsFromSelectRows = (result: any): ColumnInfo[] => { - const cols = result?.metaData?.fields || result?.metaData?.columns || result?.columnModel || result?.columns || []; - return normalizeColumns(cols); -}; - -const QueryDetails: FC<{ - schemaName: string; - queryName: string; - isUserDefined?: boolean; - onLookupClick: (schemaName: string, queryName: string, containerPath?: string) => void; -}> = ({ schemaName, queryName, isUserDefined, onLookupClick }) => { - const [loading, setLoading] = useState(false); - const [error, setError] = useState(); - const [columns, setColumns] = useState(); - - // Fetch details when schema/query changes - useEffect(() => { - let cancelled = false; - setLoading(true); - setError(undefined); - setColumns(undefined); - - const done = (cols: ColumnInfo[]) => { - if (cancelled) return; - setColumns(cols); - setLoading(false); - }; - const fail = (msg?: string) => { - if (cancelled) return; - setError(msg || 'Unable to load query details.'); - setColumns([]); - setLoading(false); - }; - - const trySelectRowsFallback = () => { - if (!LABKEY?.Query?.selectRows) { - fail('Query APIs not available.'); - return; - } - LABKEY.Query.selectRows({ - schemaName, - queryName, - maxRows: 0, - containerPath: getContainerPath(), - success: (res: any) => done(extractColumnsFromSelectRows(res)), - failure: (err: any) => fail(err?.exception || err?.message), - }); - }; - - if (LABKEY?.Query?.getQueryDetails) { - LABKEY.Query.getQueryDetails({ - schemaName, - queryName, - containerPath: getContainerPath(), - success: (res: any) => { - const cols = extractColumnsFromGetQueryDetails(res); - if (cols && cols.length) done(cols); - else trySelectRowsFallback(); - }, - failure: () => trySelectRowsFallback(), - }); - } else { - trySelectRowsFallback(); - } - - return () => { - cancelled = true; - }; - }, [schemaName, queryName]); - - return ( -
-

- {schemaName}.{queryName} -

-
- - View Data Grid - - - Derive New Query - - {(() => { - const mc = LABKEY?.moduleContext || {}; - const currentUser = LABKEY?.Security?.currentUser || {}; - const hasEditPerm = mc?.query?.hasEditQueriesPermission && currentUser?.canUpdate; - // Show edit if we have permission and either know it's user-defined or we don't know - const showEdit = !!hasEditPerm && isUserDefined !== false; - return ( - showEdit && ( - - Edit Query - - ) - ); - })()} -
- {loading &&
Loading query details…
} - {error &&
{error}
} - {!loading && columns && ( -
-
Columns
- {columns.length === 0 ? ( -
No columns available.
- ) : ( -
- - - - - - - - - - - - {columns.map(col => ( - - - - - - - - ))} - -
ColumnCaptionTypeNullableLookup
{col.name}{col.caption || ''}{col.type || ''} - {col.nullable === undefined ? '' : col.nullable ? 'Yes' : 'No'} - - {col.lookup?.schemaName && col.lookup?.queryName ? ( - - ) : ( - — - )} -
-
- )} - - {/* Dependencies Section */} -
Dependencies
- {(() => { - const depMap: { [key: string]: { schemaName: string; queryName: string; containerPath?: string } } = {}; - columns.forEach(c => { - const lk = c.lookup; - if (lk?.schemaName && lk?.queryName) { - const key = `${lk.containerPath || ''}|${lk.schemaName}|${lk.queryName}`; - if (!depMap[key]) depMap[key] = { schemaName: lk.schemaName, queryName: lk.queryName, containerPath: lk.containerPath }; - } - }); - const deps = Object.values(depMap); - if (deps.length === 0) { - return
No dependencies found.
; - } - return ( -
    - {deps.map(d => ( -
  • - - {d.containerPath && d.containerPath !== getContainerPath() && ( - ({d.containerPath}) - )} -
  • - ))} -
- ); - })()} -
- )} -
- ); -}; - -export const BrowserApp: FC = () => { - const { route, setRoute } = useHashRoute(); - const [schemas, setSchemas] = useState([]); - const [schemasLoading, setSchemasLoading] = useState(false); - const [queries, setQueries] = useState([]); - const [queriesLoading, setQueriesLoading] = useState(false); - - const selectedSchema = route.schemaName; - const selectedQuery = route.queryName; - - // Load schemas on mount - useEffect(() => { - let cancelled = false; - setSchemasLoading(true); - const onSuccess = (result: any) => { - if (cancelled) return; - // result.schemas may be an object keyed by name - const items: SchemaItem[] = []; - if (result?.schemas) { - if (Array.isArray(result.schemas)) { - result.schemas.forEach((s: any) => items.push({ name: s.name || s, displayName: s.displayName })); - } else { - Object.keys(result.schemas).forEach(name => items.push({ name, displayName: result.schemas[name]?.name || name })); - } - } - items.sort((a, b) => a.name.localeCompare(b.name)); - setSchemas(items); - setSchemasLoading(false); - }; - const onFailure = () => { - if (cancelled) return; - setSchemas([]); - setSchemasLoading(false); - }; - if (LABKEY?.Query?.getSchemas) { - LABKEY.Query.getSchemas({ - containerPath: getContainerPath(), - success: onSuccess, - failure: onFailure, - }); - } else { - onFailure(); - } - return () => { - cancelled = true; - }; - }, []); - - // Load queries when schema changes - useEffect(() => { - if (!selectedSchema) { - setQueries([]); - return; - } - let cancelled = false; - setQueriesLoading(true); - const onSuccess = (result: any) => { - if (cancelled) return; - const list: QueryItem[] = []; - const queries = result?.queries || result?.QuerySet || result?.querySet || []; - const toIsUserDefined = (q: any): boolean | undefined => { - return ( - q?.isUserDefined ?? - q?.userDefined ?? - q?.isUserDefinedQuery ?? - q?.isUserQuery ?? - undefined - ); - }; - if (Array.isArray(queries)) { - queries.forEach((q: any) => { - const name = q?.name || q?.queryName || q; - if (name) list.push({ name, schemaName: selectedSchema, isUserDefined: toIsUserDefined(q) }); - }); - } else if (queries?.queries) { - // sometimes nested - queries.queries.forEach((q: any) => list.push({ name: q.name, schemaName: selectedSchema, isUserDefined: toIsUserDefined(q) })); - } - list.sort((a, b) => a.name.localeCompare(b.name)); - setQueries(list); - setQueriesLoading(false); - }; - const onFailure = () => { - if (cancelled) return; - setQueries([]); - setQueriesLoading(false); - }; - if (LABKEY?.Query?.getQueries) { - LABKEY.Query.getQueries({ - containerPath: getContainerPath(), - schemaName: selectedSchema, - includeUserQueries: true, - includeSystemQueries: true, - success: onSuccess, - failure: onFailure, - }); - } else { - onFailure(); - } - return () => { - cancelled = true; - }; - }, [selectedSchema]); - - const onSchemaClick = useCallback((schemaName: string) => setRoute(schemaName), [setRoute]); - const onQueryClick = useCallback((schemaName: string, queryName: string) => setRoute(schemaName, queryName), [setRoute]); - - return ( -
- -
- {/* Left: Schema/Query tree */} -
-
Schemas
- {schemasLoading ? ( -
Loading schemas…
- ) : ( -
    - {schemas.map(s => ( -
  • - - {selectedSchema === s.name && ( -
    - {queriesLoading ? ( -
    Loading queries…
    - ) : ( -
      - {queries.map(q => ( -
    • - -
    • - ))} - {queries.length === 0 && !queriesLoading && ( -
    • No queries
    • - )} -
    - )} -
    - )} -
  • - ))} -
- )} -
- - {/* Right: Details */} -
- {!selectedSchema && ( -
-

Welcome

-

Select a schema on the left to view its queries.

-
- )} - {selectedSchema && !selectedQuery && ( -
-

{selectedSchema}

-

Select a query to view details.

-
- )} - {selectedSchema && selectedQuery && ( - q.schemaName === selectedSchema && q.name === selectedQuery)?.isUserDefined} - onLookupClick={(schema: string, query: string, containerPath?: string) => { - if (containerPath && containerPath !== getContainerPath()) { - const url = LABKEY.ActionURL.buildURL('query', 'begin', containerPath, { - schemaName: schema, - queryName: query, - }); - window.open(url); - } else { - onQueryClick(schema, query); - } - }} - /> - )} -
-
-
- ); -}; diff --git a/query/src/client/Hello/app.tsx b/query/src/client/Hello/app.tsx deleted file mode 100644 index 41cd34b0256..00000000000 --- a/query/src/client/Hello/app.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from 'react'; -import { createRoot } from 'react-dom/client'; -import { Hello } from './hello'; - -window.addEventListener('DOMContentLoaded', () => { - const el = document.getElementById('app'); - if (el) { - createRoot(el).render(); - } -}); \ No newline at end of file diff --git a/query/src/client/Hello/hello.tsx b/query/src/client/Hello/hello.tsx deleted file mode 100644 index ec80314424f..00000000000 --- a/query/src/client/Hello/hello.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import React, { FC } from 'react'; -import { BrowserApp } from './BrowserApp'; - -export const Hello: FC = () => { - return ; -}; \ No newline at end of file diff --git a/query/src/client/entryPoints.js b/query/src/client/entryPoints.js deleted file mode 100644 index 1ea8b3c7c6f..00000000000 --- a/query/src/client/entryPoints.js +++ /dev/null @@ -1,8 +0,0 @@ -module.exports = { - apps: [{ - name: 'hello', - title: 'React Query Schema Browser', - permissionClasses: ['org.labkey.api.security.permissions.ReadPermission'], - path: './src/client/Hello' - }] -}; \ No newline at end of file diff --git a/query/tsconfig.json b/query/tsconfig.json deleted file mode 100644 index 51a3abbe122..00000000000 --- a/query/tsconfig.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "extends": "./node_modules/@labkey/build/webpack/tsconfig.json", - "include": ["src/client/**/*"], - "exclude": ["node_modules"] -} diff --git a/study/src/org/labkey/study/controllers/StudyController.java b/study/src/org/labkey/study/controllers/StudyController.java index 607c686b86f..9b07e897a7a 100644 --- a/study/src/org/labkey/study/controllers/StudyController.java +++ b/study/src/org/labkey/study/controllers/StudyController.java @@ -1,7829 +1,7829 @@ -/* - * Copyright (c) 2008-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.labkey.study.controllers; - -import com.google.common.cache.Cache; -import com.google.common.cache.CacheBuilder; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpSession; -import org.apache.commons.beanutils.ConversionException; -import org.apache.commons.collections4.FactoryUtils; -import org.apache.commons.collections4.MapUtils; -import org.apache.commons.collections4.MultiValuedMap; -import org.apache.commons.collections4.multimap.ArrayListValuedHashMap; -import org.apache.commons.lang3.BooleanUtils; -import org.apache.commons.lang3.StringUtils; -import org.apache.commons.lang3.math.NumberUtils; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.apache.xmlbeans.XmlException; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.json.JSONObject; -import org.labkey.api.action.ApiJsonForm; -import org.labkey.api.action.ApiResponse; -import org.labkey.api.action.ApiSimpleResponse; -import org.labkey.api.action.ConfirmAction; -import org.labkey.api.action.FormApiAction; -import org.labkey.api.action.FormHandlerAction; -import org.labkey.api.action.FormViewAction; -import org.labkey.api.action.HasAllowBindParameter; -import org.labkey.api.action.HasViewContext; -import org.labkey.api.action.Marshal; -import org.labkey.api.action.Marshaller; -import org.labkey.api.action.MutatingApiAction; -import org.labkey.api.action.QueryViewAction; -import org.labkey.api.action.ReadOnlyApiAction; -import org.labkey.api.action.ReturnUrlForm; -import org.labkey.api.action.SimpleErrorView; -import org.labkey.api.action.SimpleRedirectAction; -import org.labkey.api.action.SimpleViewAction; -import org.labkey.api.action.SpringActionController; -import org.labkey.api.admin.AdminUrls; -import org.labkey.api.admin.ImportException; -import org.labkey.api.admin.notification.NotificationService; -import org.labkey.api.assay.AssayUrls; -import org.labkey.api.attachments.AttachmentFile; -import org.labkey.api.attachments.AttachmentForm; -import org.labkey.api.attachments.AttachmentParent; -import org.labkey.api.attachments.AttachmentService; -import org.labkey.api.attachments.BaseDownloadAction; -import org.labkey.api.audit.AuditLogService; -import org.labkey.api.audit.TransactionAuditProvider; -import org.labkey.api.collections.CaseInsensitiveHashMap; -import org.labkey.api.collections.IntHashMap; -import org.labkey.api.collections.IntHashSet; -import org.labkey.api.collections.LabKeyCollectors; -import org.labkey.api.compliance.ComplianceService; -import org.labkey.api.data.ActionButton; -import org.labkey.api.data.ButtonBar; -import org.labkey.api.data.ColumnHeaderType; -import org.labkey.api.data.ColumnInfo; -import org.labkey.api.data.CompareType; -import org.labkey.api.data.Container; -import org.labkey.api.data.ContainerFilter; -import org.labkey.api.data.DataRegion; -import org.labkey.api.data.DataRegionSelection; -import org.labkey.api.data.DbSchema; -import org.labkey.api.data.DbScope; -import org.labkey.api.data.DisplayColumn; -import org.labkey.api.data.PropertyManager; -import org.labkey.api.data.PropertyManager.WritablePropertyMap; -import org.labkey.api.data.RenderContext; -import org.labkey.api.data.Results; -import org.labkey.api.data.ResultsFactory; -import org.labkey.api.data.RuntimeSQLException; -import org.labkey.api.data.SQLFragment; -import org.labkey.api.data.ShowRows; -import org.labkey.api.data.SimpleDisplayColumn; -import org.labkey.api.data.SimpleFilter; -import org.labkey.api.data.Sort; -import org.labkey.api.data.SqlExecutor; -import org.labkey.api.data.SqlSelector; -import org.labkey.api.data.TSVGridWriter; -import org.labkey.api.data.Table; -import org.labkey.api.data.TableInfo; -import org.labkey.api.data.TableSelector; -import org.labkey.api.data.TableViewForm; -import org.labkey.api.data.views.DataViewService; -import org.labkey.api.exp.LsidManager; -import org.labkey.api.exp.OntologyManager; -import org.labkey.api.exp.PropertyDescriptor; -import org.labkey.api.exp.api.ExpProtocol; -import org.labkey.api.exp.api.ExpSampleType; -import org.labkey.api.exp.property.Domain; -import org.labkey.api.gwt.client.AuditBehaviorType; -import org.labkey.api.module.ModuleHtmlView; -import org.labkey.api.module.ModuleLoader; -import org.labkey.api.pipeline.PipeRoot; -import org.labkey.api.pipeline.PipelineJob; -import org.labkey.api.pipeline.PipelineService; -import org.labkey.api.pipeline.PipelineStatusUrls; -import org.labkey.api.pipeline.PipelineUrls; -import org.labkey.api.pipeline.PipelineValidationException; -import org.labkey.api.pipeline.browse.PipelinePathForm; -import org.labkey.api.qc.AbstractDeleteDataStateAction; -import org.labkey.api.qc.AbstractManageDataStatesForm; -import org.labkey.api.qc.AbstractManageQCStatesAction; -import org.labkey.api.qc.AbstractManageQCStatesBean; -import org.labkey.api.qc.DataState; -import org.labkey.api.qc.DataStateHandler; -import org.labkey.api.qc.DeleteDataStateForm; -import org.labkey.api.qc.QCStateManager; -import org.labkey.api.query.AbstractQueryImportAction; -import org.labkey.api.query.BatchValidationException; -import org.labkey.api.query.CustomView; -import org.labkey.api.query.DetailsURL; -import org.labkey.api.query.FieldKey; -import org.labkey.api.query.InvalidKeyException; -import org.labkey.api.query.QueryAction; -import org.labkey.api.query.QueryDefinition; -import org.labkey.api.query.QueryForm; -import org.labkey.api.query.QueryParseException; -import org.labkey.api.query.QueryService; -import org.labkey.api.query.QuerySettings; -import org.labkey.api.query.QueryUpdateService; -import org.labkey.api.query.QueryUpdateServiceException; -import org.labkey.api.query.QueryView; -import org.labkey.api.query.SchemaKey; -import org.labkey.api.query.UserSchema; -import org.labkey.api.query.ValidationException; -import org.labkey.api.query.snapshot.QuerySnapshotDefinition; -import org.labkey.api.query.snapshot.QuerySnapshotForm; -import org.labkey.api.query.snapshot.QuerySnapshotService; -import org.labkey.api.reader.DataLoader; -import org.labkey.api.reader.TabLoader; -import org.labkey.api.reports.Report; -import org.labkey.api.reports.ReportService; -import org.labkey.api.reports.model.ReportPropsManager; -import org.labkey.api.reports.model.ViewCategory; -import org.labkey.api.reports.model.ViewCategoryManager; -import org.labkey.api.reports.report.AbstractReportIdentifier; -import org.labkey.api.reports.report.QueryReport; -import org.labkey.api.reports.report.ReportIdentifier; -import org.labkey.api.reports.report.ReportUrls; -import org.labkey.api.search.SearchService; -import org.labkey.api.search.SearchUrls; -import org.labkey.api.security.RequiresAllOf; -import org.labkey.api.security.RequiresLogin; -import org.labkey.api.security.RequiresNoPermission; -import org.labkey.api.security.RequiresPermission; -import org.labkey.api.security.SecurityManager; -import org.labkey.api.security.User; -import org.labkey.api.security.permissions.AdminPermission; -import org.labkey.api.security.permissions.BrowserDeveloperPermission; -import org.labkey.api.security.permissions.DeletePermission; -import org.labkey.api.security.permissions.InsertPermission; -import org.labkey.api.security.permissions.Permission; -import org.labkey.api.security.permissions.PlatformDeveloperPermission; -import org.labkey.api.security.permissions.QCAnalystPermission; -import org.labkey.api.security.permissions.ReadPermission; -import org.labkey.api.security.permissions.UpdatePermission; -import org.labkey.api.settings.OptionalFeatureService; -import org.labkey.api.specimen.SpecimenManager; -import org.labkey.api.specimen.SpecimenMigrationService; -import org.labkey.api.specimen.location.LocationImpl; -import org.labkey.api.specimen.location.LocationManager; -import org.labkey.api.study.CohortFilter; -import org.labkey.api.study.CompletionType; -import org.labkey.api.study.Dataset; -import org.labkey.api.study.Dataset.KeyManagementType; -import org.labkey.api.study.DatasetTable; -import org.labkey.api.study.MasterPatientIndexService; -import org.labkey.api.study.ParticipantCategory; -import org.labkey.api.study.Study; -import org.labkey.api.study.StudyService; -import org.labkey.api.study.StudyUrls; -import org.labkey.api.study.TimepointType; -import org.labkey.api.study.Visit; -import org.labkey.api.study.model.ParticipantGroup; -import org.labkey.api.study.publish.StudyPublishService; -import org.labkey.api.study.security.permissions.ManageStudyPermission; -import org.labkey.api.studydesign.StudyDesignManager; -import org.labkey.api.util.ContainerContext; -import org.labkey.api.util.CsrfInput; -import org.labkey.api.util.DateUtil; -import org.labkey.api.util.DemoMode; -import org.labkey.api.util.FileStream; -import org.labkey.api.util.HtmlString; -import org.labkey.api.util.HtmlStringBuilder; -import org.labkey.api.util.LinkBuilder; -import org.labkey.api.util.PageFlowUtil; -import org.labkey.api.util.Pair; -import org.labkey.api.util.StringExpression; -import org.labkey.api.util.URLHelper; -import org.labkey.api.util.XmlBeansUtil; -import org.labkey.api.view.ActionURL; -import org.labkey.api.view.DataView; -import org.labkey.api.view.GridView; -import org.labkey.api.view.HtmlView; -import org.labkey.api.view.HttpView; -import org.labkey.api.view.JspView; -import org.labkey.api.view.NavTree; -import org.labkey.api.view.NotFoundException; -import org.labkey.api.view.Portal; -import org.labkey.api.view.RedirectException; -import org.labkey.api.view.UnauthorizedException; -import org.labkey.api.view.VBox; -import org.labkey.api.view.ViewBackgroundInfo; -import org.labkey.api.view.ViewContext; -import org.labkey.api.view.ViewForm; -import org.labkey.api.view.WebPartView; -import org.labkey.api.view.template.EmptyView; -import org.labkey.api.view.template.PageConfig; -import org.labkey.api.writer.FileSystemFile; -import org.labkey.api.writer.HtmlWriter; -import org.labkey.api.writer.VirtualFile; -import org.labkey.data.xml.TablesDocument; -import org.labkey.study.CohortFilterFactory; -import org.labkey.study.MasterPatientIndexMaintenanceTask; -import org.labkey.study.StudyModule; -import org.labkey.study.StudySchema; -import org.labkey.study.assay.AssayPublishConfirmAction; -import org.labkey.study.assay.AssayPublishStartAction; -import org.labkey.study.assay.StudyPublishManager; -import org.labkey.study.audit.ParticipantGroupAuditProvider; -import org.labkey.study.controllers.publish.SampleTypePublishConfirmAction; -import org.labkey.study.controllers.publish.SampleTypePublishStartAction; -import org.labkey.study.controllers.security.SecurityController; -import org.labkey.study.dataset.DatasetSnapshotProvider; -import org.labkey.study.dataset.DatasetViewProvider; -import org.labkey.study.designer.StudySchedule; -import org.labkey.study.importer.DatasetImportUtils; -import org.labkey.study.importer.SchemaReader; -import org.labkey.study.importer.SchemaXmlReader; -import org.labkey.study.importer.VisitMapImporter; -import org.labkey.study.model.CohortImpl; -import org.labkey.study.model.CohortManager; -import org.labkey.study.model.CustomParticipantView; -import org.labkey.study.model.DatasetDefinition; -import org.labkey.study.model.DatasetDomainKind; -import org.labkey.study.model.DatasetDomainKindProperties; -import org.labkey.study.model.DatasetManager; -import org.labkey.study.model.DatasetReorderer; -import org.labkey.study.model.DateDatasetDomainKind; -import org.labkey.study.model.Participant; -import org.labkey.study.model.ParticipantCategoryImpl; -import org.labkey.study.model.ParticipantGroupManager; -import org.labkey.study.model.QCStateSet; -import org.labkey.study.model.SecurityType; -import org.labkey.study.model.StudyImpl; -import org.labkey.study.model.StudyManager; -import org.labkey.study.model.StudySnapshot; -import org.labkey.study.model.UploadLog; -import org.labkey.study.model.VisitDataset; -import org.labkey.study.model.VisitDatasetType; -import org.labkey.study.model.VisitImpl; -import org.labkey.study.model.VisitMapKey; -import org.labkey.study.pipeline.DatasetFileReader; -import org.labkey.study.pipeline.MasterPatientIndexUpdateTask; -import org.labkey.study.pipeline.StudyPipeline; -import org.labkey.study.qc.StudyQCStateHandler; -import org.labkey.study.query.DatasetQuerySettings; -import org.labkey.study.query.DatasetQueryView; -import org.labkey.study.query.LocationTable; -import org.labkey.study.query.PublishedRecordQueryView; -import org.labkey.study.query.QueryDatasetTable; -import org.labkey.study.query.StudyQuerySchema; -import org.labkey.study.query.StudyQueryView; -import org.labkey.study.reports.ReportManager; -import org.labkey.study.view.SubjectsWebPart; -import org.labkey.study.visitmanager.SequenceVisitManager; -import org.labkey.study.visitmanager.VisitManager; -import org.labkey.study.visitmanager.VisitManager.VisitStatistic; -import org.labkey.study.xml.DatasetsDocument; -import org.labkey.vfs.FileLike; -import org.springframework.validation.BindException; -import org.springframework.validation.Errors; -import org.springframework.web.servlet.ModelAndView; -import org.springframework.web.servlet.mvc.Controller; - -import java.io.File; -import java.io.IOException; -import java.io.PrintWriter; -import java.math.BigDecimal; -import java.net.URISyntaxException; -import java.sql.SQLException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.Date; -import java.util.EnumSet; -import java.util.HashMap; -import java.util.HashSet; -import java.util.LinkedHashSet; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.TimeUnit; -import java.util.function.Predicate; -import java.util.regex.Pattern; - -import static org.labkey.api.util.IntegerUtils.asInteger; -import static org.labkey.study.model.QCStateSet.PUBLIC_STATES_LABEL; -import static org.labkey.study.model.QCStateSet.getQCStateFilteredURL; -import static org.labkey.study.model.QCStateSet.getQCUrlFilterKey; -import static org.labkey.study.model.QCStateSet.getQCUrlFilterValue; -import static org.labkey.study.model.QCStateSet.selectedQCStateLabelFromUrl; -import static org.labkey.study.query.DatasetQueryView.EXPERIMENTAL_ALLOW_MERGE_WITH_MANAGED_KEYS; - -public class StudyController extends BaseStudyController -{ - private static final Logger _log = LogManager.getLogger(StudyController.class); - private static final String PARTICIPANT_CACHE_PREFIX = "Study_participants/participantCache"; - private static final String EXPAND_CONTAINERS_KEY = StudyController.class.getName() + "/expandedContainers"; - private static final String DATASET_DATAREGION_NAME = "Dataset"; - - private static final ActionResolver ACTION_RESOLVER = new DefaultActionResolver( - StudyController.class, - CreateChildStudyAction.class, - AutoCompleteAction.class - ); - - public static final String DATASET_REPORT_ID_PARAMETER_NAME = "Dataset.reportId"; - public static final String DATASET_VIEW_NAME_PARAMETER_NAME = "Dataset.viewName"; - - public static class StudyUrlsImpl implements StudyUrls - { - @Override - public ActionURL getBeginURL(Container container) - { - return new ActionURL(BeginAction.class, container); - } - - @Override - public ActionURL getCompletionURL(Container studyContainer, CompletionType type) - { - if (studyContainer == null) - return null; - - ActionURL url = new ActionURL(AutoCompleteAction.class, studyContainer); - url.addParameter("type", type.name()); - url.addParameter("prefix", ""); - return url; - } - - @Override - public ActionURL getCreateStudyURL(Container container) - { - return new ActionURL(CreateStudyAction.class, container); - } - - @Override - public ActionURL getManageStudyURL(Container container) - { - return new ActionURL(ManageStudyAction.class, container); - } - - @Override - public Class getManageStudyClass() - { - return ManageStudyAction.class; - } - - @Override - public ActionURL getStudyOverviewURL(Container container) - { - return new ActionURL(OverviewAction.class, container); - } - - @Override - public ActionURL getDatasetURL(Container container, int datasetId) - { - return new ActionURL(DatasetAction.class, container).addParameter(Dataset.DATASET_KEY, datasetId); - } - - @Override - public ActionURL getDatasetsURL(Container container) - { - return new ActionURL(DatasetsAction.class, container); - } - - @Override - public ActionURL getManageDatasetsURL(Container container) - { - return new ActionURL(ManageTypesAction.class, container); - } - - @Override - public ActionURL getManageReportPermissions(Container container) - { - return new ActionURL(SecurityController.ReportPermissionsAction.class, container); - } - - @Override - public ActionURL getManageFileWatchersURL(Container container) - { - return new ActionURL(StudyController.ManageFilewatchersAction.class, container); - } - - @Override - public ActionURL getLinkToStudyURL(Container container, ExpSampleType sampleType) - { - ActionURL url = new ActionURL(SampleTypePublishStartAction.class, container); - if (sampleType != null) - url.addParameter("rowId", sampleType.getRowId()); - return url; - } - - @Override - public ActionURL getLinkToStudyURL(Container container, ExpProtocol protocol) - { - return urlProvider(AssayUrls.class).getProtocolURL(container, protocol, AssayPublishStartAction.class); - } - - @Override - public ActionURL getLinkToStudyConfirmURL(Container container, ExpProtocol protocol) - { - return urlProvider(AssayUrls.class).getProtocolURL(container, protocol, AssayPublishConfirmAction.class); - } - - @Override - public ActionURL getLinkToStudyConfirmURL(Container container, ExpSampleType sampleType) - { - ActionURL url = new ActionURL(SampleTypePublishConfirmAction.class, container); - if (sampleType != null) - url.addParameter("rowId", sampleType.getRowId()); - return url; - } - - @Override - public void addManageStudyNavTrail(NavTree root, Container container, User user) - { - _addManageStudy(root, container, user); - } - - @Override - public ActionURL getTypeNotFoundURL(Container container, int datasetId) - { - return new ActionURL(TypeNotFoundAction.class, container).addParameter("id", datasetId); - } - - @Override - public ActionURL getManageLocationsURL(Container container) - { - return new ActionURL(ManageLocationsAction.class, container); - } - - @Override - public ActionURL getManageVisitsURL(Container container) - { - return new ActionURL(ManageVisitsAction.class, container); - } - - @Override - public ActionURL getManageCohortsURL(Container container) - { - return new ActionURL(CohortController.ManageCohortsAction.class, container); - } - - @Override - public ActionURL getVisitOrderURL(Container container) - { - return new ActionURL(VisitOrderAction.class, container); - } - } - - public StudyController() - { - setActionResolver(ACTION_RESOLVER); - } - - protected void _addNavTrailVisitAdmin(NavTree root) - { - _addManageStudy(root); - - StringBuilder sb = new StringBuilder("Manage "); - - Study visitStudy = StudyManager.getInstance().getStudyForVisits(getStudy()); - if (visitStudy.getShareVisitDefinitions() == Boolean.TRUE) - sb.append("Shared "); - - sb.append(getVisitLabelPlural()); - - root.addChild(sb.toString(), new ActionURL(ManageVisitsAction.class, getContainer())); - } - - @RequiresPermission(ReadPermission.class) - public class BeginAction extends SimpleViewAction - { - private Study _study; - - @Override - public ModelAndView getView(Object o, BindException errors) throws Exception - { - _study = getStudy(); - - WebPartView overview = StudyModule.manageStudyPartFactory.getWebPartView(getViewContext(), StudyModule.manageStudyPartFactory.createWebPart()); - WebPartView views = StudyModule.reportsPartFactory.getWebPartView(getViewContext(), StudyModule.reportsPartFactory.createWebPart()); - return new VBox(overview, views); - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild(_study == null ? "No Study In Folder" : _study.getLabel()); - } - } - - @RequiresPermission(AdminPermission.class) - public class DefineDatasetTypeAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object o, BindException errors) throws Exception - { - getStudyRedirectIfNull(); - return ModuleHtmlView.get(ModuleLoader.getInstance().getModule("core"), ModuleHtmlView.getGeneratedViewPath("datasetDesigner")); - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("createDataset"); - _addNavTrailDatasetAdmin(root); - root.addChild("Create Dataset Definition"); - } - } - - @Marshal(Marshaller.Jackson) - @RequiresPermission(ReadPermission.class) - public static class GetDatasetAction extends ReadOnlyApiAction - { - @Override - public Object execute(DatasetForm form, BindException errors) throws Exception - { - DatasetDomainKindProperties properties = DatasetManager.get().getDatasetDomainKindProperties(getContainer(), form.getDatasetId()); - if (properties != null) - return properties; - else - throw new NotFoundException("Dataset does not exist in this container for datasetId " + form.getDatasetIdStr() + "."); - } - } - - @RequiresPermission(AdminPermission.class) - @SuppressWarnings("unchecked") - public class EditTypeAction extends SimpleViewAction - { - private Dataset _def; - - @Override - public ModelAndView getView(DatasetForm form, BindException errors) - { - StudyImpl study = getStudyRedirectIfNull(); - if (null == form.getDatasetId()) - throw new NotFoundException("No datasetId parameter provided."); - - DatasetDefinition def = study.getDataset(form.getDatasetId()); - _def = def; - if (null == def) - throw new NotFoundException("No dataset found for datasetId " + form.getDatasetId() + "."); - - if (def.isQueryDataset()) - throw new UnsupportedOperationException("Query dataset definition cannot be edited. Update the source query to change definition."); - - if (!def.canUpdateDefinition(getUser())) - { - ActionURL details = new ActionURL(DatasetDetailsAction.class,getContainer()).addParameter("id",def.getDatasetId()); - throw new RedirectException(details); - } - - if (null == def.getTypeURI()) - { - def = def.createMutable(); - String domainURI = StudyManager.getInstance().getDomainURI(study.getContainer(), getUser(), def); - OntologyManager.ensureDomainDescriptor(domainURI, def.getName(), study.getContainer()); - def.setTypeURI(domainURI); - } - - return ModuleHtmlView.get(ModuleLoader.getInstance().getModule("core"), ModuleHtmlView.getGeneratedViewPath("datasetDesigner")); - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("datasetProperties"); - _addNavTrailDatasetAdmin(root); - root.addChild(_def.getName(), new ActionURL(DatasetDetailsAction.class, getContainer()).addParameter("id", _def.getDatasetId())); - root.addChild("Edit Dataset Definition"); - } - } - - @RequiresPermission(ReadPermission.class) - public class DatasetDetailsAction extends SimpleViewAction - { - private DatasetDefinition _def; - - @Override - public ModelAndView getView(IdForm form, BindException errors) - { - _def = StudyManager.getInstance().getDatasetDefinition(getStudyRedirectIfNull(), form.getId()); - if (_def == null) - { - throw new NotFoundException("Invalid Dataset ID"); - } - return new StudyJspView<>(StudyManager.getInstance().getStudy(getContainer()), - "/org/labkey/study/view/datasetDetails.jsp", _def, errors); - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("datasetProperties"); - root.addChild(_def.getLabel(), urlProvider(StudyUrls.class).getDatasetURL(getContainer(), _def.getDatasetId())); - root.addChild("Dataset Properties"); - } - } - - public static class DatasetFilterForm extends QueryViewAction.QueryExportForm implements HasViewContext - { - private ViewContext _viewContext; - - @Override - public void setViewContext(ViewContext context) - { - _viewContext = context; - } - - @Override - public ViewContext getViewContext() - { - return _viewContext; - } - } - - - public static class OverviewForm extends DatasetFilterForm - { - private String _qcState; - private String[] _visitStatistic = new String[0]; - - public String getQCState() - { - return _qcState; - } - - public void setQCState(String qcState) - { - _qcState = qcState; - } - - public String[] getVisitStatistic() - { - return _visitStatistic; - } - - @SuppressWarnings({"UnusedDeclaration"}) - public void setVisitStatistic(String[] visitStatistic) - { - _visitStatistic = visitStatistic; - } - - private Set getVisitStatistics() - { - Set set = EnumSet.noneOf(VisitStatistic.class); - - for (String statName : _visitStatistic) - set.add(VisitStatistic.valueOf(statName)); - - if (set.isEmpty()) - set.add(VisitStatistic.values()[0]); - - return set; - } - } - - - @RequiresPermission(ReadPermission.class) - public class OverviewAction extends SimpleViewAction - { - private StudyImpl _study; - - @Override - public ModelAndView getView(OverviewForm form, BindException errors) throws Exception - { - _study = getStudyRedirectIfNull(); - OverviewBean bean = new OverviewBean(); - bean.study = _study; - bean.showAll = "1".equals(getViewContext().get("showAll")); - bean.canManage = getContainer().hasPermission(getUser(), ManageStudyPermission.class); - bean.showCohorts = StudyManager.getInstance().showCohorts(getContainer(), getUser()); - bean.stats = form.getVisitStatistics(); - bean.showSpecimens = SpecimenManager.get().isSpecimenModuleActive(getContainer()); - - if (QCStateManager.getInstance().showStates(getContainer())) - bean.qcStates = QCStateSet.getSelectedStates(getContainer(), form.getQCState()); - - if (!bean.showCohorts) - bean.cohortFilter = null; - else - bean.cohortFilter = CohortFilterFactory.getFromURL(getContainer(), getUser(), getViewContext().getActionURL(), DatasetQueryView.DATAREGION); - - VisitManager visitManager = StudyManager.getInstance().getVisitManager(bean.study); - bean.visitMapSummary = visitManager.getVisitSummary(getUser(), bean.cohortFilter, bean.qcStates, bean.stats, bean.showAll); - - return new StudyJspView<>(_study, "/org/labkey/study/view/overview.jsp", bean, errors); - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("studyDashboard#navigator"); - root.addChild("Overview: " + _study.getLabel()); - } - } - - public static class QueryReportForm extends QueryViewAction.QueryExportForm - { - ReportIdentifier _reportId; - - public ReportIdentifier getReportId() - { - return _reportId; - } - - public void setReportId(ReportIdentifier reportId) - { - _reportId = reportId; - } - } - - @RequiresPermission(ReadPermission.class) - public static class QueryReportAction extends QueryViewAction - { - protected Report _report; - - public QueryReportAction() - { - super(QueryReportForm.class); - } - - @Override - protected ModelAndView getHtmlView(QueryReportForm form, BindException errors) throws Exception - { - Report report = getReport(form); - - if (report != null) - return report.getRunReportView(getViewContext()); - else - throw new NotFoundException("Unable to locate the requested report: " + form.getReportId()); - } - - @Override - protected QueryView createQueryView(QueryReportForm form, BindException errors, boolean forExport, String dataRegion) throws Exception - { - Report report = getReport(form); - if (report instanceof QueryReport) - return ((QueryReport)report).getQueryViewGenerator().generateQueryView(getViewContext(), report.getDescriptor()); - - return null; - } - - @Override - public void addNavTrail(NavTree root) - { - if (_report != null) - root.addChild(_report.getDescriptor().getReportName()); - else - root.addChild("Study Query Report"); - } - - protected Report getReport(QueryReportForm form) - { - if (_report == null) - { - ReportIdentifier identifier = form.getReportId(); - if (identifier != null) - _report = identifier.getReport(getViewContext()); - } - return _report; - } - } - - @RequiresPermission(ReadPermission.class) - public class DatasetReportAction extends QueryReportAction - { - @Override - protected Report getReport(QueryReportForm form) - { - if (_report == null) - { - String reportId = (String)getViewContext().get(DATASET_REPORT_ID_PARAMETER_NAME); - - ReportIdentifier identifier = ReportService.get().getReportIdentifier(reportId, getViewContext().getUser(), getViewContext().getContainer()); - if (identifier != null) - _report = identifier.getReport(getViewContext()); - } - return _report; - } - - @Override - protected ModelAndView getHtmlView(QueryReportForm form, BindException errors) throws Exception - { - ViewContext context = getViewContext(); - Report report = getReport(form); - - // is not a report (either the default grid view or a custom view)... - if (report == null) - { - return HttpView.redirect(createRedirectURLfrom(DatasetAction.class, context)); - } - - int datasetId = NumberUtils.toInt((String)context.get(Dataset.DATASET_KEY), -1); - Dataset def = StudyManager.getInstance().getDatasetDefinition(getStudyRedirectIfNull(), datasetId); - - if (def != null) - { - ActionURL url = getViewContext().cloneActionURL().setAction(StudyController.DatasetAction.class). - replaceParameter(DATASET_REPORT_ID_PARAMETER_NAME, report.getDescriptor().getReportId().toString()). - replaceParameter(Dataset.DATASET_KEY, def.getDatasetId()); - - return HttpView.redirect(url); - } - else if (ReportManager.get().canReadReport(getUser(), getContainer(), report)) - return report.getRunReportView(getViewContext()); - else - return HtmlView.of("User does not have read permission on this report."); - } - } - - private ActionURL createRedirectURLfrom(Class action, ViewContext context) - { - ActionURL newUrl = new ActionURL(action, context.getContainer()); - return newUrl.addParameters(context.getActionURL().getParameters()); - } - - @RequiresPermission(ReadPermission.class) - public class DatasetAction extends QueryViewAction - { - private DatasetDefinition _def; - - public DatasetAction() - { - super(DatasetFilterForm.class); - } - - private DatasetDefinition getDatasetDefinition() - { - if (null == _def) - { - Object datasetKeyObject = getViewContext().get(Dataset.DATASET_KEY); - if (datasetKeyObject instanceof List list) - { - // bug 7365: It's been specified twice -- once in the POST, once in the GET. Just need one of them. - datasetKeyObject = list.get(0); - } - if (null != datasetKeyObject) - { - try - { - int id = NumberUtils.toInt(String.valueOf(datasetKeyObject), 0); - _def = StudyManager.getInstance().getDatasetDefinition(getStudyRedirectIfNull(), id); - } - catch (ConversionException x) - { - throw new NotFoundException(); - } - } - else - { - String entityId = (String)getViewContext().get("entityId"); - if (null != entityId) - _def = StudyManager.getInstance().getDatasetDefinitionByEntityId(getStudyRedirectIfNull(), entityId); - } - } - if (null == _def) - throw new NotFoundException(); - return _def; - } - - @Override - public ModelAndView getView(DatasetFilterForm form, BindException errors) throws Exception - { - ActionURL url = getViewContext().getActionURL(); - String viewName = url.getParameter(DATASET_VIEW_NAME_PARAMETER_NAME); - - // if the view name refers to a report id (legacy style), redirect to use the newer report id parameter - if (NumberUtils.isDigits(viewName)) - { - // one last check to see if there is a view with that name before trying to redirect to the report - DatasetDefinition def = getDatasetDefinition(); - - if (def != null && - QueryService.get().getCustomView(getUser(), getContainer(), getUser(), StudySchema.getInstance().getSchemaName(), def.getName(), viewName) == null) - { - ReportIdentifier reportId = AbstractReportIdentifier.fromString(viewName, getViewContext().getUser(), getViewContext().getContainer()); - if (reportId != null && reportId.getReport(getViewContext()) != null) - { - ActionURL newURL = url.clone().deleteParameter(DATASET_VIEW_NAME_PARAMETER_NAME). - addParameter(DATASET_REPORT_ID_PARAMETER_NAME, reportId.toString()); - return HttpView.redirect(newURL); - } - } - } - return super.getView(form, errors); - } - - @Override - protected ModelAndView getHtmlView(DatasetFilterForm form, BindException errors) throws Exception - { - // the full resultset is a join of all datasets for each participant - // each dataset is determined by a visitid/datasetid - - // Ensure a study is present - getStudyRedirectIfNull(); - ViewContext context = getViewContext(); - - String export = StringUtils.trimToNull(context.getActionURL().getParameter("export")); - - DatasetDefinition def = getDatasetDefinition(); - if (null == def) - return new TypeNotFoundAction().getView(form, errors); - String typeURI = def.getTypeURI(); - if (null == typeURI) - return new TypeNotFoundAction().getView(form, errors); - - boolean showEditLinks = !QueryService.get().isQuerySnapshot(getContainer(), StudySchema.getInstance().getSchemaName(), def.getName()) && - !def.isPublishedData(); - - UserSchema schema = QueryService.get().getUserSchema(getUser(), getContainer(), StudyQuerySchema.SCHEMA_NAME); - DatasetQuerySettings settings = (DatasetQuerySettings)schema.getSettings(getViewContext(), DatasetQueryView.DATAREGION, def.getName()); - - settings.setShowEditLinks(showEditLinks); - settings.setShowSourceLinks(true); - - final ActionURL url = context.getActionURL(); - - // clear the property map cache and the sort map cache - getParticipantPropsMap(context).clear(); - getDatasetSortColumnMap(context).clear(); - - QueryView queryView = schema.createView(getViewContext(), settings, errors); - final TableInfo table = queryView.getTable(); - if (table != null) - { - setColumnURL(url, queryView, schema, def); - - // Clear any cached participant lists... not really necessary, since the cache key is now the entire - // query string (including all filters & sorts), but it doesn't really hurt. List is regenerated only if - // user navigates to an individual participant. - removeParticipantListFromSession(context); - getExpandedState(context, def.getDatasetId()).clear(); - } - - if (null != export) - { - if ("tsv".equals(export)) - queryView.exportToTsv(context.getResponse()); - else if ("xls".equals(export)) - queryView.exportToExcel(context.getResponse()); - return null; - } - - HtmlStringBuilder sb = HtmlStringBuilder.of(); - if (def.getDescription() != null && !def.getDescription().isEmpty()) - sb.unsafeAppend(PageFlowUtil.filter(def.getDescription(), true, true)).unsafeAppend("
"); - CohortFilter cohortFilter = queryView instanceof StudyQueryView studyQueryView ? studyQueryView.getCohortFilter() : null; - if (cohortFilter != null) - sb.unsafeAppend("
Cohort: ").append(cohortFilter.getDescription(getContainer(), getUser())).unsafeAppend(""); - - if (QCStateManager.getInstance().showStates(getContainer())) - { - String publicQCUrlFilterValue = getQCUrlFilterValue(QCStateSet.getPublicStates(getContainer())); - String privateQCUrlFilterValue = getQCUrlFilterValue(QCStateSet.getPrivateStates(getContainer())); - - for (QCStateSet set : QCStateSet.getSelectableSets(getContainer())) - { - String selectedQCLabel = selectedQCStateLabelFromUrl(getViewContext().getActionURL(), settings.getDataRegionName(), set.getLabel(), publicQCUrlFilterValue, privateQCUrlFilterValue); - if (selectedQCLabel != null && selectedQCLabel.equals(set.getLabel())) - { - sb.unsafeAppend("
QC States: ").append(set.getLabel()).unsafeAppend(""); - break; - } - } - } - if (ReportPropsManager.get().getPropertyValue(def.getEntityId(), getContainer(), "refreshDate") != null) - { - sb.unsafeAppend("
Data Cut Date: "); - Object refreshDate = (ReportPropsManager.get().getPropertyValue(def.getEntityId(), getContainer(), "refreshDate")); - if (refreshDate instanceof Date) - { - sb.append(DateUtil.formatDate(getContainer(), (Date)refreshDate)); - } - else - { - sb.append(ReportPropsManager.get().getPropertyValue(def.getEntityId(), getContainer(), "refreshDate").toString()); - } - } - HtmlView header = new HtmlView(sb); - VBox view = new VBox(header, queryView); - - String status = (String)ReportPropsManager.get().getPropertyValue(def.getEntityId(), getContainer(), "status"); - if (status != null) - { - // inject the dataset status marker class, but it is up to the client to style the page accordingly - HtmlView scriptLock = new HtmlView(HtmlString.unsafe("")); - view.addView(scriptLock); - } - - Report report = queryView.getSettings().getReportView(context); - if (report != null && !ReportManager.get().canReadReport(getUser(), getContainer(), report)) - { - return HtmlView.of("User does not have read permission on this report."); - } - else if (report == null && (null==table || !table.hasPermission(getUser(),ReadPermission.class))) - { - return HtmlView.of("User does not have read permission on this dataset."); - } - return view; - } - - @Override - protected QueryView createQueryView(DatasetFilterForm datasetFilterForm, BindException errors, boolean forExport, String dataRegion) throws Exception - { - QuerySettings qs = new QuerySettings(getViewContext(), DATASET_DATAREGION_NAME); - Report report = qs.getReportView(getViewContext()); - if (report instanceof QueryReport) - { - return ((QueryReport)report).getQueryViewGenerator().generateQueryView(getViewContext(), report.getDescriptor()); - } - return null; - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("gridBasics"); - _addNavTrail(root, getDatasetDefinition().getDatasetId(), getViewContext().getActionURL()); - } - } - - @RequiresNoPermission - public static class ExpandStateNotifyAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object o, BindException errors) - { - final ActionURL url = getViewContext().getActionURL(); - final String collapse = url.getParameter("collapse"); - final int datasetId = NumberUtils.toInt(url.getParameter(Dataset.DATASET_KEY), -1); - final int id = NumberUtils.toInt(url.getParameter("id"), -1); - - if (datasetId != -1 && id != -1) - { - Map expandedMap = getExpandedState(getViewContext(), id); - // collapse param is only set on a collapse action - if (collapse != null) - expandedMap.put(datasetId, "collapse"); - else - expandedMap.put(datasetId, "expand"); - } - return null; - } - - @Override - public void addNavTrail(NavTree root) - { - } - } - - - Participant findParticipant(Study study, String particpantId) throws StudyManager.ParticipantNotUniqueException - { - Participant participant = StudyManager.getInstance().getParticipant(study, particpantId); - if (participant == null) - { - if (study.isDataspaceStudy()) - { - Container c = StudyManager.getInstance().findParticipant(study, particpantId); - Study s = null == c ? null : StudyManager.getInstance().getStudy(c); - if (null != s && c.hasPermission(getUser(), ReadPermission.class)) - { - participant = StudyManager.getInstance().getParticipant(s, particpantId); - } - } - } - return participant; - } - - @RequiresPermission(ReadPermission.class) - public class ParticipantAction extends SimpleViewAction - { - private ParticipantForm _bean; - - @Override - public ModelAndView getView(ParticipantForm form, BindException errors) - { - Study study = getStudyRedirectIfNull(); - _bean = form; - ActionURL previousParticipantURL = null; - ActionURL nextParticipantURL = null; - Participant participant; - StringBuilder errorMsg = new StringBuilder(); - - if (form.getParticipantId() == null) - { - errorMsg.append("No ").append(study.getSubjectNounSingular()).append(" specified"); - } - else - { - try - { - participant = findParticipant(study, form.getParticipantId()); - if (null == participant) - errorMsg.append("Could not find ").append(study.getSubjectNounSingular()).append(" ").append(form.getParticipantId()); - } - catch (StudyManager.ParticipantNotUniqueException x) - { - errorMsg.append(x.getMessage()); - } - } - - if (!errorMsg.isEmpty()) - return HtmlView.err(errorMsg.toString()); - - String viewName = (String) getViewContext().get(DATASET_VIEW_NAME_PARAMETER_NAME); - - CohortFilter cohortFilter = CohortFilterFactory.getFromURL(getContainer(), getUser(), getViewContext().getActionURL(), DatasetQueryView.DATAREGION); - // display the next and previous buttons only if we have a cached participant index - if (cohortFilter != null && !StudyManager.getInstance().showCohorts(getContainer(), getUser())) - throw new UnauthorizedException("User does not have permission to view cohort information"); - - List participants = getParticipantListFromSession(getViewContext(), form.getDatasetId(), viewName); - - if (isDebug()) - { - _log.info("Cached participants: {}", participants); - } - int idx = participants.indexOf(form.getParticipantId()); - if (idx != -1) - { - if (idx > 0) - { - final String ptid = participants.get(idx-1); - previousParticipantURL = getViewContext().cloneActionURL(); - previousParticipantURL.replaceParameter("participantId", ptid); - } - - if (idx < participants.size()-1) - { - final String ptid = participants.get(idx+1); - nextParticipantURL = getViewContext().cloneActionURL(); - nextParticipantURL.replaceParameter("participantId", ptid); - } - } - - VBox vbox = new VBox(); - ParticipantNavView navView = new ParticipantNavView(previousParticipantURL, nextParticipantURL, form.getParticipantId(), null); - vbox.addView(navView); - - CustomParticipantView customParticipantView = StudyManager.getInstance().getCustomParticipantView(study); - if (customParticipantView != null && customParticipantView.isActive()) - { - vbox.addView(customParticipantView.getView()); - } - else - { - ModelAndView characteristicsView = StudyManager.getInstance().getParticipantDemographicsView(getContainer(), form, errors); - ModelAndView dataView = StudyManager.getInstance().getParticipantView(getContainer(), form, errors); - vbox.addView(characteristicsView); - vbox.addView(dataView); - } - - return vbox; - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("participantViews"); - _addNavTrail(root, _bean.getDatasetId(), _bean.getReturnActionURL()); - root.addChild(StudyService.get().getSubjectNounSingular(getContainer()) + " - " + id(_bean.getParticipantId())); - } - } - - @RequiresPermission(ReadPermission.class) - public class Participant2Action extends SimpleViewAction - { - // TODO participant list support? cohortfilter support? - // TODO define participant context -// { -// particpantId:"", -// participantGroup:"" -// demoMode:false -// } - - - @Override - public ModelAndView getView(ParticipantForm form, BindException errors) throws Exception - { - ViewContext context = getViewContext(); - Study study = getStudyRedirectIfNull(); - - if (form.getParticipantId() == null) - { - throw new NotFoundException("No " + study.getSubjectNounSingular() + " specified"); - } - - Participant participant; - try - { - participant = findParticipant(study, form.getParticipantId()); - if (null == participant) - throw new NotFoundException("Could not find " + study.getSubjectNounSingular() + " " + form.getParticipantId()); - } - catch (StudyManager.ParticipantNotUniqueException x) - { - return HtmlView.of(x.getMessage()); - } - - PageConfig page = getPageConfig(); - - // add participant to view context for java/jsp based web parts - context.put(Participant.class.getName(), participant); - // add to javascript context for file based web parts - page.getPortalContext().put("participantId", participant.getParticipantId()); - - String pageId = Participant.class.getName(); - boolean canCustomize = context.getContainer().hasPermission("populatePortalView",context.getUser(), AdminPermission.class); - - HttpView template = PageConfig.Template.Home.getTemplate(getViewContext(), new VBox(), page); - int parts = Portal.populatePortalView(getViewContext(), pageId, template, isPrint(), canCustomize, false, true, Portal.STUDY_PARTICIPANT_PORTAL_PAGE); - - if (parts == 0 && canCustomize) - { - // TODO: make webparts out of default views and actually save portal config -// ParticipantAction pa = new ParticipantAction(); -// pa.setViewContext(context); -// ModelAndView v = pa.getView(form, errors); -// Portal.addViewToRegion(template, WebPartFactory.LOCATION_BODY, (HttpView)v); - - // force page admin mode - template = PageConfig.Template.Home.getTemplate(getViewContext(), new VBox(), page); - Portal.populatePortalView(getViewContext(), pageId, template, isPrint(), canCustomize, true, true, Participant.class.getName()); - - } - - getPageConfig().setTemplate(PageConfig.Template.None); - return template; - } - - @Override - public void addNavTrail(NavTree root) - { - } - } - - - - // Obfuscate the passed in test if this user is in "demo" mode in this container - private String id(String id) - { - return id(id, getContainer(), getUser()); - } - - // Obfuscate the passed in test if this user is in "demo" mode in this container - private static String id(String id, Container c, User user) - { - return DemoMode.id(id, c, user); - } - - - @RequiresPermission(AdminPermission.class) - public class ImportVisitMapAction extends FormViewAction - { - @Override - public ModelAndView getView(ImportVisitMapForm form, boolean reshow, BindException errors) - { - StudyImpl study = getStudyThrowIfNull(); - redirectToSharedVisitStudy(study, getViewContext().getActionURL()); - return new StudyJspView<>(getStudyRedirectIfNull(), "/org/labkey/study/view/importVisitMap.jsp", null, errors); - } - - @Override - public void validateCommand(ImportVisitMapForm form, Errors errors) - { - } - - @Override - public boolean handlePost(ImportVisitMapForm form, BindException errors) throws Exception - { - VisitMapImporter importer = new VisitMapImporter(); - List errorMsg = new LinkedList<>(); - if (!importer.process(getUser(), getStudyThrowIfNull(), form.getContent(), VisitMapImporter.Format.Xml, errorMsg, _log)) - { - for (String error : errorMsg) - errors.reject("uploadVisitMap", error); - return false; - } - return true; - } - - @Override - public ActionURL getSuccessURL(ImportVisitMapForm form) - { - return new ActionURL(BeginAction.class, getContainer()); - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("importVisitMap"); - _addNavTrailVisitAdmin(root); - root.addChild("Import Visit Map"); - } - } - - @RequiresPermission(AdminPermission.class) - public class CreateStudyAction extends FormViewAction - { - @Override - public ModelAndView getView(StudyPropertiesForm form, boolean reshow, BindException errors) throws Exception - { - if (null != getStudy()) - { - BeginAction action = (BeginAction)initAction(this, new BeginAction()); - return action.getView(form, errors); - } - // Set default values for the form - if (form.getLabel() == null) - { - form.setLabel(HttpView.currentContext().getContainer().getName() + " Study"); - } - if (form.getStartDate() == null) - { - form.setStartDate(new Date()); - } - if (form.getDefaultTimepointDuration() == 0) - { - form.setDefaultTimepointDuration(1); - } - // NOTE: should be a better way to do this (e.g. get the correct value in the form/backend to begin with) - Study sharedStudy = getStudy(getContainer().getProject()); - if (sharedStudy != null && sharedStudy.getShareVisitDefinitions() == Boolean.TRUE) - { - form.setShareVisits(sharedStudy.getShareVisitDefinitions()); - form.setTimepointType(sharedStudy.getTimepointType()); - form.setStartDate(sharedStudy.getStartDate()); - form.setDefaultTimepointDuration(sharedStudy.getDefaultTimepointDuration()); - } - return new StudyJspView<>(null, "/org/labkey/study/view/createStudy.jsp", form, errors); - } - - @Override - public void validateCommand(StudyPropertiesForm target, Errors errors) - { - if (target.getTimepointType() == TimepointType.DATE && null == target.getStartDate()) - errors.reject(ERROR_MSG, "Start date must be supplied for a date-based study."); - - target.setLabel(StringUtils.trimToNull(target.getLabel())); - if (null == target.getLabel()) - errors.reject(ERROR_MSG, "Please supply a label"); - - String message; - - if (null != (message = StudyService.get().getSubjectColumnNameValidationErrorMessage(getContainer(), target.getSubjectColumnName()))) - errors.reject(ERROR_MSG, message); - - if (null != (message = StudyService.get().getSubjectNounSingularValidationErrorMessage(getContainer(), target.getSubjectNounSingular()))) - errors.reject(ERROR_MSG, message); - - if (null != (message = StudyService.get().getSubjectNounPluralValidationErrorMessage(getContainer(), target.getSubjectNounPlural()))) - errors.reject(ERROR_MSG, message); - } - - @Override - public boolean handlePost(StudyPropertiesForm form, BindException errors) - { - createStudy(getStudy(), getContainer(), getUser(), form); - return true; - } - - @Override - public ActionURL getSuccessURL(StudyPropertiesForm form) - { - return form.getSuccessActionURL(new ActionURL(ManageStudyAction.class, getContainer())); - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild("Create Study"); - } - } - - public static StudyImpl createStudy(@Nullable StudyImpl study, Container c, User user, StudyPropertiesForm form) - { - if (null == study) - { - study = new StudyImpl(c, form.getLabel()); - study.setTimepointType(form.getTimepointType()); - study.setStartDate(form.getStartDate()); - study.setEndDate(form.getEndDate()); - study.setSecurityType(form.getSecurityType()); - study.setSubjectNounSingular(form.getSubjectNounSingular()); - study.setSubjectNounPlural(form.getSubjectNounPlural()); - study.setSubjectColumnName(form.getSubjectColumnName()); - study.setAssayPlan(form.getAssayPlan()); - study.setDescription(form.getDescription()); - study.setDefaultTimepointDuration(Math.max(form.getDefaultTimepointDuration(), 1)); - if (form.getDescriptionRendererType() != null) - study.setDescriptionRendererType(form.getDescriptionRendererType()); - study.setGrant(form.getGrant()); - study.setInvestigator(form.getInvestigator()); - study.setSpecies(form.getSpecies()); - study.setAlternateIdPrefix(form.getAlternateIdPrefix()); - study.setAlternateIdDigits(form.getAlternateIdDigits()); - study.setAllowReqLocRepository(form.isAllowReqLocRepository()); - study.setAllowReqLocClinic(form.isAllowReqLocClinic()); - study.setAllowReqLocSal(form.isAllowReqLocSal()); - study.setAllowReqLocEndpoint(form.isAllowReqLocEndpoint()); - if (c.isProject()) - { - study.setShareDatasetDefinitions(form.isShareDatasets()); - study.setShareVisitDefinitions(form.isShareVisits()); - } - - study = StudyManager.getInstance().createStudy(user, study); - SpecimenMigrationService sms = SpecimenMigrationService.get(); - if (null != sms) - sms.setDefaultRequestabilityRules(c, user); - } - return study; - } - - @RequiresPermission(ManageStudyPermission.class) - public class ManageStudyAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object o, BindException errors) - { - return new StudyJspView<>(getStudy(), "/org/labkey/study/view/manageStudy.jsp", null, errors); - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("manageStudy"); - _addManageStudy(root); - } - } - - @RequiresPermission(DeletePermission.class) - public static class DeleteParticipantAction extends MutatingApiAction - { - @Override - public Object execute(DeleteParticipantForm deleteParticipantForm, BindException errors) throws Exception - { - //Note: In the EHR system, 'Participant' tables are prefixed with "Animal". For example, the equivalent of the - //Participant table is named Animal, and ParticipantGroupMap is AnimalGroupMap, etc. - //Additionally, the participantId column is labeled as "Id" in the Animal table and other "Animal" tables. - - DbSchema schema = StudySchema.getInstance().getSchema(); - - Study study = StudyManager.getInstance().getStudy(getContainer()); - if (study == null) - { - errors.reject(ERROR_MSG, "Study not found in this folder."); - return new ApiSimpleResponse("success", false); - } - String participantId = deleteParticipantForm.getParticipantId(); - String participantIdColumnName = study.getSubjectColumnName(); - String participantTableNamePrefix = study.getSubjectNounSingular(); - - try (DbScope.Transaction transaction = schema.getScope().ensureTransaction()) - { - _log.info("Starting participant deletion for ID: " + participantId); - List datasets = study.getDatasets(); - - //delete participant rows from datasets - for (Dataset dataset : datasets) - { - if (dataset.isDemographicData()) - deleteParticipantFromDemographics(dataset.getTableInfo(getUser()), participantIdColumnName, participantId, errors); - else - deleteParticipantFromDatasets(dataset.getTableInfo(getUser()), participantIdColumnName, participantId, errors); - } - - //delete from study.participantGroupMap - TableInfo participantGroupMapTable = QueryService.get().getUserSchema(getUser(), getContainer(), "study").getTable(participantTableNamePrefix + "GroupMap"); - if (null != participantGroupMapTable) - { - TableSelector ts = new TableSelector(participantGroupMapTable, Set.of(participantIdColumnName, "GroupId"), new SimpleFilter(FieldKey.fromString(participantIdColumnName), participantId), null); - ParticipantGroupManager.ParticipantGroupMap[] pgm = ts.getArray(ParticipantGroupManager.ParticipantGroupMap.class); - deleteFromParticipantGroupMapTable(participantGroupMapTable, participantId, participantIdColumnName, pgm, errors); - } - transaction.commit(); - } - ApiSimpleResponse response = new ApiSimpleResponse(); - response.put("success", !errors.hasErrors()); - if (errors.hasErrors()) - { - _log.error("Failed to delete participant: {}", participantId); - response.put("message", errors.getMessage()); - } - else - { - _log.info("Successfully deleted participant: {}", participantId); - response.put("message", "Successfully deleted participant " + participantId); - } - return response; - } - - private void deleteParticipantFromDemographics(TableInfo ti, String participantIdColumnName, String participantId, BindException errors) - { - ColumnInfo idCol = ti.getColumn(FieldKey.fromParts(participantIdColumnName)); - deleteParticipantRows(ti, Collections.singletonList(Collections.singletonMap(idCol.getName(), participantId)), errors); - } - - private void deleteParticipantFromDatasets(TableInfo ti, String participantIdColumnName, String participantId, BindException errors) - { - TableSelector ts = new TableSelector(ti, Collections.singleton(DatasetDomainKind.LSID), new SimpleFilter(FieldKey.fromString(participantIdColumnName), participantId), null); - deleteParticipantRows(ti, ts.getMapCollection().stream().toList(), errors); - } - - private void deleteParticipantRows(TableInfo ti, List> keys, BindException errors) - { - try - { - ti.getUpdateService().deleteRows(getUser(), getContainer(), keys, null, null); - } - catch (InvalidKeyException | BatchValidationException | QueryUpdateServiceException | SQLException e) - { - String msg = "Failed to delete participant rows from " + ti.getName(); - _log.error(msg, e); - errors.reject(ERROR_MSG, msg + ": " + e.getMessage()); - } - } - - private void deleteFromParticipantGroupMapTable(TableInfo ti, String participantId, String participantColName, ParticipantGroupManager.ParticipantGroupMap[] groups, BindException errors) - { - try - { - SQLFragment sql = new SQLFragment("DELETE FROM study.participantgroupmap WHERE participantid = ?", participantId); - new SqlExecutor(ti.getSchema()).execute(sql); - } - catch (Exception e) - { - String msg = "Failed to delete row from " + ti.getSchema().getName() + "." + ti.getName() + " for " + participantColName + " '" + participantId + "'"; - _log.error(msg, e); - errors.reject(ERROR_MSG, msg + " :" + e.getMessage()); - } - - for (ParticipantGroupManager.ParticipantGroupMap group : groups) - { - ParticipantGroupAuditProvider.ParticipantGroupAuditEvent event = ParticipantGroupAuditProvider.EventFactory.participantDeleted(participantId, getContainer(), group.getLabel(), group.getGroupId()); - AuditLogService.get().addEvent(getUser(), event); - } - } - } - - public static class DeleteParticipantForm - { - private String _participantId; - - public String getParticipantId() - { - return _participantId; - } - - public void setParticipantId(String participantId) - { - this._participantId = participantId; - } - } - - @RequiresPermission(AdminPermission.class) - public class DeleteStudyAction extends FormViewAction - { - @Override - public void validateCommand(DeleteStudyForm form, Errors errors) - { - if (!form.isConfirm()) - errors.reject("deleteStudy", "Need to confirm Study deletion"); - } - - @Override - public ModelAndView getView(DeleteStudyForm form, boolean reshow, BindException errors) - { - return new StudyJspView<>(getStudyRedirectIfNull(), "/org/labkey/study/view/confirmDeleteStudy.jsp", null, errors); - } - - @Override - public boolean handlePost(DeleteStudyForm form, BindException errors) - { - StudyManager.getInstance().deleteAllStudyData(getContainer(), getUser()); - return true; - } - - @Override - public ActionURL getSuccessURL(DeleteStudyForm deleteStudyForm) - { - return getContainer().getFolderType().getStartURL(getContainer(), getUser()); - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild("Confirm Delete Study"); - } - } - - public static class DeleteStudyForm - { - private boolean confirm; - - public boolean isConfirm() - { - return confirm; - } - - public void setConfirm(boolean confirm) - { - this.confirm = confirm; - } - } - - public static class RemoveProtocolDocumentForm - { - private String _name; - - public String getName() - { - return _name; - } - - public void setName(String name) - { - _name = name; - } - } - - @RequiresPermission(AdminPermission.class) - public class RemoveProtocolDocumentAction extends FormHandlerAction - { - @Override - public void validateCommand(RemoveProtocolDocumentForm target, Errors errors) - { - } - - @Override - public boolean handlePost(RemoveProtocolDocumentForm removeProtocolDocumentForm, BindException errors) throws Exception - { - Study study = getStudyThrowIfNull(); - study.removeProtocolDocument(removeProtocolDocumentForm.getName(), getUser()); - return true; - } - - @Override - public URLHelper getSuccessURL(RemoveProtocolDocumentForm removeProtocolDocumentForm) - { - return new ActionURL(ManageStudyPropertiesAction.class, getContainer()); - } - } - - @RequiresPermission(ReadPermission.class) - public class ManageStudyPropertiesAction extends FormApiAction - { - @Override - protected @NotNull TableViewForm getCommand(HttpServletRequest request) - { - User user = getUser(); - UserSchema schema = QueryService.get().getUserSchema(user, getContainer(), SchemaKey.fromParts(StudyQuerySchema.SCHEMA_NAME)); - TableViewForm form = new TableViewForm(schema.getTable("StudyProperties")); - form.setViewContext(getViewContext()); - return form; - } - - @Override - public ModelAndView getView(TableViewForm form, BindException errors) - { - Study study = getStudy(); - if (null == study) - throw new RedirectException(new ActionURL(CreateStudyAction.class, getContainer())); - return new StudyJspView<>(getStudy(), "/org/labkey/study/view/manageStudyPropertiesExt.jsp", study, null); - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("manageStudy"); - _addManageStudy(root); - root.addChild("Study Properties"); - } - - @Override - public void validateForm(TableViewForm form, Errors errors) - { - // Skip validation if Spring binding already has an error for subject noun singular - if (errors.getFieldError("SubjectNounSingular") == null) - { - // Issue 47444 and Issue 47881: Validate that subject noun singular doesn't match the name of an existing - // study table or dataset - String subjectNounSingular = form.get("SubjectNounSingular"); - if (null != subjectNounSingular) - { - String message = StudyService.get().getSubjectNounSingularValidationErrorMessage(getContainer(), subjectNounSingular); - if (message != null) - errors.reject(ERROR_MSG, message); - } - } - - // Skip validation if Spring binding already has an error for subject noun plural - if (errors.getFieldError("SubjectNounPlural") == null) - { - String subjectNounPlural = form.get("SubjectNounPlural"); - if (null != subjectNounPlural) - { - String message = StudyService.get().getSubjectNounPluralValidationErrorMessage(getContainer(), subjectNounPlural); - if (message != null) - errors.reject(ERROR_MSG, message); - } - } - - // Skip validation if Spring binding already has an error for subject column name - if (errors.getFieldError("SubjectColumnName") == null) - { - // Issue 43898: Validate that the subject column name is not a user-defined field in one of the datasets - String subjectColName = form.get("SubjectColumnName"); - if (null != subjectColName) - { - String message = StudyService.get().getSubjectColumnNameValidationErrorMessage(getContainer(), subjectColName); - if (message != null) - errors.reject(ERROR_MSG, message); - } - } - } - - @Override - public ApiResponse execute(TableViewForm form, BindException errors) throws Exception - { - if (!getContainer().hasPermission(getUser(),AdminPermission.class)) - throw new UnauthorizedException(); - - Map values = form.getTypedValues(); - values.put("container", getContainer().getId()); - - TableInfo studyProperties = form.getTable(); - QueryUpdateService qus = studyProperties.getUpdateService(); - if (null == qus) - throw new UnauthorizedException(); - try (DbScope.Transaction transaction = StudySchema.getInstance().getSchema().getScope().ensureTransaction()) - { - BatchValidationException batchErrors = new BatchValidationException(); - qus.updateRows(getUser(), getContainer(), Collections.singletonList(values), Collections.singletonList(values), batchErrors, null, null); - if (batchErrors.hasErrors()) - throw batchErrors; - List files = getAttachmentFileList(); - getStudyThrowIfNull().attachProtocolDocument(files, getUser()); - transaction.commit(); - } - catch (BatchValidationException x) - { - x.addToErrors(errors); - return null; - } - catch (AttachmentService.DuplicateFilenameException x) - { - JSONObject json = new JSONObject(); - json.put("failure", true); - json.put("msg", x.getMessage()); - return new ApiSimpleResponse(json); - } - - JSONObject json = new JSONObject(); - json.put("success", true); - return new ApiSimpleResponse(json); - } - } - - - @RequiresPermission(AdminPermission.class) - public class ManageVisitsAction extends FormViewAction - { - @Override - public void validateCommand(StudyPropertiesForm target, Errors errors) - { - StudyImpl study = getStudy(); - if (study.getTimepointType() == TimepointType.DATE) - { - if (target.getTimepointType() == TimepointType.DATE && null == target.getStartDate()) - errors.reject(ERROR_MSG, "Start date must be supplied for a date-based study."); - if (target.getDefaultTimepointDuration() < 1) - errors.reject(ERROR_MSG, "Default timepoint duration must be a positive number."); - } - } - - @Override - public ModelAndView getView(StudyPropertiesForm form, boolean reshow, BindException errors) throws Exception - { - StudyImpl study = getStudy(); - if (null == study) - { - CreateStudyAction action = (CreateStudyAction)initAction(this, new CreateStudyAction()); - return action.getView(form, false, errors); - } - - if (study.getTimepointType() == TimepointType.CONTINUOUS) - return HtmlView.err("Unsupported operation for continuous study"); - - redirectToSharedVisitStudy(study, getViewContext().getActionURL()); - - return new StudyJspView<>(study, _jspName(study), form, errors); - } - - @Override - public boolean handlePost(StudyPropertiesForm form, BindException errors) - { - StudyImpl study = getStudyThrowIfNull().createMutable(); - redirectToSharedVisitStudy(study, getViewContext().getActionURL()); - - if (study.getTimepointType() == TimepointType.DATE) - { - study.setStartDate(form.getStartDate()); - study.setDefaultTimepointDuration(form.getDefaultTimepointDuration()); - } - study.setFailForUndefinedTimepoints(form.isFailForUndefinedTimepoints()); - - StudyManager.getInstance().updateStudy(getUser(), study); - - return true; - } - - @Override - public ActionURL getSuccessURL(StudyPropertiesForm studyPropertiesForm) - { - return new ActionURL(ManageStudyAction.class, getContainer()); - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("manageVisits"); - _addNavTrailVisitAdmin(root); - } - - private String _jspName(Study study) - { - assert study.getTimepointType() != TimepointType.CONTINUOUS; - return study.getTimepointType() == TimepointType.DATE ? "/org/labkey/study/view/manageTimepoints.jsp" : "/org/labkey/study/view/manageVisits.jsp"; - } - } - - @RequiresPermission(AdminPermission.class) - public class ManageTypesAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object o, BindException errors) - { - return new StudyJspView<>(getStudyRedirectIfNull(), "/org/labkey/study/view/manageTypes.jsp", this, errors); - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("manageDatasets"); - _addManageStudy(root); - root.addChild("Manage Datasets"); - } - } - - @RequiresPermission(AdminPermission.class) - public class ManageFilewatchersAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object o, BindException errors) - { - return new StudyJspView<>(getStudyRedirectIfNull(), "/org/labkey/study/view/manageFilewatchers.jsp", this, errors); - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("fileWatcher"); - _addManageStudy(root); - root.addChild("Manage File Watchers"); - } - } - - @RequiresPermission(AdminPermission.class) - public class ManageLocationsAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object o, BindException errors) throws Exception - { - UserSchema schema = QueryService.get().getUserSchema(getUser(), getContainer(), StudyQuerySchema.SCHEMA_NAME); - QuerySettings settings = schema.getSettings(getViewContext(), QueryView.DATAREGIONNAME_DEFAULT, StudyQuerySchema.LOCATION_TABLE_NAME); - - return schema.createView(getViewContext(), settings, errors); - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("manageLocations"); - _addManageStudy(root); - root.addChild("Manage Locations"); - } - } - - @RequiresPermission(AdminPermission.class) - public static class DeleteAllUnusedLocationsAction extends ConfirmAction - { - @Override - public ModelAndView getConfirmView(LocationForm form, BindException errors) - { - List temp = new ArrayList<>(); - for (Container c : getContainers(form)) - { - if (c.hasPermission(getUser(), AdminPermission.class)) - { - LocationManager mgr = LocationManager.get(); - for (LocationImpl loc : mgr.getLocations(c)) - { - if (!mgr.isLocationInUse(loc)) - { - temp.add(c.getName() + "/" + loc.getLabel()); - } - } - } - } - String[] labels = new String[temp.size()]; - for(int i = 0; i("/org/labkey/study/view/confirmDeleteLocation.jsp", form, errors); - } - - @Override - public boolean handlePost(LocationForm form, BindException errors) throws Exception - { - for (Container c : getContainers(form)) - { - if (c.hasPermission(getUser(), AdminPermission.class)) - { - LocationManager mgr = LocationManager.get(); - for (LocationImpl loc : mgr.getLocations(c)) - { - if (!mgr.isLocationInUse(loc)) - { - mgr.deleteLocation(loc); - } - } - } - } - return true; - } - - @Override - public void validateCommand(LocationForm locationEditForm, Errors errors) - { - } - - @NotNull - @Override - public URLHelper getSuccessURL(LocationForm form) - { - return form.getReturnUrlHelper(); - } - - private Collection getContainers(LocationForm form) - { - String containerFilterName = form.getContainerFilter(); - - if (null != containerFilterName) - return LocationTable.getStudyContainers(getContainer(), ContainerFilter.getContainerFilterByName(form.getContainerFilter(), getContainer(), getUser())); - else - return Collections.singleton(getContainer()); - } - } - - public static class LocationForm extends ViewForm - { - private int[] _ids; - private String[] _labels; - private String _containerFilter; - public String[] getLabels() - { - return _labels; - } - - public void setLabels(String[] labels) - { - _labels = labels; - } - - public int[] getIds() - { - return _ids; - } - - public void setIds(int[] ids) - { - _ids = ids; - } - - public String getContainerFilter() - { - return _containerFilter; - } - - public void setContainerFilter(String containerFilter) - { - _containerFilter = containerFilter; - } - } - - - @RequiresPermission(AdminPermission.class) - public class VisitSummaryAction extends FormViewAction - { - private VisitImpl _v; - - @Override - public void validateCommand(VisitForm target, Errors errors) - { - StudyImpl study = getStudyRedirectIfNull(); - if (study.getTimepointType() == TimepointType.CONTINUOUS) - errors.reject(null, "Unsupported operation for continuous date study"); - - Study sharedStudy = StudyManager.getInstance().getSharedStudy(study); - if (sharedStudy != null && sharedStudy.getShareVisitDefinitions()) - errors.reject(null, "Can't edit visits in a study with shared visits"); - - target.validate(errors, study); - if (errors.getErrorCount() > 0) - return; - - VisitImpl visitBean = target.getBean(); - - //check for overlapping visits that the target num is within the range - VisitManager visitMgr = StudyManager.getInstance().getVisitManager(study); - if (visitMgr.isVisitOverlapping(visitBean)) - errors.reject(null, "Visit range overlaps with an existing visit in this study. Please enter a different range."); - } - - @Override - public ModelAndView getView(VisitForm form, boolean reshow, BindException errors) - { - StudyImpl study = getStudyRedirectIfNull(); - if (study.getTimepointType() == TimepointType.CONTINUOUS) - return HtmlView.err("Unsupported operation for continuous date study"); - - redirectToSharedVisitStudy(study, getViewContext().getActionURL()); - - int id = NumberUtils.toInt((String)getViewContext().get("id")); - _v = StudyManager.getInstance().getVisitForRowId(study, id); - if (_v == null) - { - return HttpView.redirect(new ActionURL(BeginAction.class, getContainer())); - } - VisitSummaryBean visitSummary = new VisitSummaryBean(); - visitSummary.setVisit(_v); - - return new StudyJspView<>(study, "/org/labkey/study/view/editVisit.jsp", visitSummary, errors); - } - - @Override - public boolean handlePost(VisitForm form, BindException errors) - { - VisitImpl postedVisit = form.getBean(); - if (!getContainer().getId().equals(postedVisit.getContainer().getId())) - throw new UnauthorizedException(); - - StudyImpl study = getStudyThrowIfNull(); - redirectToSharedVisitStudy(study, getViewContext().getActionURL()); - - // UNDONE: how do I get struts to handle this checkbox? - postedVisit.setShowByDefault(null != StringUtils.trimToNull((String)getViewContext().get("showByDefault"))); - - // UNDONE: reshow is broken for this form, but we have to validate - Collection visits = StudyManager.getInstance().getVisitManager(study).getVisits(); - boolean validRange = true; - // make sure there is no overlapping visit - for (VisitImpl v : visits) - { - if (v.getRowId() == postedVisit.getRowId()) - continue; - BigDecimal maxL = v.getSequenceNumMin().max(postedVisit.getSequenceNumMin()); - BigDecimal minR = v.getSequenceNumMax().min(postedVisit.getSequenceNumMax()); - if (maxL.compareTo(minR) <= 0) - { - errors.reject("visitSummary", getVisitLabel() + " range overlaps with '" + v.getDisplayString() + "'"); - validRange = false; - } - } - - if (!validRange) - { - return false; - } - - StudyManager.getInstance().updateVisit(getUser(), postedVisit); - - HashMap visitTypeMap = new IntHashMap<>(); - for (VisitDataset vds : postedVisit.getVisitDatasets()) - visitTypeMap.put(vds.getDatasetId(), vds.isRequired() ? VisitDatasetType.REQUIRED : VisitDatasetType.OPTIONAL); - - if (form.getDatasetIds() != null) - { - for (int i = 0; i < form.getDatasetIds().length; i++) - { - int datasetId = form.getDatasetIds()[i]; - VisitDatasetType type = VisitDatasetType.valueOf(form.getDatasetStatus()[i]); - VisitDatasetType oldType = visitTypeMap.get(datasetId); - if (oldType == null) - oldType = VisitDatasetType.NOT_ASSOCIATED; - if (type != oldType) - { - StudyManager.getInstance().updateVisitDatasetMapping(getUser(), getContainer(), - postedVisit.getRowId(), datasetId, type); - } - } - } - return true; - } - - @Override - public ActionURL getSuccessURL(VisitForm form) - { - return new ActionURL(ManageVisitsAction.class, getContainer()); - } - - @Override - public void addNavTrail(NavTree root) - { - _addNavTrailVisitAdmin(root); - root.addChild(_v.getDisplayString()); - } - } - - public static class VisitSummaryBean - { - private VisitImpl visit; - - public VisitImpl getVisit() - { - return visit; - } - - public void setVisit(VisitImpl visit) - { - this.visit = visit; - } - } - - @RequiresPermission(ManageStudyPermission.class) - public class StudyScheduleAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object o, BindException errors) throws Exception - { - return StudyModule.studyScheduleWebPartFactory.getWebPartView(getViewContext(), StudyModule.studyScheduleWebPartFactory.createWebPart()); - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("studySchedule"); - _addManageStudy(root); - root.addChild("Study Schedule"); - } - } - - @RequiresPermission(AdminPermission.class) - public class DeleteVisitAction extends FormHandlerAction - { - @Override - public void validateCommand(IdForm target, Errors errors) - { - StudyImpl study = getStudyThrowIfNull(); - if (study.getTimepointType() == TimepointType.CONTINUOUS) - errors.reject(null, "Unsupported operation for continuous date study"); - - - Study sharedStudy = StudyManager.getInstance().getSharedStudy(study); - if (sharedStudy != null && sharedStudy.getShareVisitDefinitions()) - errors.reject(null, "Can't edit visits in a study with shared visits"); - } - - @Override - public boolean handlePost(IdForm form, BindException errors) - { - int visitId = form.getId(); - StudyImpl study = getStudyThrowIfNull(); - - redirectToSharedVisitStudy(study, getViewContext().getActionURL()); - - VisitImpl visit = StudyManager.getInstance().getVisitForRowId(study, visitId); - if (visit != null) - { - StudyManager.getInstance().deleteVisit(study, visit, getUser()); - return true; - } - throw new NotFoundException(); - } - - @Override - public ActionURL getSuccessURL(IdForm idForm) - { - return new ActionURL(ManageVisitsAction.class, getContainer()); - } - } - - - @RequiresPermission(AdminPermission.class) - public class DeleteUnusedVisitsAction extends ConfirmAction - { - @Override - public void validateCommand(IdForm target, Errors errors) - { - StudyImpl study = getStudyThrowIfNull(); - if (study.getTimepointType() == TimepointType.CONTINUOUS) - errors.reject(null, "Unsupported operation for continuous date study"); - - Study sharedStudy = StudyManager.getInstance().getSharedStudy(study); - if (sharedStudy != null && sharedStudy.getShareVisitDefinitions()) - errors.reject(null, "Can't delete visits from a study with shared visits"); - } - - @Override - public ModelAndView getConfirmView(IdForm idForm, BindException errors) - { - if (getPageConfig().getTitle() == null) - setTitle("Delete Unused Visits"); - - StudyImpl study = getStudyThrowIfNull(); - - redirectToSharedVisitStudy(study, getViewContext().getActionURL()); - - Collection visits = getUnusedVisits(); - HtmlStringBuilder sb = HtmlStringBuilder.of(); - - if (visits.isEmpty()) - { - sb.unsafeAppend("No unused visits found.
"); - } - else - { - // Put them in a table to help with StudyTest verification - sb.unsafeAppend("\n"); - sb.unsafeAppend("\n\n"); - - for (VisitImpl visit : visits) - { - sb.unsafeAppend("\n"); - } - - sb.unsafeAppend("
Are you sure you want to delete the unused visits listed below?
 
") - .append(visit.getLabel()) - .append(" (") - .append(visit.getSequenceString()) - .append(")") - .unsafeAppend("
\n"); - } - - return new HtmlView(sb); - } - - @Override - public boolean handlePost(IdForm form, BindException errors) - { - long start = System.currentTimeMillis(); - StudyImpl study = getStudyThrowIfNull(); - - StudyManager.getInstance().deleteVisits(study, getUnusedVisits(), getUser(), true); - - _log.info("Delete unused visits took: " + DateUtil.formatDuration(System.currentTimeMillis() - start)); - - return true; - } - - private @NotNull Collection getUnusedVisits() - { - return new SqlSelector(StudySchema.getInstance().getSchema(), new SQLFragment( - "SELECT * FROM study.Visit v WHERE Container = ? AND rowid NOT IN (SELECT DISTINCT VisitRowId FROM study.ParticipantVisit pv WHERE pv.Container = ?)", - getContainer(), getContainer() - )).getArrayList(VisitImpl.class); - } - - @Override - @NotNull - public ActionURL getSuccessURL(IdForm idForm) - { - return new ActionURL(ManageVisitsAction.class, getContainer()); - } - } - - @RequiresPermission(AdminPermission.class) - public class BulkDeleteVisitsAction extends FormViewAction - { - private TimepointType _timepointType; - private List _visitsToDelete; - - @Override - public ModelAndView getView(DeleteVisitsForm form, boolean reshow, BindException errors) - { - StudyImpl study = getStudyRedirectIfNull(); - _timepointType = study.getTimepointType(); - - Study sharedStudy = StudyManager.getInstance().getSharedStudy(study); - if (sharedStudy != null && sharedStudy.getShareVisitDefinitions()) - return HtmlView.err("Can't delete visits from a study with shared visits."); - - if (_timepointType == TimepointType.CONTINUOUS) - return HtmlView.err("Unsupported operation for continuous study."); - - return new StudyJspView<>(getStudyRedirectIfNull(), "/org/labkey/study/view/bulkVisitDelete.jsp", form, errors); - } - - @Override - public void addNavTrail(NavTree root) - { - _addNavTrailVisitAdmin(root); - root.addChild("Delete " + (_timepointType == TimepointType.DATE ? "Timepoints" : "Visits")); - } - - @Override - public void validateCommand(DeleteVisitsForm form, Errors errors) - { - StudyImpl study = getStudyThrowIfNull(); - - Study sharedStudy = StudyManager.getInstance().getSharedStudy(study); - if (sharedStudy != null && sharedStudy.getShareVisitDefinitions()) - { - errors.reject(null, "Can't delete visits from a study with shared visits."); - return; - } - - int[] visitIds = form.getVisitIds(); - if (visitIds == null || visitIds.length == 0) - { - errors.reject(ERROR_MSG, "No " + (_timepointType == TimepointType.DATE ? "timepoints" : "visits") + " selected."); - return; - } - - _visitsToDelete = new ArrayList<>(); - for (int id : visitIds) - { - VisitImpl visit = StudyManager.getInstance().getVisitForRowId(study, id); - if (visit == null) - errors.reject(ERROR_MSG, "Unable to find visit for id " + id); - else - _visitsToDelete.add(visit); - } - } - - @Override - public boolean handlePost(DeleteVisitsForm form, BindException errors) - { - long start = System.currentTimeMillis(); - StudyImpl study = getStudyThrowIfNull(); - StudyManager.getInstance().deleteVisits(study, _visitsToDelete, getUser(), false); - _log.info("Bulk delete visits took: " + DateUtil.formatDuration(System.currentTimeMillis() - start)); - return true; - } - - @Override - public ActionURL getSuccessURL(DeleteVisitsForm form) - { - return new ActionURL(ManageVisitsAction.class, getContainer()); - } - } - - public static class DeleteVisitsForm extends ReturnUrlForm - { - private int[] _visitIds; - - public int[] getVisitIds() - { - return _visitIds; - } - - public void setVisitIds(int[] visitIds) - { - _visitIds = visitIds; - } - } - - @RequiresPermission(AdminPermission.class) - public class ConfirmDeleteVisitAction extends SimpleViewAction - { - private VisitImpl _visit; - private TimepointType _timepointType; - - @Override - public ModelAndView getView(IdForm form, BindException errors) - { - int visitId = form.getId(); - StudyImpl study = getStudyRedirectIfNull(); - _timepointType = study.getTimepointType(); - - if (_timepointType == TimepointType.CONTINUOUS) - return HtmlView.err("Unsupported operation for continuous study"); - - redirectToSharedVisitStudy(study, getViewContext().getActionURL()); - - _visit = StudyManager.getInstance().getVisitForRowId(study, visitId); - if (null == _visit) - throw new NotFoundException(); - - return new StudyJspView<>(study, "/org/labkey/study/view/confirmDeleteVisit.jsp", _visit, errors); - } - - @Override - public void addNavTrail(NavTree root) - { - String noun = _timepointType == TimepointType.DATE ? "Timepoint" : "Visit"; - root.addChild("Delete " + noun + " -- " + _visit.getDisplayString()); - } - } - - @RequiresPermission(AdminPermission.class) - public class CreateVisitAction extends FormViewAction - { - @Override - public void validateCommand(VisitForm target, Errors errors) - { - StudyImpl study = getStudyThrowIfNull(); - if (study.getTimepointType() == TimepointType.CONTINUOUS) - errors.reject(null, "Unsupported operation for continuous date study"); - - Study sharedStudy = StudyManager.getInstance().getSharedStudy(study); - if (sharedStudy != null && sharedStudy.getShareVisitDefinitions()) - errors.reject(null, "Can't create visits in a study with shared visits"); - - target.validate(errors, study); - if (errors.getErrorCount() > 0) - return; - - //check for overlapping visits - VisitManager visitMgr = StudyManager.getInstance().getVisitManager(study); - if (visitMgr.isVisitOverlapping(target.getBean())) - errors.reject(null, "Visit range overlaps with an existing visit in this study. Please enter a different range."); - } - - @Override - public ModelAndView getView(VisitForm form, boolean reshow, BindException errors) - { - StudyImpl study = getStudyRedirectIfNull(); - - if (study.getTimepointType() == TimepointType.CONTINUOUS) - errors.reject(null, "Unsupported operation for continuous date study"); - - redirectToSharedVisitStudy(study, getViewContext().getActionURL()); - - form.setReshow(reshow); - return new StudyJspView<>(study, "/org/labkey/study/view/createVisit.jsp", form, errors); - } - - @Override - public boolean handlePost(VisitForm form, BindException errors) - { - VisitImpl visit = form.getBean(); - if (visit != null) - StudyManager.getInstance().createVisit(getStudyThrowIfNull(), getUser(), visit); - return true; - } - - @Override - public ActionURL getSuccessURL(VisitForm visitForm) - { - return visitForm.getReturnActionURL(); - } - - @Override - public void addNavTrail(NavTree root) - { - _addNavTrailVisitAdmin(root); - root.addChild("Create New " + getVisitLabel()); - } - } - - /** - * Called from the vaccine design webpart for the study design module - */ - @RequiresPermission(UpdatePermission.class) - public class CreateVisitForVaccineDesign extends MutatingApiAction - { - @Override - public void validateForm(VisitForm form, Errors errors) - { - if (!StudyDesignManager.get().isModuleActive(getContainer())) - { - errors.reject(ERROR_MSG, "This action can only be called if the study design module is active"); - return; - } - - Study study = getStudy(getContainer()); - boolean isDateBased = study.getTimepointType() == TimepointType.DATE; - - form.validate(errors, study); - if (errors.getErrorCount() > 0) - return; - - //check for overlapping visits - VisitManager visitMgr = StudyManager.getInstance().getVisitManager(study); - String range = isDateBased ? "day range" : "sequence range"; - if (visitMgr.isVisitOverlapping(form.getBean())) - errors.reject(null, "The visit " + range + " provided overlaps with an existing visit in this study. Please enter a different " + range + "."); - } - - @Override - public ApiResponse execute(VisitForm form, BindException errors) - { - ApiSimpleResponse response = new ApiSimpleResponse(); - - VisitImpl visit = form.getBean(); - visit = StudyManager.getInstance().createVisit(getStudyThrowIfNull(), getUser(), visit); - - response.put("RowId", visit.getRowId()); - response.put("Label", visit.getDisplayString()); - response.put("SequenceNumMin", visit.getSequenceNumMin()); - response.put("DisplayOrder", visit.getDisplayOrder()); - response.put("Included", true); - response.put("success", true); - - return response; - } - } - - @RequiresPermission(AdminPermission.class) - public class UpdateDatasetVisitMappingAction extends FormViewAction - { - private DatasetDefinition _def; - - @Override - public void validateCommand(DatasetForm form, Errors errors) - { - if (null == form.getDatasetId() || form.getDatasetId() < 1) - { - errors.reject(SpringActionController.ERROR_MSG, "DatasetId must be a positive integer."); - } - else - { - _def = StudyManager.getInstance().getDatasetDefinition(getStudyRedirectIfNull(), form.getDatasetId()); - if (null == _def) - errors.reject(SpringActionController.ERROR_MSG, "Dataset not found."); - } - } - - @Override - public ModelAndView getView(DatasetForm form, boolean reshow, BindException errors) throws Exception - { - validateCommand(form, errors); - - if (errors.hasErrors()) - { - getPageConfig().setTemplate(PageConfig.Template.Dialog); - return new SimpleErrorView(errors); - } - - return new JspView<>("/org/labkey/study/view/updateDatasetVisitMapping.jsp", _def, errors); - } - - @Override - public boolean handlePost(DatasetForm form, BindException errors) - { - DatasetDefinition modified = _def.createMutable(); - if (null != form.getVisitRowIds()) - { - for (int i = 0; i < form.getVisitRowIds().length; i++) - { - int visitRowId = form.getVisitRowIds()[i]; - VisitDatasetType type = VisitDatasetType.valueOf(form.getVisitStatus()[i]); - if (modified.getVisitType(visitRowId) != type) - { - StudyManager.getInstance().updateVisitDatasetMapping(getUser(), getContainer(), - visitRowId, form.getDatasetId(), type); - } - } - } - return true; - } - - @Override - public ActionURL getSuccessURL(DatasetForm datasetForm) - { - return new ActionURL(DatasetDetailsAction.class, getContainer()).addParameter("id", datasetForm.getDatasetId()); - } - - @Override - public void addNavTrail(NavTree root) - { - _addNavTrailDatasetAdmin(root); - if (_def != null) - { - VisitManager visitManager = StudyManager.getInstance().getVisitManager(getStudyThrowIfNull()); - root.addChild("Edit " + _def.getLabel() + " " + visitManager.getPluralLabel()); - } - } - } - - - @RequiresPermission(InsertPermission.class) - public class ImportAction extends AbstractQueryImportAction - { - private ImportDatasetForm _form = null; - private StudyImpl _study = null; - private DatasetDefinition _def = null; - private TableInfo _table = null; - - @Override - protected void initRequest(ImportDatasetForm form) throws ServletException - { - _form = form; - _study = getStudyRedirectIfNull(); - - if ((_study.getParticipantAliasDatasetId() != null) && (_study.getParticipantAliasDatasetId() == form.getDatasetId())) - { - super.setImportMessage("This is the Alias Dataset. You do not need to include information for the date column."); - } - - _def = StudyManager.getInstance().getDatasetDefinition(_study, form.getDatasetId()); - if (null == _def && null != form.getName()) - _def = StudyManager.getInstance().getDatasetDefinitionByName(_study, form.getName()); - if (null == _def) - throw new NotFoundException("Dataset not found"); - if (null == _def.getTypeURI()) - return; - - - User user = getUser(); - // Go through normal getTable() codepath to be sure all metadata is applied - _table = StudyQuerySchema.createSchema(_study, user).getDatasetTable(_def, null); - if (_table == null) - throw new NotFoundException("Dataset not found"); - setTarget(_table); - - if (!_table.hasPermission(user, InsertPermission.class) && getUser().isGuest()) - throw new UnauthorizedException(); - } - - @Override - protected boolean canInsert(User user) - { - return _table.hasPermission(user, InsertPermission.class); - } - - @Override - protected boolean canUpdate(User user) - { - return _table.hasPermission(user, UpdatePermission.class); - } - - @Override - public ModelAndView getView(ImportDatasetForm form, BindException errors) throws Exception - { - initRequest(form); - - // TODO need a shorthand for this check - if (_def.isShared() && _def.getContainer().equals(_def.getDefinitionContainer())) - return new HtmlView("Error", HtmlString.of("Cannot insert dataset data in this folder. Use a sub-study to import data.")); - - if (_def.getTypeURI() == null) - throw new NotFoundException("Dataset is not yet defined."); - - if (null == PipelineService.get().findPipelineRoot(getContainer())) - return new RequirePipelineView(_study, true, errors); - - boolean showImportOptions = OptionalFeatureService.get().isFeatureEnabled(EXPERIMENTAL_ALLOW_MERGE_WITH_MANAGED_KEYS) || _def.getKeyManagementType() == Dataset.KeyManagementType.None; - setShowMergeOption(showImportOptions); - setShowUpdateOption(showImportOptions); - setSuccessMessageSuffix("imported"); //Works for when the merge option is selected (may include updates) vs default "inserted" - return getDefaultImportView(form, errors); - } - - @Override - protected int importData(DataLoader dl, FileStream file, String originalName, BatchValidationException errors, @Nullable AuditBehaviorType auditBehaviorType, @Nullable TransactionAuditProvider.TransactionAuditEvent auditEvent, @Nullable String auditUserComment) - { - if (null == PipelineService.get().findPipelineRoot(getContainer())) - { - errors.addRowError(new ValidationException("Pipeline file system is not setup.")); - return -1; - } - - // Allow for mapping of the ParticipantId and Sequence Num (i.e. timepoint column), - // these are passed in for the "create dataset from a file and import data" case - Map columnMap = new CaseInsensitiveHashMap<>(); - if (null != _form.getParticipantId()) - columnMap.put(_form.getParticipantId(),"ParticipantId"); - if (null != _form.getSequenceNum()) - { - String column = _def.getDomainKind().getKindName().equalsIgnoreCase(DateDatasetDomainKind.KIND_NAME) ? "Date" : "SequenceNum"; - columnMap.put(_form.getSequenceNum(), column); - } - - Pair, UploadLog> result = StudyPublishManager.getInstance().importDatasetTSV(getUser(), _study, _def, dl, getLookupResolutionType(), file, originalName, columnMap, errors, _form.getInsertOption(), auditBehaviorType); - - if (!result.getKey().isEmpty()) - { - // Log the import when SUMMARY is configured, if DETAILED is configured the DetailedAuditLogDataIterator will handle each row change. - // It would be nice in the future to replace the DetailedAuditLogDataIterator with a general purpose AuditLogDataIterator - // that can delegate the audit behavior type to the AuditDataHandler, so this code can go away - // - String comment = "Dataset data imported. " + result.getKey().size() + " rows imported"; - new DatasetDefinition.DatasetAuditHandler(_def).addAuditEvent(getUser(), getContainer(), AuditBehaviorType.SUMMARY, comment, result.getValue()); - } - - return result.getKey().size(); - } - - @Override - public ActionURL getSuccessURL(ImportDatasetForm form) - { - return new ActionURL(DatasetAction.class, getContainer()).addParameter(Dataset.DATASET_KEY, form.getDatasetId()); - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild(_study.getLabel(), new ActionURL(BeginAction.class, getContainer())); - ActionURL datasetURL = new ActionURL(DatasetAction.class, getContainer()). - addParameter(Dataset.DATASET_KEY, _form.getDatasetId()); - root.addChild(_def.getName(), datasetURL); - root.addChild("Import Data"); - } - } - - @RequiresPermission(AdminPermission.class) - public class ImportDatasetSchemaAction extends FormViewAction - { - @Override - public void validateCommand(ImportDatasetSchemaForm target, Errors errors) - { - } - - @Override - public ModelAndView getView(ImportDatasetSchemaForm form, boolean reshow, BindException errors) - { - return new StudyJspView<>(getStudyRedirectIfNull(), "/org/labkey/study/view/importDatasetSchema.jsp", form, errors); - } - - @Override - public boolean handlePost(ImportDatasetSchemaForm form, BindException errors) throws ImportException - { - if (form.getManifest() == null) - errors.reject(null, "Manifest is required."); - - if (form.getMetadata() == null) - errors.reject(null, "Metadata is required."); - - if (errors.hasErrors()) - return false; - - DatasetsDocument.Datasets manifestDatasetsDoc; - - try - { - manifestDatasetsDoc = DatasetsDocument.Factory.parse(form.getManifest(), XmlBeansUtil.getDefaultParseOptions()).getDatasets(); - } - catch (XmlException e) - { - errors.reject(null, "Invalid manifest XML: " + e.getMessage()); - return false; - } - - TablesDocument tablesDoc; - - try - { - tablesDoc = TablesDocument.Factory.parse(form.getMetadata(), XmlBeansUtil.getDefaultParseOptions()); - } - catch (XmlException e) - { - errors.reject(null, "Invalid metadata XML: " + e.getMessage()); - return false; - } - - SchemaReader reader = new SchemaXmlReader(getStudyThrowIfNull(), "metadata XML", tablesDoc, manifestDatasetsDoc); - - ComplianceService complianceService = ComplianceService.get(); - return StudyManager.getInstance().importDatasetSchemas(getStudyThrowIfNull(), getUser(), reader, errors, false, true, complianceService.getCurrentActivity(getViewContext())); - } - - @Override - public ActionURL getSuccessURL(ImportDatasetSchemaForm bulkImportTypesForm) - { - return new ActionURL(ManageTypesAction.class, getContainer()); - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("DatasetBulkDefinition"); - _addNavTrailDatasetAdmin(root); - root.addChild("Import Dataset Schema"); - } - } - - public static class ImportDatasetSchemaForm - { - private String _metadata; - private String _manifest; - - public String getMetadata() - { - return _metadata; - } - - @SuppressWarnings("unused") - public void setMetadata(String metadata) - { - _metadata = metadata; - } - - public String getManifest() - { - return _manifest; - } - - @SuppressWarnings("unused") - public void setManifest(String manifest) - { - _manifest = manifest; - } - } - - @RequiresPermission(UpdatePermission.class) - public class ShowUploadHistoryAction extends SimpleViewAction - { - String _datasetLabel; - - @Override - public ModelAndView getView(IdForm form, BindException errors) - { - TableInfo tInfo = StudySchema.getInstance().getTableInfoUploadLog(); - DataRegion dr = new DataRegion(); - dr.addColumns(tInfo, "RowId,Created,CreatedBy,Status,Description"); - GridView gv = new GridView(dr, errors); - DisplayColumn dc = new SimpleDisplayColumn(null) { - @Override - public void renderGridCellContents(RenderContext ctx, HtmlWriter out) - { - ActionURL url = new ActionURL(DownloadTsvAction.class, ctx.getContainer()).addParameter("id", String.valueOf(ctx.get("RowId"))); - out.write(LinkBuilder.labkeyLink("Download Data File", url)); - } - }; - dr.addDisplayColumn(dc); - - SimpleFilter filter = SimpleFilter.createContainerFilter(getContainer()); - if (form.getId() != 0) - { - filter.addCondition(Dataset.DATASET_KEY, form.getId()); - DatasetDefinition dsd = StudyManager.getInstance().getDatasetDefinition(getStudyRedirectIfNull(), form.getId()); - if (dsd != null) - _datasetLabel = dsd.getLabel(); - } - - gv.setFilter(filter); - return gv; - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild("Upload History" + (null != _datasetLabel ? " for " + _datasetLabel : "")); - } - } - - @RequiresPermission(UpdatePermission.class) - public static class DownloadTsvAction extends SimpleViewAction - { - @Override - public ModelAndView getView(IdForm form, BindException errors) throws Exception - { - UploadLog ul = StudyPublishManager.getInstance().getUploadLog(getContainer(), form.getId()); - PageFlowUtil.streamFile(getViewContext().getResponse(), new File(ul.getFilePath()).toPath(), true); - - return null; - } - - @Override - public void addNavTrail(NavTree root) - { - } - } - - @RequiresPermission(ReadPermission.class) - public static class DatasetItemDetailsAction extends SimpleViewAction - { - @Override - public ModelAndView getView(SourceLsidForm form, BindException errors) - { - ActionURL url = LsidManager.get().getDisplayURL(form.getSourceLsid()); - if (url == null) - { - return HtmlView.of("The assay run that produced the data has been deleted."); - } - return HttpView.redirect(url); - } - - @Override - public void addNavTrail(NavTree root) - { - } - } - - public static class PublishHistoryDetailsForm - { - private @Nullable Integer _protocolId; - private @Nullable Integer _sampleTypeId; - private int _datasetId; - private String _sourceLsid; - private int _recordCount; - - public Integer getProtocolId() - { - return _protocolId; - } - - public void setProtocolId(Integer protocolId) - { - _protocolId = protocolId; - } - - public Integer getSampleTypeId() - { - return _sampleTypeId; - } - - public void setSampleTypeId(Integer sampleTypeId) - { - _sampleTypeId = sampleTypeId; - } - - public int getDatasetId() - { - return _datasetId; - } - - public void setDatasetId(int datasetId) - { - _datasetId = datasetId; - } - - public String getSourceLsid() - { - return _sourceLsid; - } - - public void setSourceLsid(String sourceLsid) - { - _sourceLsid = sourceLsid; - } - - public int getRecordCount() - { - return _recordCount; - } - - public void setRecordCount(int recordCount) - { - _recordCount = recordCount; - } - } - - @RequiresPermission(ReadPermission.class) - public class PublishHistoryDetailsAction extends SimpleViewAction - { - @Override - public ModelAndView getView(PublishHistoryDetailsForm form, BindException errors) - { - final StudyImpl study = getStudyRedirectIfNull(); - - VBox view = new VBox(); - - int datasetId = form.getDatasetId(); - final DatasetDefinition def = StudyManager.getInstance().getDatasetDefinition(study, datasetId); - - if (def != null) - { - final StudyQuerySchema querySchema = StudyQuerySchema.createSchema(study, getUser()); - DatasetQuerySettings qs = (DatasetQuerySettings)querySchema.getSettings(getViewContext(), DatasetQueryView.DATAREGION, def.getName()); - - if (!def.canRead(getUser())) - { - //requiresLogin(); - view.addView(new HtmlView(HtmlString.of("User does not have read permission on this dataset."))); - } - else - { - Integer protocolId = form.getProtocolId(); - Integer sampleTypeId = form.getSampleTypeId(); - - if (protocolId == null && sampleTypeId == null) - throw new IllegalArgumentException("Expected either a protocolId or sampleId parameter"); - - String sourceLsid = form.getSourceLsid(); // the assay protocol or sample type LSID - int recordCount = form.getRecordCount(); - - ActionURL deleteURL = new ActionURL(DeletePublishedRowsAction.class, getContainer()); - deleteURL.addParameter("publishSourceId", protocolId != null ? protocolId : sampleTypeId); - deleteURL.addParameter("sourceLsid", sourceLsid); - final ActionButton deleteRows = new ActionButton(deleteURL, "Recall Rows"); - - deleteRows.setRequiresSelection(true, "Recall selected row of this dataset?", "Recall selected rows of this dataset?"); - deleteRows.setActionType(ActionButton.Action.POST); - deleteRows.setDisplayPermission(DeletePermission.class); - - PublishedRecordQueryView qv = new PublishedRecordQueryView(querySchema, qs, sourceLsid, def.getPublishSource(), - protocolId != null ? protocolId : sampleTypeId, recordCount) { - - @Override - protected void populateButtonBar(DataView view, ButtonBar bar) - { - bar.add(deleteRows); - } - }; - - view.addView(qv); - } - } - else - view.addView(new HtmlView(HtmlString.of("The Dataset does not exist."))); - return view; - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild("Link to Study History Details"); - } - } - - @RequiresPermission(DeletePermission.class) - public class DeletePublishedRowsAction extends FormHandlerAction - { - private DatasetDefinition _def; - private Collection _allLsids; - private MultiValuedMap> _sourceLsidToLsidPair; - private Long _sourceRowId = null; - - @Override - public void validateCommand(DeleteDatasetRowsForm target, Errors errors) - { - _def = StudyManager.getInstance().getDatasetDefinition(getStudyThrowIfNull(), target.getDatasetId()); - if (_def == null) - throw new IllegalArgumentException("Could not find a dataset definition for id: " + target.getDatasetId()); - if (!target.isDeleteAllData()) - { - _allLsids = DataRegionSelection.getSelected(getViewContext(), true); - - if (_allLsids.isEmpty()) - { - errors.reject("deletePublishedRows", "No rows were selected"); - } - } - else - { - _allLsids = StudyManager.getInstance().getDatasetLSIDs(getUser(), _def); - } - - // Need to handle this by groups of source lsids -- each assay or SampleType container needs logging - _sourceLsidToLsidPair = new ArrayListValuedHashMap<>(); - List rowIds = new ArrayList<>(); - List> data = _def.getDatasetRows(getUser(), _allLsids); - - for (Map row : data) - { - String sourceLSID = (String)row.get(StudyPublishService.SOURCE_LSID_PROPERTY_NAME); - String datasetRowLsid = (String)row.get(StudyPublishService.LSID_PROPERTY_NAME); - Long rowId = MapUtils.getLong(row,StudyPublishService.ROWID_PROPERTY_NAME); - rowIds.add(rowId); - if (sourceLSID != null && datasetRowLsid != null) - _sourceLsidToLsidPair.put(sourceLSID, Pair.of(datasetRowLsid, rowId)); - - if (_sourceRowId == null && rowId != null) - _sourceRowId = rowId; - } - - String errorMsg = StudyPublishService.get().checkForLockedLinks(_def, rowIds); - if (!StringUtils.isEmpty(errorMsg)) - errors.reject(ERROR_MSG, errorMsg); - } - - @Override - public boolean handlePost(DeleteDatasetRowsForm form, BindException errors) - { - String originalSourceLsid = (String)getViewContext().get("sourceLsid"); - - Dataset.PublishSource publishSource = _def.getPublishSource(); - if (form.getPublishSourceId() != null && publishSource != null) - { - for (Map.Entry>> entry : _sourceLsidToLsidPair.asMap().entrySet()) - { - String sourceLsid = entry.getKey(); - Collection> pairs = entry.getValue(); - Container sourceContainer = publishSource.resolveSourceLsidContainer(sourceLsid, _sourceRowId); - if (sourceContainer != null) - StudyPublishService.get().addRecallAuditEvent(sourceContainer, getUser(), _def, pairs.size(), pairs); - } - } - - _def.deleteDatasetRows(getUser(), _allLsids); - - // if the recall was initiated from link to study details view of the publish source, redirect back to the same view - if (publishSource != null && originalSourceLsid != null && form.getPublishSourceId() != null) - { - Container container = publishSource.resolveSourceLsidContainer(originalSourceLsid, _sourceRowId); - if (container != null) - throw new RedirectException(StudyPublishService.get().getPublishHistory(container, publishSource, form.getPublishSourceId())); - } - return true; - } - - @Override - public ActionURL getSuccessURL(DeleteDatasetRowsForm form) - { - return new ActionURL(DatasetAction.class, getContainer()). - addParameter(Dataset.DATASET_KEY, form.getDatasetId()); - } - } - - public static class DeleteDatasetRowsForm - { - private int datasetId; - private boolean deleteAllData; - private Long _publishSourceId; - - public int getDatasetId() - { - return datasetId; - } - - public void setDatasetId(int datasetId) - { - this.datasetId = datasetId; - } - - public boolean isDeleteAllData() - { - return deleteAllData; - } - - public void setDeleteAllData(boolean deleteAllData) - { - this.deleteAllData = deleteAllData; - } - - public Long getPublishSourceId() - { - return _publishSourceId; - } - - public void setPublishSourceId(Long publishSourceId) - { - _publishSourceId = publishSourceId; - } - } - - // Dataset.canDelete() permissions check is below. This accommodates dataset security, where user might not have delete permission in the folder. - @RequiresPermission(ReadPermission.class) - public class DeleteDatasetRowsAction extends FormHandlerAction - { - @Override - public void validateCommand(DeleteDatasetRowsForm target, Errors errors) - { - } - - @Override - public boolean handlePost(DeleteDatasetRowsForm form, BindException errors) throws Exception - { - int datasetId = form.getDatasetId(); - StudyImpl study = getStudyThrowIfNull(); - StudyQuerySchema schema = StudyQuerySchema.createSchema(study, getUser()); - DatasetDefinition dataset = StudyManager.getInstance().getDatasetDefinition(study, datasetId); - TableInfo datasetTable = null==dataset ? null : schema.getDatasetTable(dataset, null); - - if (null == dataset || null == datasetTable) - throw new NotFoundException(); - - if (!datasetTable.hasPermission(getUser(), DeletePermission.class)) - throw new UnauthorizedException("User does not have permission to delete rows from this dataset"); - - // Operate on each individually for audit logging purposes, but transact the whole thing - DbScope scope = StudySchema.getInstance().getSchema().getScope(); - - try (DbScope.Transaction transaction = scope.ensureTransaction()) - { - Set lsids = DataRegionSelection.getSelected(getViewContext(), null, false); - List> keys = new ArrayList<>(lsids.size()); - for (String lsid : lsids) - keys.add(Collections.singletonMap("lsid", lsid)); - - QueryUpdateService qus = datasetTable.getUpdateService(); - assert qus != null; - - qus.deleteRows(getUser(), getContainer(), keys, null, null); - - transaction.commit(); - return true; - } - finally - { - DataRegionSelection.clearAll(getViewContext(), null); - } - } - - @Override - public ActionURL getSuccessURL(DeleteDatasetRowsForm form) - { - return new ActionURL(DatasetAction.class, getContainer()). - addParameter(Dataset.DATASET_KEY, form.getDatasetId()); - } - } - - public static class OverviewBean - { - public StudyImpl study; - public Map visitMapSummary; - public boolean showAll; - public boolean canManage; - public CohortFilter cohortFilter; - public boolean showCohorts; - public QCStateSet qcStates; - public Set stats; - public boolean showSpecimens; - } - - /** - * Tweak the link url for participant view so that it contains enough information to regenerate - * the cached list of participants. - */ - private void setColumnURL(final ActionURL url, final QueryView queryView, - final UserSchema querySchema, final Dataset def) - { - List columns; - try - { - columns = queryView.getDisplayColumns(); - } - catch (QueryParseException qpe) - { - return; - } - - // push any filter, sort params, and viewname - ActionURL base = new ActionURL(ParticipantAction.class, querySchema.getContainer()); - base.addParameter(Dataset.DATASET_KEY, Integer.toString(def.getDatasetId())); - for (Pair param : url.getParameters()) - { - if ((param.getKey().contains(".sort")) || - (param.getKey().contains("~")) || - (DATASET_VIEW_NAME_PARAMETER_NAME.equals(param.getKey()))) - { - base.addParameter(param.getKey(), param.getValue()); - } - } - base.addReturnUrl(url); // Set current URL so participant page can navigate back (nav trail) - - for (DisplayColumn col : columns) - { - String subjectColName = StudyService.get().getSubjectColumnName(def.getContainer()); - if (subjectColName.equalsIgnoreCase(col.getName())) - { - StringExpression old = col.getURLExpression(); - ContainerContext cc = old instanceof DetailsURL ? ((DetailsURL)old).getContainerContext() : null; - DetailsURL dets = new DetailsURL(base, "participantId", col.getColumnInfo().getFieldKey()); - dets.setContainerContext(null != cc ? cc : getContainer()); - col.setURLExpression(dets); - } - } - } - - public static ActionURL getProtocolDocumentDownloadURL(Container c, String name) - { - ActionURL url = new ActionURL(ProtocolDocumentDownloadAction.class, c); - url.addParameter("name", name); - - return url; - } - - @RequiresPermission(ReadPermission.class) - public class ProtocolDocumentDownloadAction extends BaseDownloadAction - { - @Override - public @Nullable Pair getAttachment(AttachmentForm form) - { - StudyImpl study = getStudyRedirectIfNull(); - return new Pair<>(study.getProtocolDocumentAttachmentParent(), form.getName()); - } - } - - private static final String PARTICIPANT_PROPS_CACHE = "Study_participants/propertyCache"; - private static final String DATASET_SORT_COLUMN_CACHE = "Study_participants/datasetSortColumnCache"; - @SuppressWarnings("unchecked") - private static Map> getParticipantPropsMap(ViewContext context) - { - HttpSession session = context.getRequest().getSession(true); - Map> map = (Map>) session.getAttribute(PARTICIPANT_PROPS_CACHE); - if (map == null) - { - map = new HashMap<>(); - session.setAttribute(PARTICIPANT_PROPS_CACHE, map); - } - return map; - } - - public static List getParticipantPropsFromCache(ViewContext context, String typeURI) - { - Map> map = getParticipantPropsMap(context); - List props = map.get(typeURI); - if (props == null) - { - props = OntologyManager.getPropertiesForType(typeURI, context.getContainer()); - map.put(typeURI, props); - } - return props; - } - - @SuppressWarnings("unchecked") - private static Map> getDatasetSortColumnMap(ViewContext context) - { - HttpSession session = context.getRequest().getSession(true); - Map> map = (Map>) session.getAttribute(DATASET_SORT_COLUMN_CACHE); - if (map == null) - { - map = new HashMap<>(); - session.setAttribute(DATASET_SORT_COLUMN_CACHE, map); - } - return map; - } - - public static @NotNull Map getSortedColumnList(ViewContext context, Dataset dsd) - { - Map> map = getDatasetSortColumnMap(context); - Map sortMap = map.get(dsd.getLabel()); - - if (sortMap == null) - { - QueryDefinition qd = QueryService.get().getQueryDef(context.getUser(), dsd.getContainer(), "study", dsd.getName()); - if (qd == null) - { - UserSchema schema = QueryService.get().getUserSchema(context.getUser(), context.getContainer(), "study"); - qd = schema.getQueryDefForTable(dsd.getName()); - } - CustomView cview = qd.getCustomView(context.getUser(), context.getRequest(), null); - if (cview != null) - { - sortMap = new HashMap<>(); - int i = 0; - for (FieldKey key : cview.getColumns()) - { - final String name = key.toString(); - if (!sortMap.containsKey(name)) - sortMap.put(name, i++); - } - map.put(dsd.getLabel(), sortMap); - } - else - { - // there is no custom view for this dataset - sortMap = Collections.emptyMap(); - map.put(dsd.getLabel(), Collections.emptyMap()); - } - } - return new CaseInsensitiveHashMap<>(sortMap); - } - - private static String getParticipantListCacheKey(ViewContext context) - { - // The query string includes all parameters that affect the participant list: dataset id, filters, sorts, etc. - // But need to strip off the participant ID parameter. - return context.cloneActionURL().deleteParameter("participantId").getQueryString(); - } - - public static void removeParticipantListFromSession(ViewContext context) - { - Cache> cache = getParticipantMapFromSession(context); - String key = getParticipantListCacheKey(context); - // Guava Cache doesn't tolerate null keys - if (key != null) - { - _log.debug("Invalidate participant list with key: {}", key); - cache.invalidate(key); - } - } - - @SuppressWarnings("unchecked") - private static Cache> getParticipantMapFromSession(ViewContext context) - { - HttpSession session = context.getRequest().getSession(true); - Cache> map = (Cache>) session.getAttribute(PARTICIPANT_CACHE_PREFIX); - if (map == null) - { - // Use a cache to limit the size (10) and to keep entries for no more than 10 minutes after last access - map = CacheBuilder.newBuilder().maximumSize(10).expireAfterAccess(10, TimeUnit.MINUTES).build(); - session.setAttribute(PARTICIPANT_CACHE_PREFIX, map); - } - return map; - } - - @SuppressWarnings("unchecked") - public static Map getExpandedState(ViewContext viewContext, int datasetId) - { - HttpSession session = viewContext.getRequest().getSession(true); - Map> map = (Map>) session.getAttribute(EXPAND_CONTAINERS_KEY); - if (map == null) - { - map = new HashMap<>(); - session.setAttribute(EXPAND_CONTAINERS_KEY, map); - } - - return map.computeIfAbsent(datasetId, k -> new HashMap<>()); - } - - public static @NotNull List getParticipantListFromSession(ViewContext context, int dataset, String viewName) - { - Cache> cache = getParticipantMapFromSession(context); - String key = getParticipantListCacheKey(context); - List ret = Collections.emptyList(); - - // Short-circuit for navigation from somewhere other than a dataset... esp. since Guava Cache doesn't tolerate null keys - if (null != key && dataset > 0) - { - try - { - ret = cache.get(key, () -> generateParticipantListFromURL(context, dataset, viewName)); - } - catch (ExecutionException ignored) - { - // Shouldn't ever happen since our loader doesn't throw exceptions - } - _log.debug("Get participant list of size {} with key: {}", ret.size(), key); - } - - return ret; - } - - private static List generateParticipantListFromURL(ViewContext context, int dataset, String viewName) - { - List ret; - try - { - final StudyManager studyMgr = StudyManager.getInstance(); - final StudyImpl study = studyMgr.getStudy(context.getContainer()); - - DatasetDefinition def = studyMgr.getDatasetDefinition(study, dataset); - if (null == def) - return Collections.emptyList(); - String typeURI = def.getTypeURI(); - if (null == typeURI) - return Collections.emptyList(); - - StudyQuerySchema querySchema = StudyQuerySchema.createSchema(study, context.getUser()); - QuerySettings qs = querySchema.getSettings(context, DatasetQueryView.DATAREGION, def.getName()); - qs.setViewName(viewName); - - QueryView queryView = querySchema.createView(context, qs, null); - - ret = generateParticipantList(queryView); - } - catch (Exception ignored) - { - ret = Collections.emptyList(); - } - - _log.debug("Generate participant list of size {}", ret.size()); - - return ret; - } - - public static List generateParticipantList(QueryView queryView) - { - final TableInfo table = queryView.getTable(); - - if (table != null) - { - try - { - // Do a single-column query to get the list of participants that match the filter criteria for this - // dataset - FieldKey ptidKey = FieldKey.fromParts(StudyService.get().getSubjectColumnName(queryView.getContainer())); - Map columns = QueryService.get().getColumns(table, Collections.singleton(ptidKey)); - ColumnInfo ptidColumnInfo = columns.get(ptidKey); - // Don't bother unless we actually found the participant column (we always should) - if (ptidColumnInfo != null) - { - // Go through the RenderContext directly to get the ResultSet so that we don't also end up calculating - // row counts or other aggregates we don't care about - DataView dataView = queryView.createDataView(); - RenderContext ctx = dataView.getRenderContext(); - DataRegion dataRegion = dataView.getDataRegion(); - queryView.getSettings().setShowRows(ShowRows.ALL); - try (Results results = ctx.getResults(columns, dataRegion.getDisplayColumns(), table, queryView.getSettings(), dataRegion.getQueryParameters(), Table.ALL_ROWS, dataRegion.getOffset(), dataRegion.getName(), false)) - { - int ptidIndex = ptidColumnInfo.findColumn(results); - - Set participantSet = new LinkedHashSet<>(); - while (results.next() && ptidIndex > 0) - { - String ptid = results.getString(ptidIndex); - participantSet.add(ptid); - } - - return new ArrayList<>(participantSet); - } - } - } - catch (Exception x) - { - throw new RuntimeException(x); - } - } - return Collections.emptyList(); - } - - public class ManageQCStatesBean extends AbstractManageQCStatesBean - { - ManageQCStatesBean(ActionURL returnUrl) - { - super(returnUrl); - _qcStateHandler = new StudyQCStateHandler(); - _manageAction = new ManageQCStatesAction(); - _deleteAction = DeleteQCStateAction.class; - _noun = "dataset"; - _dataNoun = "study"; - } - } - - public static class ManageQCStatesForm extends AbstractManageDataStatesForm - { - private Long _defaultPipelineQCState; - private Long _defaultPublishDataQCState; - private Long _defaultDirectEntryQCState; - private boolean _showPrivateDataByDefault; - - public Long getDefaultPipelineQCState() - { - return _defaultPipelineQCState; - } - - public void setDefaultPipelineQCState(Long defaultPipelineQCState) - { - _defaultPipelineQCState = defaultPipelineQCState; - } - - public Long getDefaultPublishDataQCState() - { - return _defaultPublishDataQCState; - } - - public void setDefaultPublishDataQCState(Long defaultPublishDataQCState) - { - _defaultPublishDataQCState = defaultPublishDataQCState; - } - - public Long getDefaultDirectEntryQCState() - { - return _defaultDirectEntryQCState; - } - - public void setDefaultDirectEntryQCState(Long defaultDirectEntryQCState) - { - _defaultDirectEntryQCState = defaultDirectEntryQCState; - } - - public boolean isShowPrivateDataByDefault() - { - return _showPrivateDataByDefault; - } - - public void setShowPrivateDataByDefault(boolean showPrivateDataByDefault) - { - _showPrivateDataByDefault = showPrivateDataByDefault; - } - } - - public static ActionURL getManageQCStatesURL(Container c, @NotNull ActionURL returnUrl) - { - return new ActionURL(ManageQCStatesAction.class, c).addReturnUrl(returnUrl); - } - - @RequiresPermission(AdminPermission.class) - public class ManageQCStatesAction extends AbstractManageQCStatesAction - { - public ManageQCStatesAction() - { - super(new StudyQCStateHandler(), ManageQCStatesForm.class); - } - - @Override - public ModelAndView getView(ManageQCStatesForm manageQCStatesForm, boolean reshow, BindException errors) - { - return new JspView<>("/org/labkey/api/qc/view/manageQCStates.jsp", - new ManageQCStatesBean(manageQCStatesForm.getReturnActionURL()), errors); - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("manageQC"); - _addManageStudy(root); - root.addChild("Manage Dataset QC States"); - } - - @Override - public URLHelper getSuccessURL(ManageQCStatesForm manageQCStatesForm) - { - ActionURL successUrl = getSuccessURL(manageQCStatesForm, ManageQCStatesAction.class, ManageStudyAction.class); - if (!manageQCStatesForm.isReshowPage() && !manageQCStatesForm.isShowPrivateDataByDefault()) - return getQCStateFilteredURL(successUrl, PUBLIC_STATES_LABEL, DATASET_DATAREGION_NAME, getContainer()); - - return successUrl; - } - - @Override - public boolean hasQcStateDefaultsPanel() - { - return true; - } - - @Override - public HtmlString getQcStateDefaultsPanel(Container container, DataStateHandler qcStateHandler) - { - _study = StudyController.getStudyThrowIfNull(container); - - HtmlStringBuilder panelHtml = HtmlStringBuilder.of(); - panelHtml.unsafeAppend(" "); - panelHtml.unsafeAppend(" "); - panelHtml.unsafeAppend(" "); - panelHtml.unsafeAppend(" "); - panelHtml.unsafeAppend(" "); - panelHtml.unsafeAppend(" "); - panelHtml.append(getQcStateHtml(container, qcStateHandler, "defaultPipelineQCState", _study.getDefaultPipelineQCState())); - panelHtml.unsafeAppend(" "); - panelHtml.unsafeAppend(" "); - panelHtml.unsafeAppend(" "); - panelHtml.append(getQcStateHtml(container, qcStateHandler, "defaultPublishDataQCState", _study.getDefaultPublishDataQCState())); - panelHtml.unsafeAppend(" "); - panelHtml.unsafeAppend(" "); - panelHtml.unsafeAppend(" "); - panelHtml.append(getQcStateHtml(container, qcStateHandler, "defaultDirectEntryQCState", _study.getDefaultDirectEntryQCState())); - panelHtml.unsafeAppend(" "); - panelHtml.unsafeAppend("
These settings allow different default QC states depending on data source."); - panelHtml.unsafeAppend(" If set, all imported data without an explicit QC state will have the selected state automatically assigned.
Pipeline imported datasets:
Data linked to this study:
Directly inserted/updated dataset data:
"); - - return panelHtml.getHtmlString(); - } - - @Override - public boolean hasDataVisibilityPanel() - { - return true; - } - - @Override - public HtmlString getDataVisibilityPanel(Container container, DataStateHandler qcStateHandler) - { - HtmlStringBuilder panelHtml = HtmlStringBuilder.of(); - panelHtml.unsafeAppend(" "); - panelHtml.unsafeAppend(" "); - panelHtml.unsafeAppend(" "); - panelHtml.unsafeAppend(" "); - panelHtml.unsafeAppend(" "); - panelHtml.unsafeAppend(" "); - panelHtml.unsafeAppend(" "); - panelHtml.unsafeAppend(" "); - panelHtml.unsafeAppend("
This setting determines whether users see non-public data by default."); - panelHtml.unsafeAppend(" Users can always explicitly choose to see data in any QC state.
Default visibility:"); - panelHtml.unsafeAppend(" "); - panelHtml.unsafeAppend("
"); - - return panelHtml.getHtmlString(); - } - - @Override - public boolean hasRequiresCommentPanel() - { - return false; - } - - @Override - public HtmlString getRequiresCommentPanel(Container container, DataStateHandler qcStateHandler) - { - throw new IllegalStateException("This action does not support a requires comment panel."); - } - } - - @RequiresPermission(AdminPermission.class) - public class DeleteQCStateAction extends AbstractDeleteDataStateAction - { - public DeleteQCStateAction() - { - super(); - _dataStateHandler = new StudyQCStateHandler(); - } - - @Override - public ActionURL getSuccessURL(DeleteDataStateForm form) - { - ActionURL returnUrl = new ActionURL(ManageQCStatesAction.class, getContainer()); - if (form.getManageReturnUrl() != null) - returnUrl.addParameter(ActionURL.Param.returnUrl, form.getManageReturnUrl()); - return returnUrl; - } - } - - public static class UpdateQCStateForm extends ReturnUrlForm - { - private String _comments; - private boolean _update; - private int _datasetId; - private String _dataRegionSelectionKey; - private Long _newState; - private DatasetQueryView _queryView; - private String _dataRegionName; - - public String getComments() - { - return _comments; - } - - public void setComments(String comments) - { - _comments = comments; - } - - public boolean isUpdate() - { - return _update; - } - - public void setUpdate(boolean update) - { - _update = update; - } - - public int getDatasetId() - { - return _datasetId; - } - - public void setDatasetId(int datasetId) - { - _datasetId = datasetId; - } - - public String getDataRegionSelectionKey() - { - return _dataRegionSelectionKey; - } - - public void setDataRegionSelectionKey(String dataRegionSelectionKey) - { - _dataRegionSelectionKey = dataRegionSelectionKey; - } - - public Long getNewState() - { - return _newState; - } - - public void setNewState(Long newState) - { - _newState = newState; - } - - public void setQueryView(DatasetQueryView queryView) - { - _queryView = queryView; - } - - public DatasetQueryView getQueryView() - { - return _queryView; - } - - public String getDataRegionName() - { - return _dataRegionName; - } - - public void setDataRegionName(String dataRegionName) - { - _dataRegionName = dataRegionName; - } - } - - @RequiresPermission(QCAnalystPermission.class) - public class UpdateQCStateAction extends FormViewAction - { - private UpdateQCStateForm _form; - - @Override - public void validateCommand(UpdateQCStateForm updateQCForm, Errors errors) - { - if (updateQCForm.isUpdate()) - { - if (updateQCForm.getComments() == null || updateQCForm.getComments().isEmpty()) - errors.reject(null, "Comments are required."); - } - } - - @Override - public ModelAndView getView(UpdateQCStateForm updateQCForm, boolean reshow, BindException errors) - { - StudyImpl study = getStudyRedirectIfNull(); - _form = updateQCForm; - int datasetId = updateQCForm.getDatasetId(); - DatasetDefinition def = StudyManager.getInstance().getDatasetDefinition(study, datasetId); - if (def == null) - { - throw new NotFoundException("No dataset found for id: " + datasetId); - } - Set lsids = null; - if (isPost()) - lsids = DataRegionSelection.getSelected(getViewContext(), updateQCForm.getDataRegionSelectionKey(), false); - if (lsids == null || lsids.isEmpty()) - return HtmlView.unsafe("No data rows selected. " + LinkBuilder.labkeyLink("back").onClick("back()")); - - StudyQuerySchema querySchema = StudyQuerySchema.createSchema(study, getUser()); - DatasetQuerySettings qs = new DatasetQuerySettings(getViewContext().getBindPropertyValues(), DatasetQueryView.DATAREGION); - - qs.setSchemaName(querySchema.getSchemaName()); - qs.setQueryName(def.getName()); - qs.setMaxRows(Table.ALL_ROWS); - qs.setShowSourceLinks(false); - qs.setShowEditLinks(false); - - final Set finalLsids = lsids; - - DatasetQueryView queryView = new DatasetQueryView(querySchema, qs, errors) - { - @Override - public DataView createDataView() - { - DataView view = super.createDataView(); - view.getDataRegion().setSortable(false); - view.getDataRegion().setShowFilters(false); - view.getDataRegion().setShowRecordSelectors(false); - view.getDataRegion().setShowPagination(false); - SimpleFilter filter = (SimpleFilter) view.getRenderContext().getBaseFilter(); - if (null == filter) - { - filter = new SimpleFilter(); - view.getRenderContext().setBaseFilter(filter); - } - filter.addInClause(FieldKey.fromParts("lsid"), new ArrayList<>(finalLsids)); - return view; - } - }; - queryView.setShowDetailsColumn(false); - updateQCForm.setQueryView(queryView); - updateQCForm.setDataRegionSelectionKey(DataRegionSelection.getSelectionKeyFromRequest(getViewContext())); - updateQCForm.setDataRegionName(queryView.getSettings().getDataRegionName()); - return new JspView<>("/org/labkey/study/view/updateQCState.jsp", updateQCForm, errors); - } - - @Override - public boolean handlePost(UpdateQCStateForm updateQCForm, BindException errors) - { - if (!updateQCForm.isUpdate()) - return false; - Set lsids = DataRegionSelection.getSelected(getViewContext(), updateQCForm.getDataRegionSelectionKey(), false); - - DataState newState = null; - if (updateQCForm.getNewState() != null) - { - newState = QCStateManager.getInstance().getStateForRowId(getContainer(), updateQCForm.getNewState()); - if (newState == null) - { - errors.reject(null, "The selected state could not be found. It may have been deleted from the database."); - return false; - } - } - StudyManager.getInstance().updateDataQCState(getContainer(), getUser(), - updateQCForm.getDatasetId(), lsids, newState, updateQCForm.getComments()); - - // if everything has succeeded, we can clear our saved checkbox state now: - DataRegionSelection.clearAll(getViewContext(), updateQCForm.getDataRegionSelectionKey()); - return true; - } - - @Override - public ActionURL getSuccessURL(UpdateQCStateForm updateQCForm) - { - ActionURL url = updateQCForm.getReturnActionURL(); - if (null == url) - { - // We've lost the returnUrl... at least redirect back to the dataset - url = new ActionURL(DatasetAction.class, getContainer()); - url.addParameter(Dataset.DATASET_KEY, updateQCForm.getDatasetId()); - } - if (updateQCForm.getNewState() != null) - url.replaceParameter(getQCUrlFilterKey(CompareType.EQUAL, updateQCForm.getDataRegionName()), QCStateManager.getInstance().getStateForRowId(getContainer(), updateQCForm.getNewState().longValue()).getLabel()); - return url; - } - - @Override - public void addNavTrail(NavTree root) - { - root = _addNavTrail(root, _form.getDatasetId(), _form.getReturnActionURL()); - root.addChild("Change QC State"); - } - } - - public static class ResetPipelinePathForm extends PipelinePathForm - { - private String _redirect; - - public String getRedirect() - { - return _redirect; - } - - public void setRedirect(String redirect) - { - _redirect = redirect; - } - } - - @RequiresPermission(AdminPermission.class) - public static class ResetPipelineAction extends FormHandlerAction - { - @Override - public void validateCommand(ResetPipelinePathForm form, Errors errors) - { - } - - @Override - public boolean handlePost(ResetPipelinePathForm form, BindException errors) throws Exception - { - for (FileLike f : form.getValidatedFiles(getContainer())) - { - if (f.isFile() && f.getName().endsWith(".lock")) - { - f.delete(); - } - } - return true; - } - - @Override - public URLHelper getSuccessURL(ResetPipelinePathForm form) - { - String redirect = form.getRedirect(); - if (null != redirect) - { - try - { - return new URLHelper(redirect); - } - catch (URISyntaxException e) - { - _log.warn("ResetPipelineAction redirect string invalid: " + redirect); - } - } - return urlProvider(PipelineStatusUrls.class).urlBegin(getContainer()); - } - } - - @RequiresPermission(ReadPermission.class) - public class DefaultDatasetReportAction extends SimpleRedirectAction - { - @Override - public ActionURL getRedirectURL(Object o) - { - ViewContext context = getViewContext(); //_study.isShowPrivateDataByDefault() - Object unparsedDatasetId = context.get(Dataset.DATASET_KEY); - - try - { - int datasetId = null == unparsedDatasetId ? 0 : Integer.parseInt(unparsedDatasetId.toString()); - - ActionURL url = context.cloneActionURL(); - url.setAction(DatasetReportAction.class); - - String defaultView = getDefaultView(context, datasetId); - if (!StringUtils.isEmpty(defaultView)) - { - ReportIdentifier reportId = ReportService.get().getReportIdentifier(defaultView, getViewContext().getUser(), getViewContext().getContainer()); - if (reportId != null) - url.addParameter(DATASET_REPORT_ID_PARAMETER_NAME, defaultView); - else - url.addParameter(DATASET_VIEW_NAME_PARAMETER_NAME, defaultView); - } - - if (!"1".equals(url.getParameter("skipDataVisibility"))) - { - StudyImpl studyImpl = StudyManager.getInstance().getStudy(getContainer()); - if (studyImpl != null && !studyImpl.isShowPrivateDataByDefault()) - url = getQCStateFilteredURL(url, PUBLIC_STATES_LABEL, DATASET_DATAREGION_NAME, getContainer()); - } - return url; - } - catch (NumberFormatException e) - { - throw new NotFoundException("No such dataset with ID: " + unparsedDatasetId); - } - } - } - - public static ActionURL getViewPreferencesURL(Container c, int id, String viewName) - { - // Issue 26030: we don't distinguish null vs empty string for url parameters. - // Empty string will be converted to null for beans so "" shouldn't be used as the url param for Default Grid View. - return new ActionURL(ViewPreferencesAction.class, c).addParameter(Dataset.DATASET_KEY, id).addParameter("defaultView", viewName != null ? (viewName.isEmpty() ? "defaultGrid": viewName) : null); - } - - public static class ViewPreferencesForm extends DatasetController.DatasetIdForm - { - private String _defaultView; - - public String getDefaultView() - { - return _defaultView; - } - - @SuppressWarnings("unused") - public void setDefaultView(String defaultView) - { - _defaultView = "defaultGrid".equals(defaultView) ? "" : defaultView; - } - } - - @RequiresPermission(ReadPermission.class) - @RequiresLogin // Don't set a default view for guests, Issue 52863 - public class ViewPreferencesAction extends FormViewAction - { - private StudyImpl _study; - private Dataset _def; - - private int init(ViewPreferencesForm form) - { - int dsid = form.getDatasetId(); - _study = getStudyRedirectIfNull(); - _def = StudyManager.getInstance().getDatasetDefinition(_study, dsid); - return dsid; - } - - @Override - public ModelAndView getView(ViewPreferencesForm form, boolean reshow, BindException errors) throws Exception - { - init(form); - if (_def != null) - { - List> views = ReportManager.get().getReportLabelsForDataset(getViewContext(), _def); - ViewPrefsBean bean = new ViewPrefsBean(views, _def); - return new StudyJspView<>(_study, "/org/labkey/study/view/viewPreferences.jsp", bean, errors); - } - throw new NotFoundException("Invalid dataset ID"); - } - - @Override - public boolean handlePost(ViewPreferencesForm form, BindException errors) throws Exception - { - int dsid = init(form); - String defaultView = form.getDefaultView(); - if ((_def != null) && (defaultView != null)) - { - setDefaultView(dsid, defaultView); - return true; - } - throw new NotFoundException("Invalid dataset ID"); - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("customViews"); - - root.addChild(_study.getLabel(), new ActionURL(BeginAction.class, getContainer())); - - ActionURL datasetURL = getViewContext().getActionURL().clone(); - datasetURL.setAction(DatasetAction.class); - - String label = _def.getLabel() != null ? _def.getLabel() : "" + _def.getDatasetId(); - root.addChild(new NavTree(label, datasetURL)); - - root.addChild(new NavTree("View Preferences")); - } - - @Override - public URLHelper getSuccessURL(ViewPreferencesForm viewPreferencesForm) { return null; } - - @Override - public void validateCommand(ViewPreferencesForm target, Errors errors) { } - } - - @RequiresPermission(AdminPermission.class) - public class ImportStudyBatchAction extends SimpleViewAction - { - private String path; - - @Override - public ModelAndView getView(PipelinePathForm form, BindException errors) throws Exception - { - Container c = getContainer(); - - File definitionFile = form.getValidatedSingleFile(c).toNioPathForRead().toFile(); - path = form.getPath(); - if (!path.endsWith("/")) - { - path += "/"; - } - path += definitionFile.getName(); - - if (!definitionFile.isFile()) - { - throw new NotFoundException(); - } - - File lockFile = StudyPipeline.lockForDataset(getStudyRedirectIfNull(), definitionFile); - - if (!definitionFile.canRead()) - errors.reject("importStudyBatch", "Can't read dataset file: " + path); - if (lockFile.exists()) - errors.reject("importStudyBatch", "Lock file exists. Delete file before running import. " + lockFile.getName()); - - VirtualFile datasetsDir = new FileSystemFile(definitionFile.getParentFile()); - DatasetFileReader reader = new DatasetFileReader(datasetsDir, definitionFile.getName(), getStudyRedirectIfNull()); - - if (!errors.hasErrors()) - { - List parseErrors = new ArrayList<>(); - reader.validate(parseErrors); - for (String error : parseErrors) - errors.reject("importStudyBatch", error); - } - - return new StudyJspView<>( - getStudyRedirectIfNull(), "/org/labkey/study/view/importStudyBatch.jsp", new ImportStudyBatchBean(reader, path), errors); - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild(getStudyRedirectIfNull().getLabel(), new ActionURL(StudyController.BeginAction.class, getContainer())); - root.addChild("Import Study Batch - " + path); - } - } - - @RequiresPermission(AdminPermission.class) - public class SubmitStudyBatchAction extends FormHandlerAction - { - private ActionURL _successUrl = null; - - @Override - public void validateCommand(PipelinePathForm target, Errors errors) - { - } - - @Override - public boolean handlePost(PipelinePathForm form, BindException errors) throws Exception - { - Study study = getStudyRedirectIfNull(); - Container c = getContainer(); - String path = form.getPath(); - File f = null; - - PipeRoot root = PipelineService.get().findPipelineRoot(c); - if (path != null) - { - if (root != null) - f = root.resolvePath(path); - } - - try - { - if (f != null) - { - VirtualFile datasetsDir = new FileSystemFile(f.getParentFile()); - DatasetImportUtils.submitStudyBatch(study, datasetsDir, f.getName(), c, getUser(), getViewContext().getActionURL(), root); - } - _successUrl = urlProvider(PipelineStatusUrls.class).urlBegin(getContainer()); - } - catch (DatasetImportUtils.DatasetLockExistsException e) - { - ActionURL importURL = new ActionURL(ImportStudyBatchAction.class, getContainer()); - importURL.addParameter("path", form.getPath()); - _successUrl = importURL; - } - - return true; - } - - @Override - public URLHelper getSuccessURL(PipelinePathForm pipelinePathForm) - { - return _successUrl; - } - } - - @RequiresPermission(ReadPermission.class) - public class TypeNotFoundAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object o, BindException errors) - { - return new StudyJspView(getStudyRedirectIfNull(), "/org/labkey/study/view/typeNotFound.jsp", null, errors); - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild("Type Not Found"); - } - } - - @RequiresPermission(AdminPermission.class) - public class UpdateParticipantVisitsAction extends FormViewAction - { - private int _count; - - @Override - public void validateCommand(Object target, Errors errors) - { - } - - @Override - public ModelAndView getView(Object o, boolean reshow, BindException errors) throws Exception - { - if (reshow) - { - return HtmlView.unsafe( - "
" + _count + " rows were updated.

" + - PageFlowUtil.button("Done").href(new ActionURL(ManageVisitsAction.class, getContainer())) + - "

"); - } - else - { - return HtmlView.unsafe( - "
Click the button below to recalculate visit dates for all participants in this study.

" + - PageFlowUtil.button("Recalculate Visit Dates").href(new ActionURL(UpdateParticipantVisitsAction.class, getContainer())).submit(true) + - new CsrfInput(getViewContext()) + - "

"); - } - } - - @Override - public boolean handlePost(Object o, BindException errors) throws Exception - { - var vm = StudyManager.getInstance().getVisitManager(getStudyRedirectIfNull()); - if (vm instanceof SequenceVisitManager svm) - { - // This could be optimized by combining with updateParticipantVisits(). - // However, updateParticipantVisits() handles incremental updates and would need to be refactored a bit - // and this isn't a common code path. - svm.purgeParticipantVisit(getUser()); - } - vm.updateParticipantVisits(getUser(), getStudyRedirectIfNull().getDatasets()); - - TableInfo tinfoParticipantVisit = StudySchema.getInstance().getTableInfoParticipantVisit(); - _count = new SqlSelector(StudySchema.getInstance().getSchema(), - "SELECT COUNT(VisitDate) FROM " + tinfoParticipantVisit + "\nWHERE Container = ?", - getContainer()).getObject(Integer.class); - - return true; - } - - @Override - public URLHelper getSuccessURL(Object o) - { - return null; - } - - @Override - public void addNavTrail(NavTree root) - { - _addNavTrailVisitAdmin(root); - root.addChild("Recalculate Visit Dates"); - } - } - - @RequiresPermission(AdminPermission.class) - public class VisitOrderAction extends FormViewAction - { - @Override - public ModelAndView getView(VisitReorderForm reorderForm, boolean reshow, BindException errors) - { - StudyImpl study = getStudyRedirectIfNull(); - redirectToSharedVisitStudy(study, getViewContext().getActionURL()); - - return new StudyJspView(study, "/org/labkey/study/view/visitOrder.jsp", reorderForm, errors); - } - - @Override - public void addNavTrail(NavTree root) - { - _addNavTrailVisitAdmin(root); - root.addChild("Visit Order"); - } - - @Override - public void validateCommand(VisitReorderForm target, Errors errors) {} - - private Map getVisitIdToOrderIndex(String orderedIds) - { - Map order = null; - if (orderedIds != null && !orderedIds.isEmpty()) - { - order = new HashMap<>(); - String[] idArray = orderedIds.split(","); - for (int i = 0; i < idArray.length; i++) - { - int id = Integer.parseInt(idArray[i]); - // 1-index display orders, since 0 is the database default, and we'd like to know - // that these were set explicitly for all visits: - order.put(id, i + 1); - } - } - return order; - } - - private Map getVisitIdToZeroMap(Collection visits) - { - Map order = new IntHashMap<>(); - for (VisitImpl visit : visits) - order.put(visit.getRowId(), 0); - return order; - } - - @Override - public boolean handlePost(VisitReorderForm form, BindException errors) - { - StudyImpl study = getStudyRedirectIfNull(); - redirectToSharedVisitStudy(study, getViewContext().getActionURL()); - - Map displayOrder = null; - Map chronologicalOrder = null; - Collection visits = StudyManager.getInstance().getVisits(study, Visit.Order.SEQUENCE_NUM); - - if (form.isExplicitDisplayOrder()) - displayOrder = getVisitIdToOrderIndex(form.getDisplayOrder()); - if (displayOrder == null) - displayOrder = getVisitIdToZeroMap(visits); - - if (form.isExplicitChronologicalOrder()) - chronologicalOrder = getVisitIdToOrderIndex(form.getChronologicalOrder()); - if (chronologicalOrder == null) - chronologicalOrder = getVisitIdToZeroMap(visits); - - for (VisitImpl visit : visits) - { - // it's possible that a new visit has been created between when the update page was rendered - // and posted. This will result in a visit that isn't in our ID maps. There's no great way - // to handle this, so we'll just skip setting display/chronological order on these visits for now. - if (displayOrder.containsKey(visit.getRowId()) && chronologicalOrder.containsKey(visit.getRowId())) - { - int displayIndex = displayOrder.get(visit.getRowId()).intValue(); - int chronologicalIndex = chronologicalOrder.get(visit.getRowId()).intValue(); - - if (visit.getDisplayOrder() != displayIndex || visit.getChronologicalOrder() != chronologicalIndex) - { - visit = visit.createMutable(); - visit.setDisplayOrder(displayIndex); - visit.setChronologicalOrder(chronologicalIndex); - StudyManager.getInstance().updateVisit(getUser(), visit); - } - } - } - - // Changing visit order can cause cohort assignments to change when advanced cohort tracking is enabled: - if (study.isAdvancedCohorts()) - CohortManager.getInstance().updateParticipantCohorts(getUser(), study); - return true; - } - - @Override - public ActionURL getSuccessURL(VisitReorderForm reorderForm) - { - return reorderForm.getReturnActionURL(); - } - } - - @RequiresPermission(AdminPermission.class) - public class VisitVisibilityAction extends FormViewAction - { - @Override - public ModelAndView getView(VisitPropertyForm visitPropertyForm, boolean reshow, BindException errors) - { - StudyImpl study = getStudyRedirectIfNull(); - redirectToSharedVisitStudy(study, getViewContext().getActionURL()); - - return new StudyJspView(study, "/org/labkey/study/view/visitVisibility.jsp", visitPropertyForm, errors); - } - - @Override - public void addNavTrail(NavTree root) - { - _addNavTrailVisitAdmin(root); - root.addChild("Properties"); - } - - @Override - public void validateCommand(VisitPropertyForm target, Errors errors) {} - - @Override - public boolean handlePost(VisitPropertyForm form, BindException errors) - { - StudyImpl study = getStudyRedirectIfNull(); - redirectToSharedVisitStudy(study, getViewContext().getActionURL()); - - int[] allIds = form.getIds() == null ? new int[0] : form.getIds(); - int[] visibleIds = form.getVisible() == null ? new int[0] : form.getVisible(); - String[] labels = form.getLabel() == null ? new String[0] : form.getLabel(); - String[] typeStrs = form.getExtraData()== null ? new String[0] : form.getExtraData(); - - Set visible = new IntHashSet(visibleIds.length); - for (int id : visibleIds) - visible.add(id); - if (allIds.length != form.getLabel().length) - throw new IllegalStateException("Arrays must be the same length."); - for (int i = 0; i < allIds.length; i++) - { - VisitImpl def = StudyManager.getInstance().getVisitForRowId(study, allIds[i]); - boolean show = visible.contains(allIds[i]); - String label = (i < labels.length) ? labels[i] : null; - String typeStr = (i < typeStrs.length) ? typeStrs[i] : null; - - Integer cohortId = null; - if (form.getCohort() != null && form.getCohort()[i] != -1) - cohortId = form.getCohort()[i]; - Character type = typeStr != null && !typeStr.isEmpty() ? typeStr.charAt(0) : null; - if (def.isShowByDefault() != show || !nullSafeEqual(label, def.getLabel()) || type != def.getTypeCode() || !nullSafeEqual(cohortId, def.getCohortId())) - { - def = def.createMutable(); - def.setShowByDefault(show); - def.setLabel(label); - def.setCohortId(cohortId); - def.setTypeCode(type); - StudyManager.getInstance().updateVisit(getUser(), def); - } - } - return true; - } - - @Override - public ActionURL getSuccessURL(VisitPropertyForm visitPropertyForm) - { - return new ActionURL(ManageVisitsAction.class, getContainer()); - } - } - - @RequiresPermission(AdminPermission.class) - public class DatasetVisibilityAction extends FormViewAction - { - @Override - public ModelAndView getView(DatasetPropertyForm form, boolean reshow, BindException errors) - { - _study = getStudyRedirectIfNull(); - var sqs = StudyQuerySchema.createSchema(_study, getUser()); - Map bean = new IntHashMap<>(); - for (DatasetDefinition def : _study.getDatasets()) - { - DatasetVisibilityData data = new DatasetVisibilityData(); - data.label = def.getLabel(); - data.categoryId = def.getViewCategory() != null ? def.getViewCategory().getRowId() : null; - data.cohort = def.getCohortId(); - data.visible = def.isShowByDefault(); - data.shared = def.isShared(); - data.inherited = def.isInherited(); - data.status = (String)ReportPropsManager.get().getPropertyValue(def.getEntityId(), getContainer(), "status"); - if ("None".equals(data.status)) - data.status = null; - DatasetTable t = sqs.getDatasetTable(def, null); - if (null != t) - { - long rowCount = new TableSelector(t).getRowCount(); - data.rowCount = rowCount; - data.empty = 0 == rowCount; - } - bean.put(def.getDatasetId(), data); - } - - // Merge with form data - Map formDataset = form.getDataset(); - if (formDataset != null) - { - for (Map.Entry entry : formDataset.entrySet()) - { - DatasetVisibilityData formData = entry.getValue(); - DatasetVisibilityData beanData = bean.get(entry.getKey()); - if (formData == null || beanData == null) - continue; - - beanData.label = formData.label; - beanData.categoryId = formData.categoryId; - beanData.cohort = formData.cohort; - beanData.visible = formData.visible; - } - } - - return new StudyJspView<>( - getStudyRedirectIfNull(), "/org/labkey/study/view/datasetVisibility.jsp", bean, errors); - } - - @Override - public void addNavTrail(NavTree root) - { - _addManageStudy(root); - root.addChild("Manage Datasets", new ActionURL(ManageTypesAction.class, getContainer())); - root.addChild("Properties"); - } - - @Override - public void validateCommand(DatasetPropertyForm form, Errors errors) - { - // Check for bad labels - Set labels = new HashSet<>(); - for (DatasetVisibilityData data : form.getDataset().values()) - { - String label = data.getLabel(); - if (StringUtils.isBlank(label)) - { - errors.reject("datasetVisibility", "Label cannot be blank"); - } - if (labels.contains(label)) - { - errors.reject("datasetVisibility", "Labels must be unique. Found two or more labels called '" + label + "'."); - } - labels.add(label); - } - } - - @Override - public boolean handlePost(DatasetPropertyForm form, BindException errors) throws Exception - { - for (Map.Entry entry : form.getDataset().entrySet()) - { - Integer id = entry.getKey(); - DatasetVisibilityData data = entry.getValue(); - - if (id == null) - throw new IllegalArgumentException("id required"); - - DatasetDefinition def = StudyManager.getInstance().getDatasetDefinition(getStudyThrowIfNull(), id); - if (def == null) - throw new NotFoundException("dataset"); - - String label = data.getLabel(); - boolean show = data.isVisible(); - Integer categoryId = data.getCategoryId(); - Integer cohortId = data.getCohort(); - if (cohortId != null && cohortId.intValue() == -1) - cohortId = null; - - if (def.isShowByDefault() != show || !nullSafeEqual(categoryId, def.getCategoryId()) || !nullSafeEqual(label, def.getLabel()) || !BaseStudyController.nullSafeEqual(cohortId, def.getCohortId())) - { - def = def.createMutable(); - def.setShowByDefault(show); - def.setCategoryId(categoryId); - def.setCohortId(cohortId); - def.setLabel(label); - List saveErrors = new ArrayList<>(); - StudyManager.getInstance().updateDatasetDefinition(getUser(), def, saveErrors); - for (String error : saveErrors) - { - errors.reject(ERROR_MSG, error); - return false; - } - } - ReportPropsManager.get().setPropertyValue(def.getEntityId(), getContainer(), "status", data.getStatus()); - } - - return true; - } - - @Override - public ActionURL getSuccessURL(DatasetPropertyForm form) - { - return new ActionURL(ManageTypesAction.class, getContainer()); - } - } - - // Bean will be an map of these - public static class DatasetVisibilityData - { - // form POSTed values - public String label; - public Integer cohort; // null for none - public String status; - public Integer categoryId; - public boolean visible; - - // not form POSTed -- used to render view - public long rowCount; - public boolean empty; - public boolean shared; - public boolean inherited; - - public String getLabel() - { - return label; - } - - public void setLabel(String label) - { - this.label = label; - } - - public Integer getCohort() - { - return cohort; - } - - public void setCohort(Integer cohort) - { - this.cohort = cohort; - } - - public String getStatus() - { - return status; - } - - public void setStatus(String status) - { - this.status = status; - } - - public boolean isVisible() - { - return visible; - } - - public void setVisible(boolean visible) - { - this.visible = visible; - } - - public Integer getCategoryId() - { - return categoryId; - } - - public void setCategoryId(Integer categoryId) - { - this.categoryId = categoryId; - } - } - - @Marshal(Marshaller.Jackson) - @RequiresPermission(AdminPermission.class) - public static class DeleteDatasetPropertyOverrideAction extends MutatingApiAction - { - @Override - public Object execute(Object o, BindException errors) - { - StudyManager.getInstance().deleteDatasetPropertyOverrides(getUser(), getContainer(), errors); - return errors.hasErrors() ? null : success(); - } - } - - @RequiresPermission(AdminPermission.class) - public class DatasetDisplayOrderAction extends FormViewAction - { - @Override - public ModelAndView getView(DatasetReorderForm form, boolean reshow, BindException errors) - { - return new StudyJspView(getStudyRedirectIfNull(), "/org/labkey/study/view/datasetDisplayOrder.jsp", form, errors); - } - - @Override - public void addNavTrail(NavTree root) - { - _addManageStudy(root); - root.addChild("Manage Datasets", new ActionURL(ManageTypesAction.class, getContainer())); - root.addChild("Display Order"); - } - - @Override - public void validateCommand(DatasetReorderForm target, Errors errors) {} - - @Override - public boolean handlePost(DatasetReorderForm form, BindException errors) - { - String order = form.getOrder(); - - if (order != null && !order.isEmpty() && !form.isResetOrder()) - { - String[] ids = order.split(","); - List orderedIds = new ArrayList<>(ids.length); - - for (String id : ids) - orderedIds.add(Integer.parseInt(id)); - - DatasetReorderer reorderer = new DatasetReorderer(getStudyThrowIfNull(), getUser()); - reorderer.reorderDatasets(orderedIds); - } - else if (form.isResetOrder()) - { - DatasetReorderer reorderer = new DatasetReorderer(getStudyThrowIfNull(), getUser()); - reorderer.resetOrder(); - } - - return true; - } - - @Override - public ActionURL getSuccessURL(DatasetReorderForm visitPropertyForm) - { - return new ActionURL(ManageTypesAction.class, getContainer()); - } - } - - - @RequiresPermission(AdminPermission.class) - public class DeleteDatasetAction extends FormHandlerAction - { - @Override - public void validateCommand(IdForm target, Errors errors) - { - - } - - @Override - public boolean handlePost(IdForm form, BindException errors) throws Exception - { - Study study = getStudyRedirectIfNull(getContainer()); - - DatasetDefinition ds = StudyManager.getInstance().getDatasetDefinition(study, form.getId()); - if (null == ds) - redirectTypeNotFound(form.getId()); - if (!ds.canDeleteDefinition(getUser())) - errors.reject(ERROR_MSG, "Can't delete this dataset: " + ds.getName()); - - if (errors.hasErrors()) - return false; - - DbScope scope = StudySchema.getInstance().getSchema().getScope(); - try (DbScope.Transaction transaction = scope.ensureTransaction()) - { - // performStudyResync==false so we can do this out of the transaction - StudyManager.getInstance().deleteDataset(getStudyRedirectIfNull(), getUser(), ds, false, null); - transaction.commit(); - } - - StudyManager.getInstance().getVisitManager(study).updateParticipantVisits(getUser(), Collections.emptySet()); - return true; - } - - @Override - public URLHelper getSuccessURL(IdForm idForm) - { - throw new RedirectException(new ActionURL(ManageTypesAction.class, getContainer())); - } - } - - - private static final String DEFAULT_PARTICIPANT_VIEW_SOURCE = - """ -
Loading...
- - - /* Adjust width of first column: */ - """; - - public static class CustomizeParticipantViewForm extends ReturnUrlForm - { - private String _customScript; - private String _participantId; - private boolean _useCustomView; - private boolean _reshow; - private boolean _editable = true; - - public boolean isEditable() - { - return _editable; - } - - public void setEditable(boolean editable) - { - _editable = editable; - } - - public String getCustomScript() - { - return _customScript; - } - - public String getDefaultScript() - { - return DEFAULT_PARTICIPANT_VIEW_SOURCE; - } - - public void setCustomScript(String customScript) - { - _customScript = customScript; - } - - public String getParticipantId() - { - return _participantId; - } - - public void setParticipantId(String participantId) - { - _participantId = participantId; - } - - public boolean isReshow() - { - return _reshow; - } - - public void setReshow(boolean reshow) - { - _reshow = reshow; - } - - public boolean isUseCustomView() - { - return _useCustomView; - } - - public void setUseCustomView(boolean useCustomView) - { - _useCustomView = useCustomView; - } - } - - @RequiresAllOf({AdminPermission.class, BrowserDeveloperPermission.class}) - public class CustomizeParticipantViewAction extends FormViewAction - { - @Override - public void validateCommand(CustomizeParticipantViewForm target, Errors errors) - { - } - - @Override - public ModelAndView getView(CustomizeParticipantViewForm form, boolean reshow, BindException errors) - { - Study study = getStudyRedirectIfNull(); - CustomParticipantView view = StudyManager.getInstance().getCustomParticipantView(study); - if (view != null) - { - form.setCustomScript(view.getBody()); - form.setUseCustomView(view.isActive()); - form.setEditable(!view.isModuleParticipantView()); - } - - return new JspView<>("/org/labkey/study/view/customizeParticipantView.jsp", form); - } - - @Override - public boolean handlePost(CustomizeParticipantViewForm form, BindException errors) - { - Study study = getStudyThrowIfNull(); - CustomParticipantView view = StudyManager.getInstance().getCustomParticipantView(study); - if (view == null) - view = new CustomParticipantView(); - view.setBody(form.getCustomScript()); - view.setActive(form.isUseCustomView()); - view = StudyManager.getInstance().saveCustomParticipantView(study, getUser(), view); - return view != null; - } - - @Override - public ActionURL getSuccessURL(CustomizeParticipantViewForm form) - { - if (form.isReshow()) - { - ActionURL reshowURL = new ActionURL(CustomizeParticipantViewAction.class, getContainer()); - if (form.getParticipantId() != null && !form.getParticipantId().isEmpty()) - reshowURL.addParameter("participantId", form.getParticipantId()); - if (form.getReturnUrl() != null && !form.getReturnUrl().isEmpty()) - reshowURL.addParameter(ActionURL.Param.returnUrl, form.getReturnUrl()); - return reshowURL; - } - else if (form.getReturnUrl() != null && !form.getReturnUrl().isEmpty()) - return new ActionURL(form.getReturnUrl()); - else - return urlProvider(ReportUrls.class).urlManageViews(getContainer()); - } - - @Override - public void addNavTrail(NavTree root) - { - _addManageStudy(root); - root.addChild("Manage Views", urlProvider(ReportUrls.class).urlManageViews(getContainer())); - root.addChild("Customize " + StudyService.get().getSubjectNounSingular(getContainer()) + " View"); - } - } - - public static class StudySnapshotForm extends QuerySnapshotForm - { - private int _snapshotDatasetId = -1; - private String _action; - private Boolean _queryDataset; - - public static final String EDIT_DATASET = "editDataset"; - public static final String CREATE_SNAPSHOT = "createSnapshot"; - public static final String CANCEL = "cancel"; - - public int getSnapshotDatasetId() - { - return _snapshotDatasetId; - } - - public void setSnapshotDatasetId(int snapshotDatasetId) - { - _snapshotDatasetId = snapshotDatasetId; - } - - public Boolean getQueryDataset() - { - return _queryDataset; - } - - public void setQueryDataset(Boolean queryDataset) - { - _queryDataset = queryDataset; - } - - public String getAction() - { - return _action; - } - - public void setAction(String action) - { - _action = action; - } - } - - @RequiresPermission(AdminPermission.class) - public static class CreateSnapshotAction extends FormViewAction - { - ActionURL _successURL; - - @Override - public void validateCommand(StudySnapshotForm form, Errors errors) - { - if (StudySnapshotForm.CANCEL.equals(form.getAction())) - return; - - Study study = StudyManager.getInstance().getStudy(getContainer()); - if (null == study) - throw new NotFoundException("No study in this folder"); - - if (form.getQueryDataset() != null) - { - if (study.getTimepointType() != TimepointType.CONTINUOUS) - { - errors.reject("snapshotQuery.error", "Query based snapshot is only available for continuous studies"); - } - else - { - TableInfo ti = QueryService.get().getUserSchema(getUser(), getContainer(), form.getSchemaName()).getTable(form.getQueryName()); - Set colNames = ti.getColumns().stream().map(ColumnInfo::getName).collect(LabKeyCollectors.toCaseInsensitiveHashSet()); - - List notFound = Arrays.stream(QueryDatasetTable.REQUIRED_COLUMNS) - .filter(value -> !colNames.contains(value)) - .toList(); - - if (!notFound.isEmpty()) - errors.reject("snapshotQuery.error", "The source query is missing the following required columns for a query backed dataset: " + String.join(", ", notFound)); - } - } - - String name = StringUtils.trimToNull(form.getSnapshotName()); - - if (name != null) - { - QuerySnapshotDefinition def = QueryService.get().getSnapshotDef(getContainer(), form.getSchemaName(), name); - if (def != null) - { - errors.reject("snapshotQuery.error", "A Snapshot with the same name already exists"); - return; - } - - // check for a dataset with the same label/name unless it's one that we created - Dataset dataset = StudyManager.getInstance().getDatasetDefinitionByQueryName(study, name); - if (dataset != null) - { - if (dataset.getDatasetId() != form.getSnapshotDatasetId()) - errors.reject("snapshotQuery.error", "A Dataset with the same name/label already exists"); - } - } - else - errors.reject("snapshotQuery.error", "The Query Snapshot name cannot be blank"); - } - - @Override - public ModelAndView getView(StudySnapshotForm form, boolean reshow, BindException errors) - { - if (!reshow || errors.hasErrors()) - { - ActionURL url = getViewContext().getActionURL(); - - if (StringUtils.isEmpty(form.getSnapshotName())) - form.setSnapshotName(url.getParameter("ff_snapshotName")); - form.setUpdateDelay(NumberUtils.toInt(url.getParameter("ff_updateDelay"))); - form.setSnapshotDatasetId(NumberUtils.toInt(url.getParameter("ff_snapshotDatasetId"), -1)); - - return new JspView("/org/labkey/study/view/createDatasetSnapshot.jsp", form, errors); - } - else if (StudySnapshotForm.EDIT_DATASET.equals(form.getAction())) - { - throw new NotFoundException("Unable to edit the created dataset definition."); - } - return null; - } - - private void deletePreviousDatasetDefinition(StudySnapshotForm form) - { - if (form.getSnapshotDatasetId() != -1) - { - StudyImpl study = StudyManager.getInstance().getStudy(getContainer()); - - // a dataset definition was edited previously, but under a different name, need to delete the old one - DatasetDefinition dsDef = StudyManager.getInstance().getDatasetDefinition(study, form.getSnapshotDatasetId()); - if (dsDef != null) - { - StudyManager.getInstance().deleteDataset(study, getUser(), dsDef, true, null); - form.setSnapshotDatasetId(-1); - } - } - } - - private Dataset createDataset(StudySnapshotForm form, BindException errors) throws Exception - { - StudyImpl study = StudyManager.getInstance().getStudy(getContainer()); - Dataset dsDef = StudyManager.getInstance().getDatasetDefinitionByName(study, form.getSnapshotName()); - - if (dsDef == null) - { - deletePreviousDatasetDefinition(form); - - // if this snapshot is being created from an existing dataset, copy key field settings - int datasetId = NumberUtils.toInt(getViewContext().getActionURL().getParameter(Dataset.DATASET_KEY), -1); - String additionalKey = null; - DatasetDefinition.KeyManagementType keyManagementType = KeyManagementType.None; - boolean isDemographicData = false; - boolean useTimeKeyField = false; - List columnsToProvision = new ArrayList<>(); - - if (datasetId != -1) - { - DatasetDefinition sourceDef = study.getDataset(datasetId); - if (sourceDef != null) - { - additionalKey = sourceDef.getKeyPropertyName(); - keyManagementType = sourceDef.getKeyManagementType(); - isDemographicData = sourceDef.isDemographicData(); - useTimeKeyField = sourceDef.getUseTimeKeyField(); - - // make sure we provision any managed key fields - if ((additionalKey != null) && (keyManagementType != KeyManagementType.None)) - { - TableInfo sourceTable = sourceDef.getTableInfo(getUser()); - ColumnInfo col = sourceTable.getColumn(FieldKey.fromParts(additionalKey)); - if (col != null) - columnsToProvision.add(col); - } - } - } - - DatasetDefinition.Builder builder = new DatasetDefinition.Builder(form.getSnapshotName()) - .setStudy(study) - .setKeyPropertyName(additionalKey) - .setDemographicData(isDemographicData) - .setUseTimeKeyField(useTimeKeyField); - - - if (Boolean.TRUE.equals(form.getQueryDataset())) - { - builder.setSourceQueryName(form.getQueryName()) - .setSourceQuerySchema(form.getSchemaName()) - .setSourceQueryContainer(getContainer()) - .setKeyPropertyName("Key"); - } - - DatasetDefinition def = StudyPublishManager.getInstance().createDataset(getUser(), builder); - - form.setSnapshotDatasetId(def.getDatasetId()); - if (keyManagementType != KeyManagementType.None) - { - def = def.createMutable(); - def.setKeyManagementType(keyManagementType); - - StudyManager.getInstance().updateDatasetDefinition(getUser(), def); - } - - // NOTE getDisplayColumns() indirectly causes a query of the datasets, - // Do this before provisionTable() so we don't query the dataset we are about to create - // causes a problem on postgres (bug 11153) - for (DisplayColumn dc : QuerySnapshotService.get(form.getSchemaName()).getDisplayColumns(form, errors)) - { - ColumnInfo col = dc.getColumnInfo(); - if (col != null && !DatasetDefinition.isDefaultFieldName(col.getName(), study)) - columnsToProvision.add(col); - } - - // def may not be provisioned yet, create before we start adding properties - if (def.isQueryDataset()) - { - def.provisionQueryDataset(true); - } - else - { - def.provisionTable(true); - } - - Domain d = def.getDomain(true); - - for (ColumnInfo col : columnsToProvision) - { - DatasetSnapshotProvider.addAsDomainProperty(d, col); - } - d.save(getUser()); - - return def; - } - - return dsDef; - } - - @Override - public boolean handlePost(StudySnapshotForm form, BindException errors) throws Exception - { - DbSchema schema = StudySchema.getInstance().getSchema(); - - try (DbScope.Transaction transaction = schema.getScope().ensureTransaction()) - { - if (StudySnapshotForm.EDIT_DATASET.equals(form.getAction())) - { - Dataset def = createDataset(form, errors); - if (!errors.hasErrors() && def != null) - { - ActionURL returnUrl = getViewContext().cloneActionURL() - .replaceParameter("ff_snapshotName", form.getSnapshotName()) - .replaceParameter("ff_updateDelay", form.getUpdateDelay()) - .replaceParameter("ff_snapshotDatasetId", form.getSnapshotDatasetId()); - - _successURL = new ActionURL(StudyController.EditTypeAction.class, getContainer()) - .addParameter("datasetId", def.getDatasetId()) - .addReturnUrl(returnUrl); - } - } - else if (StudySnapshotForm.CREATE_SNAPSHOT.equals(form.getAction())) - { - Dataset def = createDataset(form, errors); - if (!errors.hasErrors()) - if (Boolean.TRUE.equals(form.getQueryDataset())) - { - _successURL = new ActionURL(StudyController.DatasetAction.class, getContainer()). - addParameter(Dataset.DATASET_KEY, def.getDatasetId()); - } - else - { - _successURL = QuerySnapshotService.get(form.getSchemaName()).createSnapshot(form, errors); - } - } - else if (StudySnapshotForm.CANCEL.equals(form.getAction())) - { - deletePreviousDatasetDefinition(form); - String redirect = getViewContext().getActionURL().getParameter(ActionURL.Param.redirectUrl); - if (redirect != null) - _successURL = new ActionURL(PageFlowUtil.decode(redirect)); - } - - if (!errors.hasErrors()) - transaction.commit(); - } - - return !errors.hasErrors(); - } - - @Override - public ActionURL getSuccessURL(StudySnapshotForm queryForm) - { - return _successURL; - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("querySnapshot"); - root.addChild("Create Query Snapshot"); - } - } - - /** - * Provides a view to update study query snapshots. Since query snapshots are implemented as datasets, the - * dataset properties editor can be shown in this view. - */ - @RequiresPermission(AdminPermission.class) - public static class EditSnapshotAction extends FormViewAction - { - ActionURL _successURL; - - @Override - public void validateCommand(StudySnapshotForm form, Errors errors) - { - } - - @Override - public ModelAndView getView(StudySnapshotForm form, boolean reshow, BindException errors) throws Exception - { - form.setEdit(true); - if (!reshow) - form.init(QueryService.get().getSnapshotDef(getContainer(), form.getSchemaName(), form.getSnapshotName()), getUser()); - - VBox box = new VBox(); - - QuerySnapshotService.Provider provider = QuerySnapshotService.get(form.getSchemaName()); - if (provider != null) - { - box.addView(new JspView("/org/labkey/study/view/editSnapshot.jsp", form)); - box.addView(new JspView("/org/labkey/study/view/createDatasetSnapshot.jsp", form, errors)); - - boolean showHistory = BooleanUtils.toBoolean(getViewContext().getActionURL().getParameter("showHistory")); - if (showHistory) - { - HttpView historyView = provider.createAuditView(form, errors); - if (historyView != null) - box.addView(historyView); - } - } - return box; - } - - @Override - public boolean handlePost(StudySnapshotForm form, BindException errors) throws Exception - { - if (StudySnapshotForm.CANCEL.equals(form.getAction())) - { - String redirect = getViewContext().getActionURL().getParameter(ActionURL.Param.redirectUrl); - if (redirect != null) - _successURL = new ActionURL(PageFlowUtil.decode(redirect)); - } - else if (form.isUpdateSnapshot()) - { - _successURL = QuerySnapshotService.get(form.getSchemaName()).updateSnapshot(form, errors); - - return !errors.hasErrors(); - } - else - { - QuerySnapshotDefinition def = QueryService.get().getSnapshotDef(getContainer(), form.getSchemaName(), form.getSnapshotName()); - if (def != null) - { - def.setUpdateDelay(form.getUpdateDelay()); - _successURL = QuerySnapshotService.get(form.getSchemaName()).updateSnapshotDefinition(getViewContext(), def, errors); - return !errors.hasErrors(); - } - else - { - errors.reject("snapshotQuery.error", "Unable to create QuerySnapshotDefinition"); - return false; - } - } - return true; - } - - @Override - public ActionURL getSuccessURL(StudySnapshotForm form) - { - return _successURL; - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild("Edit Query Snapshot"); - } - } - - public static class DatasetPropertyForm implements HasAllowBindParameter - { - private Map _map = MapUtils.lazyMap(new IntHashMap<>(), FactoryUtils.instantiateFactory(DatasetVisibilityData.class)); - - public Map getDataset() - { - return _map; - } - - public void setDataset(Map map) - { - _map = map; - } - - private static final Pattern pat = Pattern.compile("dataset\\[(\\d*)]\\.(\\w*)"); - - @Override - public Predicate allowBindParameter() - { - return (name) -> - { - if (name.startsWith(SpringActionController.FIELD_MARKER)) - name = name.substring(SpringActionController.FIELD_MARKER.length()); - if (HasAllowBindParameter.getDefaultPredicate().test(name)) - return true; - return pat.matcher(name).matches(); - }; - } - } - - public static class RequirePipelineView extends StudyJspView - { - public RequirePipelineView(StudyImpl study, boolean showGoBack, BindException errors) - { - super(study, "/org/labkey/study/view/requirePipeline.jsp", showGoBack, errors); - } - } - - public static class VisitPropertyForm extends PropertyForm - { - private int[] _ids; - private int[] _visible; - - public int[] getIds() - { - return _ids; - } - - public void setIds(int[] ids) - { - _ids = ids; - } - - public int[] getVisible() - { - return _visible; - } - - public void setVisible(int[] visible) - { - _visible = visible; - } - } - - public abstract static class PropertyForm - { - private String[] _label; - private String[] _extraData; - private int[] _cohort; - - public String[] getExtraData() - { - return _extraData; - } - - public void setExtraData(String[] extraData) - { - _extraData = extraData; - } - - public String[] getLabel() - { - return _label; - } - - public void setLabel(String[] label) - { - _label = label; - } - - public int[] getCohort() - { - return _cohort; - } - - public void setCohort(int[] cohort) - { - _cohort = cohort; - } - } - - - public static class DatasetReorderForm - { - private String order; - private boolean resetOrder = false; - - public String getOrder() {return order;} - - public void setOrder(String order) {this.order = order;} - - public boolean isResetOrder() - { - return resetOrder; - } - - public void setResetOrder(boolean resetOrder) - { - this.resetOrder = resetOrder; - } - } - - public static class VisitReorderForm extends ReturnUrlForm - { - private boolean _explicitDisplayOrder; - private boolean _explicitChronologicalOrder; - private String _displayOrder; - private String _chronologicalOrder; - - public String getDisplayOrder() - { - return _displayOrder; - } - - public void setDisplayOrder(String displayOrder) - { - _displayOrder = displayOrder; - } - - public String getChronologicalOrder() - { - return _chronologicalOrder; - } - - public void setChronologicalOrder(String chronologicalOrder) - { - _chronologicalOrder = chronologicalOrder; - } - - public boolean isExplicitDisplayOrder() - { - return _explicitDisplayOrder; - } - - public void setExplicitDisplayOrder(boolean explicitDisplayOrder) - { - _explicitDisplayOrder = explicitDisplayOrder; - } - - public boolean isExplicitChronologicalOrder() - { - return _explicitChronologicalOrder; - } - - public void setExplicitChronologicalOrder(boolean explicitChronologicalOrder) - { - _explicitChronologicalOrder = explicitChronologicalOrder; - } - } - - public static class ImportStudyBatchBean - { - private final DatasetFileReader reader; - private final String path; - - public ImportStudyBatchBean(DatasetFileReader reader, String path) - { - this.reader = reader; - this.path = path; - } - - public DatasetFileReader getReader() - { - return reader; - } - - public String getPath() - { - return path; - } - } - - public static class ViewPrefsBean - { - private final List> _views; - private final Dataset _def; - - public ViewPrefsBean(List> views, Dataset def) - { - _views = views; - _def = def; - } - - public List> getViews(){return _views;} - public Dataset getDatasetDefinition(){return _def;} - } - - - private static final String DEFAULT_DATASET_VIEW = "Study.defaultDatasetView"; - - public static String getDefaultView(ViewContext context, int datasetId) - { - User user = context.getUser(); - // Don't return a default view for guests, Issue 52863 - if (!user.isGuest()) - { - Map viewMap = PropertyManager.getProperties(user, context.getContainer(), DEFAULT_DATASET_VIEW); - - final String key = Integer.toString(datasetId); - if (viewMap.containsKey(key)) - { - return viewMap.get(key); - } - } - return ""; - } - - private void setDefaultView(int datasetId, String view) - { - User user = getUser(); - if (user.isGuest()) - throw new IllegalStateException("Can't set a default view for guests"); - WritablePropertyMap viewMap = PropertyManager.getWritableProperties(user, getContainer(), DEFAULT_DATASET_VIEW, true); - - viewMap.put(Integer.toString(datasetId), view); - viewMap.save(); - } - - private String getVisitLabel() - { - StudyImpl study = getStudy(); - if (study != null) - { - return StudyManager.getInstance().getVisitManager(getStudyRedirectIfNull()).getLabel(); - } - return "Visit"; - } - - - private String getVisitLabelPlural() - { - StudyImpl study = getStudy(); - if (study != null) - { - return StudyManager.getInstance().getVisitManager(getStudyRedirectIfNull()).getPluralLabel(); - } - return "Visits"; - } - - public static class ParticipantForm extends ViewForm implements StudyManager.ParticipantViewConfig - { - private String participantId; - private int datasetId; - private double sequenceNum; - private String action; - private Map aliases; - - @Override - public String getParticipantId(){return participantId;} - - public void setParticipantId(String participantId) - { - this.participantId = participantId; - aliases = StudyManager.getInstance().getAliasMap(StudyManager.getInstance().getStudy(getContainer()), getUser(), participantId); - } - - @Override - public Map getAliases() - { - return null == aliases ? Map.of() : aliases; - } - - @Override - public int getDatasetId(){return datasetId;} - public void setDatasetId(int datasetId){this.datasetId = datasetId;} - - public double getSequenceNum(){return sequenceNum;} - public void setSequenceNum(double sequenceNum){this.sequenceNum = sequenceNum;} - - public String getAction(){return action;} - public void setAction(String action){this.action = action;} - } - - public static class StudyPropertiesForm extends ReturnUrlForm - { - private String _label; - private TimepointType _timepointType; - private Date _startDate; - private Date _endDate; - private SecurityType _securityType; - private String _subjectNounSingular = "Participant"; - private String _subjectNounPlural = "Participants"; - private String _subjectColumnName = "ParticipantId"; - private String _assayPlan; - private String _description; - private String _descriptionRendererType; - private String _grant; - private String _investigator; - private String _species; - private int _defaultTimepointDuration = 0; - private String _alternateIdPrefix; - private int _alternateIdDigits; - private boolean _allowReqLocRepository = true; - private boolean _allowReqLocClinic = true; - private boolean _allowReqLocSal = true; - private boolean _allowReqLocEndpoint = true; - private boolean _shareDatasets = false; - private boolean _shareVisits = false; - private boolean _failForUndefinedTimepoints; - - public String getLabel() - { - return _label; - } - - public void setLabel(String label) - { - _label = label; - } - - public TimepointType getTimepointType() - { - return _timepointType; - } - - public void setTimepointType(TimepointType timepointType) - { - _timepointType = timepointType; - } - - public Date getStartDate() - { - return _startDate; - } - - public void setStartDate(Date startDate) - { - _startDate = startDate; - } - - public void setSecurityString(String security) - { - _securityType = SecurityType.valueOf(security); - } - - public String getSecurityString() - { - return _securityType == null ? null : _securityType.name(); - } - - public void setSecurityType(SecurityType securityType) - { - _securityType = securityType; - } - - public SecurityType getSecurityType() - { - return _securityType; - } - - public String getSubjectNounSingular() - { - return _subjectNounSingular; - } - - public void setSubjectNounSingular(String subjectNounSingular) - { - _subjectNounSingular = subjectNounSingular; - } - - public String getSubjectNounPlural() - { - return _subjectNounPlural; - } - - public void setSubjectNounPlural(String subjectNounPlural) - { - _subjectNounPlural = subjectNounPlural; - } - - public String getSubjectColumnName() - { - return _subjectColumnName; - } - - public void setSubjectColumnName(String subjectColumnName) - { - _subjectColumnName = subjectColumnName; - } - - public String getDescription() - { - return _description; - } - - public void setDescription(String description) - { - _description = description; - } - - public String getDescriptionRendererType() - { - return _descriptionRendererType; - } - - public void setDescriptionRendererType(String descriptionRendererType) - { - _descriptionRendererType = descriptionRendererType; - } - - public String getInvestigator() - { - return _investigator; - } - - public void setInvestigator(String investigator) - { - _investigator = investigator; - } - - public String getGrant() - { - return _grant; - } - - public void setGrant(String grant) - { - _grant = grant; - } - - public int getDefaultTimepointDuration() - { - return _defaultTimepointDuration; - } - - public void setDefaultTimepointDuration(int defaultTimepointDuration) - { - _defaultTimepointDuration = defaultTimepointDuration; - } - - public String getAlternateIdPrefix() - { - return _alternateIdPrefix; - } - - public void setAlternateIdPrefix(String alternateIdPrefix) - { - _alternateIdPrefix = alternateIdPrefix; - } - - public int getAlternateIdDigits() - { - return _alternateIdDigits; - } - - public void setAlternateIdDigits(int alternateIdDigits) - { - _alternateIdDigits = alternateIdDigits; - } - - public boolean isAllowReqLocRepository() - { - return _allowReqLocRepository; - } - - public void setAllowReqLocRepository(boolean allowReqLocRepository) - { - _allowReqLocRepository = allowReqLocRepository; - } - - public boolean isAllowReqLocClinic() - { - return _allowReqLocClinic; - } - - public void setAllowReqLocClinic(boolean allowReqLocClinic) - { - _allowReqLocClinic = allowReqLocClinic; - } - - public boolean isAllowReqLocSal() - { - return _allowReqLocSal; - } - - public void setAllowReqLocSal(boolean allowReqLocSal) - { - _allowReqLocSal = allowReqLocSal; - } - - public boolean isAllowReqLocEndpoint() - { - return _allowReqLocEndpoint; - } - - public void setAllowReqLocEndpoint(boolean allowReqLocEndpoint) - { - _allowReqLocEndpoint = allowReqLocEndpoint; - } - - public Date getEndDate() - { - return _endDate; - } - - public void setEndDate(Date endDate) - { - _endDate = endDate; - } - - public String getAssayPlan() - { - return _assayPlan; - } - - public void setAssayPlan(String assayPlan) - { - _assayPlan = assayPlan; - } - - public String getSpecies() - { - return _species; - } - - public void setSpecies(String species) - { - _species = species; - } - - public boolean isShareDatasets() - { - return _shareDatasets; - } - - public void setShareDatasets(boolean shareDatasets) - { - _shareDatasets = shareDatasets; - } - - public boolean isShareVisits() - { - return _shareVisits; - } - - public void setShareVisits(boolean shareDatasets) - { - _shareVisits = shareDatasets; - } - - public boolean isFailForUndefinedTimepoints() - { - return _failForUndefinedTimepoints; - } - - public void setFailForUndefinedTimepoints(boolean failForUndefinedTimepoints) - { - _failForUndefinedTimepoints = failForUndefinedTimepoints; - } - } - - public static class IdForm - { - private int _id; - - public int getId() {return _id;} - - public void setId(int id) {_id = id;} - } - - public static class SourceLsidForm - { - private String _sourceLsid; - - public String getSourceLsid() {return _sourceLsid;} - - public void setSourceLsid(String sourceLsid) {_sourceLsid = sourceLsid;} - } - - /** - * Adds next and prev buttons to the participant view - */ - public static class ParticipantNavView extends HttpView - { - private final ActionURL _prevURL; - private final ActionURL _nextURL; - private final String _display; - private final String _currentParticipantId; - private boolean _showCustomizeLink = true; - - public ParticipantNavView(ActionURL prevURL, ActionURL nextURL, String currentParticipantId, String display) - { - _prevURL = prevURL; - _nextURL = nextURL; - _display = display; - _currentParticipantId = currentParticipantId; - } - - @Override - protected void renderInternal(Object model, PrintWriter out) - { - Container c = getViewContext().getContainer(); - User user = getViewContext().getUser(); - - String subjectNoun = PageFlowUtil.filter(StudyService.get().getSubjectNounSingular(getViewContext().getContainer())); - out.print("
"); - if (_prevURL != null) - { - LinkBuilder.labkeyLink("Previous " + subjectNoun, _prevURL).appendTo(out); - out.print(" "); - } - - if (_nextURL != null) - { - LinkBuilder.labkeyLink("Next " + subjectNoun, _nextURL).appendTo(out); - out.print(" "); - } - - SearchService ss = SearchService.get(); - - if (null != _currentParticipantId) - { - ActionURL search = urlProvider(SearchUrls.class).getSearchURL(c, "+" + ss.escapeTerm(_currentParticipantId)); - LinkBuilder.labkeyLink("Search for '" + id(_currentParticipantId, c, user) + "'", search).appendTo(out); - out.print(" "); - } - - // Show customize link to site admins (who are always developers) and folder admins who are developers: - Set> permissions = new HashSet<>(); - permissions.add(AdminPermission.class); - permissions.add(PlatformDeveloperPermission.class); - if (_showCustomizeLink && c.hasPermissions(getViewContext().getUser(), permissions)) - { - ActionURL customizeURL = new ActionURL(CustomizeParticipantViewAction.class, c); - customizeURL.addReturnUrl(getViewContext().getActionURL()); - customizeURL.addParameter("participantId", _currentParticipantId); - out.print(""); - LinkBuilder.labkeyLink("Customize View", customizeURL).appendTo(out); - } - - if (_display != null) - { - out.print(""); - out.print(PageFlowUtil.filter(_display)); - } - out.print("
"); - } - - public void setShowCustomizeLink(boolean showCustomizeLink) - { - _showCustomizeLink = showCustomizeLink; - } - } - - public static class ImportDatasetForm - { - private int datasetId = 0; - private String typeURI; - private String tsv; - private String keys; - private String _participantId; - private String _sequenceNum; - private String _name; - private QueryUpdateService.InsertOption _insertOption = QueryUpdateService.InsertOption.IMPORT; - - public int getDatasetId() - { - return datasetId; - } - - public void setDatasetId(int datasetId) - { - this.datasetId = datasetId; - } - - public String getTsv() - { - return tsv; - } - - public void setTsv(String tsv) - { - this.tsv = tsv; - } - - public String getKeys() - { - return keys; - } - - public void setKeys(String keys) - { - this.keys = keys; - } - - public String getTypeURI() - { - return typeURI; - } - - public void setTypeURI(String typeURI) - { - this.typeURI = typeURI; - } - - public String getParticipantId() - { - return _participantId; - } - - public void setParticipantId(String participantId) - { - _participantId = participantId; - } - - public String getSequenceNum() - { - return _sequenceNum; - } - - public void setSequenceNum(String sequenceNum) - { - _sequenceNum = sequenceNum; - } - - public String getName() - { - return _name; - } - - public void setName(String name) - { - _name = name; - } - - public QueryUpdateService.InsertOption getInsertOption() - { - return _insertOption; - } - - public void setInsertOption(QueryUpdateService.InsertOption insertOption) - { - _insertOption = insertOption; - } - } - - public static class DatasetForm - { - private String _name; - private String _label; - private Integer _datasetId; - private String _category; - private boolean _showByDefault; - private String _visitDatePropertyName; - private String[] _visitStatus; - private int[] _visitRowIds; - private String _description; - private Integer _cohortId; - private boolean _demographicData; - private boolean _create; - - public boolean isShowByDefault() - { - return _showByDefault; - } - - public void setShowByDefault(boolean showByDefault) - { - _showByDefault = showByDefault; - } - - public String getCategory() - { - return _category; - } - - public void setCategory(String category) - { - _category = category; - } - - public String getDatasetIdStr() - { - return _datasetId > 0 ? String.valueOf(_datasetId) : ""; - } - - /** - * Don't blow up when posting bad value - */ - public void setDatasetIdStr(String datasetIdStr) - { - try - { - if (null == StringUtils.trimToNull(datasetIdStr)) - _datasetId = 0; - else - _datasetId = Integer.parseInt(datasetIdStr); - } - catch (Exception x) - { - _datasetId = 0; - } - } - - public Integer getDatasetId() - { - return _datasetId; - } - - public void setDatasetId(Integer datasetId) - { - _datasetId = datasetId; - } - - public String getLabel() - { - return _label; - } - - public void setLabel(String label) - { - _label = label; - } - - public String getName() - { - return _name; - } - - public void setName(String name) - { - _name = name; - } - - public String[] getVisitStatus() - { - return _visitStatus; - } - - public void setVisitStatus(String[] visitStatus) - { - _visitStatus = visitStatus; - } - - public int[] getVisitRowIds() - { - return _visitRowIds; - } - - public void setVisitRowIds(int[] visitIds) - { - _visitRowIds = visitIds; - } - - public String getVisitDatePropertyName() - { - return _visitDatePropertyName; - } - - public void setVisitDatePropertyName(String visitDatePropertyName) - { - _visitDatePropertyName = visitDatePropertyName; - } - - public String getDescription() - { - return _description; - } - - public void setDescription(String description) - { - _description = description; - } - - public boolean isDemographicData() - { - return _demographicData; - } - - public void setDemographicData(boolean demographicData) - { - _demographicData = demographicData; - } - - public boolean isCreate() - { - return _create; - } - - public void setCreate(boolean create) - { - _create = create; - } - - public Integer getCohortId() - { - return _cohortId; - } - - public void setCohortId(Integer cohortId) - { - _cohortId = cohortId; - } - } - - @RequiresPermission(ReadPermission.class) - public class DatasetsAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object o, BindException errors) throws Exception - { - return StudyModule.datasetsPartFactory.getWebPartView(getViewContext(), StudyModule.datasetsPartFactory.createWebPart()); - } - - @Override - public void addNavTrail(NavTree root) - { - _addNavTrail(root); - root.addChild("Datasets"); - } - } - - @RequiresPermission(ReadPermission.class) - public static class ViewDataAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object o, BindException errors) throws Exception - { - return new VBox( - StudyModule.reportsPartFactory.getWebPartView(getViewContext(), StudyModule.reportsPartFactory.createWebPart()), - StudyModule.datasetsPartFactory.getWebPartView(getViewContext(), StudyModule.datasetsPartFactory.createWebPart()) - ); - } - - @Override - public void addNavTrail(NavTree root) - { - } - } - - private static class DatasetDetailRedirectForm extends ReturnUrlForm - { - private String _datasetId; - private String _lsid; - - public String getDatasetId() - { - return _datasetId; - } - - public void setDatasetId(String datasetId) - { - _datasetId = datasetId; - } - - public String getLsid() - { - return _lsid; - } - - public void setLsid(String lsid) - { - _lsid = lsid; - } - } - - @RequiresPermission(AdminPermission.class) - public class ManageExternalReloadAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object form, BindException errors) - { - return new StudyJspView<>(getStudyRedirectIfNull(), "/org/labkey/study/view/manageExternalReload.jsp", form, errors); - } - - @Override - public void addNavTrail(NavTree root) - { - _addManageStudy(root); - root.addChild("Manage External Reloading"); - } - } - - @RequiresPermission(ReadPermission.class) - public static class DatasetDetailRedirectAction extends SimpleRedirectAction - { - @Override - public URLHelper getRedirectURL(DatasetDetailRedirectForm form) - { - StudyImpl study = StudyManager.getInstance().getStudy(getContainer()); - if (study == null) - { - throw new NotFoundException("No study found"); - } - // First try the dataset id as an entityid - DatasetDefinition dataset = StudyManager.getInstance().getDatasetDefinitionByEntityId(study, form.getDatasetId()); - if (dataset == null) - { - try - { - // Then try the dataset id as an integer - int id = Integer.parseInt(form.getDatasetId()); - dataset = StudyManager.getInstance().getDatasetDefinition(study, id); - } - catch (NumberFormatException ignored) {} - - if (dataset == null) - { - throw new NotFoundException("Could not find dataset " + form.getDatasetId()); - } - } - - if (form.getLsid() == null) - { - throw new NotFoundException("No LSID specified"); - } - - StudyQuerySchema schema = StudyQuerySchema.createSchema(study, getUser()); - - QueryDefinition queryDef = QueryService.get().createQueryDefForTable(schema, dataset.getName()); - assert queryDef != null : "Dataset was found but couldn't get a corresponding TableInfo"; - - ActionURL url = queryDef.urlFor(QueryAction.detailsQueryRow, getContainer(), Collections.singletonMap("lsid", form.getLsid())); - String referrer = getViewContext().getRequest().getHeader("Referer"); - if (referrer != null) - { - url.addParameter(ActionURL.Param.returnUrl, referrer); - } - - return url; - } - } - - public static class ImportVisitMapForm - { - private String _content; - - public String getContent() - { - return _content; - } - - public void setContent(String content) - { - _content = content; - } - } - - @RequiresPermission(AdminPermission.class) - public class DemoModeAction extends FormViewAction - { - @Override - public URLHelper getSuccessURL(DemoModeForm form) - { - return null; - } - - @Override - public void validateCommand(DemoModeForm form, Errors errors) - { - } - - @Override - public ModelAndView getView(DemoModeForm form, boolean reshow, BindException errors) - { - return new JspView<>("/org/labkey/study/view/demoMode.jsp"); - } - - @Override - public boolean handlePost(DemoModeForm form, BindException errors) - { - DemoMode.setDemoMode(getContainer(), getUser(), form.getMode()); - return false; // Reshow page - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("demoMode"); - _addManageStudy(root); - root.addChild("Demo Mode"); - } - } - - - public static class DemoModeForm - { - private boolean mode; - - public boolean getMode() - { - return mode; - } - - public void setMode(boolean mode) - { - this.mode = mode; - } - } - - - @RequiresPermission(AdminPermission.class) - public class ShowVisitImportMappingAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object o, BindException errors) - { - return new JspView<>("/org/labkey/study/view/visitImportMapping.jsp", new ImportMappingBean(getStudyRedirectIfNull())); - } - - @Override - public void addNavTrail(NavTree root) - { - _addNavTrailVisitAdmin(root); - root.addChild("Visit Import Mapping"); - } - } - - - public static class ImportMappingBean - { - private final Collection _customMapping; - private final Collection _standardMapping; - - public ImportMappingBean(Study study) - { - _customMapping = StudyManager.getInstance().getCustomVisitImportMapping(study); - _standardMapping = StudyManager.getInstance().getStandardVisitImportMapping(study); - } - - public Collection getCustomMapping() - { - return _customMapping; - } - - public Collection getStandardMapping() - { - return _standardMapping; - } - } - - - @RequiresPermission(AdminPermission.class) - public class ImportVisitAliasesAction extends FormViewAction - { - @Override - public URLHelper getSuccessURL(VisitAliasesForm form) - { - return new ActionURL(ShowVisitImportMappingAction.class, getContainer()); - } - - @Override - public void validateCommand(VisitAliasesForm form, Errors errors) - { - } - - @Override - public ModelAndView getView(VisitAliasesForm form, boolean reshow, BindException errors) - { - getPageConfig().setFocusId("tsv"); - return new JspView<>("/org/labkey/study/view/importVisitAliases.jsp", null, errors); - } - - @Override - public boolean handlePost(VisitAliasesForm form, BindException errors) - { - boolean hadCustomMapping = !StudyManager.getInstance().getCustomVisitImportMapping(getStudyThrowIfNull()).isEmpty(); - - try - { - String tsv = form.getTsv(); - - if (null == tsv) - { - errors.reject(ERROR_MSG, "Please insert tab-separated data with two columns, Name and SequenceNum"); - return false; - } - - StudyManager.getInstance().importVisitAliases(getStudyThrowIfNull(), getUser(), new TabLoader(form.getTsv(), true)); - } - catch (RuntimeSQLException e) - { - if (e.isConstraintException()) - { - errors.reject(ERROR_MSG, "The visit import mapping includes duplicate visit names: " + e.getMessage()); - return false; - } - else - { - throw e; - } - } - catch (ValidationException e) - { - errors.reject(ERROR_MSG, e.getMessage()); - return false; - } - - // TODO: Change to audit log - _log.info("The visit import custom mapping was " + (hadCustomMapping ? "replaced" : "imported")); - - return true; - } - - @Override - public void addNavTrail(NavTree root) - { - _addNavTrailVisitAdmin(root); - root.addChild("Import Visit Aliases"); - } - } - - - public static class VisitAliasesForm - { - private String _tsv; - - public String getTsv() - { - return _tsv; - } - - @SuppressWarnings({"UnusedDeclaration"}) - public void setTsv(String tsv) - { - _tsv = tsv; - } - } - - - @RequiresPermission(AdminPermission.class) - public class ClearVisitAliasesAction extends ConfirmAction - { - @Override - public ModelAndView getConfirmView(Object o, BindException errors) - { - if (getPageConfig().getTitle() == null) - setTitle("Clear Custom Mapping"); - - return HtmlView.of("Are you sure you want to delete the visit import custom mapping for this study?"); - } - - @Override - public boolean handlePost(Object o, BindException errors) - { - StudyManager.getInstance().clearVisitAliases(getStudyThrowIfNull()); - // TODO: Change to audit log - _log.info("The visit import custom mapping was cleared"); - - return true; - } - - @Override - public void validateCommand(Object o, Errors errors) - { - } - - @Override - public @NotNull URLHelper getSuccessURL(Object o) - { - return new ActionURL(ShowVisitImportMappingAction.class, getContainer()); - } - } - - @RequiresPermission(ReadPermission.class) @RequiresLogin - public class ManageParticipantCategoriesAction extends SimpleViewAction - { - @Override - public ModelAndView getView(SentGroupForm form, BindException errors) - { - // if the user is viewing a sent participant group, remove any notifications related to it - if (form.getGroupId() != null) - { - NotificationService.get().removeNotifications(getContainer(), form.getGroupId().toString(), - Collections.singletonList(ParticipantCategory.SEND_PARTICIPANT_GROUP_TYPE), getUser().getUserId()); - } - - return new JspView<>("/org/labkey/study/view/manageParticipantCategories.jsp"); - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("participantGroups"); - _addManageStudy(root); - root.addChild("Manage " + getStudyRedirectIfNull().getSubjectNounSingular() + " Groups"); - } - } - - public static class SentGroupForm - { - private Integer _groupId; - - public Integer getGroupId() - { - return _groupId; - } - - public void setGroupId(Integer groupId) - { - _groupId = groupId; - } - } - - @RequiresLogin @RequiresPermission(ReadPermission.class) - public class SendParticipantGroupAction extends FormViewAction - { - List _validRecipients = new ArrayList<>(); - - @Override - public URLHelper getSuccessURL(SendParticipantGroupForm form) - { - return form.getReturnActionURL(form.getDefaultUrl(getContainer())); - } - - @Override - public ModelAndView getView(SendParticipantGroupForm form, boolean reshow, BindException errors) - { - if (form.getRowId() == null) - { - return HtmlView.err("No participant group RowId provided."); - } - else - { - ParticipantGroup group = ParticipantGroupManager.getInstance().getParticipantGroup(getContainer(), getUser(), form.getRowId()); - if (group != null) - { - ParticipantCategoryImpl category = ParticipantGroupManager.getInstance().getParticipantCategory(getContainer(), getUser(), group.getCategoryId()); - if (category != null && category.canRead(getContainer(), getUser())) - { - form.setLabel(group.getLabel()); - return new JspView<>("/org/labkey/study/view/sendParticipantGroup.jsp", form, errors); - } - } - - return HtmlView.err("Could not find participant group for RowId " + form.getRowId() + " or you do not have permission to read it."); - } - } - - @Override - public void validateCommand(SendParticipantGroupForm form, Errors errors) - { - _validRecipients = SecurityManager.parseRecipientListForContainer(getContainer(), form.getRecipientList(), errors); - } - - @Override - public boolean handlePost(SendParticipantGroupForm form, BindException errors) throws Exception - { - if (!errors.hasErrors() && !_validRecipients.isEmpty()) - { - for (User recipient : _validRecipients) - { - NotificationService.get().sendMessageForRecipient( - getContainer(), getUser(), recipient, - form.getMessageSubject(), form.getMessageBody(), form.getSendGroupUrl(getContainer()), - form.getRowId().toString(), ParticipantCategory.SEND_PARTICIPANT_GROUP_TYPE - ); - - String auditMsg = "The following participant group was shared: recipient: " + recipient.getName() + " (" + recipient.getUserId() + ")" - + ", groupId: " + form.getRowId() + ", name: " + form.getLabel(); - StudyService.get().addStudyAuditEvent(getContainer(), getUser(), auditMsg); - } - } - - return !errors.hasErrors(); - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("participantGroups"); - String manageGroupsTitle = "Manage " + getStudyRedirectIfNull().getSubjectNounSingular() + " Groups"; - root.addChild(manageGroupsTitle, new ActionURL(ManageParticipantCategoriesAction.class, getContainer())); - root.addChild("Send Participant Group"); - } - } - - public static class SendParticipantGroupForm extends ReturnUrlForm - { - private Integer _rowId; - private String _label; - private String _recipientList; - private String _messageSubject; - private String _messageBody; - - public Integer getRowId() - { - return _rowId; - } - - public void setRowId(Integer rowId) - { - _rowId = rowId; - } - - public String getLabel() - { - return _label; - } - - public void setLabel(String label) - { - _label = label; - } - - public String getRecipientList() - { - return _recipientList; - } - - public void setRecipientList(String recipientList) - { - _recipientList = recipientList; - } - - public String getMessageSubject() - { - return _messageSubject; - } - - public void setMessageSubject(String messageSubject) - { - _messageSubject = messageSubject; - } - - public String getMessageBody() - { - return _messageBody; - } - - public void setMessageBody(String messageBody) - { - _messageBody = messageBody; - } - - public ActionURL getDefaultUrl(Container container) - { - return new ActionURL(ManageParticipantCategoriesAction.class, container); - } - - public ActionURL getSendGroupUrl(Container container) - { - ActionURL sendGroupUrl = getReturnActionURL(getDefaultUrl(container)); - sendGroupUrl.addParameter("groupId", getRowId()); - return sendGroupUrl; - } - } - - @RequiresPermission(AdminPermission.class) - public class ManageParticipantsAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object form, BindException errors) - { - ChangeAlternateIdsForm changeAlternateIdsForm = getChangeAlternateIdForm(getStudyRedirectIfNull()); - return new JspView<>("/org/labkey/study/view/manageParticipants.jsp", changeAlternateIdsForm); - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("alternateIDs"); - _addManageStudy(root); - String pluralNoun = getStudyRedirectIfNull().getSubjectNounPlural(); - root.addChild("Manage " + pluralNoun, new ActionURL(ManageParticipantsAction.class, getContainer())); - } - } - - @RequiresPermission(AdminPermission.class) - public class MergeParticipantsAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object form, BindException errors) - { - return new JspView<>("/org/labkey/study/view/mergeParticipants.jsp"); - } - - @Override - public void addNavTrail(NavTree root) - { - // Add Manage Participants nav trail - ManageParticipantsAction manageParticipantsAction = new ManageParticipantsAction(); - manageParticipantsAction.setViewContext(getViewContext()); - manageParticipantsAction.setPageConfig(new PageConfig(getViewContext().getRequest())); - manageParticipantsAction.addNavTrail(root); - - String subjectColumnName = getStudyRedirectIfNull().getSubjectColumnName(); - root.addChild("Change or Merge " + subjectColumnName + "s"); - } - } - - @RequiresPermission(ReadPermission.class) - public static class SubjectListAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object o, BindException errors) - { - return new SubjectsWebPart(getViewContext(), true, 0); - } - - @Override - public void addNavTrail(NavTree root) - { - } - } - - @RequiresPermission(ReadPermission.class) - public static class BrowseStudyScheduleAction extends MutatingApiAction - { - @Override - public ApiResponse execute(BrowseStudyForm browseDataForm, BindException errors) throws Exception - { - ApiSimpleResponse response = new ApiSimpleResponse(); - StudyManager manager = StudyManager.getInstance(); - Study study = manager.getStudy(getContainer()); - StudySchedule schedule = new StudySchedule(); - CohortImpl cohort = null; - - if (browseDataForm.getCohortId() != null) - { - cohort = manager.getCohortForRowId(getContainer(), getUser(), browseDataForm.getCohortId()); - } - - if (cohort == null && browseDataForm.getCohortLabel() != null) - { - cohort = manager.getCohortByLabel(getContainer(), getUser(), browseDataForm.getCohortLabel()); - } - - if (study != null) - { - schedule.setVisits(manager.getVisits(study, cohort, getUser(), Visit.Order.DISPLAY)); - schedule.setDatasets( - manager.getDatasetDefinitions(study, cohort, Dataset.TYPE_STANDARD, Dataset.TYPE_PLACEHOLDER), - DataViewService.get().getViews(getViewContext(), Collections.singletonList(DatasetViewProvider.TYPE))); - - response.put("schedule", schedule.toJSON(getUser())); - response.put("success", true); - - return response; - } - else - throw new IllegalStateException("A study does not exist in this folder"); - } - } - - @RequiresPermission(ReadPermission.class) - public static class GetStudyTimepointsAction extends MutatingApiAction - { - @Override - public ApiResponse execute(BrowseStudyForm browseDataForm, BindException errors) - { - ApiSimpleResponse response = new ApiSimpleResponse(); - StudyManager manager = StudyManager.getInstance(); - Study study = manager.getStudy(getContainer()); - StudySchedule schedule = new StudySchedule(); - CohortImpl cohort = null; - - if (browseDataForm.getCohortId() != null) - { - cohort = manager.getCohortForRowId(getContainer(), getUser(), browseDataForm.getCohortId()); - } - - if (cohort == null && browseDataForm.getCohortLabel() != null) - { - cohort = manager.getCohortByLabel(getContainer(), getUser(), browseDataForm.getCohortLabel()); - } - - if (study != null) - { - schedule.setVisits(manager.getVisits(study, cohort, getUser(), Visit.Order.DISPLAY)); - - response.put("schedule", schedule.toJSON(getUser())); - response.put("success", true); - - return response; - } - else - throw new IllegalStateException("A study does not exist in this folder"); - } - } - - @RequiresPermission(AdminPermission.class) - public static class UpdateStudyScheduleAction extends MutatingApiAction - { - @Override - public void validateForm(StudySchedule form, Errors errors) - { - if (form.getSchedule().size() <= 0) - errors.reject(ERROR_MSG, "No study schedule records have been specified"); - } - - @Override - public ApiResponse execute(StudySchedule form, BindException errors) - { - ApiSimpleResponse response = new ApiSimpleResponse(); - Study study = StudyManager.getInstance().getStudy(getContainer()); - - if (study != null) - { - for (Map.Entry> entry : form.getSchedule().entrySet()) - { - Dataset ds = StudyService.get().getDataset(getContainer(), entry.getKey()); - if (ds != null) - { - for (VisitDataset visit : entry.getValue()) - { - VisitDatasetType type = visit.isRequired() ? VisitDatasetType.REQUIRED : VisitDatasetType.NOT_ASSOCIATED; - - StudyManager.getInstance().updateVisitDatasetMapping(getUser(), getContainer(), - visit.getVisitRowId(), ds.getDatasetId(), type); - } - } - } - response.put("success", true); - - return response; - } - else - throw new IllegalStateException("A study does not exist in this folder"); - } - } - - public static class BrowseStudyForm - { - private Integer _cohortId; - private String _cohortLabel; - - public Integer getCohortId() - { - return _cohortId; - } - - public void setCohortId(Integer cohortId) - { - _cohortId = cohortId; - } - - public String getCohortLabel() - { - return _cohortLabel; - } - - public void setCohortLabel(String cohortLabel) - { - _cohortLabel = cohortLabel; - } - } - - @RequiresPermission(AdminPermission.class) - public class DefineDatasetAction extends MutatingApiAction - { - private StudyImpl _study; - - @Override - public void validateForm(DefineDatasetForm form, Errors errors) - { - _study = StudyManager.getInstance().getStudy(getContainer()); - - if (_study != null) - { - switch (form.getType()) - { - case defineManually: - case placeHolder: - if (StringUtils.isEmpty(form.getName())) - errors.reject(ERROR_MSG, "A Dataset name must be specified."); - else if (StudyManager.getInstance().getDatasetDefinitionByName(_study, form.getName()) != null) - errors.reject(ERROR_MSG, "A Dataset named: " + form.getName() + " already exists in this folder."); - break; - - case linkToTarget: - if (form.getExpectationDataset() == null || form.getTargetDataset() == null) - errors.reject(ERROR_MSG, "An expectation Dataset and target Dataset must be specified."); - break; - - case linkManually: - if (form.getExpectationDataset() == null) - errors.reject(ERROR_MSG, "An expectation Dataset must be specified."); - break; - } - } - else - errors.reject(ERROR_MSG, "A study does not exist in this folder"); - } - - @Override - public ApiResponse execute(DefineDatasetForm form, BindException errors) - { - ApiSimpleResponse response = new ApiSimpleResponse(); - DatasetDefinition def; - - DbScope scope = StudySchema.getInstance().getSchema().getScope(); - - try (DbScope.Transaction transaction = scope.ensureTransaction()) - { - Integer categoryId = null; - - if (form.getCategory() != null) - { - ViewCategory category = ViewCategoryManager.getInstance().ensureViewCategory(getContainer(), getUser(), form.getCategory().getLabel()); - categoryId = category.getRowId(); - } - - switch (form.getType()) - { - case defineManually: - { - def = StudyPublishManager.getInstance().createDataset(getUser(), new DatasetDefinition.Builder(form.getName()) - .setStudy(_study) - .setDemographicData(false) - .setCategoryId(categoryId)); - def.provisionTable(false); - - ActionURL redirect = new ActionURL(EditTypeAction.class, getContainer()).addParameter(Dataset.DATASET_KEY, def.getDatasetId()); - response.put("redirectUrl", redirect.getLocalURIString()); - break; - } - case placeHolder: - def = StudyPublishManager.getInstance().createDataset(getUser(), new DatasetDefinition.Builder(form.getName()) - .setStudy(_study) - .setDemographicData(false) - .setType(Dataset.TYPE_PLACEHOLDER) - .setCategoryId(categoryId)); - def.provisionTable(false); - response.put("datasetId", def.getDatasetId()); - break; - - case linkManually: - def = StudyManager.getInstance().getDatasetDefinition(_study, form.getExpectationDataset()); - if (def != null) - { - def = def.createMutable(); - - def.setType(Dataset.TYPE_STANDARD); - def.save(getUser()); - - // add a cancel url to rollback either the manual link or import from file link - ActionURL cancelURL = new ActionURL(CancelDefineDatasetAction.class, getContainer()).addParameter("expectationDataset", form.getExpectationDataset()); - - ActionURL redirect = new ActionURL(EditTypeAction.class, getContainer()).addParameter(Dataset.DATASET_KEY, form.getExpectationDataset()); - redirect.addCancelURL(cancelURL); - response.put("redirectUrl", redirect.getLocalURIString()); - } - else - throw new IllegalArgumentException("The expectation Dataset did not exist"); - break; - - case linkToTarget: - DatasetDefinition expectationDataset = StudyManager.getInstance().getDatasetDefinition(_study, form.getExpectationDataset()); - DatasetDefinition targetDataset = StudyManager.getInstance().getDatasetDefinition(_study, form.getTargetDataset()); - - StudyManager.getInstance().linkPlaceHolderDataset(_study, getUser(), expectationDataset, targetDataset); - break; - } - response.put("success", true); - transaction.commit(); - } - - return response; - } - } - - @RequiresPermission(AdminPermission.class) - public class CancelDefineDatasetAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object form, BindException errors) - { - // switch the dataset back to a placeholder type - Study study = getStudy(getContainer()); - if (study != null) - { - String expectationDataset = getViewContext().getActionURL().getParameter("expectationDataset"); - if (NumberUtils.isDigits(expectationDataset)) - { - DatasetDefinition def = StudyManager.getInstance().getDatasetDefinition(study, NumberUtils.toInt(expectationDataset)); - if (def != null) - { - def = def.createMutable(); - - def.setType(Dataset.TYPE_PLACEHOLDER); - def.save(getUser()); - } - } - } - throw new RedirectException(new ActionURL(StudyScheduleAction.class, getContainer())); - } - - @Override - public void addNavTrail(NavTree root) - { - } - } - - public static class DefineDatasetForm implements ApiJsonForm, HasViewContext - { - enum Type - { - defineManually, - placeHolder, - linkToTarget, - linkManually, - } - - private ViewContext _context; - private DefineDatasetForm.Type _type; - private String _name; - private ViewCategory _category; - private Integer _expectationDataset; - private Integer _targetDataset; - - public Type getType() - { - return _type; - } - - public String getName() - { - return _name; - } - - public ViewCategory getCategory() - { - return _category; - } - - public Integer getExpectationDataset() - { - return _expectationDataset; - } - - public Integer getTargetDataset() - { - return _targetDataset; - } - - @Override - public void bindJson(JSONObject json) - { - JSONObject categoryProp = json.optJSONObject("category"); - if (null != categoryProp) - { - _category = ViewCategory.fromJSON(_context.getContainer(), categoryProp); - } - - _name = json.optString("name", null); - - String type = json.optString("type", null); - if (null != type) - _type = Type.valueOf(type); - - _expectationDataset = asInteger(json.opt("expectationDataset")); - _targetDataset = asInteger(json.opt("targetDataset")); - } - - @Override - public void setViewContext(ViewContext context) - { - _context = context; - } - - @Override - public ViewContext getViewContext() - { - return _context; - } - } - - public static class ChangeAlternateIdsForm - { - private String _prefix = ""; - private int _numDigits = StudyManager.ALTERNATEID_DEFAULT_NUM_DIGITS; - private int _aliasDatasetId = -1; - private String _aliasColumn = ""; - private String _sourceColumn = ""; - - public String getAliasColumn() - { - return _aliasColumn; - } - - public void setAliasColumn(String aliasColumn) - { - _aliasColumn = aliasColumn; - } - - public String getSourceColumn() - { - return _sourceColumn; - } - - public void setSourceColumn(String sourceColumn) - { - _sourceColumn = sourceColumn; - } - - public String getPrefix() - { - return _prefix; - } - - public void setPrefix(String prefix) - { - _prefix = prefix; - } - - public int getNumDigits() - { - return _numDigits; - } - - public void setNumDigits(int numDigits) - { - _numDigits = numDigits; - } - - public int getAliasDatasetId() - { - return _aliasDatasetId; - } - public void setAliasDatasetId(int aliasDatasetId) - { - _aliasDatasetId = aliasDatasetId; - } - } - - @RequiresPermission(AdminPermission.class) - public class ChangeAlternateIdsAction extends MutatingApiAction - { - @Override - public ApiResponse execute(ChangeAlternateIdsForm form, BindException errors) - { - ApiSimpleResponse response = new ApiSimpleResponse(); - StudyImpl study = StudyManager.getInstance().getStudy(getContainer()); - if (study != null) - { - setAlternateIdProperties(study, form.getPrefix(), form.getNumDigits()); - StudyManager.getInstance().clearAlternateParticipantIds(study); - response.put("success", true); - return response; - } - else - throw new IllegalStateException("A study does not exist in this folder"); - } - } - - public static class MapAliasIdsForm - { - private int _datasetId; - private String _aliasColumn = ""; - private String _sourceColumn = ""; - - public int getDatasetId() - { - return _datasetId; - } - - public void setDatasetId(int datasetId) - { - _datasetId = datasetId; - } - - public String getAliasColumn() - { - return _aliasColumn; - } - - public void setAliasColumn(String aliasColumn) - { - _aliasColumn = aliasColumn; - } - - public String getSourceColumn() - { - return _sourceColumn; - } - - public void setSourceColumn(String sourceColumn) - { - _sourceColumn = sourceColumn; - } - } - - @RequiresPermission(AdminPermission.class) - public class MapAliasIdsAction extends MutatingApiAction - { - @Override - public ApiResponse execute(MapAliasIdsForm form, BindException errors) - { - ApiSimpleResponse response = new ApiSimpleResponse(); - StudyImpl study = StudyManager.getInstance().getStudy(getContainer()); - if (study != null) - { - setAliasMappingProperties(study, form.getDatasetId(), form.getAliasColumn(), form.getSourceColumn()); - StudyManager.getInstance().clearAlternateParticipantIds(study); - response.put("success", true); - return response; - } - else - throw new IllegalStateException("A study does not exist in this folder"); - } - } - - - @RequiresPermission(AdminPermission.class) - public static class ExportParticipantTransformsAction extends FormHandlerAction - { - @Override - public void validateCommand(Object target, Errors errors) - { - } - - @Override - public boolean handlePost(Object o, BindException errors) throws Exception - { - Study study = StudyManager.getInstance().getStudy(getContainer()); - if (study != null) - { - // Ensure alternateIds are generated for all participants - StudyManager.getInstance().generateNeededAlternateParticipantIds(study, getUser()); - - TableInfo ti = StudySchema.getInstance().getTableInfoParticipant(); - List cols = new ArrayList<>(); - cols.add(ti.getColumn("participantid")); - cols.add(ti.getColumn("alternateid")); - cols.add(ti.getColumn("dateoffset")); - SimpleFilter filter = new SimpleFilter(); - filter.addCondition(ti.getColumn("container"), getContainer()); - ResultsFactory factory = ()->QueryService.get().select(ti, cols, filter, new Sort("participantid")); - - // NOTE: TSVGridWriter closes PrintWriter and ResultSet - try (TSVGridWriter writer = new TSVGridWriter(factory)) - { - writer.setApplyFormats(false); - writer.setFilenamePrefix("ParticipantTransforms"); - writer.setColumnHeaderType(ColumnHeaderType.DisplayFieldKey); // CONSIDER: Use FieldKey instead - writer.write(getViewContext().getResponse()); - } - - return true; - } - else - throw new IllegalStateException("A study does not exist in this folder"); - } - - @Override - public URLHelper getSuccessURL(Object o) - { - return null; - } - } - - public static ChangeAlternateIdsForm getChangeAlternateIdForm(StudyImpl study) - { - ChangeAlternateIdsForm changeAlternateIdsForm = new ChangeAlternateIdsForm(); - changeAlternateIdsForm.setPrefix(study.getAlternateIdPrefix()); - changeAlternateIdsForm.setNumDigits(study.getAlternateIdDigits()); - if (study.getParticipantAliasDatasetId() != null) - { - changeAlternateIdsForm.setAliasDatasetId(study.getParticipantAliasDatasetId()); - changeAlternateIdsForm.setAliasColumn(study.getParticipantAliasProperty()); - changeAlternateIdsForm.setSourceColumn(study.getParticipantAliasSourceProperty()); - } - - return changeAlternateIdsForm; - } - - private void setAlternateIdProperties(StudyImpl study, String prefix, int numDigits) - { - study = study.createMutable(); - study.setAlternateIdPrefix(prefix); - study.setAlternateIdDigits(numDigits); - StudyManager.getInstance().updateStudy(getUser(), study); - } - - private void setAliasMappingProperties(StudyImpl study, int datasetId, String aliasColumn, String sourceColumn) - { - study = study.createMutable(); - study.setParticipantAliasDatasetId(datasetId); - study.setParticipantAliasProperty(aliasColumn); - study.setParticipantAliasSourceProperty(sourceColumn); - StudyManager.getInstance().updateStudy(getUser(), study); - } - - @RequiresPermission(ManageStudyPermission.class) - public class ImportAlternateIdMappingAction extends AbstractQueryImportAction - { - private Study _study; - private int _requestId = -1; - - @Override - protected void initRequest(IdForm form) throws ServletException - { - _requestId = form.getId(); - setHasColumnHeaders(true); - if (null != getStudy()) - { - _study = getStudy(); - setImportMessage("Upload a mapping of " + _study.getSubjectNounPlural() + " to Alternate IDs and date offsets from a TXT, CSV or Excel file or paste the mapping directly into the text box below. " + - "There must be a header row, which must contain ParticipantId and either AlternateId, DateOffset or both. Click the button below to export the current mapping."); - } - setTarget(StudySchema.getInstance().getTableInfoParticipant()); - setHideTsvCsvCombo(true); - setSuccessMessageSuffix("uploaded"); - } - - @Override - public ModelAndView getView(IdForm form, BindException errors) throws Exception - { - _study = getStudyThrowIfNull(); - initRequest(form); - return getDefaultImportView(form, errors); - } - - @Override - protected boolean skipInsertOptionValidation() - { - return true; // allow QueryUpdateService.InsertOption.INSERT for study.participant - } - - @Override - protected void validatePermission(User user, BindException errors) - { - checkPermissions(); - } - - @Override - protected boolean canInsert(User user) - { - return getContainer().hasPermission(user, ManageStudyPermission.class); - } - - @Override - protected int importData(DataLoader dl, FileStream file, String originalName, BatchValidationException errors, @Nullable AuditBehaviorType auditBehaviorType, TransactionAuditProvider.@Nullable TransactionAuditEvent auditEvent, @Nullable String auditUserComment) throws IOException - { - if (null == _study) - return 0; - int rows = StudyManager.getInstance().setImportedAlternateParticipantIds(_study, dl, errors); - - // Insert a clear warning at the top that the mappings have not been imported, #36517 - if (errors.hasErrors()) - { - List rowErrors = errors.getRowErrors(); - int count = rowErrors.size(); - rowErrors.add(0, new ValidationException("Warning: NONE of participant mappings have been imported because this mapping file contains " + (1 == count ? "an error" : "errors") + "! Please correct the following:")); - } - - return rows; - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild("Upload " + _study.getSubjectNounSingular() + " Mapping"); - } - - @Override - protected ActionURL getSuccessURL(IdForm form) - { - return new ActionURL(ManageParticipantsAction.class, getContainer()); - } - } - - @RequiresPermission(AdminPermission.class) - public class SnapshotSettingsAction extends FormViewAction - { - private StudyImpl _study; - - @Override - public ModelAndView getView(SnapshotSettingsForm form, boolean reshow, BindException errors) - { - _study = getStudyRedirectIfNull(); - StudySnapshot snapshot = StudyManager.getInstance().getStudySnapshot(_study.getStudySnapshot()); - - if (null == snapshot) - { - errors.reject(null, "This is not a published study"); - return new SimpleErrorView(errors); - } - else - { - return new JspView<>("/org/labkey/study/view/snapshotSettings.jsp", snapshot); - } - } - - @Override - public void addNavTrail(NavTree root) - { - setHelpTopic("studyPubRefresh"); - _addManageStudy(root); - root.addChild((_study.getStudySnapshotType() != null ? _study.getStudySnapshotType().getTitle() : "") + " Study Settings"); - } - - @Override - public void validateCommand(SnapshotSettingsForm form, Errors errors) - { - } - - @Override - public boolean handlePost(SnapshotSettingsForm form, BindException errors) - { - StudyImpl study = getStudyRedirectIfNull(); - StudySnapshot snapshot = StudyManager.getInstance().getStudySnapshot(study.getStudySnapshot()); - assert null != snapshot; - snapshot.setRefresh(form.isRefresh()); - StudyManager.getInstance().updateStudySnapshot(snapshot, getUser()); - return false; - } - - @Override - public URLHelper getSuccessURL(SnapshotSettingsForm form) - { - return new ActionURL(getClass(), getContainer()); - } - } - - public static class SnapshotSettingsForm - { - private boolean _refresh = false; - - public boolean isRefresh() - { - return _refresh; - } - - public void setRefresh(boolean refresh) - { - _refresh = refresh; - } - } - - /** - * Set up the site wide settings for a master patient provider - */ - @RequiresPermission(AdminPermission.class) - public static class MasterPatientProviderAction extends FormViewAction - { - @Override - public void validateCommand(MasterPatientProviderSettings form, Errors errors) - { - if (!form.isValid()) - errors.reject(ERROR_MSG, "All required fields are not specified"); - } - - @Override - public ModelAndView getView(MasterPatientProviderSettings form, boolean reshow, BindException errors) throws Exception - { - return new JspView<>("/org/labkey/study/view/masterPatientProvider.jsp", form, errors); - } - - @Override - public boolean handlePost(MasterPatientProviderSettings form, BindException errors) throws Exception - { - if (form.getType() != null) - { - try (DbScope.Transaction transaction = StudySchema.getInstance().getScope().ensureTransaction()) - { - MasterPatientIndexService svc = MasterPatientIndexService.getProvider(form.getType()); - if (svc != null) - { - WritablePropertyMap map = PropertyManager.getNormalStore().getWritableProperties(MasterPatientProviderSettings.CATEGORY, true); - - map.put(MasterPatientProviderSettings.TYPE, form.getType()); - map.save(); - - svc.setServerSettings(form); - transaction.commit(); - } - } - } - return true; - } - - @Override - public URLHelper getSuccessURL(MasterPatientProviderSettings form) - { - return urlProvider(AdminUrls.class).getAdminConsoleURL(); - } - - @Override - public void addNavTrail(NavTree root) - { - urlProvider(AdminUrls.class).addAdminNavTrail(root, "Configure Master Patient Index", getClass(), getContainer()); - } - } - - @RequiresPermission(AdminPermission.class) - public static class TestMasterPatientProviderAction extends MutatingApiAction - { - @Override - public void validateForm(MasterPatientProviderSettings form, Errors errors) - { - if (!form.isValid()) - errors.reject(ERROR_MSG, "All required fields are not specified"); - } - - @Override - public Object execute(MasterPatientProviderSettings form, BindException errors) throws Exception - { - ApiSimpleResponse response = new ApiSimpleResponse(); - - if (form.getType() != null) - { - MasterPatientIndexService svc = MasterPatientIndexService.getProvider(form.getType()); - if (svc != null) - { - if (svc.checkServerSettings(form)) - { - response.put("success", true); - response.put("message", "The specified settings are valid."); - } - else - { - response.put("success", false); - response.put("message", "The specified settings are not valid."); - } - } - } - return response; - } - } - - public static class MasterPatientProviderSettings extends MasterPatientIndexService.ServerSettings - { - public static final String CATEGORY = "MASTER_PATIENT_PROVIDER"; - public static final String TYPE = "TYPE"; - - private String _type; - - public String getType() - { - return _type; - } - - public void setType(String type) - { - _type = type; - } - } - - @RequiresPermission(AdminPermission.class) - public class ConfigureMasterPatientSettingsAction extends FormViewAction - { - private MasterPatientIndexService _svc; - - @Override - public void validateCommand(MasterPatientIndexService.FolderSettings form, Errors errors) - { - if (!form.isValid()) - errors.reject(ERROR_MSG, "All required fields are not specified"); - } - - @Override - public ModelAndView getView(MasterPatientIndexService.FolderSettings form, boolean reshow, BindException errors) throws Exception - { - return new JspView<>("/org/labkey/study/view/manageMasterPatientConfig.jsp", getService(), errors); - } - - @Override - public boolean handlePost(MasterPatientIndexService.FolderSettings form, BindException errors) throws Exception - { - MasterPatientIndexService svc = getService(); - if (svc != null) - { - form.setReloadUser(getUser().getUserId()); - svc.setFolderSettings(getContainer(), form); - } - return true; - } - - @Override - public URLHelper getSuccessURL(MasterPatientIndexService.FolderSettings form) - { - return new ActionURL(ManageStudyAction.class, getContainer()); - } - - @Override - public void addNavTrail(NavTree root) - { - MasterPatientIndexService svc = getService(); - if (svc != null) - root.addChild("Manage " + svc.getName() + " Configuration"); - else - root.addChild("Manage Master Patient Index Configuration"); - } - - private MasterPatientIndexService getService() - { - if (_svc == null) - { - _svc = MasterPatientIndexMaintenanceTask.getConfiguredService(); - } - return _svc; - } - } - - @RequiresPermission(AdminPermission.class) - public static class RefreshMasterPatientIndexAction extends MutatingApiAction - { - @Override - public ApiResponse execute(Object o, BindException errors) throws Exception - { - ApiSimpleResponse response = new ApiSimpleResponse(); - try - { - ViewBackgroundInfo info = new ViewBackgroundInfo(getContainer(), getUser(), getViewContext().getActionURL()); - MasterPatientIndexService svc = MasterPatientIndexMaintenanceTask.getConfiguredService(); - - MasterPatientIndexService.FolderSettings settings = svc.getFolderSettings(getContainer()); - if (settings.isEnabled()) - { - PipelineJob job = new MasterPatientIndexUpdateTask(info, PipelineService.get().findPipelineRoot(getContainer()), svc); - - PipelineService.get().queueJob(job); - - response.put("success", true); - response.put(ActionURL.Param.returnUrl.name(), urlProvider(PipelineUrls.class).urlBegin(getContainer())); - } - else - { - response.put("success", false); - response.put("message", "The specified configuration is not enabled."); - } - } - catch (PipelineValidationException e) - { - throw new IOException(e); - } - return response; - } - } - - @RequiresPermission(AdminPermission.class) - public static class DeleteMasterPatientRecordsAction extends MutatingApiAction - { - @Override - public ApiResponse execute(DeleteMPIForm form, BindException errors) throws Exception - { - ApiSimpleResponse response = new ApiSimpleResponse(); - - List> params = form.getParams(); - MasterPatientIndexService svc = MasterPatientIndexMaintenanceTask.getConfiguredService(); - if (svc != null && !params.isEmpty()) - { - int count = svc.deleteMatchingRecords(params); - - response.put("success", true); - response.put("count", count); - } - return response; - } - } - - public static class DeleteMPIForm implements ApiJsonForm - { - private final List> _params = new ArrayList<>(); - - public List> getParams() - { - return _params; - } - - @Override - public void bindJson(JSONObject json) - { - for (String key : json.keySet()) - { - _params.add(new Pair<>(key, String.valueOf(json.get(key)))); - } - } - } - - // Render the HTML description if a study exists in this folder. Used by the client-side CSP validator. - @RequiresPermission(ReadPermission.class) - public static class DescriptionAction extends SimpleViewAction - { - private StudyImpl _study; - - @Override - public ModelAndView getView(Object o, BindException errors) throws Exception - { - _study = getStudy(getContainer()); - return null != _study ? new HtmlView(_study.getDescriptionHtml()) : new EmptyView(); - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild(_study != null ? "Overview: " + _study.getLabel() : "No Study"); - } - } -} +/* + * Copyright (c) 2008-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.labkey.study.controllers; + +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import org.apache.commons.beanutils.ConversionException; +import org.apache.commons.collections4.FactoryUtils; +import org.apache.commons.collections4.MapUtils; +import org.apache.commons.collections4.MultiValuedMap; +import org.apache.commons.collections4.multimap.ArrayListValuedHashMap; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.math.NumberUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.xmlbeans.XmlException; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.json.JSONObject; +import org.labkey.api.action.ApiJsonForm; +import org.labkey.api.action.ApiResponse; +import org.labkey.api.action.ApiSimpleResponse; +import org.labkey.api.action.ConfirmAction; +import org.labkey.api.action.FormApiAction; +import org.labkey.api.action.FormHandlerAction; +import org.labkey.api.action.FormViewAction; +import org.labkey.api.action.HasAllowBindParameter; +import org.labkey.api.action.HasViewContext; +import org.labkey.api.action.Marshal; +import org.labkey.api.action.Marshaller; +import org.labkey.api.action.MutatingApiAction; +import org.labkey.api.action.QueryViewAction; +import org.labkey.api.action.ReadOnlyApiAction; +import org.labkey.api.action.ReturnUrlForm; +import org.labkey.api.action.SimpleErrorView; +import org.labkey.api.action.SimpleRedirectAction; +import org.labkey.api.action.SimpleViewAction; +import org.labkey.api.action.SpringActionController; +import org.labkey.api.admin.AdminUrls; +import org.labkey.api.admin.ImportException; +import org.labkey.api.admin.notification.NotificationService; +import org.labkey.api.assay.AssayUrls; +import org.labkey.api.attachments.AttachmentFile; +import org.labkey.api.attachments.AttachmentForm; +import org.labkey.api.attachments.AttachmentParent; +import org.labkey.api.attachments.AttachmentService; +import org.labkey.api.attachments.BaseDownloadAction; +import org.labkey.api.audit.AuditLogService; +import org.labkey.api.audit.TransactionAuditProvider; +import org.labkey.api.collections.CaseInsensitiveHashMap; +import org.labkey.api.collections.IntHashMap; +import org.labkey.api.collections.IntHashSet; +import org.labkey.api.collections.LabKeyCollectors; +import org.labkey.api.compliance.ComplianceService; +import org.labkey.api.data.ActionButton; +import org.labkey.api.data.ButtonBar; +import org.labkey.api.data.ColumnHeaderType; +import org.labkey.api.data.ColumnInfo; +import org.labkey.api.data.CompareType; +import org.labkey.api.data.Container; +import org.labkey.api.data.ContainerFilter; +import org.labkey.api.data.DataRegion; +import org.labkey.api.data.DataRegionSelection; +import org.labkey.api.data.DbSchema; +import org.labkey.api.data.DbScope; +import org.labkey.api.data.DisplayColumn; +import org.labkey.api.data.PropertyManager; +import org.labkey.api.data.PropertyManager.WritablePropertyMap; +import org.labkey.api.data.RenderContext; +import org.labkey.api.data.Results; +import org.labkey.api.data.ResultsFactory; +import org.labkey.api.data.RuntimeSQLException; +import org.labkey.api.data.SQLFragment; +import org.labkey.api.data.ShowRows; +import org.labkey.api.data.SimpleDisplayColumn; +import org.labkey.api.data.SimpleFilter; +import org.labkey.api.data.Sort; +import org.labkey.api.data.SqlExecutor; +import org.labkey.api.data.SqlSelector; +import org.labkey.api.data.TSVGridWriter; +import org.labkey.api.data.Table; +import org.labkey.api.data.TableInfo; +import org.labkey.api.data.TableSelector; +import org.labkey.api.data.TableViewForm; +import org.labkey.api.data.views.DataViewService; +import org.labkey.api.exp.LsidManager; +import org.labkey.api.exp.OntologyManager; +import org.labkey.api.exp.PropertyDescriptor; +import org.labkey.api.exp.api.ExpProtocol; +import org.labkey.api.exp.api.ExpSampleType; +import org.labkey.api.exp.property.Domain; +import org.labkey.api.gwt.client.AuditBehaviorType; +import org.labkey.api.module.ModuleHtmlView; +import org.labkey.api.module.ModuleLoader; +import org.labkey.api.pipeline.PipeRoot; +import org.labkey.api.pipeline.PipelineJob; +import org.labkey.api.pipeline.PipelineService; +import org.labkey.api.pipeline.PipelineStatusUrls; +import org.labkey.api.pipeline.PipelineUrls; +import org.labkey.api.pipeline.PipelineValidationException; +import org.labkey.api.pipeline.browse.PipelinePathForm; +import org.labkey.api.qc.AbstractDeleteDataStateAction; +import org.labkey.api.qc.AbstractManageDataStatesForm; +import org.labkey.api.qc.AbstractManageQCStatesAction; +import org.labkey.api.qc.AbstractManageQCStatesBean; +import org.labkey.api.qc.DataState; +import org.labkey.api.qc.DataStateHandler; +import org.labkey.api.qc.DeleteDataStateForm; +import org.labkey.api.qc.QCStateManager; +import org.labkey.api.query.AbstractQueryImportAction; +import org.labkey.api.query.BatchValidationException; +import org.labkey.api.query.CustomView; +import org.labkey.api.query.DetailsURL; +import org.labkey.api.query.FieldKey; +import org.labkey.api.query.InvalidKeyException; +import org.labkey.api.query.QueryAction; +import org.labkey.api.query.QueryDefinition; +import org.labkey.api.query.QueryForm; +import org.labkey.api.query.QueryParseException; +import org.labkey.api.query.QueryService; +import org.labkey.api.query.QuerySettings; +import org.labkey.api.query.QueryUpdateService; +import org.labkey.api.query.QueryUpdateServiceException; +import org.labkey.api.query.QueryView; +import org.labkey.api.query.SchemaKey; +import org.labkey.api.query.UserSchema; +import org.labkey.api.query.ValidationException; +import org.labkey.api.query.snapshot.QuerySnapshotDefinition; +import org.labkey.api.query.snapshot.QuerySnapshotForm; +import org.labkey.api.query.snapshot.QuerySnapshotService; +import org.labkey.api.reader.DataLoader; +import org.labkey.api.reader.TabLoader; +import org.labkey.api.reports.Report; +import org.labkey.api.reports.ReportService; +import org.labkey.api.reports.model.ReportPropsManager; +import org.labkey.api.reports.model.ViewCategory; +import org.labkey.api.reports.model.ViewCategoryManager; +import org.labkey.api.reports.report.AbstractReportIdentifier; +import org.labkey.api.reports.report.QueryReport; +import org.labkey.api.reports.report.ReportIdentifier; +import org.labkey.api.reports.report.ReportUrls; +import org.labkey.api.search.SearchService; +import org.labkey.api.search.SearchUrls; +import org.labkey.api.security.RequiresAllOf; +import org.labkey.api.security.RequiresLogin; +import org.labkey.api.security.RequiresNoPermission; +import org.labkey.api.security.RequiresPermission; +import org.labkey.api.security.SecurityManager; +import org.labkey.api.security.User; +import org.labkey.api.security.permissions.AdminPermission; +import org.labkey.api.security.permissions.BrowserDeveloperPermission; +import org.labkey.api.security.permissions.DeletePermission; +import org.labkey.api.security.permissions.InsertPermission; +import org.labkey.api.security.permissions.Permission; +import org.labkey.api.security.permissions.PlatformDeveloperPermission; +import org.labkey.api.security.permissions.QCAnalystPermission; +import org.labkey.api.security.permissions.ReadPermission; +import org.labkey.api.security.permissions.UpdatePermission; +import org.labkey.api.settings.OptionalFeatureService; +import org.labkey.api.specimen.SpecimenManager; +import org.labkey.api.specimen.SpecimenMigrationService; +import org.labkey.api.specimen.location.LocationImpl; +import org.labkey.api.specimen.location.LocationManager; +import org.labkey.api.study.CohortFilter; +import org.labkey.api.study.CompletionType; +import org.labkey.api.study.Dataset; +import org.labkey.api.study.Dataset.KeyManagementType; +import org.labkey.api.study.DatasetTable; +import org.labkey.api.study.MasterPatientIndexService; +import org.labkey.api.study.ParticipantCategory; +import org.labkey.api.study.Study; +import org.labkey.api.study.StudyService; +import org.labkey.api.study.StudyUrls; +import org.labkey.api.study.TimepointType; +import org.labkey.api.study.Visit; +import org.labkey.api.study.model.ParticipantGroup; +import org.labkey.api.study.publish.StudyPublishService; +import org.labkey.api.study.security.permissions.ManageStudyPermission; +import org.labkey.api.studydesign.StudyDesignManager; +import org.labkey.api.util.ContainerContext; +import org.labkey.api.util.CsrfInput; +import org.labkey.api.util.DateUtil; +import org.labkey.api.util.DemoMode; +import org.labkey.api.util.FileStream; +import org.labkey.api.util.HtmlString; +import org.labkey.api.util.HtmlStringBuilder; +import org.labkey.api.util.LinkBuilder; +import org.labkey.api.util.PageFlowUtil; +import org.labkey.api.util.Pair; +import org.labkey.api.util.StringExpression; +import org.labkey.api.util.URLHelper; +import org.labkey.api.util.XmlBeansUtil; +import org.labkey.api.view.ActionURL; +import org.labkey.api.view.DataView; +import org.labkey.api.view.GridView; +import org.labkey.api.view.HtmlView; +import org.labkey.api.view.HttpView; +import org.labkey.api.view.JspView; +import org.labkey.api.view.NavTree; +import org.labkey.api.view.NotFoundException; +import org.labkey.api.view.Portal; +import org.labkey.api.view.RedirectException; +import org.labkey.api.view.UnauthorizedException; +import org.labkey.api.view.VBox; +import org.labkey.api.view.ViewBackgroundInfo; +import org.labkey.api.view.ViewContext; +import org.labkey.api.view.ViewForm; +import org.labkey.api.view.WebPartView; +import org.labkey.api.view.template.EmptyView; +import org.labkey.api.view.template.PageConfig; +import org.labkey.api.writer.FileSystemFile; +import org.labkey.api.writer.HtmlWriter; +import org.labkey.api.writer.VirtualFile; +import org.labkey.data.xml.TablesDocument; +import org.labkey.study.CohortFilterFactory; +import org.labkey.study.MasterPatientIndexMaintenanceTask; +import org.labkey.study.StudyModule; +import org.labkey.study.StudySchema; +import org.labkey.study.assay.AssayPublishConfirmAction; +import org.labkey.study.assay.AssayPublishStartAction; +import org.labkey.study.assay.StudyPublishManager; +import org.labkey.study.audit.ParticipantGroupAuditProvider; +import org.labkey.study.controllers.publish.SampleTypePublishConfirmAction; +import org.labkey.study.controllers.publish.SampleTypePublishStartAction; +import org.labkey.study.controllers.security.SecurityController; +import org.labkey.study.dataset.DatasetSnapshotProvider; +import org.labkey.study.dataset.DatasetViewProvider; +import org.labkey.study.designer.StudySchedule; +import org.labkey.study.importer.DatasetImportUtils; +import org.labkey.study.importer.SchemaReader; +import org.labkey.study.importer.SchemaXmlReader; +import org.labkey.study.importer.VisitMapImporter; +import org.labkey.study.model.CohortImpl; +import org.labkey.study.model.CohortManager; +import org.labkey.study.model.CustomParticipantView; +import org.labkey.study.model.DatasetDefinition; +import org.labkey.study.model.DatasetDomainKind; +import org.labkey.study.model.DatasetDomainKindProperties; +import org.labkey.study.model.DatasetManager; +import org.labkey.study.model.DatasetReorderer; +import org.labkey.study.model.DateDatasetDomainKind; +import org.labkey.study.model.Participant; +import org.labkey.study.model.ParticipantCategoryImpl; +import org.labkey.study.model.ParticipantGroupManager; +import org.labkey.study.model.QCStateSet; +import org.labkey.study.model.SecurityType; +import org.labkey.study.model.StudyImpl; +import org.labkey.study.model.StudyManager; +import org.labkey.study.model.StudySnapshot; +import org.labkey.study.model.UploadLog; +import org.labkey.study.model.VisitDataset; +import org.labkey.study.model.VisitDatasetType; +import org.labkey.study.model.VisitImpl; +import org.labkey.study.model.VisitMapKey; +import org.labkey.study.pipeline.DatasetFileReader; +import org.labkey.study.pipeline.MasterPatientIndexUpdateTask; +import org.labkey.study.pipeline.StudyPipeline; +import org.labkey.study.qc.StudyQCStateHandler; +import org.labkey.study.query.DatasetQuerySettings; +import org.labkey.study.query.DatasetQueryView; +import org.labkey.study.query.LocationTable; +import org.labkey.study.query.PublishedRecordQueryView; +import org.labkey.study.query.QueryDatasetTable; +import org.labkey.study.query.StudyQuerySchema; +import org.labkey.study.query.StudyQueryView; +import org.labkey.study.reports.ReportManager; +import org.labkey.study.view.SubjectsWebPart; +import org.labkey.study.visitmanager.SequenceVisitManager; +import org.labkey.study.visitmanager.VisitManager; +import org.labkey.study.visitmanager.VisitManager.VisitStatistic; +import org.labkey.study.xml.DatasetsDocument; +import org.labkey.vfs.FileLike; +import org.springframework.validation.BindException; +import org.springframework.validation.Errors; +import org.springframework.web.servlet.ModelAndView; +import org.springframework.web.servlet.mvc.Controller; + +import java.io.File; +import java.io.IOException; +import java.io.PrintWriter; +import java.math.BigDecimal; +import java.net.URISyntaxException; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Date; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.function.Predicate; +import java.util.regex.Pattern; + +import static org.labkey.api.util.IntegerUtils.asInteger; +import static org.labkey.study.model.QCStateSet.PUBLIC_STATES_LABEL; +import static org.labkey.study.model.QCStateSet.getQCStateFilteredURL; +import static org.labkey.study.model.QCStateSet.getQCUrlFilterKey; +import static org.labkey.study.model.QCStateSet.getQCUrlFilterValue; +import static org.labkey.study.model.QCStateSet.selectedQCStateLabelFromUrl; +import static org.labkey.study.query.DatasetQueryView.EXPERIMENTAL_ALLOW_MERGE_WITH_MANAGED_KEYS; + +public class StudyController extends BaseStudyController +{ + private static final Logger _log = LogManager.getLogger(StudyController.class); + private static final String PARTICIPANT_CACHE_PREFIX = "Study_participants/participantCache"; + private static final String EXPAND_CONTAINERS_KEY = StudyController.class.getName() + "/expandedContainers"; + private static final String DATASET_DATAREGION_NAME = "Dataset"; + + private static final ActionResolver ACTION_RESOLVER = new DefaultActionResolver( + StudyController.class, + CreateChildStudyAction.class, + AutoCompleteAction.class + ); + + public static final String DATASET_REPORT_ID_PARAMETER_NAME = "Dataset.reportId"; + public static final String DATASET_VIEW_NAME_PARAMETER_NAME = "Dataset.viewName"; + + public static class StudyUrlsImpl implements StudyUrls + { + @Override + public ActionURL getBeginURL(Container container) + { + return new ActionURL(BeginAction.class, container); + } + + @Override + public ActionURL getCompletionURL(Container studyContainer, CompletionType type) + { + if (studyContainer == null) + return null; + + ActionURL url = new ActionURL(AutoCompleteAction.class, studyContainer); + url.addParameter("type", type.name()); + url.addParameter("prefix", ""); + return url; + } + + @Override + public ActionURL getCreateStudyURL(Container container) + { + return new ActionURL(CreateStudyAction.class, container); + } + + @Override + public ActionURL getManageStudyURL(Container container) + { + return new ActionURL(ManageStudyAction.class, container); + } + + @Override + public Class getManageStudyClass() + { + return ManageStudyAction.class; + } + + @Override + public ActionURL getStudyOverviewURL(Container container) + { + return new ActionURL(OverviewAction.class, container); + } + + @Override + public ActionURL getDatasetURL(Container container, int datasetId) + { + return new ActionURL(DatasetAction.class, container).addParameter(Dataset.DATASET_KEY, datasetId); + } + + @Override + public ActionURL getDatasetsURL(Container container) + { + return new ActionURL(DatasetsAction.class, container); + } + + @Override + public ActionURL getManageDatasetsURL(Container container) + { + return new ActionURL(ManageTypesAction.class, container); + } + + @Override + public ActionURL getManageReportPermissions(Container container) + { + return new ActionURL(SecurityController.ReportPermissionsAction.class, container); + } + + @Override + public ActionURL getManageFileWatchersURL(Container container) + { + return new ActionURL(StudyController.ManageFilewatchersAction.class, container); + } + + @Override + public ActionURL getLinkToStudyURL(Container container, ExpSampleType sampleType) + { + ActionURL url = new ActionURL(SampleTypePublishStartAction.class, container); + if (sampleType != null) + url.addParameter("rowId", sampleType.getRowId()); + return url; + } + + @Override + public ActionURL getLinkToStudyURL(Container container, ExpProtocol protocol) + { + return urlProvider(AssayUrls.class).getProtocolURL(container, protocol, AssayPublishStartAction.class); + } + + @Override + public ActionURL getLinkToStudyConfirmURL(Container container, ExpProtocol protocol) + { + return urlProvider(AssayUrls.class).getProtocolURL(container, protocol, AssayPublishConfirmAction.class); + } + + @Override + public ActionURL getLinkToStudyConfirmURL(Container container, ExpSampleType sampleType) + { + ActionURL url = new ActionURL(SampleTypePublishConfirmAction.class, container); + if (sampleType != null) + url.addParameter("rowId", sampleType.getRowId()); + return url; + } + + @Override + public void addManageStudyNavTrail(NavTree root, Container container, User user) + { + _addManageStudy(root, container, user); + } + + @Override + public ActionURL getTypeNotFoundURL(Container container, int datasetId) + { + return new ActionURL(TypeNotFoundAction.class, container).addParameter("id", datasetId); + } + + @Override + public ActionURL getManageLocationsURL(Container container) + { + return new ActionURL(ManageLocationsAction.class, container); + } + + @Override + public ActionURL getManageVisitsURL(Container container) + { + return new ActionURL(ManageVisitsAction.class, container); + } + + @Override + public ActionURL getManageCohortsURL(Container container) + { + return new ActionURL(CohortController.ManageCohortsAction.class, container); + } + + @Override + public ActionURL getVisitOrderURL(Container container) + { + return new ActionURL(VisitOrderAction.class, container); + } + } + + public StudyController() + { + setActionResolver(ACTION_RESOLVER); + } + + protected void _addNavTrailVisitAdmin(NavTree root) + { + _addManageStudy(root); + + StringBuilder sb = new StringBuilder("Manage "); + + Study visitStudy = StudyManager.getInstance().getStudyForVisits(getStudy()); + if (visitStudy.getShareVisitDefinitions() == Boolean.TRUE) + sb.append("Shared "); + + sb.append(getVisitLabelPlural()); + + root.addChild(sb.toString(), new ActionURL(ManageVisitsAction.class, getContainer())); + } + + @RequiresPermission(ReadPermission.class) + public class BeginAction extends SimpleViewAction + { + private Study _study; + + @Override + public ModelAndView getView(Object o, BindException errors) throws Exception + { + _study = getStudy(); + + WebPartView overview = StudyModule.manageStudyPartFactory.getWebPartView(getViewContext(), StudyModule.manageStudyPartFactory.createWebPart()); + WebPartView views = StudyModule.reportsPartFactory.getWebPartView(getViewContext(), StudyModule.reportsPartFactory.createWebPart()); + return new VBox(overview, views); + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild(_study == null ? "No Study In Folder" : _study.getLabel()); + } + } + + @RequiresPermission(AdminPermission.class) + public class DefineDatasetTypeAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object o, BindException errors) throws Exception + { + getStudyRedirectIfNull(); + return ModuleHtmlView.get(ModuleLoader.getInstance().getModule("core"), ModuleHtmlView.getGeneratedViewPath("datasetDesigner")); + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("createDataset"); + _addNavTrailDatasetAdmin(root); + root.addChild("Create Dataset Definition"); + } + } + + @Marshal(Marshaller.Jackson) + @RequiresPermission(ReadPermission.class) + public static class GetDatasetAction extends ReadOnlyApiAction + { + @Override + public Object execute(DatasetForm form, BindException errors) throws Exception + { + DatasetDomainKindProperties properties = DatasetManager.get().getDatasetDomainKindProperties(getContainer(), form.getDatasetId()); + if (properties != null) + return properties; + else + throw new NotFoundException("Dataset does not exist in this container for datasetId " + form.getDatasetIdStr() + "."); + } + } + + @RequiresPermission(AdminPermission.class) + @SuppressWarnings("unchecked") + public class EditTypeAction extends SimpleViewAction + { + private Dataset _def; + + @Override + public ModelAndView getView(DatasetForm form, BindException errors) + { + StudyImpl study = getStudyRedirectIfNull(); + if (null == form.getDatasetId()) + throw new NotFoundException("No datasetId parameter provided."); + + DatasetDefinition def = study.getDataset(form.getDatasetId()); + _def = def; + if (null == def) + throw new NotFoundException("No dataset found for datasetId " + form.getDatasetId() + "."); + + if (def.isQueryDataset()) + throw new UnsupportedOperationException("Query dataset definition cannot be edited. Update the source query to change definition."); + + if (!def.canUpdateDefinition(getUser())) + { + ActionURL details = new ActionURL(DatasetDetailsAction.class,getContainer()).addParameter("id",def.getDatasetId()); + throw new RedirectException(details); + } + + if (null == def.getTypeURI()) + { + def = def.createMutable(); + String domainURI = StudyManager.getInstance().getDomainURI(study.getContainer(), getUser(), def); + OntologyManager.ensureDomainDescriptor(domainURI, def.getName(), study.getContainer()); + def.setTypeURI(domainURI); + } + + return ModuleHtmlView.get(ModuleLoader.getInstance().getModule("core"), ModuleHtmlView.getGeneratedViewPath("datasetDesigner")); + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("datasetProperties"); + _addNavTrailDatasetAdmin(root); + root.addChild(_def.getName(), new ActionURL(DatasetDetailsAction.class, getContainer()).addParameter("id", _def.getDatasetId())); + root.addChild("Edit Dataset Definition"); + } + } + + @RequiresPermission(ReadPermission.class) + public class DatasetDetailsAction extends SimpleViewAction + { + private DatasetDefinition _def; + + @Override + public ModelAndView getView(IdForm form, BindException errors) + { + _def = StudyManager.getInstance().getDatasetDefinition(getStudyRedirectIfNull(), form.getId()); + if (_def == null) + { + throw new NotFoundException("Invalid Dataset ID"); + } + return new StudyJspView<>(StudyManager.getInstance().getStudy(getContainer()), + "/org/labkey/study/view/datasetDetails.jsp", _def, errors); + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("datasetProperties"); + root.addChild(_def.getLabel(), urlProvider(StudyUrls.class).getDatasetURL(getContainer(), _def.getDatasetId())); + root.addChild("Dataset Properties"); + } + } + + public static class DatasetFilterForm extends QueryViewAction.QueryExportForm implements HasViewContext + { + private ViewContext _viewContext; + + @Override + public void setViewContext(ViewContext context) + { + _viewContext = context; + } + + @Override + public ViewContext getViewContext() + { + return _viewContext; + } + } + + + public static class OverviewForm extends DatasetFilterForm + { + private String _qcState; + private String[] _visitStatistic = new String[0]; + + public String getQCState() + { + return _qcState; + } + + public void setQCState(String qcState) + { + _qcState = qcState; + } + + public String[] getVisitStatistic() + { + return _visitStatistic; + } + + @SuppressWarnings({"UnusedDeclaration"}) + public void setVisitStatistic(String[] visitStatistic) + { + _visitStatistic = visitStatistic; + } + + private Set getVisitStatistics() + { + Set set = EnumSet.noneOf(VisitStatistic.class); + + for (String statName : _visitStatistic) + set.add(VisitStatistic.valueOf(statName)); + + if (set.isEmpty()) + set.add(VisitStatistic.values()[0]); + + return set; + } + } + + + @RequiresPermission(ReadPermission.class) + public class OverviewAction extends SimpleViewAction + { + private StudyImpl _study; + + @Override + public ModelAndView getView(OverviewForm form, BindException errors) throws Exception + { + _study = getStudyRedirectIfNull(); + OverviewBean bean = new OverviewBean(); + bean.study = _study; + bean.showAll = "1".equals(getViewContext().get("showAll")); + bean.canManage = getContainer().hasPermission(getUser(), ManageStudyPermission.class); + bean.showCohorts = StudyManager.getInstance().showCohorts(getContainer(), getUser()); + bean.stats = form.getVisitStatistics(); + bean.showSpecimens = SpecimenManager.get().isSpecimenModuleActive(getContainer()); + + if (QCStateManager.getInstance().showStates(getContainer())) + bean.qcStates = QCStateSet.getSelectedStates(getContainer(), form.getQCState()); + + if (!bean.showCohorts) + bean.cohortFilter = null; + else + bean.cohortFilter = CohortFilterFactory.getFromURL(getContainer(), getUser(), getViewContext().getActionURL(), DatasetQueryView.DATAREGION); + + VisitManager visitManager = StudyManager.getInstance().getVisitManager(bean.study); + bean.visitMapSummary = visitManager.getVisitSummary(getUser(), bean.cohortFilter, bean.qcStates, bean.stats, bean.showAll); + + return new StudyJspView<>(_study, "/org/labkey/study/view/overview.jsp", bean, errors); + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("studyDashboard#navigator"); + root.addChild("Overview: " + _study.getLabel()); + } + } + + public static class QueryReportForm extends QueryViewAction.QueryExportForm + { + ReportIdentifier _reportId; + + public ReportIdentifier getReportId() + { + return _reportId; + } + + public void setReportId(ReportIdentifier reportId) + { + _reportId = reportId; + } + } + + @RequiresPermission(ReadPermission.class) + public static class QueryReportAction extends QueryViewAction + { + protected Report _report; + + public QueryReportAction() + { + super(QueryReportForm.class); + } + + @Override + protected ModelAndView getHtmlView(QueryReportForm form, BindException errors) throws Exception + { + Report report = getReport(form); + + if (report != null) + return report.getRunReportView(getViewContext()); + else + throw new NotFoundException("Unable to locate the requested report: " + form.getReportId()); + } + + @Override + protected QueryView createQueryView(QueryReportForm form, BindException errors, boolean forExport, String dataRegion) throws Exception + { + Report report = getReport(form); + if (report instanceof QueryReport) + return ((QueryReport)report).getQueryViewGenerator().generateQueryView(getViewContext(), report.getDescriptor()); + + return null; + } + + @Override + public void addNavTrail(NavTree root) + { + if (_report != null) + root.addChild(_report.getDescriptor().getReportName()); + else + root.addChild("Study Query Report"); + } + + protected Report getReport(QueryReportForm form) + { + if (_report == null) + { + ReportIdentifier identifier = form.getReportId(); + if (identifier != null) + _report = identifier.getReport(getViewContext()); + } + return _report; + } + } + + @RequiresPermission(ReadPermission.class) + public class DatasetReportAction extends QueryReportAction + { + @Override + protected Report getReport(QueryReportForm form) + { + if (_report == null) + { + String reportId = (String)getViewContext().get(DATASET_REPORT_ID_PARAMETER_NAME); + + ReportIdentifier identifier = ReportService.get().getReportIdentifier(reportId, getViewContext().getUser(), getViewContext().getContainer()); + if (identifier != null) + _report = identifier.getReport(getViewContext()); + } + return _report; + } + + @Override + protected ModelAndView getHtmlView(QueryReportForm form, BindException errors) throws Exception + { + ViewContext context = getViewContext(); + Report report = getReport(form); + + // is not a report (either the default grid view or a custom view)... + if (report == null) + { + return HttpView.redirect(createRedirectURLfrom(DatasetAction.class, context)); + } + + int datasetId = NumberUtils.toInt((String)context.get(Dataset.DATASET_KEY), -1); + Dataset def = StudyManager.getInstance().getDatasetDefinition(getStudyRedirectIfNull(), datasetId); + + if (def != null) + { + ActionURL url = getViewContext().cloneActionURL().setAction(StudyController.DatasetAction.class). + replaceParameter(DATASET_REPORT_ID_PARAMETER_NAME, report.getDescriptor().getReportId().toString()). + replaceParameter(Dataset.DATASET_KEY, def.getDatasetId()); + + return HttpView.redirect(url); + } + else if (ReportManager.get().canReadReport(getUser(), getContainer(), report)) + return report.getRunReportView(getViewContext()); + else + return HtmlView.of("User does not have read permission on this report."); + } + } + + private ActionURL createRedirectURLfrom(Class action, ViewContext context) + { + ActionURL newUrl = new ActionURL(action, context.getContainer()); + return newUrl.addParameters(context.getActionURL().getParameters()); + } + + @RequiresPermission(ReadPermission.class) + public class DatasetAction extends QueryViewAction + { + private DatasetDefinition _def; + + public DatasetAction() + { + super(DatasetFilterForm.class); + } + + private DatasetDefinition getDatasetDefinition() + { + if (null == _def) + { + Object datasetKeyObject = getViewContext().get(Dataset.DATASET_KEY); + if (datasetKeyObject instanceof List list) + { + // bug 7365: It's been specified twice -- once in the POST, once in the GET. Just need one of them. + datasetKeyObject = list.get(0); + } + if (null != datasetKeyObject) + { + try + { + int id = NumberUtils.toInt(String.valueOf(datasetKeyObject), 0); + _def = StudyManager.getInstance().getDatasetDefinition(getStudyRedirectIfNull(), id); + } + catch (ConversionException x) + { + throw new NotFoundException(); + } + } + else + { + String entityId = (String)getViewContext().get("entityId"); + if (null != entityId) + _def = StudyManager.getInstance().getDatasetDefinitionByEntityId(getStudyRedirectIfNull(), entityId); + } + } + if (null == _def) + throw new NotFoundException(); + return _def; + } + + @Override + public ModelAndView getView(DatasetFilterForm form, BindException errors) throws Exception + { + ActionURL url = getViewContext().getActionURL(); + String viewName = url.getParameter(DATASET_VIEW_NAME_PARAMETER_NAME); + + // if the view name refers to a report id (legacy style), redirect to use the newer report id parameter + if (NumberUtils.isDigits(viewName)) + { + // one last check to see if there is a view with that name before trying to redirect to the report + DatasetDefinition def = getDatasetDefinition(); + + if (def != null && + QueryService.get().getCustomView(getUser(), getContainer(), getUser(), StudySchema.getInstance().getSchemaName(), def.getName(), viewName) == null) + { + ReportIdentifier reportId = AbstractReportIdentifier.fromString(viewName, getViewContext().getUser(), getViewContext().getContainer()); + if (reportId != null && reportId.getReport(getViewContext()) != null) + { + ActionURL newURL = url.clone().deleteParameter(DATASET_VIEW_NAME_PARAMETER_NAME). + addParameter(DATASET_REPORT_ID_PARAMETER_NAME, reportId.toString()); + return HttpView.redirect(newURL); + } + } + } + return super.getView(form, errors); + } + + @Override + protected ModelAndView getHtmlView(DatasetFilterForm form, BindException errors) throws Exception + { + // the full resultset is a join of all datasets for each participant + // each dataset is determined by a visitid/datasetid + + // Ensure a study is present + getStudyRedirectIfNull(); + ViewContext context = getViewContext(); + + String export = StringUtils.trimToNull(context.getActionURL().getParameter("export")); + + DatasetDefinition def = getDatasetDefinition(); + if (null == def) + return new TypeNotFoundAction().getView(form, errors); + String typeURI = def.getTypeURI(); + if (null == typeURI) + return new TypeNotFoundAction().getView(form, errors); + + boolean showEditLinks = !QueryService.get().isQuerySnapshot(getContainer(), StudySchema.getInstance().getSchemaName(), def.getName()) && + !def.isPublishedData(); + + UserSchema schema = QueryService.get().getUserSchema(getUser(), getContainer(), StudyQuerySchema.SCHEMA_NAME); + DatasetQuerySettings settings = (DatasetQuerySettings)schema.getSettings(getViewContext(), DatasetQueryView.DATAREGION, def.getName()); + + settings.setShowEditLinks(showEditLinks); + settings.setShowSourceLinks(true); + + final ActionURL url = context.getActionURL(); + + // clear the property map cache and the sort map cache + getParticipantPropsMap(context).clear(); + getDatasetSortColumnMap(context).clear(); + + QueryView queryView = schema.createView(getViewContext(), settings, errors); + final TableInfo table = queryView.getTable(); + if (table != null) + { + setColumnURL(url, queryView, schema, def); + + // Clear any cached participant lists... not really necessary, since the cache key is now the entire + // query string (including all filters & sorts), but it doesn't really hurt. List is regenerated only if + // user navigates to an individual participant. + removeParticipantListFromSession(context); + getExpandedState(context, def.getDatasetId()).clear(); + } + + if (null != export) + { + if ("tsv".equals(export)) + queryView.exportToTsv(context.getResponse()); + else if ("xls".equals(export)) + queryView.exportToExcel(context.getResponse()); + return null; + } + + HtmlStringBuilder sb = HtmlStringBuilder.of(); + if (def.getDescription() != null && !def.getDescription().isEmpty()) + sb.unsafeAppend(PageFlowUtil.filter(def.getDescription(), true, true)).unsafeAppend("
"); + CohortFilter cohortFilter = queryView instanceof StudyQueryView studyQueryView ? studyQueryView.getCohortFilter() : null; + if (cohortFilter != null) + sb.unsafeAppend("
Cohort: ").append(cohortFilter.getDescription(getContainer(), getUser())).unsafeAppend(""); + + if (QCStateManager.getInstance().showStates(getContainer())) + { + String publicQCUrlFilterValue = getQCUrlFilterValue(QCStateSet.getPublicStates(getContainer())); + String privateQCUrlFilterValue = getQCUrlFilterValue(QCStateSet.getPrivateStates(getContainer())); + + for (QCStateSet set : QCStateSet.getSelectableSets(getContainer())) + { + String selectedQCLabel = selectedQCStateLabelFromUrl(getViewContext().getActionURL(), settings.getDataRegionName(), set.getLabel(), publicQCUrlFilterValue, privateQCUrlFilterValue); + if (selectedQCLabel != null && selectedQCLabel.equals(set.getLabel())) + { + sb.unsafeAppend("
QC States: ").append(set.getLabel()).unsafeAppend(""); + break; + } + } + } + if (ReportPropsManager.get().getPropertyValue(def.getEntityId(), getContainer(), "refreshDate") != null) + { + sb.unsafeAppend("
Data Cut Date: "); + Object refreshDate = (ReportPropsManager.get().getPropertyValue(def.getEntityId(), getContainer(), "refreshDate")); + if (refreshDate instanceof Date) + { + sb.append(DateUtil.formatDate(getContainer(), (Date)refreshDate)); + } + else + { + sb.append(ReportPropsManager.get().getPropertyValue(def.getEntityId(), getContainer(), "refreshDate").toString()); + } + } + HtmlView header = new HtmlView(sb); + VBox view = new VBox(header, queryView); + + String status = (String)ReportPropsManager.get().getPropertyValue(def.getEntityId(), getContainer(), "status"); + if (status != null) + { + // inject the dataset status marker class, but it is up to the client to style the page accordingly + HtmlView scriptLock = new HtmlView(HtmlString.unsafe("")); + view.addView(scriptLock); + } + + Report report = queryView.getSettings().getReportView(context); + if (report != null && !ReportManager.get().canReadReport(getUser(), getContainer(), report)) + { + return HtmlView.of("User does not have read permission on this report."); + } + else if (report == null && (null==table || !table.hasPermission(getUser(),ReadPermission.class))) + { + return HtmlView.of("User does not have read permission on this dataset."); + } + return view; + } + + @Override + protected QueryView createQueryView(DatasetFilterForm datasetFilterForm, BindException errors, boolean forExport, String dataRegion) throws Exception + { + QuerySettings qs = new QuerySettings(getViewContext(), DATASET_DATAREGION_NAME); + Report report = qs.getReportView(getViewContext()); + if (report instanceof QueryReport) + { + return ((QueryReport)report).getQueryViewGenerator().generateQueryView(getViewContext(), report.getDescriptor()); + } + return null; + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("gridBasics"); + _addNavTrail(root, getDatasetDefinition().getDatasetId(), getViewContext().getActionURL()); + } + } + + @RequiresNoPermission + public static class ExpandStateNotifyAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object o, BindException errors) + { + final ActionURL url = getViewContext().getActionURL(); + final String collapse = url.getParameter("collapse"); + final int datasetId = NumberUtils.toInt(url.getParameter(Dataset.DATASET_KEY), -1); + final int id = NumberUtils.toInt(url.getParameter("id"), -1); + + if (datasetId != -1 && id != -1) + { + Map expandedMap = getExpandedState(getViewContext(), id); + // collapse param is only set on a collapse action + if (collapse != null) + expandedMap.put(datasetId, "collapse"); + else + expandedMap.put(datasetId, "expand"); + } + return null; + } + + @Override + public void addNavTrail(NavTree root) + { + } + } + + + Participant findParticipant(Study study, String particpantId) throws StudyManager.ParticipantNotUniqueException + { + Participant participant = StudyManager.getInstance().getParticipant(study, particpantId); + if (participant == null) + { + if (study.isDataspaceStudy()) + { + Container c = StudyManager.getInstance().findParticipant(study, particpantId); + Study s = null == c ? null : StudyManager.getInstance().getStudy(c); + if (null != s && c.hasPermission(getUser(), ReadPermission.class)) + { + participant = StudyManager.getInstance().getParticipant(s, particpantId); + } + } + } + return participant; + } + + @RequiresPermission(ReadPermission.class) + public class ParticipantAction extends SimpleViewAction + { + private ParticipantForm _bean; + + @Override + public ModelAndView getView(ParticipantForm form, BindException errors) + { + Study study = getStudyRedirectIfNull(); + _bean = form; + ActionURL previousParticipantURL = null; + ActionURL nextParticipantURL = null; + Participant participant; + StringBuilder errorMsg = new StringBuilder(); + + if (form.getParticipantId() == null) + { + errorMsg.append("No ").append(study.getSubjectNounSingular()).append(" specified"); + } + else + { + try + { + participant = findParticipant(study, form.getParticipantId()); + if (null == participant) + errorMsg.append("Could not find ").append(study.getSubjectNounSingular()).append(" ").append(form.getParticipantId()); + } + catch (StudyManager.ParticipantNotUniqueException x) + { + errorMsg.append(x.getMessage()); + } + } + + if (!errorMsg.isEmpty()) + return HtmlView.err(errorMsg.toString()); + + String viewName = (String) getViewContext().get(DATASET_VIEW_NAME_PARAMETER_NAME); + + CohortFilter cohortFilter = CohortFilterFactory.getFromURL(getContainer(), getUser(), getViewContext().getActionURL(), DatasetQueryView.DATAREGION); + // display the next and previous buttons only if we have a cached participant index + if (cohortFilter != null && !StudyManager.getInstance().showCohorts(getContainer(), getUser())) + throw new UnauthorizedException("User does not have permission to view cohort information"); + + List participants = getParticipantListFromSession(getViewContext(), form.getDatasetId(), viewName); + + if (isDebug()) + { + _log.info("Cached participants: {}", participants); + } + int idx = participants.indexOf(form.getParticipantId()); + if (idx != -1) + { + if (idx > 0) + { + final String ptid = participants.get(idx-1); + previousParticipantURL = getViewContext().cloneActionURL(); + previousParticipantURL.replaceParameter("participantId", ptid); + } + + if (idx < participants.size()-1) + { + final String ptid = participants.get(idx+1); + nextParticipantURL = getViewContext().cloneActionURL(); + nextParticipantURL.replaceParameter("participantId", ptid); + } + } + + VBox vbox = new VBox(); + ParticipantNavView navView = new ParticipantNavView(previousParticipantURL, nextParticipantURL, form.getParticipantId(), null); + vbox.addView(navView); + + CustomParticipantView customParticipantView = StudyManager.getInstance().getCustomParticipantView(study); + if (customParticipantView != null && customParticipantView.isActive()) + { + vbox.addView(customParticipantView.getView()); + } + else + { + ModelAndView characteristicsView = StudyManager.getInstance().getParticipantDemographicsView(getContainer(), form, errors); + ModelAndView dataView = StudyManager.getInstance().getParticipantView(getContainer(), form, errors); + vbox.addView(characteristicsView); + vbox.addView(dataView); + } + + return vbox; + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("participantViews"); + _addNavTrail(root, _bean.getDatasetId(), _bean.getReturnActionURL()); + root.addChild(StudyService.get().getSubjectNounSingular(getContainer()) + " - " + id(_bean.getParticipantId())); + } + } + + @RequiresPermission(ReadPermission.class) + public class Participant2Action extends SimpleViewAction + { + // TODO participant list support? cohortfilter support? + // TODO define participant context +// { +// particpantId:"", +// participantGroup:"" +// demoMode:false +// } + + + @Override + public ModelAndView getView(ParticipantForm form, BindException errors) throws Exception + { + ViewContext context = getViewContext(); + Study study = getStudyRedirectIfNull(); + + if (form.getParticipantId() == null) + { + throw new NotFoundException("No " + study.getSubjectNounSingular() + " specified"); + } + + Participant participant; + try + { + participant = findParticipant(study, form.getParticipantId()); + if (null == participant) + throw new NotFoundException("Could not find " + study.getSubjectNounSingular() + " " + form.getParticipantId()); + } + catch (StudyManager.ParticipantNotUniqueException x) + { + return HtmlView.of(x.getMessage()); + } + + PageConfig page = getPageConfig(); + + // add participant to view context for java/jsp based web parts + context.put(Participant.class.getName(), participant); + // add to javascript context for file based web parts + page.getPortalContext().put("participantId", participant.getParticipantId()); + + String pageId = Participant.class.getName(); + boolean canCustomize = context.getContainer().hasPermission("populatePortalView",context.getUser(), AdminPermission.class); + + HttpView template = PageConfig.Template.Home.getTemplate(getViewContext(), new VBox(), page); + int parts = Portal.populatePortalView(getViewContext(), pageId, template, isPrint(), canCustomize, false, true, Portal.STUDY_PARTICIPANT_PORTAL_PAGE); + + if (parts == 0 && canCustomize) + { + // TODO: make webparts out of default views and actually save portal config +// ParticipantAction pa = new ParticipantAction(); +// pa.setViewContext(context); +// ModelAndView v = pa.getView(form, errors); +// Portal.addViewToRegion(template, WebPartFactory.LOCATION_BODY, (HttpView)v); + + // force page admin mode + template = PageConfig.Template.Home.getTemplate(getViewContext(), new VBox(), page); + Portal.populatePortalView(getViewContext(), pageId, template, isPrint(), canCustomize, true, true, Participant.class.getName()); + + } + + getPageConfig().setTemplate(PageConfig.Template.None); + return template; + } + + @Override + public void addNavTrail(NavTree root) + { + } + } + + + + // Obfuscate the passed in test if this user is in "demo" mode in this container + private String id(String id) + { + return id(id, getContainer(), getUser()); + } + + // Obfuscate the passed in test if this user is in "demo" mode in this container + private static String id(String id, Container c, User user) + { + return DemoMode.id(id, c, user); + } + + + @RequiresPermission(AdminPermission.class) + public class ImportVisitMapAction extends FormViewAction + { + @Override + public ModelAndView getView(ImportVisitMapForm form, boolean reshow, BindException errors) + { + StudyImpl study = getStudyThrowIfNull(); + redirectToSharedVisitStudy(study, getViewContext().getActionURL()); + return new StudyJspView<>(getStudyRedirectIfNull(), "/org/labkey/study/view/importVisitMap.jsp", null, errors); + } + + @Override + public void validateCommand(ImportVisitMapForm form, Errors errors) + { + } + + @Override + public boolean handlePost(ImportVisitMapForm form, BindException errors) throws Exception + { + VisitMapImporter importer = new VisitMapImporter(); + List errorMsg = new LinkedList<>(); + if (!importer.process(getUser(), getStudyThrowIfNull(), form.getContent(), VisitMapImporter.Format.Xml, errorMsg, _log)) + { + for (String error : errorMsg) + errors.reject("uploadVisitMap", error); + return false; + } + return true; + } + + @Override + public ActionURL getSuccessURL(ImportVisitMapForm form) + { + return new ActionURL(BeginAction.class, getContainer()); + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("importVisitMap"); + _addNavTrailVisitAdmin(root); + root.addChild("Import Visit Map"); + } + } + + @RequiresPermission(AdminPermission.class) + public class CreateStudyAction extends FormViewAction + { + @Override + public ModelAndView getView(StudyPropertiesForm form, boolean reshow, BindException errors) throws Exception + { + if (null != getStudy()) + { + BeginAction action = (BeginAction)initAction(this, new BeginAction()); + return action.getView(form, errors); + } + // Set default values for the form + if (form.getLabel() == null) + { + form.setLabel(HttpView.currentContext().getContainer().getName() + " Study"); + } + if (form.getStartDate() == null) + { + form.setStartDate(new Date()); + } + if (form.getDefaultTimepointDuration() == 0) + { + form.setDefaultTimepointDuration(1); + } + // NOTE: should be a better way to do this (e.g. get the correct value in the form/backend to begin with) + Study sharedStudy = getStudy(getContainer().getProject()); + if (sharedStudy != null && sharedStudy.getShareVisitDefinitions() == Boolean.TRUE) + { + form.setShareVisits(sharedStudy.getShareVisitDefinitions()); + form.setTimepointType(sharedStudy.getTimepointType()); + form.setStartDate(sharedStudy.getStartDate()); + form.setDefaultTimepointDuration(sharedStudy.getDefaultTimepointDuration()); + } + return new StudyJspView<>(null, "/org/labkey/study/view/createStudy.jsp", form, errors); + } + + @Override + public void validateCommand(StudyPropertiesForm target, Errors errors) + { + if (target.getTimepointType() == TimepointType.DATE && null == target.getStartDate()) + errors.reject(ERROR_MSG, "Start date must be supplied for a date-based study."); + + target.setLabel(StringUtils.trimToNull(target.getLabel())); + if (null == target.getLabel()) + errors.reject(ERROR_MSG, "Please supply a label"); + + String message; + + if (null != (message = StudyService.get().getSubjectColumnNameValidationErrorMessage(getContainer(), target.getSubjectColumnName()))) + errors.reject(ERROR_MSG, message); + + if (null != (message = StudyService.get().getSubjectNounSingularValidationErrorMessage(getContainer(), target.getSubjectNounSingular()))) + errors.reject(ERROR_MSG, message); + + if (null != (message = StudyService.get().getSubjectNounPluralValidationErrorMessage(getContainer(), target.getSubjectNounPlural()))) + errors.reject(ERROR_MSG, message); + } + + @Override + public boolean handlePost(StudyPropertiesForm form, BindException errors) + { + createStudy(getStudy(), getContainer(), getUser(), form); + return true; + } + + @Override + public ActionURL getSuccessURL(StudyPropertiesForm form) + { + return form.getSuccessActionURL(new ActionURL(ManageStudyAction.class, getContainer())); + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("Create Study"); + } + } + + public static StudyImpl createStudy(@Nullable StudyImpl study, Container c, User user, StudyPropertiesForm form) + { + if (null == study) + { + study = new StudyImpl(c, form.getLabel()); + study.setTimepointType(form.getTimepointType()); + study.setStartDate(form.getStartDate()); + study.setEndDate(form.getEndDate()); + study.setSecurityType(form.getSecurityType()); + study.setSubjectNounSingular(form.getSubjectNounSingular()); + study.setSubjectNounPlural(form.getSubjectNounPlural()); + study.setSubjectColumnName(form.getSubjectColumnName()); + study.setAssayPlan(form.getAssayPlan()); + study.setDescription(form.getDescription()); + study.setDefaultTimepointDuration(Math.max(form.getDefaultTimepointDuration(), 1)); + if (form.getDescriptionRendererType() != null) + study.setDescriptionRendererType(form.getDescriptionRendererType()); + study.setGrant(form.getGrant()); + study.setInvestigator(form.getInvestigator()); + study.setSpecies(form.getSpecies()); + study.setAlternateIdPrefix(form.getAlternateIdPrefix()); + study.setAlternateIdDigits(form.getAlternateIdDigits()); + study.setAllowReqLocRepository(form.isAllowReqLocRepository()); + study.setAllowReqLocClinic(form.isAllowReqLocClinic()); + study.setAllowReqLocSal(form.isAllowReqLocSal()); + study.setAllowReqLocEndpoint(form.isAllowReqLocEndpoint()); + if (c.isProject()) + { + study.setShareDatasetDefinitions(form.isShareDatasets()); + study.setShareVisitDefinitions(form.isShareVisits()); + } + + study = StudyManager.getInstance().createStudy(user, study); + SpecimenMigrationService sms = SpecimenMigrationService.get(); + if (null != sms) + sms.setDefaultRequestabilityRules(c, user); + } + return study; + } + + @RequiresPermission(ManageStudyPermission.class) + public class ManageStudyAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object o, BindException errors) + { + return new StudyJspView<>(getStudy(), "/org/labkey/study/view/manageStudy.jsp", null, errors); + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("manageStudy"); + _addManageStudy(root); + } + } + + @RequiresPermission(DeletePermission.class) + public static class DeleteParticipantAction extends MutatingApiAction + { + @Override + public Object execute(DeleteParticipantForm deleteParticipantForm, BindException errors) throws Exception + { + //Note: In the EHR system, 'Participant' tables are prefixed with "Animal". For example, the equivalent of the + //Participant table is named Animal, and ParticipantGroupMap is AnimalGroupMap, etc. + //Additionally, the participantId column is labeled as "Id" in the Animal table and other "Animal" tables. + + DbSchema schema = StudySchema.getInstance().getSchema(); + + Study study = StudyManager.getInstance().getStudy(getContainer()); + if (study == null) + { + errors.reject(ERROR_MSG, "Study not found in this folder."); + return new ApiSimpleResponse("success", false); + } + String participantId = deleteParticipantForm.getParticipantId(); + String participantIdColumnName = study.getSubjectColumnName(); + String participantTableNamePrefix = study.getSubjectNounSingular(); + + try (DbScope.Transaction transaction = schema.getScope().ensureTransaction()) + { + _log.info("Starting participant deletion for ID: " + participantId); + List datasets = study.getDatasets(); + + //delete participant rows from datasets + for (Dataset dataset : datasets) + { + if (dataset.isDemographicData()) + deleteParticipantFromDemographics(dataset.getTableInfo(getUser()), participantIdColumnName, participantId, errors); + else + deleteParticipantFromDatasets(dataset.getTableInfo(getUser()), participantIdColumnName, participantId, errors); + } + + //delete from study.participantGroupMap + TableInfo participantGroupMapTable = QueryService.get().getUserSchema(getUser(), getContainer(), "study").getTable(participantTableNamePrefix + "GroupMap"); + if (null != participantGroupMapTable) + { + TableSelector ts = new TableSelector(participantGroupMapTable, Set.of(participantIdColumnName, "GroupId"), new SimpleFilter(FieldKey.fromString(participantIdColumnName), participantId), null); + ParticipantGroupManager.ParticipantGroupMap[] pgm = ts.getArray(ParticipantGroupManager.ParticipantGroupMap.class); + deleteFromParticipantGroupMapTable(participantGroupMapTable, participantId, participantIdColumnName, pgm, errors); + } + transaction.commit(); + } + ApiSimpleResponse response = new ApiSimpleResponse(); + response.put("success", !errors.hasErrors()); + if (errors.hasErrors()) + { + _log.error("Failed to delete participant: {}", participantId); + response.put("message", errors.getMessage()); + } + else + { + _log.info("Successfully deleted participant: {}", participantId); + response.put("message", "Successfully deleted participant " + participantId); + } + return response; + } + + private void deleteParticipantFromDemographics(TableInfo ti, String participantIdColumnName, String participantId, BindException errors) + { + ColumnInfo idCol = ti.getColumn(FieldKey.fromParts(participantIdColumnName)); + deleteParticipantRows(ti, Collections.singletonList(Collections.singletonMap(idCol.getName(), participantId)), errors); + } + + private void deleteParticipantFromDatasets(TableInfo ti, String participantIdColumnName, String participantId, BindException errors) + { + TableSelector ts = new TableSelector(ti, Collections.singleton(DatasetDomainKind.LSID), new SimpleFilter(FieldKey.fromString(participantIdColumnName), participantId), null); + deleteParticipantRows(ti, ts.getMapCollection().stream().toList(), errors); + } + + private void deleteParticipantRows(TableInfo ti, List> keys, BindException errors) + { + try + { + ti.getUpdateService().deleteRows(getUser(), getContainer(), keys, null, null); + } + catch (InvalidKeyException | BatchValidationException | QueryUpdateServiceException | SQLException e) + { + String msg = "Failed to delete participant rows from " + ti.getName(); + _log.error(msg, e); + errors.reject(ERROR_MSG, msg + ": " + e.getMessage()); + } + } + + private void deleteFromParticipantGroupMapTable(TableInfo ti, String participantId, String participantColName, ParticipantGroupManager.ParticipantGroupMap[] groups, BindException errors) + { + try + { + SQLFragment sql = new SQLFragment("DELETE FROM study.participantgroupmap WHERE participantid = ?", participantId); + new SqlExecutor(ti.getSchema()).execute(sql); + } + catch (Exception e) + { + String msg = "Failed to delete row from " + ti.getSchema().getName() + "." + ti.getName() + " for " + participantColName + " '" + participantId + "'"; + _log.error(msg, e); + errors.reject(ERROR_MSG, msg + " :" + e.getMessage()); + } + + for (ParticipantGroupManager.ParticipantGroupMap group : groups) + { + ParticipantGroupAuditProvider.ParticipantGroupAuditEvent event = ParticipantGroupAuditProvider.EventFactory.participantDeleted(participantId, getContainer(), group.getLabel(), group.getGroupId()); + AuditLogService.get().addEvent(getUser(), event); + } + } + } + + public static class DeleteParticipantForm + { + private String _participantId; + + public String getParticipantId() + { + return _participantId; + } + + public void setParticipantId(String participantId) + { + this._participantId = participantId; + } + } + + @RequiresPermission(AdminPermission.class) + public class DeleteStudyAction extends FormViewAction + { + @Override + public void validateCommand(DeleteStudyForm form, Errors errors) + { + if (!form.isConfirm()) + errors.reject("deleteStudy", "Need to confirm Study deletion"); + } + + @Override + public ModelAndView getView(DeleteStudyForm form, boolean reshow, BindException errors) + { + return new StudyJspView<>(getStudyRedirectIfNull(), "/org/labkey/study/view/confirmDeleteStudy.jsp", null, errors); + } + + @Override + public boolean handlePost(DeleteStudyForm form, BindException errors) + { + StudyManager.getInstance().deleteAllStudyData(getContainer(), getUser()); + return true; + } + + @Override + public ActionURL getSuccessURL(DeleteStudyForm deleteStudyForm) + { + return getContainer().getFolderType().getStartURL(getContainer(), getUser()); + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("Confirm Delete Study"); + } + } + + public static class DeleteStudyForm + { + private boolean confirm; + + public boolean isConfirm() + { + return confirm; + } + + public void setConfirm(boolean confirm) + { + this.confirm = confirm; + } + } + + public static class RemoveProtocolDocumentForm + { + private String _name; + + public String getName() + { + return _name; + } + + public void setName(String name) + { + _name = name; + } + } + + @RequiresPermission(AdminPermission.class) + public class RemoveProtocolDocumentAction extends FormHandlerAction + { + @Override + public void validateCommand(RemoveProtocolDocumentForm target, Errors errors) + { + } + + @Override + public boolean handlePost(RemoveProtocolDocumentForm removeProtocolDocumentForm, BindException errors) throws Exception + { + Study study = getStudyThrowIfNull(); + study.removeProtocolDocument(removeProtocolDocumentForm.getName(), getUser()); + return true; + } + + @Override + public URLHelper getSuccessURL(RemoveProtocolDocumentForm removeProtocolDocumentForm) + { + return new ActionURL(ManageStudyPropertiesAction.class, getContainer()); + } + } + + @RequiresPermission(ReadPermission.class) + public class ManageStudyPropertiesAction extends FormApiAction + { + @Override + protected @NotNull TableViewForm getCommand(HttpServletRequest request) + { + User user = getUser(); + UserSchema schema = QueryService.get().getUserSchema(user, getContainer(), SchemaKey.fromParts(StudyQuerySchema.SCHEMA_NAME)); + TableViewForm form = new TableViewForm(schema.getTable("StudyProperties")); + form.setViewContext(getViewContext()); + return form; + } + + @Override + public ModelAndView getView(TableViewForm form, BindException errors) + { + Study study = getStudy(); + if (null == study) + throw new RedirectException(new ActionURL(CreateStudyAction.class, getContainer())); + return new StudyJspView<>(getStudy(), "/org/labkey/study/view/manageStudyPropertiesExt.jsp", study, null); + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("manageStudy"); + _addManageStudy(root); + root.addChild("Study Properties"); + } + + @Override + public void validateForm(TableViewForm form, Errors errors) + { + // Skip validation if Spring binding already has an error for subject noun singular + if (errors.getFieldError("SubjectNounSingular") == null) + { + // Issue 47444 and Issue 47881: Validate that subject noun singular doesn't match the name of an existing + // study table or dataset + String subjectNounSingular = form.get("SubjectNounSingular"); + if (null != subjectNounSingular) + { + String message = StudyService.get().getSubjectNounSingularValidationErrorMessage(getContainer(), subjectNounSingular); + if (message != null) + errors.reject(ERROR_MSG, message); + } + } + + // Skip validation if Spring binding already has an error for subject noun plural + if (errors.getFieldError("SubjectNounPlural") == null) + { + String subjectNounPlural = form.get("SubjectNounPlural"); + if (null != subjectNounPlural) + { + String message = StudyService.get().getSubjectNounPluralValidationErrorMessage(getContainer(), subjectNounPlural); + if (message != null) + errors.reject(ERROR_MSG, message); + } + } + + // Skip validation if Spring binding already has an error for subject column name + if (errors.getFieldError("SubjectColumnName") == null) + { + // Issue 43898: Validate that the subject column name is not a user-defined field in one of the datasets + String subjectColName = form.get("SubjectColumnName"); + if (null != subjectColName) + { + String message = StudyService.get().getSubjectColumnNameValidationErrorMessage(getContainer(), subjectColName); + if (message != null) + errors.reject(ERROR_MSG, message); + } + } + } + + @Override + public ApiResponse execute(TableViewForm form, BindException errors) throws Exception + { + if (!getContainer().hasPermission(getUser(),AdminPermission.class)) + throw new UnauthorizedException(); + + Map values = form.getTypedValues(); + values.put("container", getContainer().getId()); + + TableInfo studyProperties = form.getTable(); + QueryUpdateService qus = studyProperties.getUpdateService(); + if (null == qus) + throw new UnauthorizedException(); + try (DbScope.Transaction transaction = StudySchema.getInstance().getSchema().getScope().ensureTransaction()) + { + BatchValidationException batchErrors = new BatchValidationException(); + qus.updateRows(getUser(), getContainer(), Collections.singletonList(values), Collections.singletonList(values), batchErrors, null, null); + if (batchErrors.hasErrors()) + throw batchErrors; + List files = getAttachmentFileList(); + getStudyThrowIfNull().attachProtocolDocument(files, getUser()); + transaction.commit(); + } + catch (BatchValidationException x) + { + x.addToErrors(errors); + return null; + } + catch (AttachmentService.DuplicateFilenameException x) + { + JSONObject json = new JSONObject(); + json.put("failure", true); + json.put("msg", x.getMessage()); + return new ApiSimpleResponse(json); + } + + JSONObject json = new JSONObject(); + json.put("success", true); + return new ApiSimpleResponse(json); + } + } + + + @RequiresPermission(AdminPermission.class) + public class ManageVisitsAction extends FormViewAction + { + @Override + public void validateCommand(StudyPropertiesForm target, Errors errors) + { + StudyImpl study = getStudy(); + if (study.getTimepointType() == TimepointType.DATE) + { + if (target.getTimepointType() == TimepointType.DATE && null == target.getStartDate()) + errors.reject(ERROR_MSG, "Start date must be supplied for a date-based study."); + if (target.getDefaultTimepointDuration() < 1) + errors.reject(ERROR_MSG, "Default timepoint duration must be a positive number."); + } + } + + @Override + public ModelAndView getView(StudyPropertiesForm form, boolean reshow, BindException errors) throws Exception + { + StudyImpl study = getStudy(); + if (null == study) + { + CreateStudyAction action = (CreateStudyAction)initAction(this, new CreateStudyAction()); + return action.getView(form, false, errors); + } + + if (study.getTimepointType() == TimepointType.CONTINUOUS) + return HtmlView.err("Unsupported operation for continuous study"); + + redirectToSharedVisitStudy(study, getViewContext().getActionURL()); + + return new StudyJspView<>(study, _jspName(study), form, errors); + } + + @Override + public boolean handlePost(StudyPropertiesForm form, BindException errors) + { + StudyImpl study = getStudyThrowIfNull().createMutable(); + redirectToSharedVisitStudy(study, getViewContext().getActionURL()); + + if (study.getTimepointType() == TimepointType.DATE) + { + study.setStartDate(form.getStartDate()); + study.setDefaultTimepointDuration(form.getDefaultTimepointDuration()); + } + study.setFailForUndefinedTimepoints(form.isFailForUndefinedTimepoints()); + + StudyManager.getInstance().updateStudy(getUser(), study); + + return true; + } + + @Override + public ActionURL getSuccessURL(StudyPropertiesForm studyPropertiesForm) + { + return new ActionURL(ManageStudyAction.class, getContainer()); + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("manageVisits"); + _addNavTrailVisitAdmin(root); + } + + private String _jspName(Study study) + { + assert study.getTimepointType() != TimepointType.CONTINUOUS; + return study.getTimepointType() == TimepointType.DATE ? "/org/labkey/study/view/manageTimepoints.jsp" : "/org/labkey/study/view/manageVisits.jsp"; + } + } + + @RequiresPermission(AdminPermission.class) + public class ManageTypesAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object o, BindException errors) + { + return new StudyJspView<>(getStudyRedirectIfNull(), "/org/labkey/study/view/manageTypes.jsp", this, errors); + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("manageDatasets"); + _addManageStudy(root); + root.addChild("Manage Datasets"); + } + } + + @RequiresPermission(AdminPermission.class) + public class ManageFilewatchersAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object o, BindException errors) + { + return new StudyJspView<>(getStudyRedirectIfNull(), "/org/labkey/study/view/manageFilewatchers.jsp", this, errors); + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("fileWatcher"); + _addManageStudy(root); + root.addChild("Manage File Watchers"); + } + } + + @RequiresPermission(AdminPermission.class) + public class ManageLocationsAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object o, BindException errors) throws Exception + { + UserSchema schema = QueryService.get().getUserSchema(getUser(), getContainer(), StudyQuerySchema.SCHEMA_NAME); + QuerySettings settings = schema.getSettings(getViewContext(), QueryView.DATAREGIONNAME_DEFAULT, StudyQuerySchema.LOCATION_TABLE_NAME); + + return schema.createView(getViewContext(), settings, errors); + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("manageLocations"); + _addManageStudy(root); + root.addChild("Manage Locations"); + } + } + + @RequiresPermission(AdminPermission.class) + public static class DeleteAllUnusedLocationsAction extends ConfirmAction + { + @Override + public ModelAndView getConfirmView(LocationForm form, BindException errors) + { + List temp = new ArrayList<>(); + for (Container c : getContainers(form)) + { + if (c.hasPermission(getUser(), AdminPermission.class)) + { + LocationManager mgr = LocationManager.get(); + for (LocationImpl loc : mgr.getLocations(c)) + { + if (!mgr.isLocationInUse(loc)) + { + temp.add(c.getName() + "/" + loc.getLabel()); + } + } + } + } + String[] labels = new String[temp.size()]; + for(int i = 0; i("/org/labkey/study/view/confirmDeleteLocation.jsp", form, errors); + } + + @Override + public boolean handlePost(LocationForm form, BindException errors) throws Exception + { + for (Container c : getContainers(form)) + { + if (c.hasPermission(getUser(), AdminPermission.class)) + { + LocationManager mgr = LocationManager.get(); + for (LocationImpl loc : mgr.getLocations(c)) + { + if (!mgr.isLocationInUse(loc)) + { + mgr.deleteLocation(loc); + } + } + } + } + return true; + } + + @Override + public void validateCommand(LocationForm locationEditForm, Errors errors) + { + } + + @NotNull + @Override + public URLHelper getSuccessURL(LocationForm form) + { + return form.getReturnUrlHelper(); + } + + private Collection getContainers(LocationForm form) + { + String containerFilterName = form.getContainerFilter(); + + if (null != containerFilterName) + return LocationTable.getStudyContainers(getContainer(), ContainerFilter.getContainerFilterByName(form.getContainerFilter(), getContainer(), getUser())); + else + return Collections.singleton(getContainer()); + } + } + + public static class LocationForm extends ViewForm + { + private int[] _ids; + private String[] _labels; + private String _containerFilter; + public String[] getLabels() + { + return _labels; + } + + public void setLabels(String[] labels) + { + _labels = labels; + } + + public int[] getIds() + { + return _ids; + } + + public void setIds(int[] ids) + { + _ids = ids; + } + + public String getContainerFilter() + { + return _containerFilter; + } + + public void setContainerFilter(String containerFilter) + { + _containerFilter = containerFilter; + } + } + + + @RequiresPermission(AdminPermission.class) + public class VisitSummaryAction extends FormViewAction + { + private VisitImpl _v; + + @Override + public void validateCommand(VisitForm target, Errors errors) + { + StudyImpl study = getStudyRedirectIfNull(); + if (study.getTimepointType() == TimepointType.CONTINUOUS) + errors.reject(null, "Unsupported operation for continuous date study"); + + Study sharedStudy = StudyManager.getInstance().getSharedStudy(study); + if (sharedStudy != null && sharedStudy.getShareVisitDefinitions()) + errors.reject(null, "Can't edit visits in a study with shared visits"); + + target.validate(errors, study); + if (errors.getErrorCount() > 0) + return; + + VisitImpl visitBean = target.getBean(); + + //check for overlapping visits that the target num is within the range + VisitManager visitMgr = StudyManager.getInstance().getVisitManager(study); + if (visitMgr.isVisitOverlapping(visitBean)) + errors.reject(null, "Visit range overlaps with an existing visit in this study. Please enter a different range."); + } + + @Override + public ModelAndView getView(VisitForm form, boolean reshow, BindException errors) + { + StudyImpl study = getStudyRedirectIfNull(); + if (study.getTimepointType() == TimepointType.CONTINUOUS) + return HtmlView.err("Unsupported operation for continuous date study"); + + redirectToSharedVisitStudy(study, getViewContext().getActionURL()); + + int id = NumberUtils.toInt((String)getViewContext().get("id")); + _v = StudyManager.getInstance().getVisitForRowId(study, id); + if (_v == null) + { + return HttpView.redirect(new ActionURL(BeginAction.class, getContainer())); + } + VisitSummaryBean visitSummary = new VisitSummaryBean(); + visitSummary.setVisit(_v); + + return new StudyJspView<>(study, "/org/labkey/study/view/editVisit.jsp", visitSummary, errors); + } + + @Override + public boolean handlePost(VisitForm form, BindException errors) + { + VisitImpl postedVisit = form.getBean(); + if (!getContainer().getId().equals(postedVisit.getContainer().getId())) + throw new UnauthorizedException(); + + StudyImpl study = getStudyThrowIfNull(); + redirectToSharedVisitStudy(study, getViewContext().getActionURL()); + + // UNDONE: how do I get struts to handle this checkbox? + postedVisit.setShowByDefault(null != StringUtils.trimToNull((String)getViewContext().get("showByDefault"))); + + // UNDONE: reshow is broken for this form, but we have to validate + Collection visits = StudyManager.getInstance().getVisitManager(study).getVisits(); + boolean validRange = true; + // make sure there is no overlapping visit + for (VisitImpl v : visits) + { + if (v.getRowId() == postedVisit.getRowId()) + continue; + BigDecimal maxL = v.getSequenceNumMin().max(postedVisit.getSequenceNumMin()); + BigDecimal minR = v.getSequenceNumMax().min(postedVisit.getSequenceNumMax()); + if (maxL.compareTo(minR) <= 0) + { + errors.reject("visitSummary", getVisitLabel() + " range overlaps with '" + v.getDisplayString() + "'"); + validRange = false; + } + } + + if (!validRange) + { + return false; + } + + StudyManager.getInstance().updateVisit(getUser(), postedVisit); + + HashMap visitTypeMap = new IntHashMap<>(); + for (VisitDataset vds : postedVisit.getVisitDatasets()) + visitTypeMap.put(vds.getDatasetId(), vds.isRequired() ? VisitDatasetType.REQUIRED : VisitDatasetType.OPTIONAL); + + if (form.getDatasetIds() != null) + { + for (int i = 0; i < form.getDatasetIds().length; i++) + { + int datasetId = form.getDatasetIds()[i]; + VisitDatasetType type = VisitDatasetType.valueOf(form.getDatasetStatus()[i]); + VisitDatasetType oldType = visitTypeMap.get(datasetId); + if (oldType == null) + oldType = VisitDatasetType.NOT_ASSOCIATED; + if (type != oldType) + { + StudyManager.getInstance().updateVisitDatasetMapping(getUser(), getContainer(), + postedVisit.getRowId(), datasetId, type); + } + } + } + return true; + } + + @Override + public ActionURL getSuccessURL(VisitForm form) + { + return new ActionURL(ManageVisitsAction.class, getContainer()); + } + + @Override + public void addNavTrail(NavTree root) + { + _addNavTrailVisitAdmin(root); + root.addChild(_v.getDisplayString()); + } + } + + public static class VisitSummaryBean + { + private VisitImpl visit; + + public VisitImpl getVisit() + { + return visit; + } + + public void setVisit(VisitImpl visit) + { + this.visit = visit; + } + } + + @RequiresPermission(ManageStudyPermission.class) + public class StudyScheduleAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object o, BindException errors) throws Exception + { + return StudyModule.studyScheduleWebPartFactory.getWebPartView(getViewContext(), StudyModule.studyScheduleWebPartFactory.createWebPart()); + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("studySchedule"); + _addManageStudy(root); + root.addChild("Study Schedule"); + } + } + + @RequiresPermission(AdminPermission.class) + public class DeleteVisitAction extends FormHandlerAction + { + @Override + public void validateCommand(IdForm target, Errors errors) + { + StudyImpl study = getStudyThrowIfNull(); + if (study.getTimepointType() == TimepointType.CONTINUOUS) + errors.reject(null, "Unsupported operation for continuous date study"); + + + Study sharedStudy = StudyManager.getInstance().getSharedStudy(study); + if (sharedStudy != null && sharedStudy.getShareVisitDefinitions()) + errors.reject(null, "Can't edit visits in a study with shared visits"); + } + + @Override + public boolean handlePost(IdForm form, BindException errors) + { + int visitId = form.getId(); + StudyImpl study = getStudyThrowIfNull(); + + redirectToSharedVisitStudy(study, getViewContext().getActionURL()); + + VisitImpl visit = StudyManager.getInstance().getVisitForRowId(study, visitId); + if (visit != null) + { + StudyManager.getInstance().deleteVisit(study, visit, getUser()); + return true; + } + throw new NotFoundException(); + } + + @Override + public ActionURL getSuccessURL(IdForm idForm) + { + return new ActionURL(ManageVisitsAction.class, getContainer()); + } + } + + + @RequiresPermission(AdminPermission.class) + public class DeleteUnusedVisitsAction extends ConfirmAction + { + @Override + public void validateCommand(IdForm target, Errors errors) + { + StudyImpl study = getStudyThrowIfNull(); + if (study.getTimepointType() == TimepointType.CONTINUOUS) + errors.reject(null, "Unsupported operation for continuous date study"); + + Study sharedStudy = StudyManager.getInstance().getSharedStudy(study); + if (sharedStudy != null && sharedStudy.getShareVisitDefinitions()) + errors.reject(null, "Can't delete visits from a study with shared visits"); + } + + @Override + public ModelAndView getConfirmView(IdForm idForm, BindException errors) + { + if (getPageConfig().getTitle() == null) + setTitle("Delete Unused Visits"); + + StudyImpl study = getStudyThrowIfNull(); + + redirectToSharedVisitStudy(study, getViewContext().getActionURL()); + + Collection visits = getUnusedVisits(); + HtmlStringBuilder sb = HtmlStringBuilder.of(); + + if (visits.isEmpty()) + { + sb.unsafeAppend("No unused visits found.
"); + } + else + { + // Put them in a table to help with StudyTest verification + sb.unsafeAppend("\n"); + sb.unsafeAppend("\n\n"); + + for (VisitImpl visit : visits) + { + sb.unsafeAppend("\n"); + } + + sb.unsafeAppend("
Are you sure you want to delete the unused visits listed below?
 
") + .append(visit.getLabel()) + .append(" (") + .append(visit.getSequenceString()) + .append(")") + .unsafeAppend("
\n"); + } + + return new HtmlView(sb); + } + + @Override + public boolean handlePost(IdForm form, BindException errors) + { + long start = System.currentTimeMillis(); + StudyImpl study = getStudyThrowIfNull(); + + StudyManager.getInstance().deleteVisits(study, getUnusedVisits(), getUser(), true); + + _log.info("Delete unused visits took: " + DateUtil.formatDuration(System.currentTimeMillis() - start)); + + return true; + } + + private @NotNull Collection getUnusedVisits() + { + return new SqlSelector(StudySchema.getInstance().getSchema(), new SQLFragment( + "SELECT * FROM study.Visit v WHERE Container = ? AND rowid NOT IN (SELECT DISTINCT VisitRowId FROM study.ParticipantVisit pv WHERE pv.Container = ?)", + getContainer(), getContainer() + )).getArrayList(VisitImpl.class); + } + + @Override + @NotNull + public ActionURL getSuccessURL(IdForm idForm) + { + return new ActionURL(ManageVisitsAction.class, getContainer()); + } + } + + @RequiresPermission(AdminPermission.class) + public class BulkDeleteVisitsAction extends FormViewAction + { + private TimepointType _timepointType; + private List _visitsToDelete; + + @Override + public ModelAndView getView(DeleteVisitsForm form, boolean reshow, BindException errors) + { + StudyImpl study = getStudyRedirectIfNull(); + _timepointType = study.getTimepointType(); + + Study sharedStudy = StudyManager.getInstance().getSharedStudy(study); + if (sharedStudy != null && sharedStudy.getShareVisitDefinitions()) + return HtmlView.err("Can't delete visits from a study with shared visits."); + + if (_timepointType == TimepointType.CONTINUOUS) + return HtmlView.err("Unsupported operation for continuous study."); + + return new StudyJspView<>(getStudyRedirectIfNull(), "/org/labkey/study/view/bulkVisitDelete.jsp", form, errors); + } + + @Override + public void addNavTrail(NavTree root) + { + _addNavTrailVisitAdmin(root); + root.addChild("Delete " + (_timepointType == TimepointType.DATE ? "Timepoints" : "Visits")); + } + + @Override + public void validateCommand(DeleteVisitsForm form, Errors errors) + { + StudyImpl study = getStudyThrowIfNull(); + + Study sharedStudy = StudyManager.getInstance().getSharedStudy(study); + if (sharedStudy != null && sharedStudy.getShareVisitDefinitions()) + { + errors.reject(null, "Can't delete visits from a study with shared visits."); + return; + } + + int[] visitIds = form.getVisitIds(); + if (visitIds == null || visitIds.length == 0) + { + errors.reject(ERROR_MSG, "No " + (_timepointType == TimepointType.DATE ? "timepoints" : "visits") + " selected."); + return; + } + + _visitsToDelete = new ArrayList<>(); + for (int id : visitIds) + { + VisitImpl visit = StudyManager.getInstance().getVisitForRowId(study, id); + if (visit == null) + errors.reject(ERROR_MSG, "Unable to find visit for id " + id); + else + _visitsToDelete.add(visit); + } + } + + @Override + public boolean handlePost(DeleteVisitsForm form, BindException errors) + { + long start = System.currentTimeMillis(); + StudyImpl study = getStudyThrowIfNull(); + StudyManager.getInstance().deleteVisits(study, _visitsToDelete, getUser(), false); + _log.info("Bulk delete visits took: " + DateUtil.formatDuration(System.currentTimeMillis() - start)); + return true; + } + + @Override + public ActionURL getSuccessURL(DeleteVisitsForm form) + { + return new ActionURL(ManageVisitsAction.class, getContainer()); + } + } + + public static class DeleteVisitsForm extends ReturnUrlForm + { + private int[] _visitIds; + + public int[] getVisitIds() + { + return _visitIds; + } + + public void setVisitIds(int[] visitIds) + { + _visitIds = visitIds; + } + } + + @RequiresPermission(AdminPermission.class) + public class ConfirmDeleteVisitAction extends SimpleViewAction + { + private VisitImpl _visit; + private TimepointType _timepointType; + + @Override + public ModelAndView getView(IdForm form, BindException errors) + { + int visitId = form.getId(); + StudyImpl study = getStudyRedirectIfNull(); + _timepointType = study.getTimepointType(); + + if (_timepointType == TimepointType.CONTINUOUS) + return HtmlView.err("Unsupported operation for continuous study"); + + redirectToSharedVisitStudy(study, getViewContext().getActionURL()); + + _visit = StudyManager.getInstance().getVisitForRowId(study, visitId); + if (null == _visit) + throw new NotFoundException(); + + return new StudyJspView<>(study, "/org/labkey/study/view/confirmDeleteVisit.jsp", _visit, errors); + } + + @Override + public void addNavTrail(NavTree root) + { + String noun = _timepointType == TimepointType.DATE ? "Timepoint" : "Visit"; + root.addChild("Delete " + noun + " -- " + _visit.getDisplayString()); + } + } + + @RequiresPermission(AdminPermission.class) + public class CreateVisitAction extends FormViewAction + { + @Override + public void validateCommand(VisitForm target, Errors errors) + { + StudyImpl study = getStudyThrowIfNull(); + if (study.getTimepointType() == TimepointType.CONTINUOUS) + errors.reject(null, "Unsupported operation for continuous date study"); + + Study sharedStudy = StudyManager.getInstance().getSharedStudy(study); + if (sharedStudy != null && sharedStudy.getShareVisitDefinitions()) + errors.reject(null, "Can't create visits in a study with shared visits"); + + target.validate(errors, study); + if (errors.getErrorCount() > 0) + return; + + //check for overlapping visits + VisitManager visitMgr = StudyManager.getInstance().getVisitManager(study); + if (visitMgr.isVisitOverlapping(target.getBean())) + errors.reject(null, "Visit range overlaps with an existing visit in this study. Please enter a different range."); + } + + @Override + public ModelAndView getView(VisitForm form, boolean reshow, BindException errors) + { + StudyImpl study = getStudyRedirectIfNull(); + + if (study.getTimepointType() == TimepointType.CONTINUOUS) + errors.reject(null, "Unsupported operation for continuous date study"); + + redirectToSharedVisitStudy(study, getViewContext().getActionURL()); + + form.setReshow(reshow); + return new StudyJspView<>(study, "/org/labkey/study/view/createVisit.jsp", form, errors); + } + + @Override + public boolean handlePost(VisitForm form, BindException errors) + { + VisitImpl visit = form.getBean(); + if (visit != null) + StudyManager.getInstance().createVisit(getStudyThrowIfNull(), getUser(), visit); + return true; + } + + @Override + public ActionURL getSuccessURL(VisitForm visitForm) + { + return visitForm.getReturnActionURL(); + } + + @Override + public void addNavTrail(NavTree root) + { + _addNavTrailVisitAdmin(root); + root.addChild("Create New " + getVisitLabel()); + } + } + + /** + * Called from the vaccine design webpart for the study design module + */ + @RequiresPermission(UpdatePermission.class) + public class CreateVisitForVaccineDesign extends MutatingApiAction + { + @Override + public void validateForm(VisitForm form, Errors errors) + { + if (!StudyDesignManager.get().isModuleActive(getContainer())) + { + errors.reject(ERROR_MSG, "This action can only be called if the study design module is active"); + return; + } + + Study study = getStudy(getContainer()); + boolean isDateBased = study.getTimepointType() == TimepointType.DATE; + + form.validate(errors, study); + if (errors.getErrorCount() > 0) + return; + + //check for overlapping visits + VisitManager visitMgr = StudyManager.getInstance().getVisitManager(study); + String range = isDateBased ? "day range" : "sequence range"; + if (visitMgr.isVisitOverlapping(form.getBean())) + errors.reject(null, "The visit " + range + " provided overlaps with an existing visit in this study. Please enter a different " + range + "."); + } + + @Override + public ApiResponse execute(VisitForm form, BindException errors) + { + ApiSimpleResponse response = new ApiSimpleResponse(); + + VisitImpl visit = form.getBean(); + visit = StudyManager.getInstance().createVisit(getStudyThrowIfNull(), getUser(), visit); + + response.put("RowId", visit.getRowId()); + response.put("Label", visit.getDisplayString()); + response.put("SequenceNumMin", visit.getSequenceNumMin()); + response.put("DisplayOrder", visit.getDisplayOrder()); + response.put("Included", true); + response.put("success", true); + + return response; + } + } + + @RequiresPermission(AdminPermission.class) + public class UpdateDatasetVisitMappingAction extends FormViewAction + { + private DatasetDefinition _def; + + @Override + public void validateCommand(DatasetForm form, Errors errors) + { + if (null == form.getDatasetId() || form.getDatasetId() < 1) + { + errors.reject(SpringActionController.ERROR_MSG, "DatasetId must be a positive integer."); + } + else + { + _def = StudyManager.getInstance().getDatasetDefinition(getStudyRedirectIfNull(), form.getDatasetId()); + if (null == _def) + errors.reject(SpringActionController.ERROR_MSG, "Dataset not found."); + } + } + + @Override + public ModelAndView getView(DatasetForm form, boolean reshow, BindException errors) throws Exception + { + validateCommand(form, errors); + + if (errors.hasErrors()) + { + getPageConfig().setTemplate(PageConfig.Template.Dialog); + return new SimpleErrorView(errors); + } + + return new JspView<>("/org/labkey/study/view/updateDatasetVisitMapping.jsp", _def, errors); + } + + @Override + public boolean handlePost(DatasetForm form, BindException errors) + { + DatasetDefinition modified = _def.createMutable(); + if (null != form.getVisitRowIds()) + { + for (int i = 0; i < form.getVisitRowIds().length; i++) + { + int visitRowId = form.getVisitRowIds()[i]; + VisitDatasetType type = VisitDatasetType.valueOf(form.getVisitStatus()[i]); + if (modified.getVisitType(visitRowId) != type) + { + StudyManager.getInstance().updateVisitDatasetMapping(getUser(), getContainer(), + visitRowId, form.getDatasetId(), type); + } + } + } + return true; + } + + @Override + public ActionURL getSuccessURL(DatasetForm datasetForm) + { + return new ActionURL(DatasetDetailsAction.class, getContainer()).addParameter("id", datasetForm.getDatasetId()); + } + + @Override + public void addNavTrail(NavTree root) + { + _addNavTrailDatasetAdmin(root); + if (_def != null) + { + VisitManager visitManager = StudyManager.getInstance().getVisitManager(getStudyThrowIfNull()); + root.addChild("Edit " + _def.getLabel() + " " + visitManager.getPluralLabel()); + } + } + } + + + @RequiresPermission(InsertPermission.class) + public class ImportAction extends AbstractQueryImportAction + { + private ImportDatasetForm _form = null; + private StudyImpl _study = null; + private DatasetDefinition _def = null; + private TableInfo _table = null; + + @Override + protected void initRequest(ImportDatasetForm form) throws ServletException + { + _form = form; + _study = getStudyRedirectIfNull(); + + if ((_study.getParticipantAliasDatasetId() != null) && (_study.getParticipantAliasDatasetId() == form.getDatasetId())) + { + super.setImportMessage("This is the Alias Dataset. You do not need to include information for the date column."); + } + + _def = StudyManager.getInstance().getDatasetDefinition(_study, form.getDatasetId()); + if (null == _def && null != form.getName()) + _def = StudyManager.getInstance().getDatasetDefinitionByName(_study, form.getName()); + if (null == _def) + throw new NotFoundException("Dataset not found"); + if (null == _def.getTypeURI()) + return; + + + User user = getUser(); + // Go through normal getTable() codepath to be sure all metadata is applied + _table = StudyQuerySchema.createSchema(_study, user).getDatasetTable(_def, null); + if (_table == null) + throw new NotFoundException("Dataset not found"); + setTarget(_table); + + if (!_table.hasPermission(user, InsertPermission.class) && getUser().isGuest()) + throw new UnauthorizedException(); + } + + @Override + protected boolean canInsert(User user) + { + return _table.hasPermission(user, InsertPermission.class); + } + + @Override + protected boolean canUpdate(User user) + { + return _table.hasPermission(user, UpdatePermission.class); + } + + @Override + public ModelAndView getView(ImportDatasetForm form, BindException errors) throws Exception + { + initRequest(form); + + // TODO need a shorthand for this check + if (_def.isShared() && _def.getContainer().equals(_def.getDefinitionContainer())) + return new HtmlView("Error", HtmlString.of("Cannot insert dataset data in this folder. Use a sub-study to import data.")); + + if (_def.getTypeURI() == null) + throw new NotFoundException("Dataset is not yet defined."); + + if (null == PipelineService.get().findPipelineRoot(getContainer())) + return new RequirePipelineView(_study, true, errors); + + boolean showImportOptions = OptionalFeatureService.get().isFeatureEnabled(EXPERIMENTAL_ALLOW_MERGE_WITH_MANAGED_KEYS) || _def.getKeyManagementType() == Dataset.KeyManagementType.None; + setShowMergeOption(showImportOptions); + setShowUpdateOption(showImportOptions); + setSuccessMessageSuffix("imported"); //Works for when the merge option is selected (may include updates) vs default "inserted" + return getDefaultImportView(form, errors); + } + + @Override + protected int importData(DataLoader dl, FileStream file, String originalName, BatchValidationException errors, @Nullable AuditBehaviorType auditBehaviorType, @Nullable TransactionAuditProvider.TransactionAuditEvent auditEvent, @Nullable String auditUserComment) + { + if (null == PipelineService.get().findPipelineRoot(getContainer())) + { + errors.addRowError(new ValidationException("Pipeline file system is not setup.")); + return -1; + } + + // Allow for mapping of the ParticipantId and Sequence Num (i.e. timepoint column), + // these are passed in for the "create dataset from a file and import data" case + Map columnMap = new CaseInsensitiveHashMap<>(); + if (null != _form.getParticipantId()) + columnMap.put(_form.getParticipantId(),"ParticipantId"); + if (null != _form.getSequenceNum()) + { + String column = _def.getDomainKind().getKindName().equalsIgnoreCase(DateDatasetDomainKind.KIND_NAME) ? "Date" : "SequenceNum"; + columnMap.put(_form.getSequenceNum(), column); + } + + Pair, UploadLog> result = StudyPublishManager.getInstance().importDatasetTSV(getUser(), _study, _def, dl, getLookupResolutionType(), file, originalName, columnMap, errors, _form.getInsertOption(), auditBehaviorType); + + if (!result.getKey().isEmpty()) + { + // Log the import when SUMMARY is configured, if DETAILED is configured the DetailedAuditLogDataIterator will handle each row change. + // It would be nice in the future to replace the DetailedAuditLogDataIterator with a general purpose AuditLogDataIterator + // that can delegate the audit behavior type to the AuditDataHandler, so this code can go away + // + String comment = "Dataset data imported. " + result.getKey().size() + " rows imported"; + new DatasetDefinition.DatasetAuditHandler(_def).addAuditEvent(getUser(), getContainer(), AuditBehaviorType.SUMMARY, comment, result.getValue()); + } + + return result.getKey().size(); + } + + @Override + public ActionURL getSuccessURL(ImportDatasetForm form) + { + return new ActionURL(DatasetAction.class, getContainer()).addParameter(Dataset.DATASET_KEY, form.getDatasetId()); + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild(_study.getLabel(), new ActionURL(BeginAction.class, getContainer())); + ActionURL datasetURL = new ActionURL(DatasetAction.class, getContainer()). + addParameter(Dataset.DATASET_KEY, _form.getDatasetId()); + root.addChild(_def.getName(), datasetURL); + root.addChild("Import Data"); + } + } + + @RequiresPermission(AdminPermission.class) + public class ImportDatasetSchemaAction extends FormViewAction + { + @Override + public void validateCommand(ImportDatasetSchemaForm target, Errors errors) + { + } + + @Override + public ModelAndView getView(ImportDatasetSchemaForm form, boolean reshow, BindException errors) + { + return new StudyJspView<>(getStudyRedirectIfNull(), "/org/labkey/study/view/importDatasetSchema.jsp", form, errors); + } + + @Override + public boolean handlePost(ImportDatasetSchemaForm form, BindException errors) throws ImportException + { + if (form.getManifest() == null) + errors.reject(null, "Manifest is required."); + + if (form.getMetadata() == null) + errors.reject(null, "Metadata is required."); + + if (errors.hasErrors()) + return false; + + DatasetsDocument.Datasets manifestDatasetsDoc; + + try + { + manifestDatasetsDoc = DatasetsDocument.Factory.parse(form.getManifest(), XmlBeansUtil.getDefaultParseOptions()).getDatasets(); + } + catch (XmlException e) + { + errors.reject(null, "Invalid manifest XML: " + e.getMessage()); + return false; + } + + TablesDocument tablesDoc; + + try + { + tablesDoc = TablesDocument.Factory.parse(form.getMetadata(), XmlBeansUtil.getDefaultParseOptions()); + } + catch (XmlException e) + { + errors.reject(null, "Invalid metadata XML: " + e.getMessage()); + return false; + } + + SchemaReader reader = new SchemaXmlReader(getStudyThrowIfNull(), "metadata XML", tablesDoc, manifestDatasetsDoc); + + ComplianceService complianceService = ComplianceService.get(); + return StudyManager.getInstance().importDatasetSchemas(getStudyThrowIfNull(), getUser(), reader, errors, false, true, complianceService.getCurrentActivity(getViewContext())); + } + + @Override + public ActionURL getSuccessURL(ImportDatasetSchemaForm bulkImportTypesForm) + { + return new ActionURL(ManageTypesAction.class, getContainer()); + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("DatasetBulkDefinition"); + _addNavTrailDatasetAdmin(root); + root.addChild("Import Dataset Schema"); + } + } + + public static class ImportDatasetSchemaForm + { + private String _metadata; + private String _manifest; + + public String getMetadata() + { + return _metadata; + } + + @SuppressWarnings("unused") + public void setMetadata(String metadata) + { + _metadata = metadata; + } + + public String getManifest() + { + return _manifest; + } + + @SuppressWarnings("unused") + public void setManifest(String manifest) + { + _manifest = manifest; + } + } + + @RequiresPermission(UpdatePermission.class) + public class ShowUploadHistoryAction extends SimpleViewAction + { + String _datasetLabel; + + @Override + public ModelAndView getView(IdForm form, BindException errors) + { + TableInfo tInfo = StudySchema.getInstance().getTableInfoUploadLog(); + DataRegion dr = new DataRegion(); + dr.addColumns(tInfo, "RowId,Created,CreatedBy,Status,Description"); + GridView gv = new GridView(dr, errors); + DisplayColumn dc = new SimpleDisplayColumn(null) { + @Override + public void renderGridCellContents(RenderContext ctx, HtmlWriter out) + { + ActionURL url = new ActionURL(DownloadTsvAction.class, ctx.getContainer()).addParameter("id", String.valueOf(ctx.get("RowId"))); + out.write(LinkBuilder.labkeyLink("Download Data File", url)); + } + }; + dr.addDisplayColumn(dc); + + SimpleFilter filter = SimpleFilter.createContainerFilter(getContainer()); + if (form.getId() != 0) + { + filter.addCondition(Dataset.DATASET_KEY, form.getId()); + DatasetDefinition dsd = StudyManager.getInstance().getDatasetDefinition(getStudyRedirectIfNull(), form.getId()); + if (dsd != null) + _datasetLabel = dsd.getLabel(); + } + + gv.setFilter(filter); + return gv; + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("Upload History" + (null != _datasetLabel ? " for " + _datasetLabel : "")); + } + } + + @RequiresPermission(UpdatePermission.class) + public static class DownloadTsvAction extends SimpleViewAction + { + @Override + public ModelAndView getView(IdForm form, BindException errors) throws Exception + { + UploadLog ul = StudyPublishManager.getInstance().getUploadLog(getContainer(), form.getId()); + PageFlowUtil.streamFile(getViewContext().getResponse(), new File(ul.getFilePath()).toPath(), true); + + return null; + } + + @Override + public void addNavTrail(NavTree root) + { + } + } + + @RequiresPermission(ReadPermission.class) + public static class DatasetItemDetailsAction extends SimpleViewAction + { + @Override + public ModelAndView getView(SourceLsidForm form, BindException errors) + { + ActionURL url = LsidManager.get().getDisplayURL(form.getSourceLsid()); + if (url == null) + { + return HtmlView.of("The assay run that produced the data has been deleted."); + } + return HttpView.redirect(url); + } + + @Override + public void addNavTrail(NavTree root) + { + } + } + + public static class PublishHistoryDetailsForm + { + private @Nullable Integer _protocolId; + private @Nullable Integer _sampleTypeId; + private int _datasetId; + private String _sourceLsid; + private int _recordCount; + + public Integer getProtocolId() + { + return _protocolId; + } + + public void setProtocolId(Integer protocolId) + { + _protocolId = protocolId; + } + + public Integer getSampleTypeId() + { + return _sampleTypeId; + } + + public void setSampleTypeId(Integer sampleTypeId) + { + _sampleTypeId = sampleTypeId; + } + + public int getDatasetId() + { + return _datasetId; + } + + public void setDatasetId(int datasetId) + { + _datasetId = datasetId; + } + + public String getSourceLsid() + { + return _sourceLsid; + } + + public void setSourceLsid(String sourceLsid) + { + _sourceLsid = sourceLsid; + } + + public int getRecordCount() + { + return _recordCount; + } + + public void setRecordCount(int recordCount) + { + _recordCount = recordCount; + } + } + + @RequiresPermission(ReadPermission.class) + public class PublishHistoryDetailsAction extends SimpleViewAction + { + @Override + public ModelAndView getView(PublishHistoryDetailsForm form, BindException errors) + { + final StudyImpl study = getStudyRedirectIfNull(); + + VBox view = new VBox(); + + int datasetId = form.getDatasetId(); + final DatasetDefinition def = StudyManager.getInstance().getDatasetDefinition(study, datasetId); + + if (def != null) + { + final StudyQuerySchema querySchema = StudyQuerySchema.createSchema(study, getUser()); + DatasetQuerySettings qs = (DatasetQuerySettings)querySchema.getSettings(getViewContext(), DatasetQueryView.DATAREGION, def.getName()); + + if (!def.canRead(getUser())) + { + //requiresLogin(); + view.addView(new HtmlView(HtmlString.of("User does not have read permission on this dataset."))); + } + else + { + Integer protocolId = form.getProtocolId(); + Integer sampleTypeId = form.getSampleTypeId(); + + if (protocolId == null && sampleTypeId == null) + throw new IllegalArgumentException("Expected either a protocolId or sampleId parameter"); + + String sourceLsid = form.getSourceLsid(); // the assay protocol or sample type LSID + int recordCount = form.getRecordCount(); + + ActionURL deleteURL = new ActionURL(DeletePublishedRowsAction.class, getContainer()); + deleteURL.addParameter("publishSourceId", protocolId != null ? protocolId : sampleTypeId); + deleteURL.addParameter("sourceLsid", sourceLsid); + final ActionButton deleteRows = new ActionButton(deleteURL, "Recall Rows"); + + deleteRows.setRequiresSelection(true, "Recall selected row of this dataset?", "Recall selected rows of this dataset?"); + deleteRows.setActionType(ActionButton.Action.POST); + deleteRows.setDisplayPermission(DeletePermission.class); + + PublishedRecordQueryView qv = new PublishedRecordQueryView(querySchema, qs, sourceLsid, def.getPublishSource(), + protocolId != null ? protocolId : sampleTypeId, recordCount) { + + @Override + protected void populateButtonBar(DataView view, ButtonBar bar) + { + bar.add(deleteRows); + } + }; + + view.addView(qv); + } + } + else + view.addView(new HtmlView(HtmlString.of("The Dataset does not exist."))); + return view; + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("Link to Study History Details"); + } + } + + @RequiresPermission(DeletePermission.class) + public class DeletePublishedRowsAction extends FormHandlerAction + { + private DatasetDefinition _def; + private Collection _allLsids; + private MultiValuedMap> _sourceLsidToLsidPair; + private Long _sourceRowId = null; + + @Override + public void validateCommand(DeleteDatasetRowsForm target, Errors errors) + { + _def = StudyManager.getInstance().getDatasetDefinition(getStudyThrowIfNull(), target.getDatasetId()); + if (_def == null) + throw new IllegalArgumentException("Could not find a dataset definition for id: " + target.getDatasetId()); + if (!target.isDeleteAllData()) + { + _allLsids = DataRegionSelection.getSelected(getViewContext(), true); + + if (_allLsids.isEmpty()) + { + errors.reject("deletePublishedRows", "No rows were selected"); + } + } + else + { + _allLsids = StudyManager.getInstance().getDatasetLSIDs(getUser(), _def); + } + + // Need to handle this by groups of source lsids -- each assay or SampleType container needs logging + _sourceLsidToLsidPair = new ArrayListValuedHashMap<>(); + List rowIds = new ArrayList<>(); + List> data = _def.getDatasetRows(getUser(), _allLsids); + + for (Map row : data) + { + String sourceLSID = (String)row.get(StudyPublishService.SOURCE_LSID_PROPERTY_NAME); + String datasetRowLsid = (String)row.get(StudyPublishService.LSID_PROPERTY_NAME); + Long rowId = MapUtils.getLong(row,StudyPublishService.ROWID_PROPERTY_NAME); + rowIds.add(rowId); + if (sourceLSID != null && datasetRowLsid != null) + _sourceLsidToLsidPair.put(sourceLSID, Pair.of(datasetRowLsid, rowId)); + + if (_sourceRowId == null && rowId != null) + _sourceRowId = rowId; + } + + String errorMsg = StudyPublishService.get().checkForLockedLinks(_def, rowIds); + if (!StringUtils.isEmpty(errorMsg)) + errors.reject(ERROR_MSG, errorMsg); + } + + @Override + public boolean handlePost(DeleteDatasetRowsForm form, BindException errors) + { + String originalSourceLsid = (String)getViewContext().get("sourceLsid"); + + Dataset.PublishSource publishSource = _def.getPublishSource(); + if (form.getPublishSourceId() != null && publishSource != null) + { + for (Map.Entry>> entry : _sourceLsidToLsidPair.asMap().entrySet()) + { + String sourceLsid = entry.getKey(); + Collection> pairs = entry.getValue(); + Container sourceContainer = publishSource.resolveSourceLsidContainer(sourceLsid, _sourceRowId); + if (sourceContainer != null) + StudyPublishService.get().addRecallAuditEvent(sourceContainer, getUser(), _def, pairs.size(), pairs); + } + } + + _def.deleteDatasetRows(getUser(), _allLsids); + + // if the recall was initiated from link to study details view of the publish source, redirect back to the same view + if (publishSource != null && originalSourceLsid != null && form.getPublishSourceId() != null) + { + Container container = publishSource.resolveSourceLsidContainer(originalSourceLsid, _sourceRowId); + if (container != null) + throw new RedirectException(StudyPublishService.get().getPublishHistory(container, publishSource, form.getPublishSourceId())); + } + return true; + } + + @Override + public ActionURL getSuccessURL(DeleteDatasetRowsForm form) + { + return new ActionURL(DatasetAction.class, getContainer()). + addParameter(Dataset.DATASET_KEY, form.getDatasetId()); + } + } + + public static class DeleteDatasetRowsForm + { + private int datasetId; + private boolean deleteAllData; + private Long _publishSourceId; + + public int getDatasetId() + { + return datasetId; + } + + public void setDatasetId(int datasetId) + { + this.datasetId = datasetId; + } + + public boolean isDeleteAllData() + { + return deleteAllData; + } + + public void setDeleteAllData(boolean deleteAllData) + { + this.deleteAllData = deleteAllData; + } + + public Long getPublishSourceId() + { + return _publishSourceId; + } + + public void setPublishSourceId(Long publishSourceId) + { + _publishSourceId = publishSourceId; + } + } + + // Dataset.canDelete() permissions check is below. This accommodates dataset security, where user might not have delete permission in the folder. + @RequiresPermission(ReadPermission.class) + public class DeleteDatasetRowsAction extends FormHandlerAction + { + @Override + public void validateCommand(DeleteDatasetRowsForm target, Errors errors) + { + } + + @Override + public boolean handlePost(DeleteDatasetRowsForm form, BindException errors) throws Exception + { + int datasetId = form.getDatasetId(); + StudyImpl study = getStudyThrowIfNull(); + StudyQuerySchema schema = StudyQuerySchema.createSchema(study, getUser()); + DatasetDefinition dataset = StudyManager.getInstance().getDatasetDefinition(study, datasetId); + TableInfo datasetTable = null==dataset ? null : schema.getDatasetTable(dataset, null); + + if (null == dataset || null == datasetTable) + throw new NotFoundException(); + + if (!datasetTable.hasPermission(getUser(), DeletePermission.class)) + throw new UnauthorizedException("User does not have permission to delete rows from this dataset"); + + // Operate on each individually for audit logging purposes, but transact the whole thing + DbScope scope = StudySchema.getInstance().getSchema().getScope(); + + try (DbScope.Transaction transaction = scope.ensureTransaction()) + { + Set lsids = DataRegionSelection.getSelected(getViewContext(), null, false); + List> keys = new ArrayList<>(lsids.size()); + for (String lsid : lsids) + keys.add(Collections.singletonMap("lsid", lsid)); + + QueryUpdateService qus = datasetTable.getUpdateService(); + assert qus != null; + + qus.deleteRows(getUser(), getContainer(), keys, null, null); + + transaction.commit(); + return true; + } + finally + { + DataRegionSelection.clearAll(getViewContext(), null); + } + } + + @Override + public ActionURL getSuccessURL(DeleteDatasetRowsForm form) + { + return new ActionURL(DatasetAction.class, getContainer()). + addParameter(Dataset.DATASET_KEY, form.getDatasetId()); + } + } + + public static class OverviewBean + { + public StudyImpl study; + public Map visitMapSummary; + public boolean showAll; + public boolean canManage; + public CohortFilter cohortFilter; + public boolean showCohorts; + public QCStateSet qcStates; + public Set stats; + public boolean showSpecimens; + } + + /** + * Tweak the link url for participant view so that it contains enough information to regenerate + * the cached list of participants. + */ + private void setColumnURL(final ActionURL url, final QueryView queryView, + final UserSchema querySchema, final Dataset def) + { + List columns; + try + { + columns = queryView.getDisplayColumns(); + } + catch (QueryParseException qpe) + { + return; + } + + // push any filter, sort params, and viewname + ActionURL base = new ActionURL(ParticipantAction.class, querySchema.getContainer()); + base.addParameter(Dataset.DATASET_KEY, Integer.toString(def.getDatasetId())); + for (Pair param : url.getParameters()) + { + if ((param.getKey().contains(".sort")) || + (param.getKey().contains("~")) || + (DATASET_VIEW_NAME_PARAMETER_NAME.equals(param.getKey()))) + { + base.addParameter(param.getKey(), param.getValue()); + } + } + base.addReturnUrl(url); // Set current URL so participant page can navigate back (nav trail) + + for (DisplayColumn col : columns) + { + String subjectColName = StudyService.get().getSubjectColumnName(def.getContainer()); + if (subjectColName.equalsIgnoreCase(col.getName())) + { + StringExpression old = col.getURLExpression(); + ContainerContext cc = old instanceof DetailsURL ? ((DetailsURL)old).getContainerContext() : null; + DetailsURL dets = new DetailsURL(base, "participantId", col.getColumnInfo().getFieldKey()); + dets.setContainerContext(null != cc ? cc : getContainer()); + col.setURLExpression(dets); + } + } + } + + public static ActionURL getProtocolDocumentDownloadURL(Container c, String name) + { + ActionURL url = new ActionURL(ProtocolDocumentDownloadAction.class, c); + url.addParameter("name", name); + + return url; + } + + @RequiresPermission(ReadPermission.class) + public class ProtocolDocumentDownloadAction extends BaseDownloadAction + { + @Override + public @Nullable Pair getAttachment(AttachmentForm form) + { + StudyImpl study = getStudyRedirectIfNull(); + return new Pair<>(study.getProtocolDocumentAttachmentParent(), form.getName()); + } + } + + private static final String PARTICIPANT_PROPS_CACHE = "Study_participants/propertyCache"; + private static final String DATASET_SORT_COLUMN_CACHE = "Study_participants/datasetSortColumnCache"; + @SuppressWarnings("unchecked") + private static Map> getParticipantPropsMap(ViewContext context) + { + HttpSession session = context.getRequest().getSession(true); + Map> map = (Map>) session.getAttribute(PARTICIPANT_PROPS_CACHE); + if (map == null) + { + map = new HashMap<>(); + session.setAttribute(PARTICIPANT_PROPS_CACHE, map); + } + return map; + } + + public static List getParticipantPropsFromCache(ViewContext context, String typeURI) + { + Map> map = getParticipantPropsMap(context); + List props = map.get(typeURI); + if (props == null) + { + props = OntologyManager.getPropertiesForType(typeURI, context.getContainer()); + map.put(typeURI, props); + } + return props; + } + + @SuppressWarnings("unchecked") + private static Map> getDatasetSortColumnMap(ViewContext context) + { + HttpSession session = context.getRequest().getSession(true); + Map> map = (Map>) session.getAttribute(DATASET_SORT_COLUMN_CACHE); + if (map == null) + { + map = new HashMap<>(); + session.setAttribute(DATASET_SORT_COLUMN_CACHE, map); + } + return map; + } + + public static @NotNull Map getSortedColumnList(ViewContext context, Dataset dsd) + { + Map> map = getDatasetSortColumnMap(context); + Map sortMap = map.get(dsd.getLabel()); + + if (sortMap == null) + { + QueryDefinition qd = QueryService.get().getQueryDef(context.getUser(), dsd.getContainer(), "study", dsd.getName()); + if (qd == null) + { + UserSchema schema = QueryService.get().getUserSchema(context.getUser(), context.getContainer(), "study"); + qd = schema.getQueryDefForTable(dsd.getName()); + } + CustomView cview = qd.getCustomView(context.getUser(), context.getRequest(), null); + if (cview != null) + { + sortMap = new HashMap<>(); + int i = 0; + for (FieldKey key : cview.getColumns()) + { + final String name = key.toString(); + if (!sortMap.containsKey(name)) + sortMap.put(name, i++); + } + map.put(dsd.getLabel(), sortMap); + } + else + { + // there is no custom view for this dataset + sortMap = Collections.emptyMap(); + map.put(dsd.getLabel(), Collections.emptyMap()); + } + } + return new CaseInsensitiveHashMap<>(sortMap); + } + + private static String getParticipantListCacheKey(ViewContext context) + { + // The query string includes all parameters that affect the participant list: dataset id, filters, sorts, etc. + // But need to strip off the participant ID parameter. + return context.cloneActionURL().deleteParameter("participantId").getQueryString(); + } + + public static void removeParticipantListFromSession(ViewContext context) + { + Cache> cache = getParticipantMapFromSession(context); + String key = getParticipantListCacheKey(context); + // Guava Cache doesn't tolerate null keys + if (key != null) + { + _log.debug("Invalidate participant list with key: {}", key); + cache.invalidate(key); + } + } + + @SuppressWarnings("unchecked") + private static Cache> getParticipantMapFromSession(ViewContext context) + { + HttpSession session = context.getRequest().getSession(true); + Cache> map = (Cache>) session.getAttribute(PARTICIPANT_CACHE_PREFIX); + if (map == null) + { + // Use a cache to limit the size (10) and to keep entries for no more than 10 minutes after last access + map = CacheBuilder.newBuilder().maximumSize(10).expireAfterAccess(10, TimeUnit.MINUTES).build(); + session.setAttribute(PARTICIPANT_CACHE_PREFIX, map); + } + return map; + } + + @SuppressWarnings("unchecked") + public static Map getExpandedState(ViewContext viewContext, int datasetId) + { + HttpSession session = viewContext.getRequest().getSession(true); + Map> map = (Map>) session.getAttribute(EXPAND_CONTAINERS_KEY); + if (map == null) + { + map = new HashMap<>(); + session.setAttribute(EXPAND_CONTAINERS_KEY, map); + } + + return map.computeIfAbsent(datasetId, k -> new HashMap<>()); + } + + public static @NotNull List getParticipantListFromSession(ViewContext context, int dataset, String viewName) + { + Cache> cache = getParticipantMapFromSession(context); + String key = getParticipantListCacheKey(context); + List ret = Collections.emptyList(); + + // Short-circuit for navigation from somewhere other than a dataset... esp. since Guava Cache doesn't tolerate null keys + if (null != key && dataset > 0) + { + try + { + ret = cache.get(key, () -> generateParticipantListFromURL(context, dataset, viewName)); + } + catch (ExecutionException ignored) + { + // Shouldn't ever happen since our loader doesn't throw exceptions + } + _log.debug("Get participant list of size {} with key: {}", ret.size(), key); + } + + return ret; + } + + private static List generateParticipantListFromURL(ViewContext context, int dataset, String viewName) + { + List ret; + try + { + final StudyManager studyMgr = StudyManager.getInstance(); + final StudyImpl study = studyMgr.getStudy(context.getContainer()); + + DatasetDefinition def = studyMgr.getDatasetDefinition(study, dataset); + if (null == def) + return Collections.emptyList(); + String typeURI = def.getTypeURI(); + if (null == typeURI) + return Collections.emptyList(); + + StudyQuerySchema querySchema = StudyQuerySchema.createSchema(study, context.getUser()); + QuerySettings qs = querySchema.getSettings(context, DatasetQueryView.DATAREGION, def.getName()); + qs.setViewName(viewName); + + QueryView queryView = querySchema.createView(context, qs, null); + + ret = generateParticipantList(queryView); + } + catch (Exception ignored) + { + ret = Collections.emptyList(); + } + + _log.debug("Generate participant list of size {}", ret.size()); + + return ret; + } + + public static List generateParticipantList(QueryView queryView) + { + final TableInfo table = queryView.getTable(); + + if (table != null) + { + try + { + // Do a single-column query to get the list of participants that match the filter criteria for this + // dataset + FieldKey ptidKey = FieldKey.fromParts(StudyService.get().getSubjectColumnName(queryView.getContainer())); + Map columns = QueryService.get().getColumns(table, Collections.singleton(ptidKey)); + ColumnInfo ptidColumnInfo = columns.get(ptidKey); + // Don't bother unless we actually found the participant column (we always should) + if (ptidColumnInfo != null) + { + // Go through the RenderContext directly to get the ResultSet so that we don't also end up calculating + // row counts or other aggregates we don't care about + DataView dataView = queryView.createDataView(); + RenderContext ctx = dataView.getRenderContext(); + DataRegion dataRegion = dataView.getDataRegion(); + queryView.getSettings().setShowRows(ShowRows.ALL); + try (Results results = ctx.getResults(columns, dataRegion.getDisplayColumns(), table, queryView.getSettings(), dataRegion.getQueryParameters(), Table.ALL_ROWS, dataRegion.getOffset(), dataRegion.getName(), false)) + { + int ptidIndex = ptidColumnInfo.findColumn(results); + + Set participantSet = new LinkedHashSet<>(); + while (results.next() && ptidIndex > 0) + { + String ptid = results.getString(ptidIndex); + participantSet.add(ptid); + } + + return new ArrayList<>(participantSet); + } + } + } + catch (Exception x) + { + throw new RuntimeException(x); + } + } + return Collections.emptyList(); + } + + public class ManageQCStatesBean extends AbstractManageQCStatesBean + { + ManageQCStatesBean(ActionURL returnUrl) + { + super(returnUrl); + _qcStateHandler = new StudyQCStateHandler(); + _manageAction = new ManageQCStatesAction(); + _deleteAction = DeleteQCStateAction.class; + _noun = "dataset"; + _dataNoun = "study"; + } + } + + public static class ManageQCStatesForm extends AbstractManageDataStatesForm + { + private Long _defaultPipelineQCState; + private Long _defaultPublishDataQCState; + private Long _defaultDirectEntryQCState; + private boolean _showPrivateDataByDefault; + + public Long getDefaultPipelineQCState() + { + return _defaultPipelineQCState; + } + + public void setDefaultPipelineQCState(Long defaultPipelineQCState) + { + _defaultPipelineQCState = defaultPipelineQCState; + } + + public Long getDefaultPublishDataQCState() + { + return _defaultPublishDataQCState; + } + + public void setDefaultPublishDataQCState(Long defaultPublishDataQCState) + { + _defaultPublishDataQCState = defaultPublishDataQCState; + } + + public Long getDefaultDirectEntryQCState() + { + return _defaultDirectEntryQCState; + } + + public void setDefaultDirectEntryQCState(Long defaultDirectEntryQCState) + { + _defaultDirectEntryQCState = defaultDirectEntryQCState; + } + + public boolean isShowPrivateDataByDefault() + { + return _showPrivateDataByDefault; + } + + public void setShowPrivateDataByDefault(boolean showPrivateDataByDefault) + { + _showPrivateDataByDefault = showPrivateDataByDefault; + } + } + + public static ActionURL getManageQCStatesURL(Container c, @NotNull ActionURL returnUrl) + { + return new ActionURL(ManageQCStatesAction.class, c).addReturnUrl(returnUrl); + } + + @RequiresPermission(AdminPermission.class) + public class ManageQCStatesAction extends AbstractManageQCStatesAction + { + public ManageQCStatesAction() + { + super(new StudyQCStateHandler(), ManageQCStatesForm.class); + } + + @Override + public ModelAndView getView(ManageQCStatesForm manageQCStatesForm, boolean reshow, BindException errors) + { + return new JspView<>("/org/labkey/api/qc/view/manageQCStates.jsp", + new ManageQCStatesBean(manageQCStatesForm.getReturnActionURL()), errors); + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("manageQC"); + _addManageStudy(root); + root.addChild("Manage Dataset QC States"); + } + + @Override + public URLHelper getSuccessURL(ManageQCStatesForm manageQCStatesForm) + { + ActionURL successUrl = getSuccessURL(manageQCStatesForm, ManageQCStatesAction.class, ManageStudyAction.class); + if (!manageQCStatesForm.isReshowPage() && !manageQCStatesForm.isShowPrivateDataByDefault()) + return getQCStateFilteredURL(successUrl, PUBLIC_STATES_LABEL, DATASET_DATAREGION_NAME, getContainer()); + + return successUrl; + } + + @Override + public boolean hasQcStateDefaultsPanel() + { + return true; + } + + @Override + public HtmlString getQcStateDefaultsPanel(Container container, DataStateHandler qcStateHandler) + { + _study = StudyController.getStudyThrowIfNull(container); + + HtmlStringBuilder panelHtml = HtmlStringBuilder.of(); + panelHtml.unsafeAppend(" "); + panelHtml.unsafeAppend(" "); + panelHtml.unsafeAppend(" "); + panelHtml.unsafeAppend(" "); + panelHtml.unsafeAppend(" "); + panelHtml.unsafeAppend(" "); + panelHtml.append(getQcStateHtml(container, qcStateHandler, "defaultPipelineQCState", _study.getDefaultPipelineQCState())); + panelHtml.unsafeAppend(" "); + panelHtml.unsafeAppend(" "); + panelHtml.unsafeAppend(" "); + panelHtml.append(getQcStateHtml(container, qcStateHandler, "defaultPublishDataQCState", _study.getDefaultPublishDataQCState())); + panelHtml.unsafeAppend(" "); + panelHtml.unsafeAppend(" "); + panelHtml.unsafeAppend(" "); + panelHtml.append(getQcStateHtml(container, qcStateHandler, "defaultDirectEntryQCState", _study.getDefaultDirectEntryQCState())); + panelHtml.unsafeAppend(" "); + panelHtml.unsafeAppend("
These settings allow different default QC states depending on data source."); + panelHtml.unsafeAppend(" If set, all imported data without an explicit QC state will have the selected state automatically assigned.
Pipeline imported datasets:
Data linked to this study:
Directly inserted/updated dataset data:
"); + + return panelHtml.getHtmlString(); + } + + @Override + public boolean hasDataVisibilityPanel() + { + return true; + } + + @Override + public HtmlString getDataVisibilityPanel(Container container, DataStateHandler qcStateHandler) + { + HtmlStringBuilder panelHtml = HtmlStringBuilder.of(); + panelHtml.unsafeAppend(" "); + panelHtml.unsafeAppend(" "); + panelHtml.unsafeAppend(" "); + panelHtml.unsafeAppend(" "); + panelHtml.unsafeAppend(" "); + panelHtml.unsafeAppend(" "); + panelHtml.unsafeAppend(" "); + panelHtml.unsafeAppend(" "); + panelHtml.unsafeAppend("
This setting determines whether users see non-public data by default."); + panelHtml.unsafeAppend(" Users can always explicitly choose to see data in any QC state.
Default visibility:"); + panelHtml.unsafeAppend(" "); + panelHtml.unsafeAppend("
"); + + return panelHtml.getHtmlString(); + } + + @Override + public boolean hasRequiresCommentPanel() + { + return false; + } + + @Override + public HtmlString getRequiresCommentPanel(Container container, DataStateHandler qcStateHandler) + { + throw new IllegalStateException("This action does not support a requires comment panel."); + } + } + + @RequiresPermission(AdminPermission.class) + public class DeleteQCStateAction extends AbstractDeleteDataStateAction + { + public DeleteQCStateAction() + { + super(); + _dataStateHandler = new StudyQCStateHandler(); + } + + @Override + public ActionURL getSuccessURL(DeleteDataStateForm form) + { + ActionURL returnUrl = new ActionURL(ManageQCStatesAction.class, getContainer()); + if (form.getManageReturnUrl() != null) + returnUrl.addParameter(ActionURL.Param.returnUrl, form.getManageReturnUrl()); + return returnUrl; + } + } + + public static class UpdateQCStateForm extends ReturnUrlForm + { + private String _comments; + private boolean _update; + private int _datasetId; + private String _dataRegionSelectionKey; + private Long _newState; + private DatasetQueryView _queryView; + private String _dataRegionName; + + public String getComments() + { + return _comments; + } + + public void setComments(String comments) + { + _comments = comments; + } + + public boolean isUpdate() + { + return _update; + } + + public void setUpdate(boolean update) + { + _update = update; + } + + public int getDatasetId() + { + return _datasetId; + } + + public void setDatasetId(int datasetId) + { + _datasetId = datasetId; + } + + public String getDataRegionSelectionKey() + { + return _dataRegionSelectionKey; + } + + public void setDataRegionSelectionKey(String dataRegionSelectionKey) + { + _dataRegionSelectionKey = dataRegionSelectionKey; + } + + public Long getNewState() + { + return _newState; + } + + public void setNewState(Long newState) + { + _newState = newState; + } + + public void setQueryView(DatasetQueryView queryView) + { + _queryView = queryView; + } + + public DatasetQueryView getQueryView() + { + return _queryView; + } + + public String getDataRegionName() + { + return _dataRegionName; + } + + public void setDataRegionName(String dataRegionName) + { + _dataRegionName = dataRegionName; + } + } + + @RequiresPermission(QCAnalystPermission.class) + public class UpdateQCStateAction extends FormViewAction + { + private UpdateQCStateForm _form; + + @Override + public void validateCommand(UpdateQCStateForm updateQCForm, Errors errors) + { + if (updateQCForm.isUpdate()) + { + if (updateQCForm.getComments() == null || updateQCForm.getComments().isEmpty()) + errors.reject(null, "Comments are required."); + } + } + + @Override + public ModelAndView getView(UpdateQCStateForm updateQCForm, boolean reshow, BindException errors) + { + StudyImpl study = getStudyRedirectIfNull(); + _form = updateQCForm; + int datasetId = updateQCForm.getDatasetId(); + DatasetDefinition def = StudyManager.getInstance().getDatasetDefinition(study, datasetId); + if (def == null) + { + throw new NotFoundException("No dataset found for id: " + datasetId); + } + Set lsids = null; + if (isPost()) + lsids = DataRegionSelection.getSelected(getViewContext(), updateQCForm.getDataRegionSelectionKey(), false); + if (lsids == null || lsids.isEmpty()) + return HtmlView.unsafe("No data rows selected. " + LinkBuilder.labkeyLink("back").onClick("back()")); + + StudyQuerySchema querySchema = StudyQuerySchema.createSchema(study, getUser()); + DatasetQuerySettings qs = new DatasetQuerySettings(getViewContext().getBindPropertyValues(), DatasetQueryView.DATAREGION); + + qs.setSchemaName(querySchema.getSchemaName()); + qs.setQueryName(def.getName()); + qs.setMaxRows(Table.ALL_ROWS); + qs.setShowSourceLinks(false); + qs.setShowEditLinks(false); + + final Set finalLsids = lsids; + + DatasetQueryView queryView = new DatasetQueryView(querySchema, qs, errors) + { + @Override + public DataView createDataView() + { + DataView view = super.createDataView(); + view.getDataRegion().setSortable(false); + view.getDataRegion().setShowFilters(false); + view.getDataRegion().setShowRecordSelectors(false); + view.getDataRegion().setShowPagination(false); + SimpleFilter filter = (SimpleFilter) view.getRenderContext().getBaseFilter(); + if (null == filter) + { + filter = new SimpleFilter(); + view.getRenderContext().setBaseFilter(filter); + } + filter.addInClause(FieldKey.fromParts("lsid"), new ArrayList<>(finalLsids)); + return view; + } + }; + queryView.setShowDetailsColumn(false); + updateQCForm.setQueryView(queryView); + updateQCForm.setDataRegionSelectionKey(DataRegionSelection.getSelectionKeyFromRequest(getViewContext())); + updateQCForm.setDataRegionName(queryView.getSettings().getDataRegionName()); + return new JspView<>("/org/labkey/study/view/updateQCState.jsp", updateQCForm, errors); + } + + @Override + public boolean handlePost(UpdateQCStateForm updateQCForm, BindException errors) + { + if (!updateQCForm.isUpdate()) + return false; + Set lsids = DataRegionSelection.getSelected(getViewContext(), updateQCForm.getDataRegionSelectionKey(), false); + + DataState newState = null; + if (updateQCForm.getNewState() != null) + { + newState = QCStateManager.getInstance().getStateForRowId(getContainer(), updateQCForm.getNewState()); + if (newState == null) + { + errors.reject(null, "The selected state could not be found. It may have been deleted from the database."); + return false; + } + } + StudyManager.getInstance().updateDataQCState(getContainer(), getUser(), + updateQCForm.getDatasetId(), lsids, newState, updateQCForm.getComments()); + + // if everything has succeeded, we can clear our saved checkbox state now: + DataRegionSelection.clearAll(getViewContext(), updateQCForm.getDataRegionSelectionKey()); + return true; + } + + @Override + public ActionURL getSuccessURL(UpdateQCStateForm updateQCForm) + { + ActionURL url = updateQCForm.getReturnActionURL(); + if (null == url) + { + // We've lost the returnUrl... at least redirect back to the dataset + url = new ActionURL(DatasetAction.class, getContainer()); + url.addParameter(Dataset.DATASET_KEY, updateQCForm.getDatasetId()); + } + if (updateQCForm.getNewState() != null) + url.replaceParameter(getQCUrlFilterKey(CompareType.EQUAL, updateQCForm.getDataRegionName()), QCStateManager.getInstance().getStateForRowId(getContainer(), updateQCForm.getNewState().longValue()).getLabel()); + return url; + } + + @Override + public void addNavTrail(NavTree root) + { + root = _addNavTrail(root, _form.getDatasetId(), _form.getReturnActionURL()); + root.addChild("Change QC State"); + } + } + + public static class ResetPipelinePathForm extends PipelinePathForm + { + private String _redirect; + + public String getRedirect() + { + return _redirect; + } + + public void setRedirect(String redirect) + { + _redirect = redirect; + } + } + + @RequiresPermission(AdminPermission.class) + public static class ResetPipelineAction extends FormHandlerAction + { + @Override + public void validateCommand(ResetPipelinePathForm form, Errors errors) + { + } + + @Override + public boolean handlePost(ResetPipelinePathForm form, BindException errors) throws Exception + { + for (FileLike f : form.getValidatedFiles(getContainer())) + { + if (f.isFile() && f.getName().endsWith(".lock")) + { + f.delete(); + } + } + return true; + } + + @Override + public URLHelper getSuccessURL(ResetPipelinePathForm form) + { + String redirect = form.getRedirect(); + if (null != redirect) + { + try + { + return new URLHelper(redirect); + } + catch (URISyntaxException e) + { + _log.warn("ResetPipelineAction redirect string invalid: " + redirect); + } + } + return urlProvider(PipelineStatusUrls.class).urlBegin(getContainer()); + } + } + + @RequiresPermission(ReadPermission.class) + public class DefaultDatasetReportAction extends SimpleRedirectAction + { + @Override + public ActionURL getRedirectURL(Object o) + { + ViewContext context = getViewContext(); //_study.isShowPrivateDataByDefault() + Object unparsedDatasetId = context.get(Dataset.DATASET_KEY); + + try + { + int datasetId = null == unparsedDatasetId ? 0 : Integer.parseInt(unparsedDatasetId.toString()); + + ActionURL url = context.cloneActionURL(); + url.setAction(DatasetReportAction.class); + + String defaultView = getDefaultView(context, datasetId); + if (!StringUtils.isEmpty(defaultView)) + { + ReportIdentifier reportId = ReportService.get().getReportIdentifier(defaultView, getViewContext().getUser(), getViewContext().getContainer()); + if (reportId != null) + url.addParameter(DATASET_REPORT_ID_PARAMETER_NAME, defaultView); + else + url.addParameter(DATASET_VIEW_NAME_PARAMETER_NAME, defaultView); + } + + if (!"1".equals(url.getParameter("skipDataVisibility"))) + { + StudyImpl studyImpl = StudyManager.getInstance().getStudy(getContainer()); + if (studyImpl != null && !studyImpl.isShowPrivateDataByDefault()) + url = getQCStateFilteredURL(url, PUBLIC_STATES_LABEL, DATASET_DATAREGION_NAME, getContainer()); + } + return url; + } + catch (NumberFormatException e) + { + throw new NotFoundException("No such dataset with ID: " + unparsedDatasetId); + } + } + } + + public static ActionURL getViewPreferencesURL(Container c, int id, String viewName) + { + // Issue 26030: we don't distinguish null vs empty string for url parameters. + // Empty string will be converted to null for beans so "" shouldn't be used as the url param for Default Grid View. + return new ActionURL(ViewPreferencesAction.class, c).addParameter(Dataset.DATASET_KEY, id).addParameter("defaultView", viewName != null ? (viewName.isEmpty() ? "defaultGrid": viewName) : null); + } + + public static class ViewPreferencesForm extends DatasetController.DatasetIdForm + { + private String _defaultView; + + public String getDefaultView() + { + return _defaultView; + } + + @SuppressWarnings("unused") + public void setDefaultView(String defaultView) + { + _defaultView = "defaultGrid".equals(defaultView) ? "" : defaultView; + } + } + + @RequiresPermission(ReadPermission.class) + @RequiresLogin // Don't set a default view for guests, Issue 52863 + public class ViewPreferencesAction extends FormViewAction + { + private StudyImpl _study; + private Dataset _def; + + private int init(ViewPreferencesForm form) + { + int dsid = form.getDatasetId(); + _study = getStudyRedirectIfNull(); + _def = StudyManager.getInstance().getDatasetDefinition(_study, dsid); + return dsid; + } + + @Override + public ModelAndView getView(ViewPreferencesForm form, boolean reshow, BindException errors) throws Exception + { + init(form); + if (_def != null) + { + List> views = ReportManager.get().getReportLabelsForDataset(getViewContext(), _def); + ViewPrefsBean bean = new ViewPrefsBean(views, _def); + return new StudyJspView<>(_study, "/org/labkey/study/view/viewPreferences.jsp", bean, errors); + } + throw new NotFoundException("Invalid dataset ID"); + } + + @Override + public boolean handlePost(ViewPreferencesForm form, BindException errors) throws Exception + { + int dsid = init(form); + String defaultView = form.getDefaultView(); + if ((_def != null) && (defaultView != null)) + { + setDefaultView(dsid, defaultView); + return true; + } + throw new NotFoundException("Invalid dataset ID"); + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("customViews"); + + root.addChild(_study.getLabel(), new ActionURL(BeginAction.class, getContainer())); + + ActionURL datasetURL = getViewContext().getActionURL().clone(); + datasetURL.setAction(DatasetAction.class); + + String label = _def.getLabel() != null ? _def.getLabel() : "" + _def.getDatasetId(); + root.addChild(new NavTree(label, datasetURL)); + + root.addChild(new NavTree("View Preferences")); + } + + @Override + public URLHelper getSuccessURL(ViewPreferencesForm viewPreferencesForm) { return null; } + + @Override + public void validateCommand(ViewPreferencesForm target, Errors errors) { } + } + + @RequiresPermission(AdminPermission.class) + public class ImportStudyBatchAction extends SimpleViewAction + { + private String path; + + @Override + public ModelAndView getView(PipelinePathForm form, BindException errors) throws Exception + { + Container c = getContainer(); + + File definitionFile = form.getValidatedSingleFile(c).toNioPathForRead().toFile(); + path = form.getPath(); + if (!path.endsWith("/")) + { + path += "/"; + } + path += definitionFile.getName(); + + if (!definitionFile.isFile()) + { + throw new NotFoundException(); + } + + File lockFile = StudyPipeline.lockForDataset(getStudyRedirectIfNull(), definitionFile); + + if (!definitionFile.canRead()) + errors.reject("importStudyBatch", "Can't read dataset file: " + path); + if (lockFile.exists()) + errors.reject("importStudyBatch", "Lock file exists. Delete file before running import. " + lockFile.getName()); + + VirtualFile datasetsDir = new FileSystemFile(definitionFile.getParentFile()); + DatasetFileReader reader = new DatasetFileReader(datasetsDir, definitionFile.getName(), getStudyRedirectIfNull()); + + if (!errors.hasErrors()) + { + List parseErrors = new ArrayList<>(); + reader.validate(parseErrors); + for (String error : parseErrors) + errors.reject("importStudyBatch", error); + } + + return new StudyJspView<>( + getStudyRedirectIfNull(), "/org/labkey/study/view/importStudyBatch.jsp", new ImportStudyBatchBean(reader, path), errors); + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild(getStudyRedirectIfNull().getLabel(), new ActionURL(StudyController.BeginAction.class, getContainer())); + root.addChild("Import Study Batch - " + path); + } + } + + @RequiresPermission(AdminPermission.class) + public class SubmitStudyBatchAction extends FormHandlerAction + { + private ActionURL _successUrl = null; + + @Override + public void validateCommand(PipelinePathForm target, Errors errors) + { + } + + @Override + public boolean handlePost(PipelinePathForm form, BindException errors) throws Exception + { + Study study = getStudyRedirectIfNull(); + Container c = getContainer(); + String path = form.getPath(); + File f = null; + + PipeRoot root = PipelineService.get().findPipelineRoot(c); + if (path != null) + { + if (root != null) + f = root.resolvePath(path); + } + + try + { + if (f != null) + { + VirtualFile datasetsDir = new FileSystemFile(f.getParentFile()); + DatasetImportUtils.submitStudyBatch(study, datasetsDir, f.getName(), c, getUser(), getViewContext().getActionURL(), root); + } + _successUrl = urlProvider(PipelineStatusUrls.class).urlBegin(getContainer()); + } + catch (DatasetImportUtils.DatasetLockExistsException e) + { + ActionURL importURL = new ActionURL(ImportStudyBatchAction.class, getContainer()); + importURL.addParameter("path", form.getPath()); + _successUrl = importURL; + } + + return true; + } + + @Override + public URLHelper getSuccessURL(PipelinePathForm pipelinePathForm) + { + return _successUrl; + } + } + + @RequiresPermission(ReadPermission.class) + public class TypeNotFoundAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object o, BindException errors) + { + return new StudyJspView(getStudyRedirectIfNull(), "/org/labkey/study/view/typeNotFound.jsp", null, errors); + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("Type Not Found"); + } + } + + @RequiresPermission(AdminPermission.class) + public class UpdateParticipantVisitsAction extends FormViewAction + { + private int _count; + + @Override + public void validateCommand(Object target, Errors errors) + { + } + + @Override + public ModelAndView getView(Object o, boolean reshow, BindException errors) throws Exception + { + if (reshow) + { + return HtmlView.unsafe( + "
" + _count + " rows were updated.

" + + PageFlowUtil.button("Done").href(new ActionURL(ManageVisitsAction.class, getContainer())) + + "

"); + } + else + { + return HtmlView.unsafe( + "
Click the button below to recalculate visit dates for all participants in this study.

" + + PageFlowUtil.button("Recalculate Visit Dates").href(new ActionURL(UpdateParticipantVisitsAction.class, getContainer())).submit(true) + + new CsrfInput(getViewContext()) + + "

"); + } + } + + @Override + public boolean handlePost(Object o, BindException errors) throws Exception + { + var vm = StudyManager.getInstance().getVisitManager(getStudyRedirectIfNull()); + if (vm instanceof SequenceVisitManager svm) + { + // This could be optimized by combining with updateParticipantVisits(). + // However, updateParticipantVisits() handles incremental updates and would need to be refactored a bit + // and this isn't a common code path. + svm.purgeParticipantVisit(getUser()); + } + vm.updateParticipantVisits(getUser(), getStudyRedirectIfNull().getDatasets()); + + TableInfo tinfoParticipantVisit = StudySchema.getInstance().getTableInfoParticipantVisit(); + _count = new SqlSelector(StudySchema.getInstance().getSchema(), + "SELECT COUNT(VisitDate) FROM " + tinfoParticipantVisit + "\nWHERE Container = ?", + getContainer()).getObject(Integer.class); + + return true; + } + + @Override + public URLHelper getSuccessURL(Object o) + { + return null; + } + + @Override + public void addNavTrail(NavTree root) + { + _addNavTrailVisitAdmin(root); + root.addChild("Recalculate Visit Dates"); + } + } + + @RequiresPermission(AdminPermission.class) + public class VisitOrderAction extends FormViewAction + { + @Override + public ModelAndView getView(VisitReorderForm reorderForm, boolean reshow, BindException errors) + { + StudyImpl study = getStudyRedirectIfNull(); + redirectToSharedVisitStudy(study, getViewContext().getActionURL()); + + return new StudyJspView(study, "/org/labkey/study/view/visitOrder.jsp", reorderForm, errors); + } + + @Override + public void addNavTrail(NavTree root) + { + _addNavTrailVisitAdmin(root); + root.addChild("Visit Order"); + } + + @Override + public void validateCommand(VisitReorderForm target, Errors errors) {} + + private Map getVisitIdToOrderIndex(String orderedIds) + { + Map order = null; + if (orderedIds != null && !orderedIds.isEmpty()) + { + order = new HashMap<>(); + String[] idArray = orderedIds.split(","); + for (int i = 0; i < idArray.length; i++) + { + int id = Integer.parseInt(idArray[i]); + // 1-index display orders, since 0 is the database default, and we'd like to know + // that these were set explicitly for all visits: + order.put(id, i + 1); + } + } + return order; + } + + private Map getVisitIdToZeroMap(Collection visits) + { + Map order = new IntHashMap<>(); + for (VisitImpl visit : visits) + order.put(visit.getRowId(), 0); + return order; + } + + @Override + public boolean handlePost(VisitReorderForm form, BindException errors) + { + StudyImpl study = getStudyRedirectIfNull(); + redirectToSharedVisitStudy(study, getViewContext().getActionURL()); + + Map displayOrder = null; + Map chronologicalOrder = null; + Collection visits = StudyManager.getInstance().getVisits(study, Visit.Order.SEQUENCE_NUM); + + if (form.isExplicitDisplayOrder()) + displayOrder = getVisitIdToOrderIndex(form.getDisplayOrder()); + if (displayOrder == null) + displayOrder = getVisitIdToZeroMap(visits); + + if (form.isExplicitChronologicalOrder()) + chronologicalOrder = getVisitIdToOrderIndex(form.getChronologicalOrder()); + if (chronologicalOrder == null) + chronologicalOrder = getVisitIdToZeroMap(visits); + + for (VisitImpl visit : visits) + { + // it's possible that a new visit has been created between when the update page was rendered + // and posted. This will result in a visit that isn't in our ID maps. There's no great way + // to handle this, so we'll just skip setting display/chronological order on these visits for now. + if (displayOrder.containsKey(visit.getRowId()) && chronologicalOrder.containsKey(visit.getRowId())) + { + int displayIndex = displayOrder.get(visit.getRowId()).intValue(); + int chronologicalIndex = chronologicalOrder.get(visit.getRowId()).intValue(); + + if (visit.getDisplayOrder() != displayIndex || visit.getChronologicalOrder() != chronologicalIndex) + { + visit = visit.createMutable(); + visit.setDisplayOrder(displayIndex); + visit.setChronologicalOrder(chronologicalIndex); + StudyManager.getInstance().updateVisit(getUser(), visit); + } + } + } + + // Changing visit order can cause cohort assignments to change when advanced cohort tracking is enabled: + if (study.isAdvancedCohorts()) + CohortManager.getInstance().updateParticipantCohorts(getUser(), study); + return true; + } + + @Override + public ActionURL getSuccessURL(VisitReorderForm reorderForm) + { + return reorderForm.getReturnActionURL(); + } + } + + @RequiresPermission(AdminPermission.class) + public class VisitVisibilityAction extends FormViewAction + { + @Override + public ModelAndView getView(VisitPropertyForm visitPropertyForm, boolean reshow, BindException errors) + { + StudyImpl study = getStudyRedirectIfNull(); + redirectToSharedVisitStudy(study, getViewContext().getActionURL()); + + return new StudyJspView(study, "/org/labkey/study/view/visitVisibility.jsp", visitPropertyForm, errors); + } + + @Override + public void addNavTrail(NavTree root) + { + _addNavTrailVisitAdmin(root); + root.addChild("Properties"); + } + + @Override + public void validateCommand(VisitPropertyForm target, Errors errors) {} + + @Override + public boolean handlePost(VisitPropertyForm form, BindException errors) + { + StudyImpl study = getStudyRedirectIfNull(); + redirectToSharedVisitStudy(study, getViewContext().getActionURL()); + + int[] allIds = form.getIds() == null ? new int[0] : form.getIds(); + int[] visibleIds = form.getVisible() == null ? new int[0] : form.getVisible(); + String[] labels = form.getLabel() == null ? new String[0] : form.getLabel(); + String[] typeStrs = form.getExtraData()== null ? new String[0] : form.getExtraData(); + + Set visible = new IntHashSet(visibleIds.length); + for (int id : visibleIds) + visible.add(id); + if (allIds.length != form.getLabel().length) + throw new IllegalStateException("Arrays must be the same length."); + for (int i = 0; i < allIds.length; i++) + { + VisitImpl def = StudyManager.getInstance().getVisitForRowId(study, allIds[i]); + boolean show = visible.contains(allIds[i]); + String label = (i < labels.length) ? labels[i] : null; + String typeStr = (i < typeStrs.length) ? typeStrs[i] : null; + + Integer cohortId = null; + if (form.getCohort() != null && form.getCohort()[i] != -1) + cohortId = form.getCohort()[i]; + Character type = typeStr != null && !typeStr.isEmpty() ? typeStr.charAt(0) : null; + if (def.isShowByDefault() != show || !nullSafeEqual(label, def.getLabel()) || type != def.getTypeCode() || !nullSafeEqual(cohortId, def.getCohortId())) + { + def = def.createMutable(); + def.setShowByDefault(show); + def.setLabel(label); + def.setCohortId(cohortId); + def.setTypeCode(type); + StudyManager.getInstance().updateVisit(getUser(), def); + } + } + return true; + } + + @Override + public ActionURL getSuccessURL(VisitPropertyForm visitPropertyForm) + { + return new ActionURL(ManageVisitsAction.class, getContainer()); + } + } + + @RequiresPermission(AdminPermission.class) + public class DatasetVisibilityAction extends FormViewAction + { + @Override + public ModelAndView getView(DatasetPropertyForm form, boolean reshow, BindException errors) + { + _study = getStudyRedirectIfNull(); + var sqs = StudyQuerySchema.createSchema(_study, getUser()); + Map bean = new IntHashMap<>(); + for (DatasetDefinition def : _study.getDatasets()) + { + DatasetVisibilityData data = new DatasetVisibilityData(); + data.label = def.getLabel(); + data.categoryId = def.getViewCategory() != null ? def.getViewCategory().getRowId() : null; + data.cohort = def.getCohortId(); + data.visible = def.isShowByDefault(); + data.shared = def.isShared(); + data.inherited = def.isInherited(); + data.status = (String)ReportPropsManager.get().getPropertyValue(def.getEntityId(), getContainer(), "status"); + if ("None".equals(data.status)) + data.status = null; + DatasetTable t = sqs.getDatasetTable(def, null); + if (null != t) + { + long rowCount = new TableSelector(t).getRowCount(); + data.rowCount = rowCount; + data.empty = 0 == rowCount; + } + bean.put(def.getDatasetId(), data); + } + + // Merge with form data + Map formDataset = form.getDataset(); + if (formDataset != null) + { + for (Map.Entry entry : formDataset.entrySet()) + { + DatasetVisibilityData formData = entry.getValue(); + DatasetVisibilityData beanData = bean.get(entry.getKey()); + if (formData == null || beanData == null) + continue; + + beanData.label = formData.label; + beanData.categoryId = formData.categoryId; + beanData.cohort = formData.cohort; + beanData.visible = formData.visible; + } + } + + return new StudyJspView<>( + getStudyRedirectIfNull(), "/org/labkey/study/view/datasetVisibility.jsp", bean, errors); + } + + @Override + public void addNavTrail(NavTree root) + { + _addManageStudy(root); + root.addChild("Manage Datasets", new ActionURL(ManageTypesAction.class, getContainer())); + root.addChild("Properties"); + } + + @Override + public void validateCommand(DatasetPropertyForm form, Errors errors) + { + // Check for bad labels + Set labels = new HashSet<>(); + for (DatasetVisibilityData data : form.getDataset().values()) + { + String label = data.getLabel(); + if (StringUtils.isBlank(label)) + { + errors.reject("datasetVisibility", "Label cannot be blank"); + } + if (labels.contains(label)) + { + errors.reject("datasetVisibility", "Labels must be unique. Found two or more labels called '" + label + "'."); + } + labels.add(label); + } + } + + @Override + public boolean handlePost(DatasetPropertyForm form, BindException errors) throws Exception + { + for (Map.Entry entry : form.getDataset().entrySet()) + { + Integer id = entry.getKey(); + DatasetVisibilityData data = entry.getValue(); + + if (id == null) + throw new IllegalArgumentException("id required"); + + DatasetDefinition def = StudyManager.getInstance().getDatasetDefinition(getStudyThrowIfNull(), id); + if (def == null) + throw new NotFoundException("dataset"); + + String label = data.getLabel(); + boolean show = data.isVisible(); + Integer categoryId = data.getCategoryId(); + Integer cohortId = data.getCohort(); + if (cohortId != null && cohortId.intValue() == -1) + cohortId = null; + + if (def.isShowByDefault() != show || !nullSafeEqual(categoryId, def.getCategoryId()) || !nullSafeEqual(label, def.getLabel()) || !BaseStudyController.nullSafeEqual(cohortId, def.getCohortId())) + { + def = def.createMutable(); + def.setShowByDefault(show); + def.setCategoryId(categoryId); + def.setCohortId(cohortId); + def.setLabel(label); + List saveErrors = new ArrayList<>(); + StudyManager.getInstance().updateDatasetDefinition(getUser(), def, saveErrors); + for (String error : saveErrors) + { + errors.reject(ERROR_MSG, error); + return false; + } + } + ReportPropsManager.get().setPropertyValue(def.getEntityId(), getContainer(), "status", data.getStatus()); + } + + return true; + } + + @Override + public ActionURL getSuccessURL(DatasetPropertyForm form) + { + return new ActionURL(ManageTypesAction.class, getContainer()); + } + } + + // Bean will be an map of these + public static class DatasetVisibilityData + { + // form POSTed values + public String label; + public Integer cohort; // null for none + public String status; + public Integer categoryId; + public boolean visible; + + // not form POSTed -- used to render view + public long rowCount; + public boolean empty; + public boolean shared; + public boolean inherited; + + public String getLabel() + { + return label; + } + + public void setLabel(String label) + { + this.label = label; + } + + public Integer getCohort() + { + return cohort; + } + + public void setCohort(Integer cohort) + { + this.cohort = cohort; + } + + public String getStatus() + { + return status; + } + + public void setStatus(String status) + { + this.status = status; + } + + public boolean isVisible() + { + return visible; + } + + public void setVisible(boolean visible) + { + this.visible = visible; + } + + public Integer getCategoryId() + { + return categoryId; + } + + public void setCategoryId(Integer categoryId) + { + this.categoryId = categoryId; + } + } + + @Marshal(Marshaller.Jackson) + @RequiresPermission(AdminPermission.class) + public static class DeleteDatasetPropertyOverrideAction extends MutatingApiAction + { + @Override + public Object execute(Object o, BindException errors) + { + StudyManager.getInstance().deleteDatasetPropertyOverrides(getUser(), getContainer(), errors); + return errors.hasErrors() ? null : success(); + } + } + + @RequiresPermission(AdminPermission.class) + public class DatasetDisplayOrderAction extends FormViewAction + { + @Override + public ModelAndView getView(DatasetReorderForm form, boolean reshow, BindException errors) + { + return new StudyJspView(getStudyRedirectIfNull(), "/org/labkey/study/view/datasetDisplayOrder.jsp", form, errors); + } + + @Override + public void addNavTrail(NavTree root) + { + _addManageStudy(root); + root.addChild("Manage Datasets", new ActionURL(ManageTypesAction.class, getContainer())); + root.addChild("Display Order"); + } + + @Override + public void validateCommand(DatasetReorderForm target, Errors errors) {} + + @Override + public boolean handlePost(DatasetReorderForm form, BindException errors) + { + String order = form.getOrder(); + + if (order != null && !order.isEmpty() && !form.isResetOrder()) + { + String[] ids = order.split(","); + List orderedIds = new ArrayList<>(ids.length); + + for (String id : ids) + orderedIds.add(Integer.parseInt(id)); + + DatasetReorderer reorderer = new DatasetReorderer(getStudyThrowIfNull(), getUser()); + reorderer.reorderDatasets(orderedIds); + } + else if (form.isResetOrder()) + { + DatasetReorderer reorderer = new DatasetReorderer(getStudyThrowIfNull(), getUser()); + reorderer.resetOrder(); + } + + return true; + } + + @Override + public ActionURL getSuccessURL(DatasetReorderForm visitPropertyForm) + { + return new ActionURL(ManageTypesAction.class, getContainer()); + } + } + + + @RequiresPermission(AdminPermission.class) + public class DeleteDatasetAction extends FormHandlerAction + { + @Override + public void validateCommand(IdForm target, Errors errors) + { + + } + + @Override + public boolean handlePost(IdForm form, BindException errors) throws Exception + { + Study study = getStudyRedirectIfNull(getContainer()); + + DatasetDefinition ds = StudyManager.getInstance().getDatasetDefinition(study, form.getId()); + if (null == ds) + redirectTypeNotFound(form.getId()); + if (!ds.canDeleteDefinition(getUser())) + errors.reject(ERROR_MSG, "Can't delete this dataset: " + ds.getName()); + + if (errors.hasErrors()) + return false; + + DbScope scope = StudySchema.getInstance().getSchema().getScope(); + try (DbScope.Transaction transaction = scope.ensureTransaction()) + { + // performStudyResync==false so we can do this out of the transaction + StudyManager.getInstance().deleteDataset(getStudyRedirectIfNull(), getUser(), ds, false, null); + transaction.commit(); + } + + StudyManager.getInstance().getVisitManager(study).updateParticipantVisits(getUser(), Collections.emptySet()); + return true; + } + + @Override + public URLHelper getSuccessURL(IdForm idForm) + { + throw new RedirectException(new ActionURL(ManageTypesAction.class, getContainer())); + } + } + + + private static final String DEFAULT_PARTICIPANT_VIEW_SOURCE = + """ +
Loading...
+ + + /* Adjust width of first column: */ + """; + + public static class CustomizeParticipantViewForm extends ReturnUrlForm + { + private String _customScript; + private String _participantId; + private boolean _useCustomView; + private boolean _reshow; + private boolean _editable = true; + + public boolean isEditable() + { + return _editable; + } + + public void setEditable(boolean editable) + { + _editable = editable; + } + + public String getCustomScript() + { + return _customScript; + } + + public String getDefaultScript() + { + return DEFAULT_PARTICIPANT_VIEW_SOURCE; + } + + public void setCustomScript(String customScript) + { + _customScript = customScript; + } + + public String getParticipantId() + { + return _participantId; + } + + public void setParticipantId(String participantId) + { + _participantId = participantId; + } + + public boolean isReshow() + { + return _reshow; + } + + public void setReshow(boolean reshow) + { + _reshow = reshow; + } + + public boolean isUseCustomView() + { + return _useCustomView; + } + + public void setUseCustomView(boolean useCustomView) + { + _useCustomView = useCustomView; + } + } + + @RequiresAllOf({AdminPermission.class, BrowserDeveloperPermission.class}) + public class CustomizeParticipantViewAction extends FormViewAction + { + @Override + public void validateCommand(CustomizeParticipantViewForm target, Errors errors) + { + } + + @Override + public ModelAndView getView(CustomizeParticipantViewForm form, boolean reshow, BindException errors) + { + Study study = getStudyRedirectIfNull(); + CustomParticipantView view = StudyManager.getInstance().getCustomParticipantView(study); + if (view != null) + { + form.setCustomScript(view.getBody()); + form.setUseCustomView(view.isActive()); + form.setEditable(!view.isModuleParticipantView()); + } + + return new JspView<>("/org/labkey/study/view/customizeParticipantView.jsp", form); + } + + @Override + public boolean handlePost(CustomizeParticipantViewForm form, BindException errors) + { + Study study = getStudyThrowIfNull(); + CustomParticipantView view = StudyManager.getInstance().getCustomParticipantView(study); + if (view == null) + view = new CustomParticipantView(); + view.setBody(form.getCustomScript()); + view.setActive(form.isUseCustomView()); + view = StudyManager.getInstance().saveCustomParticipantView(study, getUser(), view); + return view != null; + } + + @Override + public ActionURL getSuccessURL(CustomizeParticipantViewForm form) + { + if (form.isReshow()) + { + ActionURL reshowURL = new ActionURL(CustomizeParticipantViewAction.class, getContainer()); + if (form.getParticipantId() != null && !form.getParticipantId().isEmpty()) + reshowURL.addParameter("participantId", form.getParticipantId()); + if (form.getReturnUrl() != null && !form.getReturnUrl().isEmpty()) + reshowURL.addParameter(ActionURL.Param.returnUrl, form.getReturnUrl()); + return reshowURL; + } + else if (form.getReturnUrl() != null && !form.getReturnUrl().isEmpty()) + return new ActionURL(form.getReturnUrl()); + else + return urlProvider(ReportUrls.class).urlManageViews(getContainer()); + } + + @Override + public void addNavTrail(NavTree root) + { + _addManageStudy(root); + root.addChild("Manage Views", urlProvider(ReportUrls.class).urlManageViews(getContainer())); + root.addChild("Customize " + StudyService.get().getSubjectNounSingular(getContainer()) + " View"); + } + } + + public static class StudySnapshotForm extends QuerySnapshotForm + { + private int _snapshotDatasetId = -1; + private String _action; + private Boolean _queryDataset; + + public static final String EDIT_DATASET = "editDataset"; + public static final String CREATE_SNAPSHOT = "createSnapshot"; + public static final String CANCEL = "cancel"; + + public int getSnapshotDatasetId() + { + return _snapshotDatasetId; + } + + public void setSnapshotDatasetId(int snapshotDatasetId) + { + _snapshotDatasetId = snapshotDatasetId; + } + + public Boolean getQueryDataset() + { + return _queryDataset; + } + + public void setQueryDataset(Boolean queryDataset) + { + _queryDataset = queryDataset; + } + + public String getAction() + { + return _action; + } + + public void setAction(String action) + { + _action = action; + } + } + + @RequiresPermission(AdminPermission.class) + public static class CreateSnapshotAction extends FormViewAction + { + ActionURL _successURL; + + @Override + public void validateCommand(StudySnapshotForm form, Errors errors) + { + if (StudySnapshotForm.CANCEL.equals(form.getAction())) + return; + + Study study = StudyManager.getInstance().getStudy(getContainer()); + if (null == study) + throw new NotFoundException("No study in this folder"); + + if (form.getQueryDataset() != null) + { + if (study.getTimepointType() != TimepointType.CONTINUOUS) + { + errors.reject("snapshotQuery.error", "Query based snapshot is only available for continuous studies"); + } + else + { + TableInfo ti = QueryService.get().getUserSchema(getUser(), getContainer(), form.getSchemaName()).getTable(form.getQueryName()); + Set colNames = ti.getColumns().stream().map(ColumnInfo::getName).collect(LabKeyCollectors.toCaseInsensitiveHashSet()); + + List notFound = Arrays.stream(QueryDatasetTable.REQUIRED_COLUMNS) + .filter(value -> !colNames.contains(value)) + .toList(); + + if (!notFound.isEmpty()) + errors.reject("snapshotQuery.error", "The source query is missing the following required columns for a query backed dataset: " + String.join(", ", notFound)); + } + } + + String name = StringUtils.trimToNull(form.getSnapshotName()); + + if (name != null) + { + QuerySnapshotDefinition def = QueryService.get().getSnapshotDef(getContainer(), form.getSchemaName(), name); + if (def != null) + { + errors.reject("snapshotQuery.error", "A Snapshot with the same name already exists"); + return; + } + + // check for a dataset with the same label/name unless it's one that we created + Dataset dataset = StudyManager.getInstance().getDatasetDefinitionByQueryName(study, name); + if (dataset != null) + { + if (dataset.getDatasetId() != form.getSnapshotDatasetId()) + errors.reject("snapshotQuery.error", "A Dataset with the same name/label already exists"); + } + } + else + errors.reject("snapshotQuery.error", "The Query Snapshot name cannot be blank"); + } + + @Override + public ModelAndView getView(StudySnapshotForm form, boolean reshow, BindException errors) + { + if (!reshow || errors.hasErrors()) + { + ActionURL url = getViewContext().getActionURL(); + + if (StringUtils.isEmpty(form.getSnapshotName())) + form.setSnapshotName(url.getParameter("ff_snapshotName")); + form.setUpdateDelay(NumberUtils.toInt(url.getParameter("ff_updateDelay"))); + form.setSnapshotDatasetId(NumberUtils.toInt(url.getParameter("ff_snapshotDatasetId"), -1)); + + return new JspView("/org/labkey/study/view/createDatasetSnapshot.jsp", form, errors); + } + else if (StudySnapshotForm.EDIT_DATASET.equals(form.getAction())) + { + throw new NotFoundException("Unable to edit the created dataset definition."); + } + return null; + } + + private void deletePreviousDatasetDefinition(StudySnapshotForm form) + { + if (form.getSnapshotDatasetId() != -1) + { + StudyImpl study = StudyManager.getInstance().getStudy(getContainer()); + + // a dataset definition was edited previously, but under a different name, need to delete the old one + DatasetDefinition dsDef = StudyManager.getInstance().getDatasetDefinition(study, form.getSnapshotDatasetId()); + if (dsDef != null) + { + StudyManager.getInstance().deleteDataset(study, getUser(), dsDef, true, null); + form.setSnapshotDatasetId(-1); + } + } + } + + private Dataset createDataset(StudySnapshotForm form, BindException errors) throws Exception + { + StudyImpl study = StudyManager.getInstance().getStudy(getContainer()); + Dataset dsDef = StudyManager.getInstance().getDatasetDefinitionByName(study, form.getSnapshotName()); + + if (dsDef == null) + { + deletePreviousDatasetDefinition(form); + + // if this snapshot is being created from an existing dataset, copy key field settings + int datasetId = NumberUtils.toInt(getViewContext().getActionURL().getParameter(Dataset.DATASET_KEY), -1); + String additionalKey = null; + DatasetDefinition.KeyManagementType keyManagementType = KeyManagementType.None; + boolean isDemographicData = false; + boolean useTimeKeyField = false; + List columnsToProvision = new ArrayList<>(); + + if (datasetId != -1) + { + DatasetDefinition sourceDef = study.getDataset(datasetId); + if (sourceDef != null) + { + additionalKey = sourceDef.getKeyPropertyName(); + keyManagementType = sourceDef.getKeyManagementType(); + isDemographicData = sourceDef.isDemographicData(); + useTimeKeyField = sourceDef.getUseTimeKeyField(); + + // make sure we provision any managed key fields + if ((additionalKey != null) && (keyManagementType != KeyManagementType.None)) + { + TableInfo sourceTable = sourceDef.getTableInfo(getUser()); + ColumnInfo col = sourceTable.getColumn(FieldKey.fromParts(additionalKey)); + if (col != null) + columnsToProvision.add(col); + } + } + } + + DatasetDefinition.Builder builder = new DatasetDefinition.Builder(form.getSnapshotName()) + .setStudy(study) + .setKeyPropertyName(additionalKey) + .setDemographicData(isDemographicData) + .setUseTimeKeyField(useTimeKeyField); + + + if (Boolean.TRUE.equals(form.getQueryDataset())) + { + builder.setSourceQueryName(form.getQueryName()) + .setSourceQuerySchema(form.getSchemaName()) + .setSourceQueryContainer(getContainer()) + .setKeyPropertyName("Key"); + } + + DatasetDefinition def = StudyPublishManager.getInstance().createDataset(getUser(), builder); + + form.setSnapshotDatasetId(def.getDatasetId()); + if (keyManagementType != KeyManagementType.None) + { + def = def.createMutable(); + def.setKeyManagementType(keyManagementType); + + StudyManager.getInstance().updateDatasetDefinition(getUser(), def); + } + + // NOTE getDisplayColumns() indirectly causes a query of the datasets, + // Do this before provisionTable() so we don't query the dataset we are about to create + // causes a problem on postgres (bug 11153) + for (DisplayColumn dc : QuerySnapshotService.get(form.getSchemaName()).getDisplayColumns(form, errors)) + { + ColumnInfo col = dc.getColumnInfo(); + if (col != null && !DatasetDefinition.isDefaultFieldName(col.getName(), study)) + columnsToProvision.add(col); + } + + // def may not be provisioned yet, create before we start adding properties + if (def.isQueryDataset()) + { + def.provisionQueryDataset(true); + } + else + { + def.provisionTable(true); + } + + Domain d = def.getDomain(true); + + for (ColumnInfo col : columnsToProvision) + { + DatasetSnapshotProvider.addAsDomainProperty(d, col); + } + d.save(getUser()); + + return def; + } + + return dsDef; + } + + @Override + public boolean handlePost(StudySnapshotForm form, BindException errors) throws Exception + { + DbSchema schema = StudySchema.getInstance().getSchema(); + + try (DbScope.Transaction transaction = schema.getScope().ensureTransaction()) + { + if (StudySnapshotForm.EDIT_DATASET.equals(form.getAction())) + { + Dataset def = createDataset(form, errors); + if (!errors.hasErrors() && def != null) + { + ActionURL returnUrl = getViewContext().cloneActionURL() + .replaceParameter("ff_snapshotName", form.getSnapshotName()) + .replaceParameter("ff_updateDelay", form.getUpdateDelay()) + .replaceParameter("ff_snapshotDatasetId", form.getSnapshotDatasetId()); + + _successURL = new ActionURL(StudyController.EditTypeAction.class, getContainer()) + .addParameter("datasetId", def.getDatasetId()) + .addReturnUrl(returnUrl); + } + } + else if (StudySnapshotForm.CREATE_SNAPSHOT.equals(form.getAction())) + { + Dataset def = createDataset(form, errors); + if (!errors.hasErrors()) + if (Boolean.TRUE.equals(form.getQueryDataset())) + { + _successURL = new ActionURL(StudyController.DatasetAction.class, getContainer()). + addParameter(Dataset.DATASET_KEY, def.getDatasetId()); + } + else + { + _successURL = QuerySnapshotService.get(form.getSchemaName()).createSnapshot(form, errors); + } + } + else if (StudySnapshotForm.CANCEL.equals(form.getAction())) + { + deletePreviousDatasetDefinition(form); + String redirect = getViewContext().getActionURL().getParameter(ActionURL.Param.redirectUrl); + if (redirect != null) + _successURL = new ActionURL(PageFlowUtil.decode(redirect)); + } + + if (!errors.hasErrors()) + transaction.commit(); + } + + return !errors.hasErrors(); + } + + @Override + public ActionURL getSuccessURL(StudySnapshotForm queryForm) + { + return _successURL; + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("querySnapshot"); + root.addChild("Create Query Snapshot"); + } + } + + /** + * Provides a view to update study query snapshots. Since query snapshots are implemented as datasets, the + * dataset properties editor can be shown in this view. + */ + @RequiresPermission(AdminPermission.class) + public static class EditSnapshotAction extends FormViewAction + { + ActionURL _successURL; + + @Override + public void validateCommand(StudySnapshotForm form, Errors errors) + { + } + + @Override + public ModelAndView getView(StudySnapshotForm form, boolean reshow, BindException errors) throws Exception + { + form.setEdit(true); + if (!reshow) + form.init(QueryService.get().getSnapshotDef(getContainer(), form.getSchemaName(), form.getSnapshotName()), getUser()); + + VBox box = new VBox(); + + QuerySnapshotService.Provider provider = QuerySnapshotService.get(form.getSchemaName()); + if (provider != null) + { + box.addView(new JspView("/org/labkey/study/view/editSnapshot.jsp", form)); + box.addView(new JspView("/org/labkey/study/view/createDatasetSnapshot.jsp", form, errors)); + + boolean showHistory = BooleanUtils.toBoolean(getViewContext().getActionURL().getParameter("showHistory")); + if (showHistory) + { + HttpView historyView = provider.createAuditView(form, errors); + if (historyView != null) + box.addView(historyView); + } + } + return box; + } + + @Override + public boolean handlePost(StudySnapshotForm form, BindException errors) throws Exception + { + if (StudySnapshotForm.CANCEL.equals(form.getAction())) + { + String redirect = getViewContext().getActionURL().getParameter(ActionURL.Param.redirectUrl); + if (redirect != null) + _successURL = new ActionURL(PageFlowUtil.decode(redirect)); + } + else if (form.isUpdateSnapshot()) + { + _successURL = QuerySnapshotService.get(form.getSchemaName()).updateSnapshot(form, errors); + + return !errors.hasErrors(); + } + else + { + QuerySnapshotDefinition def = QueryService.get().getSnapshotDef(getContainer(), form.getSchemaName(), form.getSnapshotName()); + if (def != null) + { + def.setUpdateDelay(form.getUpdateDelay()); + _successURL = QuerySnapshotService.get(form.getSchemaName()).updateSnapshotDefinition(getViewContext(), def, errors); + return !errors.hasErrors(); + } + else + { + errors.reject("snapshotQuery.error", "Unable to create QuerySnapshotDefinition"); + return false; + } + } + return true; + } + + @Override + public ActionURL getSuccessURL(StudySnapshotForm form) + { + return _successURL; + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("Edit Query Snapshot"); + } + } + + public static class DatasetPropertyForm implements HasAllowBindParameter + { + private Map _map = MapUtils.lazyMap(new IntHashMap<>(), FactoryUtils.instantiateFactory(DatasetVisibilityData.class)); + + public Map getDataset() + { + return _map; + } + + public void setDataset(Map map) + { + _map = map; + } + + private static final Pattern pat = Pattern.compile("dataset\\[(\\d*)]\\.(\\w*)"); + + @Override + public Predicate allowBindParameter() + { + return (name) -> + { + if (name.startsWith(SpringActionController.FIELD_MARKER)) + name = name.substring(SpringActionController.FIELD_MARKER.length()); + if (HasAllowBindParameter.getDefaultPredicate().test(name)) + return true; + return pat.matcher(name).matches(); + }; + } + } + + public static class RequirePipelineView extends StudyJspView + { + public RequirePipelineView(StudyImpl study, boolean showGoBack, BindException errors) + { + super(study, "/org/labkey/study/view/requirePipeline.jsp", showGoBack, errors); + } + } + + public static class VisitPropertyForm extends PropertyForm + { + private int[] _ids; + private int[] _visible; + + public int[] getIds() + { + return _ids; + } + + public void setIds(int[] ids) + { + _ids = ids; + } + + public int[] getVisible() + { + return _visible; + } + + public void setVisible(int[] visible) + { + _visible = visible; + } + } + + public abstract static class PropertyForm + { + private String[] _label; + private String[] _extraData; + private int[] _cohort; + + public String[] getExtraData() + { + return _extraData; + } + + public void setExtraData(String[] extraData) + { + _extraData = extraData; + } + + public String[] getLabel() + { + return _label; + } + + public void setLabel(String[] label) + { + _label = label; + } + + public int[] getCohort() + { + return _cohort; + } + + public void setCohort(int[] cohort) + { + _cohort = cohort; + } + } + + + public static class DatasetReorderForm + { + private String order; + private boolean resetOrder = false; + + public String getOrder() {return order;} + + public void setOrder(String order) {this.order = order;} + + public boolean isResetOrder() + { + return resetOrder; + } + + public void setResetOrder(boolean resetOrder) + { + this.resetOrder = resetOrder; + } + } + + public static class VisitReorderForm extends ReturnUrlForm + { + private boolean _explicitDisplayOrder; + private boolean _explicitChronologicalOrder; + private String _displayOrder; + private String _chronologicalOrder; + + public String getDisplayOrder() + { + return _displayOrder; + } + + public void setDisplayOrder(String displayOrder) + { + _displayOrder = displayOrder; + } + + public String getChronologicalOrder() + { + return _chronologicalOrder; + } + + public void setChronologicalOrder(String chronologicalOrder) + { + _chronologicalOrder = chronologicalOrder; + } + + public boolean isExplicitDisplayOrder() + { + return _explicitDisplayOrder; + } + + public void setExplicitDisplayOrder(boolean explicitDisplayOrder) + { + _explicitDisplayOrder = explicitDisplayOrder; + } + + public boolean isExplicitChronologicalOrder() + { + return _explicitChronologicalOrder; + } + + public void setExplicitChronologicalOrder(boolean explicitChronologicalOrder) + { + _explicitChronologicalOrder = explicitChronologicalOrder; + } + } + + public static class ImportStudyBatchBean + { + private final DatasetFileReader reader; + private final String path; + + public ImportStudyBatchBean(DatasetFileReader reader, String path) + { + this.reader = reader; + this.path = path; + } + + public DatasetFileReader getReader() + { + return reader; + } + + public String getPath() + { + return path; + } + } + + public static class ViewPrefsBean + { + private final List> _views; + private final Dataset _def; + + public ViewPrefsBean(List> views, Dataset def) + { + _views = views; + _def = def; + } + + public List> getViews(){return _views;} + public Dataset getDatasetDefinition(){return _def;} + } + + + private static final String DEFAULT_DATASET_VIEW = "Study.defaultDatasetView"; + + public static String getDefaultView(ViewContext context, int datasetId) + { + User user = context.getUser(); + // Don't return a default view for guests, Issue 52863 + if (!user.isGuest()) + { + Map viewMap = PropertyManager.getProperties(user, context.getContainer(), DEFAULT_DATASET_VIEW); + + final String key = Integer.toString(datasetId); + if (viewMap.containsKey(key)) + { + return viewMap.get(key); + } + } + return ""; + } + + private void setDefaultView(int datasetId, String view) + { + User user = getUser(); + if (user.isGuest()) + throw new IllegalStateException("Can't set a default view for guests"); + WritablePropertyMap viewMap = PropertyManager.getWritableProperties(user, getContainer(), DEFAULT_DATASET_VIEW, true); + + viewMap.put(Integer.toString(datasetId), view); + viewMap.save(); + } + + private String getVisitLabel() + { + StudyImpl study = getStudy(); + if (study != null) + { + return StudyManager.getInstance().getVisitManager(getStudyRedirectIfNull()).getLabel(); + } + return "Visit"; + } + + + private String getVisitLabelPlural() + { + StudyImpl study = getStudy(); + if (study != null) + { + return StudyManager.getInstance().getVisitManager(getStudyRedirectIfNull()).getPluralLabel(); + } + return "Visits"; + } + + public static class ParticipantForm extends ViewForm implements StudyManager.ParticipantViewConfig + { + private String participantId; + private int datasetId; + private double sequenceNum; + private String action; + private Map aliases; + + @Override + public String getParticipantId(){return participantId;} + + public void setParticipantId(String participantId) + { + this.participantId = participantId; + aliases = StudyManager.getInstance().getAliasMap(StudyManager.getInstance().getStudy(getContainer()), getUser(), participantId); + } + + @Override + public Map getAliases() + { + return null == aliases ? Map.of() : aliases; + } + + @Override + public int getDatasetId(){return datasetId;} + public void setDatasetId(int datasetId){this.datasetId = datasetId;} + + public double getSequenceNum(){return sequenceNum;} + public void setSequenceNum(double sequenceNum){this.sequenceNum = sequenceNum;} + + public String getAction(){return action;} + public void setAction(String action){this.action = action;} + } + + public static class StudyPropertiesForm extends ReturnUrlForm + { + private String _label; + private TimepointType _timepointType; + private Date _startDate; + private Date _endDate; + private SecurityType _securityType; + private String _subjectNounSingular = "Participant"; + private String _subjectNounPlural = "Participants"; + private String _subjectColumnName = "ParticipantId"; + private String _assayPlan; + private String _description; + private String _descriptionRendererType; + private String _grant; + private String _investigator; + private String _species; + private int _defaultTimepointDuration = 0; + private String _alternateIdPrefix; + private int _alternateIdDigits; + private boolean _allowReqLocRepository = true; + private boolean _allowReqLocClinic = true; + private boolean _allowReqLocSal = true; + private boolean _allowReqLocEndpoint = true; + private boolean _shareDatasets = false; + private boolean _shareVisits = false; + private boolean _failForUndefinedTimepoints; + + public String getLabel() + { + return _label; + } + + public void setLabel(String label) + { + _label = label; + } + + public TimepointType getTimepointType() + { + return _timepointType; + } + + public void setTimepointType(TimepointType timepointType) + { + _timepointType = timepointType; + } + + public Date getStartDate() + { + return _startDate; + } + + public void setStartDate(Date startDate) + { + _startDate = startDate; + } + + public void setSecurityString(String security) + { + _securityType = SecurityType.valueOf(security); + } + + public String getSecurityString() + { + return _securityType == null ? null : _securityType.name(); + } + + public void setSecurityType(SecurityType securityType) + { + _securityType = securityType; + } + + public SecurityType getSecurityType() + { + return _securityType; + } + + public String getSubjectNounSingular() + { + return _subjectNounSingular; + } + + public void setSubjectNounSingular(String subjectNounSingular) + { + _subjectNounSingular = subjectNounSingular; + } + + public String getSubjectNounPlural() + { + return _subjectNounPlural; + } + + public void setSubjectNounPlural(String subjectNounPlural) + { + _subjectNounPlural = subjectNounPlural; + } + + public String getSubjectColumnName() + { + return _subjectColumnName; + } + + public void setSubjectColumnName(String subjectColumnName) + { + _subjectColumnName = subjectColumnName; + } + + public String getDescription() + { + return _description; + } + + public void setDescription(String description) + { + _description = description; + } + + public String getDescriptionRendererType() + { + return _descriptionRendererType; + } + + public void setDescriptionRendererType(String descriptionRendererType) + { + _descriptionRendererType = descriptionRendererType; + } + + public String getInvestigator() + { + return _investigator; + } + + public void setInvestigator(String investigator) + { + _investigator = investigator; + } + + public String getGrant() + { + return _grant; + } + + public void setGrant(String grant) + { + _grant = grant; + } + + public int getDefaultTimepointDuration() + { + return _defaultTimepointDuration; + } + + public void setDefaultTimepointDuration(int defaultTimepointDuration) + { + _defaultTimepointDuration = defaultTimepointDuration; + } + + public String getAlternateIdPrefix() + { + return _alternateIdPrefix; + } + + public void setAlternateIdPrefix(String alternateIdPrefix) + { + _alternateIdPrefix = alternateIdPrefix; + } + + public int getAlternateIdDigits() + { + return _alternateIdDigits; + } + + public void setAlternateIdDigits(int alternateIdDigits) + { + _alternateIdDigits = alternateIdDigits; + } + + public boolean isAllowReqLocRepository() + { + return _allowReqLocRepository; + } + + public void setAllowReqLocRepository(boolean allowReqLocRepository) + { + _allowReqLocRepository = allowReqLocRepository; + } + + public boolean isAllowReqLocClinic() + { + return _allowReqLocClinic; + } + + public void setAllowReqLocClinic(boolean allowReqLocClinic) + { + _allowReqLocClinic = allowReqLocClinic; + } + + public boolean isAllowReqLocSal() + { + return _allowReqLocSal; + } + + public void setAllowReqLocSal(boolean allowReqLocSal) + { + _allowReqLocSal = allowReqLocSal; + } + + public boolean isAllowReqLocEndpoint() + { + return _allowReqLocEndpoint; + } + + public void setAllowReqLocEndpoint(boolean allowReqLocEndpoint) + { + _allowReqLocEndpoint = allowReqLocEndpoint; + } + + public Date getEndDate() + { + return _endDate; + } + + public void setEndDate(Date endDate) + { + _endDate = endDate; + } + + public String getAssayPlan() + { + return _assayPlan; + } + + public void setAssayPlan(String assayPlan) + { + _assayPlan = assayPlan; + } + + public String getSpecies() + { + return _species; + } + + public void setSpecies(String species) + { + _species = species; + } + + public boolean isShareDatasets() + { + return _shareDatasets; + } + + public void setShareDatasets(boolean shareDatasets) + { + _shareDatasets = shareDatasets; + } + + public boolean isShareVisits() + { + return _shareVisits; + } + + public void setShareVisits(boolean shareDatasets) + { + _shareVisits = shareDatasets; + } + + public boolean isFailForUndefinedTimepoints() + { + return _failForUndefinedTimepoints; + } + + public void setFailForUndefinedTimepoints(boolean failForUndefinedTimepoints) + { + _failForUndefinedTimepoints = failForUndefinedTimepoints; + } + } + + public static class IdForm + { + private int _id; + + public int getId() {return _id;} + + public void setId(int id) {_id = id;} + } + + public static class SourceLsidForm + { + private String _sourceLsid; + + public String getSourceLsid() {return _sourceLsid;} + + public void setSourceLsid(String sourceLsid) {_sourceLsid = sourceLsid;} + } + + /** + * Adds next and prev buttons to the participant view + */ + public static class ParticipantNavView extends HttpView + { + private final ActionURL _prevURL; + private final ActionURL _nextURL; + private final String _display; + private final String _currentParticipantId; + private boolean _showCustomizeLink = true; + + public ParticipantNavView(ActionURL prevURL, ActionURL nextURL, String currentParticipantId, String display) + { + _prevURL = prevURL; + _nextURL = nextURL; + _display = display; + _currentParticipantId = currentParticipantId; + } + + @Override + protected void renderInternal(Object model, PrintWriter out) + { + Container c = getViewContext().getContainer(); + User user = getViewContext().getUser(); + + String subjectNoun = PageFlowUtil.filter(StudyService.get().getSubjectNounSingular(getViewContext().getContainer())); + out.print("
"); + if (_prevURL != null) + { + LinkBuilder.labkeyLink("Previous " + subjectNoun, _prevURL).appendTo(out); + out.print(" "); + } + + if (_nextURL != null) + { + LinkBuilder.labkeyLink("Next " + subjectNoun, _nextURL).appendTo(out); + out.print(" "); + } + + SearchService ss = SearchService.get(); + + if (null != _currentParticipantId) + { + ActionURL search = urlProvider(SearchUrls.class).getSearchURL(c, "+" + ss.escapeTerm(_currentParticipantId)); + LinkBuilder.labkeyLink("Search for '" + id(_currentParticipantId, c, user) + "'", search).appendTo(out); + out.print(" "); + } + + // Show customize link to site admins (who are always developers) and folder admins who are developers: + Set> permissions = new HashSet<>(); + permissions.add(AdminPermission.class); + permissions.add(PlatformDeveloperPermission.class); + if (_showCustomizeLink && c.hasPermissions(getViewContext().getUser(), permissions)) + { + ActionURL customizeURL = new ActionURL(CustomizeParticipantViewAction.class, c); + customizeURL.addReturnUrl(getViewContext().getActionURL()); + customizeURL.addParameter("participantId", _currentParticipantId); + out.print(""); + LinkBuilder.labkeyLink("Customize View", customizeURL).appendTo(out); + } + + if (_display != null) + { + out.print(""); + out.print(PageFlowUtil.filter(_display)); + } + out.print("
"); + } + + public void setShowCustomizeLink(boolean showCustomizeLink) + { + _showCustomizeLink = showCustomizeLink; + } + } + + public static class ImportDatasetForm + { + private int datasetId = 0; + private String typeURI; + private String tsv; + private String keys; + private String _participantId; + private String _sequenceNum; + private String _name; + private QueryUpdateService.InsertOption _insertOption = QueryUpdateService.InsertOption.IMPORT; + + public int getDatasetId() + { + return datasetId; + } + + public void setDatasetId(int datasetId) + { + this.datasetId = datasetId; + } + + public String getTsv() + { + return tsv; + } + + public void setTsv(String tsv) + { + this.tsv = tsv; + } + + public String getKeys() + { + return keys; + } + + public void setKeys(String keys) + { + this.keys = keys; + } + + public String getTypeURI() + { + return typeURI; + } + + public void setTypeURI(String typeURI) + { + this.typeURI = typeURI; + } + + public String getParticipantId() + { + return _participantId; + } + + public void setParticipantId(String participantId) + { + _participantId = participantId; + } + + public String getSequenceNum() + { + return _sequenceNum; + } + + public void setSequenceNum(String sequenceNum) + { + _sequenceNum = sequenceNum; + } + + public String getName() + { + return _name; + } + + public void setName(String name) + { + _name = name; + } + + public QueryUpdateService.InsertOption getInsertOption() + { + return _insertOption; + } + + public void setInsertOption(QueryUpdateService.InsertOption insertOption) + { + _insertOption = insertOption; + } + } + + public static class DatasetForm + { + private String _name; + private String _label; + private Integer _datasetId; + private String _category; + private boolean _showByDefault; + private String _visitDatePropertyName; + private String[] _visitStatus; + private int[] _visitRowIds; + private String _description; + private Integer _cohortId; + private boolean _demographicData; + private boolean _create; + + public boolean isShowByDefault() + { + return _showByDefault; + } + + public void setShowByDefault(boolean showByDefault) + { + _showByDefault = showByDefault; + } + + public String getCategory() + { + return _category; + } + + public void setCategory(String category) + { + _category = category; + } + + public String getDatasetIdStr() + { + return _datasetId > 0 ? String.valueOf(_datasetId) : ""; + } + + /** + * Don't blow up when posting bad value + */ + public void setDatasetIdStr(String datasetIdStr) + { + try + { + if (null == StringUtils.trimToNull(datasetIdStr)) + _datasetId = 0; + else + _datasetId = Integer.parseInt(datasetIdStr); + } + catch (Exception x) + { + _datasetId = 0; + } + } + + public Integer getDatasetId() + { + return _datasetId; + } + + public void setDatasetId(Integer datasetId) + { + _datasetId = datasetId; + } + + public String getLabel() + { + return _label; + } + + public void setLabel(String label) + { + _label = label; + } + + public String getName() + { + return _name; + } + + public void setName(String name) + { + _name = name; + } + + public String[] getVisitStatus() + { + return _visitStatus; + } + + public void setVisitStatus(String[] visitStatus) + { + _visitStatus = visitStatus; + } + + public int[] getVisitRowIds() + { + return _visitRowIds; + } + + public void setVisitRowIds(int[] visitIds) + { + _visitRowIds = visitIds; + } + + public String getVisitDatePropertyName() + { + return _visitDatePropertyName; + } + + public void setVisitDatePropertyName(String visitDatePropertyName) + { + _visitDatePropertyName = visitDatePropertyName; + } + + public String getDescription() + { + return _description; + } + + public void setDescription(String description) + { + _description = description; + } + + public boolean isDemographicData() + { + return _demographicData; + } + + public void setDemographicData(boolean demographicData) + { + _demographicData = demographicData; + } + + public boolean isCreate() + { + return _create; + } + + public void setCreate(boolean create) + { + _create = create; + } + + public Integer getCohortId() + { + return _cohortId; + } + + public void setCohortId(Integer cohortId) + { + _cohortId = cohortId; + } + } + + @RequiresPermission(ReadPermission.class) + public class DatasetsAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object o, BindException errors) throws Exception + { + return StudyModule.datasetsPartFactory.getWebPartView(getViewContext(), StudyModule.datasetsPartFactory.createWebPart()); + } + + @Override + public void addNavTrail(NavTree root) + { + _addNavTrail(root); + root.addChild("Datasets"); + } + } + + @RequiresPermission(ReadPermission.class) + public static class ViewDataAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object o, BindException errors) throws Exception + { + return new VBox( + StudyModule.reportsPartFactory.getWebPartView(getViewContext(), StudyModule.reportsPartFactory.createWebPart()), + StudyModule.datasetsPartFactory.getWebPartView(getViewContext(), StudyModule.datasetsPartFactory.createWebPart()) + ); + } + + @Override + public void addNavTrail(NavTree root) + { + } + } + + private static class DatasetDetailRedirectForm extends ReturnUrlForm + { + private String _datasetId; + private String _lsid; + + public String getDatasetId() + { + return _datasetId; + } + + public void setDatasetId(String datasetId) + { + _datasetId = datasetId; + } + + public String getLsid() + { + return _lsid; + } + + public void setLsid(String lsid) + { + _lsid = lsid; + } + } + + @RequiresPermission(AdminPermission.class) + public class ManageExternalReloadAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object form, BindException errors) + { + return new StudyJspView<>(getStudyRedirectIfNull(), "/org/labkey/study/view/manageExternalReload.jsp", form, errors); + } + + @Override + public void addNavTrail(NavTree root) + { + _addManageStudy(root); + root.addChild("Manage External Reloading"); + } + } + + @RequiresPermission(ReadPermission.class) + public static class DatasetDetailRedirectAction extends SimpleRedirectAction + { + @Override + public URLHelper getRedirectURL(DatasetDetailRedirectForm form) + { + StudyImpl study = StudyManager.getInstance().getStudy(getContainer()); + if (study == null) + { + throw new NotFoundException("No study found"); + } + // First try the dataset id as an entityid + DatasetDefinition dataset = StudyManager.getInstance().getDatasetDefinitionByEntityId(study, form.getDatasetId()); + if (dataset == null) + { + try + { + // Then try the dataset id as an integer + int id = Integer.parseInt(form.getDatasetId()); + dataset = StudyManager.getInstance().getDatasetDefinition(study, id); + } + catch (NumberFormatException ignored) {} + + if (dataset == null) + { + throw new NotFoundException("Could not find dataset " + form.getDatasetId()); + } + } + + if (form.getLsid() == null) + { + throw new NotFoundException("No LSID specified"); + } + + StudyQuerySchema schema = StudyQuerySchema.createSchema(study, getUser()); + + QueryDefinition queryDef = QueryService.get().createQueryDefForTable(schema, dataset.getName()); + assert queryDef != null : "Dataset was found but couldn't get a corresponding TableInfo"; + + ActionURL url = queryDef.urlFor(QueryAction.detailsQueryRow, getContainer(), Collections.singletonMap("lsid", form.getLsid())); + String referrer = getViewContext().getRequest().getHeader("Referer"); + if (referrer != null) + { + url.addParameter(ActionURL.Param.returnUrl, referrer); + } + + return url; + } + } + + public static class ImportVisitMapForm + { + private String _content; + + public String getContent() + { + return _content; + } + + public void setContent(String content) + { + _content = content; + } + } + + @RequiresPermission(AdminPermission.class) + public class DemoModeAction extends FormViewAction + { + @Override + public URLHelper getSuccessURL(DemoModeForm form) + { + return null; + } + + @Override + public void validateCommand(DemoModeForm form, Errors errors) + { + } + + @Override + public ModelAndView getView(DemoModeForm form, boolean reshow, BindException errors) + { + return new JspView<>("/org/labkey/study/view/demoMode.jsp"); + } + + @Override + public boolean handlePost(DemoModeForm form, BindException errors) + { + DemoMode.setDemoMode(getContainer(), getUser(), form.getMode()); + return false; // Reshow page + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("demoMode"); + _addManageStudy(root); + root.addChild("Demo Mode"); + } + } + + + public static class DemoModeForm + { + private boolean mode; + + public boolean getMode() + { + return mode; + } + + public void setMode(boolean mode) + { + this.mode = mode; + } + } + + + @RequiresPermission(AdminPermission.class) + public class ShowVisitImportMappingAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object o, BindException errors) + { + return new JspView<>("/org/labkey/study/view/visitImportMapping.jsp", new ImportMappingBean(getStudyRedirectIfNull())); + } + + @Override + public void addNavTrail(NavTree root) + { + _addNavTrailVisitAdmin(root); + root.addChild("Visit Import Mapping"); + } + } + + + public static class ImportMappingBean + { + private final Collection _customMapping; + private final Collection _standardMapping; + + public ImportMappingBean(Study study) + { + _customMapping = StudyManager.getInstance().getCustomVisitImportMapping(study); + _standardMapping = StudyManager.getInstance().getStandardVisitImportMapping(study); + } + + public Collection getCustomMapping() + { + return _customMapping; + } + + public Collection getStandardMapping() + { + return _standardMapping; + } + } + + + @RequiresPermission(AdminPermission.class) + public class ImportVisitAliasesAction extends FormViewAction + { + @Override + public URLHelper getSuccessURL(VisitAliasesForm form) + { + return new ActionURL(ShowVisitImportMappingAction.class, getContainer()); + } + + @Override + public void validateCommand(VisitAliasesForm form, Errors errors) + { + } + + @Override + public ModelAndView getView(VisitAliasesForm form, boolean reshow, BindException errors) + { + getPageConfig().setFocusId("tsv"); + return new JspView<>("/org/labkey/study/view/importVisitAliases.jsp", null, errors); + } + + @Override + public boolean handlePost(VisitAliasesForm form, BindException errors) + { + boolean hadCustomMapping = !StudyManager.getInstance().getCustomVisitImportMapping(getStudyThrowIfNull()).isEmpty(); + + try + { + String tsv = form.getTsv(); + + if (null == tsv) + { + errors.reject(ERROR_MSG, "Please insert tab-separated data with two columns, Name and SequenceNum"); + return false; + } + + StudyManager.getInstance().importVisitAliases(getStudyThrowIfNull(), getUser(), new TabLoader(form.getTsv(), true)); + } + catch (RuntimeSQLException e) + { + if (e.isConstraintException()) + { + errors.reject(ERROR_MSG, "The visit import mapping includes duplicate visit names: " + e.getMessage()); + return false; + } + else + { + throw e; + } + } + catch (ValidationException e) + { + errors.reject(ERROR_MSG, e.getMessage()); + return false; + } + + // TODO: Change to audit log + _log.info("The visit import custom mapping was " + (hadCustomMapping ? "replaced" : "imported")); + + return true; + } + + @Override + public void addNavTrail(NavTree root) + { + _addNavTrailVisitAdmin(root); + root.addChild("Import Visit Aliases"); + } + } + + + public static class VisitAliasesForm + { + private String _tsv; + + public String getTsv() + { + return _tsv; + } + + @SuppressWarnings({"UnusedDeclaration"}) + public void setTsv(String tsv) + { + _tsv = tsv; + } + } + + + @RequiresPermission(AdminPermission.class) + public class ClearVisitAliasesAction extends ConfirmAction + { + @Override + public ModelAndView getConfirmView(Object o, BindException errors) + { + if (getPageConfig().getTitle() == null) + setTitle("Clear Custom Mapping"); + + return HtmlView.of("Are you sure you want to delete the visit import custom mapping for this study?"); + } + + @Override + public boolean handlePost(Object o, BindException errors) + { + StudyManager.getInstance().clearVisitAliases(getStudyThrowIfNull()); + // TODO: Change to audit log + _log.info("The visit import custom mapping was cleared"); + + return true; + } + + @Override + public void validateCommand(Object o, Errors errors) + { + } + + @Override + public @NotNull URLHelper getSuccessURL(Object o) + { + return new ActionURL(ShowVisitImportMappingAction.class, getContainer()); + } + } + + @RequiresPermission(ReadPermission.class) @RequiresLogin + public class ManageParticipantCategoriesAction extends SimpleViewAction + { + @Override + public ModelAndView getView(SentGroupForm form, BindException errors) + { + // if the user is viewing a sent participant group, remove any notifications related to it + if (form.getGroupId() != null) + { + NotificationService.get().removeNotifications(getContainer(), form.getGroupId().toString(), + Collections.singletonList(ParticipantCategory.SEND_PARTICIPANT_GROUP_TYPE), getUser().getUserId()); + } + + return new JspView<>("/org/labkey/study/view/manageParticipantCategories.jsp"); + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("participantGroups"); + _addManageStudy(root); + root.addChild("Manage " + getStudyRedirectIfNull().getSubjectNounSingular() + " Groups"); + } + } + + public static class SentGroupForm + { + private Integer _groupId; + + public Integer getGroupId() + { + return _groupId; + } + + public void setGroupId(Integer groupId) + { + _groupId = groupId; + } + } + + @RequiresLogin @RequiresPermission(ReadPermission.class) + public class SendParticipantGroupAction extends FormViewAction + { + List _validRecipients = new ArrayList<>(); + + @Override + public URLHelper getSuccessURL(SendParticipantGroupForm form) + { + return form.getReturnActionURL(form.getDefaultUrl(getContainer())); + } + + @Override + public ModelAndView getView(SendParticipantGroupForm form, boolean reshow, BindException errors) + { + if (form.getRowId() == null) + { + return HtmlView.err("No participant group RowId provided."); + } + else + { + ParticipantGroup group = ParticipantGroupManager.getInstance().getParticipantGroup(getContainer(), getUser(), form.getRowId()); + if (group != null) + { + ParticipantCategoryImpl category = ParticipantGroupManager.getInstance().getParticipantCategory(getContainer(), getUser(), group.getCategoryId()); + if (category != null && category.canRead(getContainer(), getUser())) + { + form.setLabel(group.getLabel()); + return new JspView<>("/org/labkey/study/view/sendParticipantGroup.jsp", form, errors); + } + } + + return HtmlView.err("Could not find participant group for RowId " + form.getRowId() + " or you do not have permission to read it."); + } + } + + @Override + public void validateCommand(SendParticipantGroupForm form, Errors errors) + { + _validRecipients = SecurityManager.parseRecipientListForContainer(getContainer(), form.getRecipientList(), errors); + } + + @Override + public boolean handlePost(SendParticipantGroupForm form, BindException errors) throws Exception + { + if (!errors.hasErrors() && !_validRecipients.isEmpty()) + { + for (User recipient : _validRecipients) + { + NotificationService.get().sendMessageForRecipient( + getContainer(), getUser(), recipient, + form.getMessageSubject(), form.getMessageBody(), form.getSendGroupUrl(getContainer()), + form.getRowId().toString(), ParticipantCategory.SEND_PARTICIPANT_GROUP_TYPE + ); + + String auditMsg = "The following participant group was shared: recipient: " + recipient.getName() + " (" + recipient.getUserId() + ")" + + ", groupId: " + form.getRowId() + ", name: " + form.getLabel(); + StudyService.get().addStudyAuditEvent(getContainer(), getUser(), auditMsg); + } + } + + return !errors.hasErrors(); + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("participantGroups"); + String manageGroupsTitle = "Manage " + getStudyRedirectIfNull().getSubjectNounSingular() + " Groups"; + root.addChild(manageGroupsTitle, new ActionURL(ManageParticipantCategoriesAction.class, getContainer())); + root.addChild("Send Participant Group"); + } + } + + public static class SendParticipantGroupForm extends ReturnUrlForm + { + private Integer _rowId; + private String _label; + private String _recipientList; + private String _messageSubject; + private String _messageBody; + + public Integer getRowId() + { + return _rowId; + } + + public void setRowId(Integer rowId) + { + _rowId = rowId; + } + + public String getLabel() + { + return _label; + } + + public void setLabel(String label) + { + _label = label; + } + + public String getRecipientList() + { + return _recipientList; + } + + public void setRecipientList(String recipientList) + { + _recipientList = recipientList; + } + + public String getMessageSubject() + { + return _messageSubject; + } + + public void setMessageSubject(String messageSubject) + { + _messageSubject = messageSubject; + } + + public String getMessageBody() + { + return _messageBody; + } + + public void setMessageBody(String messageBody) + { + _messageBody = messageBody; + } + + public ActionURL getDefaultUrl(Container container) + { + return new ActionURL(ManageParticipantCategoriesAction.class, container); + } + + public ActionURL getSendGroupUrl(Container container) + { + ActionURL sendGroupUrl = getReturnActionURL(getDefaultUrl(container)); + sendGroupUrl.addParameter("groupId", getRowId()); + return sendGroupUrl; + } + } + + @RequiresPermission(AdminPermission.class) + public class ManageParticipantsAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object form, BindException errors) + { + ChangeAlternateIdsForm changeAlternateIdsForm = getChangeAlternateIdForm(getStudyRedirectIfNull()); + return new JspView<>("/org/labkey/study/view/manageParticipants.jsp", changeAlternateIdsForm); + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("alternateIDs"); + _addManageStudy(root); + String pluralNoun = getStudyRedirectIfNull().getSubjectNounPlural(); + root.addChild("Manage " + pluralNoun, new ActionURL(ManageParticipantsAction.class, getContainer())); + } + } + + @RequiresPermission(AdminPermission.class) + public class MergeParticipantsAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object form, BindException errors) + { + return new JspView<>("/org/labkey/study/view/mergeParticipants.jsp"); + } + + @Override + public void addNavTrail(NavTree root) + { + // Add Manage Participants nav trail + ManageParticipantsAction manageParticipantsAction = new ManageParticipantsAction(); + manageParticipantsAction.setViewContext(getViewContext()); + manageParticipantsAction.setPageConfig(new PageConfig(getViewContext().getRequest())); + manageParticipantsAction.addNavTrail(root); + + String subjectColumnName = getStudyRedirectIfNull().getSubjectColumnName(); + root.addChild("Change or Merge " + subjectColumnName + "s"); + } + } + + @RequiresPermission(ReadPermission.class) + public static class SubjectListAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object o, BindException errors) + { + return new SubjectsWebPart(getViewContext(), true, 0); + } + + @Override + public void addNavTrail(NavTree root) + { + } + } + + @RequiresPermission(ReadPermission.class) + public static class BrowseStudyScheduleAction extends MutatingApiAction + { + @Override + public ApiResponse execute(BrowseStudyForm browseDataForm, BindException errors) throws Exception + { + ApiSimpleResponse response = new ApiSimpleResponse(); + StudyManager manager = StudyManager.getInstance(); + Study study = manager.getStudy(getContainer()); + StudySchedule schedule = new StudySchedule(); + CohortImpl cohort = null; + + if (browseDataForm.getCohortId() != null) + { + cohort = manager.getCohortForRowId(getContainer(), getUser(), browseDataForm.getCohortId()); + } + + if (cohort == null && browseDataForm.getCohortLabel() != null) + { + cohort = manager.getCohortByLabel(getContainer(), getUser(), browseDataForm.getCohortLabel()); + } + + if (study != null) + { + schedule.setVisits(manager.getVisits(study, cohort, getUser(), Visit.Order.DISPLAY)); + schedule.setDatasets( + manager.getDatasetDefinitions(study, cohort, Dataset.TYPE_STANDARD, Dataset.TYPE_PLACEHOLDER), + DataViewService.get().getViews(getViewContext(), Collections.singletonList(DatasetViewProvider.TYPE))); + + response.put("schedule", schedule.toJSON(getUser())); + response.put("success", true); + + return response; + } + else + throw new IllegalStateException("A study does not exist in this folder"); + } + } + + @RequiresPermission(ReadPermission.class) + public static class GetStudyTimepointsAction extends MutatingApiAction + { + @Override + public ApiResponse execute(BrowseStudyForm browseDataForm, BindException errors) + { + ApiSimpleResponse response = new ApiSimpleResponse(); + StudyManager manager = StudyManager.getInstance(); + Study study = manager.getStudy(getContainer()); + StudySchedule schedule = new StudySchedule(); + CohortImpl cohort = null; + + if (browseDataForm.getCohortId() != null) + { + cohort = manager.getCohortForRowId(getContainer(), getUser(), browseDataForm.getCohortId()); + } + + if (cohort == null && browseDataForm.getCohortLabel() != null) + { + cohort = manager.getCohortByLabel(getContainer(), getUser(), browseDataForm.getCohortLabel()); + } + + if (study != null) + { + schedule.setVisits(manager.getVisits(study, cohort, getUser(), Visit.Order.DISPLAY)); + + response.put("schedule", schedule.toJSON(getUser())); + response.put("success", true); + + return response; + } + else + throw new IllegalStateException("A study does not exist in this folder"); + } + } + + @RequiresPermission(AdminPermission.class) + public static class UpdateStudyScheduleAction extends MutatingApiAction + { + @Override + public void validateForm(StudySchedule form, Errors errors) + { + if (form.getSchedule().size() <= 0) + errors.reject(ERROR_MSG, "No study schedule records have been specified"); + } + + @Override + public ApiResponse execute(StudySchedule form, BindException errors) + { + ApiSimpleResponse response = new ApiSimpleResponse(); + Study study = StudyManager.getInstance().getStudy(getContainer()); + + if (study != null) + { + for (Map.Entry> entry : form.getSchedule().entrySet()) + { + Dataset ds = StudyService.get().getDataset(getContainer(), entry.getKey()); + if (ds != null) + { + for (VisitDataset visit : entry.getValue()) + { + VisitDatasetType type = visit.isRequired() ? VisitDatasetType.REQUIRED : VisitDatasetType.NOT_ASSOCIATED; + + StudyManager.getInstance().updateVisitDatasetMapping(getUser(), getContainer(), + visit.getVisitRowId(), ds.getDatasetId(), type); + } + } + } + response.put("success", true); + + return response; + } + else + throw new IllegalStateException("A study does not exist in this folder"); + } + } + + public static class BrowseStudyForm + { + private Integer _cohortId; + private String _cohortLabel; + + public Integer getCohortId() + { + return _cohortId; + } + + public void setCohortId(Integer cohortId) + { + _cohortId = cohortId; + } + + public String getCohortLabel() + { + return _cohortLabel; + } + + public void setCohortLabel(String cohortLabel) + { + _cohortLabel = cohortLabel; + } + } + + @RequiresPermission(AdminPermission.class) + public class DefineDatasetAction extends MutatingApiAction + { + private StudyImpl _study; + + @Override + public void validateForm(DefineDatasetForm form, Errors errors) + { + _study = StudyManager.getInstance().getStudy(getContainer()); + + if (_study != null) + { + switch (form.getType()) + { + case defineManually: + case placeHolder: + if (StringUtils.isEmpty(form.getName())) + errors.reject(ERROR_MSG, "A Dataset name must be specified."); + else if (StudyManager.getInstance().getDatasetDefinitionByName(_study, form.getName()) != null) + errors.reject(ERROR_MSG, "A Dataset named: " + form.getName() + " already exists in this folder."); + break; + + case linkToTarget: + if (form.getExpectationDataset() == null || form.getTargetDataset() == null) + errors.reject(ERROR_MSG, "An expectation Dataset and target Dataset must be specified."); + break; + + case linkManually: + if (form.getExpectationDataset() == null) + errors.reject(ERROR_MSG, "An expectation Dataset must be specified."); + break; + } + } + else + errors.reject(ERROR_MSG, "A study does not exist in this folder"); + } + + @Override + public ApiResponse execute(DefineDatasetForm form, BindException errors) + { + ApiSimpleResponse response = new ApiSimpleResponse(); + DatasetDefinition def; + + DbScope scope = StudySchema.getInstance().getSchema().getScope(); + + try (DbScope.Transaction transaction = scope.ensureTransaction()) + { + Integer categoryId = null; + + if (form.getCategory() != null) + { + ViewCategory category = ViewCategoryManager.getInstance().ensureViewCategory(getContainer(), getUser(), form.getCategory().getLabel()); + categoryId = category.getRowId(); + } + + switch (form.getType()) + { + case defineManually: + { + def = StudyPublishManager.getInstance().createDataset(getUser(), new DatasetDefinition.Builder(form.getName()) + .setStudy(_study) + .setDemographicData(false) + .setCategoryId(categoryId)); + def.provisionTable(false); + + ActionURL redirect = new ActionURL(EditTypeAction.class, getContainer()).addParameter(Dataset.DATASET_KEY, def.getDatasetId()); + response.put("redirectUrl", redirect.getLocalURIString()); + break; + } + case placeHolder: + def = StudyPublishManager.getInstance().createDataset(getUser(), new DatasetDefinition.Builder(form.getName()) + .setStudy(_study) + .setDemographicData(false) + .setType(Dataset.TYPE_PLACEHOLDER) + .setCategoryId(categoryId)); + def.provisionTable(false); + response.put("datasetId", def.getDatasetId()); + break; + + case linkManually: + def = StudyManager.getInstance().getDatasetDefinition(_study, form.getExpectationDataset()); + if (def != null) + { + def = def.createMutable(); + + def.setType(Dataset.TYPE_STANDARD); + def.save(getUser()); + + // add a cancel url to rollback either the manual link or import from file link + ActionURL cancelURL = new ActionURL(CancelDefineDatasetAction.class, getContainer()).addParameter("expectationDataset", form.getExpectationDataset()); + + ActionURL redirect = new ActionURL(EditTypeAction.class, getContainer()).addParameter(Dataset.DATASET_KEY, form.getExpectationDataset()); + redirect.addCancelURL(cancelURL); + response.put("redirectUrl", redirect.getLocalURIString()); + } + else + throw new IllegalArgumentException("The expectation Dataset did not exist"); + break; + + case linkToTarget: + DatasetDefinition expectationDataset = StudyManager.getInstance().getDatasetDefinition(_study, form.getExpectationDataset()); + DatasetDefinition targetDataset = StudyManager.getInstance().getDatasetDefinition(_study, form.getTargetDataset()); + + StudyManager.getInstance().linkPlaceHolderDataset(_study, getUser(), expectationDataset, targetDataset); + break; + } + response.put("success", true); + transaction.commit(); + } + + return response; + } + } + + @RequiresPermission(AdminPermission.class) + public class CancelDefineDatasetAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object form, BindException errors) + { + // switch the dataset back to a placeholder type + Study study = getStudy(getContainer()); + if (study != null) + { + String expectationDataset = getViewContext().getActionURL().getParameter("expectationDataset"); + if (NumberUtils.isDigits(expectationDataset)) + { + DatasetDefinition def = StudyManager.getInstance().getDatasetDefinition(study, NumberUtils.toInt(expectationDataset)); + if (def != null) + { + def = def.createMutable(); + + def.setType(Dataset.TYPE_PLACEHOLDER); + def.save(getUser()); + } + } + } + throw new RedirectException(new ActionURL(StudyScheduleAction.class, getContainer())); + } + + @Override + public void addNavTrail(NavTree root) + { + } + } + + public static class DefineDatasetForm implements ApiJsonForm, HasViewContext + { + enum Type + { + defineManually, + placeHolder, + linkToTarget, + linkManually, + } + + private ViewContext _context; + private DefineDatasetForm.Type _type; + private String _name; + private ViewCategory _category; + private Integer _expectationDataset; + private Integer _targetDataset; + + public Type getType() + { + return _type; + } + + public String getName() + { + return _name; + } + + public ViewCategory getCategory() + { + return _category; + } + + public Integer getExpectationDataset() + { + return _expectationDataset; + } + + public Integer getTargetDataset() + { + return _targetDataset; + } + + @Override + public void bindJson(JSONObject json) + { + JSONObject categoryProp = json.optJSONObject("category"); + if (null != categoryProp) + { + _category = ViewCategory.fromJSON(_context.getContainer(), categoryProp); + } + + _name = json.optString("name", null); + + String type = json.optString("type", null); + if (null != type) + _type = Type.valueOf(type); + + _expectationDataset = asInteger(json.opt("expectationDataset")); + _targetDataset = asInteger(json.opt("targetDataset")); + } + + @Override + public void setViewContext(ViewContext context) + { + _context = context; + } + + @Override + public ViewContext getViewContext() + { + return _context; + } + } + + public static class ChangeAlternateIdsForm + { + private String _prefix = ""; + private int _numDigits = StudyManager.ALTERNATEID_DEFAULT_NUM_DIGITS; + private int _aliasDatasetId = -1; + private String _aliasColumn = ""; + private String _sourceColumn = ""; + + public String getAliasColumn() + { + return _aliasColumn; + } + + public void setAliasColumn(String aliasColumn) + { + _aliasColumn = aliasColumn; + } + + public String getSourceColumn() + { + return _sourceColumn; + } + + public void setSourceColumn(String sourceColumn) + { + _sourceColumn = sourceColumn; + } + + public String getPrefix() + { + return _prefix; + } + + public void setPrefix(String prefix) + { + _prefix = prefix; + } + + public int getNumDigits() + { + return _numDigits; + } + + public void setNumDigits(int numDigits) + { + _numDigits = numDigits; + } + + public int getAliasDatasetId() + { + return _aliasDatasetId; + } + public void setAliasDatasetId(int aliasDatasetId) + { + _aliasDatasetId = aliasDatasetId; + } + } + + @RequiresPermission(AdminPermission.class) + public class ChangeAlternateIdsAction extends MutatingApiAction + { + @Override + public ApiResponse execute(ChangeAlternateIdsForm form, BindException errors) + { + ApiSimpleResponse response = new ApiSimpleResponse(); + StudyImpl study = StudyManager.getInstance().getStudy(getContainer()); + if (study != null) + { + setAlternateIdProperties(study, form.getPrefix(), form.getNumDigits()); + StudyManager.getInstance().clearAlternateParticipantIds(study); + response.put("success", true); + return response; + } + else + throw new IllegalStateException("A study does not exist in this folder"); + } + } + + public static class MapAliasIdsForm + { + private int _datasetId; + private String _aliasColumn = ""; + private String _sourceColumn = ""; + + public int getDatasetId() + { + return _datasetId; + } + + public void setDatasetId(int datasetId) + { + _datasetId = datasetId; + } + + public String getAliasColumn() + { + return _aliasColumn; + } + + public void setAliasColumn(String aliasColumn) + { + _aliasColumn = aliasColumn; + } + + public String getSourceColumn() + { + return _sourceColumn; + } + + public void setSourceColumn(String sourceColumn) + { + _sourceColumn = sourceColumn; + } + } + + @RequiresPermission(AdminPermission.class) + public class MapAliasIdsAction extends MutatingApiAction + { + @Override + public ApiResponse execute(MapAliasIdsForm form, BindException errors) + { + ApiSimpleResponse response = new ApiSimpleResponse(); + StudyImpl study = StudyManager.getInstance().getStudy(getContainer()); + if (study != null) + { + setAliasMappingProperties(study, form.getDatasetId(), form.getAliasColumn(), form.getSourceColumn()); + StudyManager.getInstance().clearAlternateParticipantIds(study); + response.put("success", true); + return response; + } + else + throw new IllegalStateException("A study does not exist in this folder"); + } + } + + + @RequiresPermission(AdminPermission.class) + public static class ExportParticipantTransformsAction extends FormHandlerAction + { + @Override + public void validateCommand(Object target, Errors errors) + { + } + + @Override + public boolean handlePost(Object o, BindException errors) throws Exception + { + Study study = StudyManager.getInstance().getStudy(getContainer()); + if (study != null) + { + // Ensure alternateIds are generated for all participants + StudyManager.getInstance().generateNeededAlternateParticipantIds(study, getUser()); + + TableInfo ti = StudySchema.getInstance().getTableInfoParticipant(); + List cols = new ArrayList<>(); + cols.add(ti.getColumn("participantid")); + cols.add(ti.getColumn("alternateid")); + cols.add(ti.getColumn("dateoffset")); + SimpleFilter filter = new SimpleFilter(); + filter.addCondition(ti.getColumn("container"), getContainer()); + ResultsFactory factory = ()->QueryService.get().select(ti, cols, filter, new Sort("participantid")); + + // NOTE: TSVGridWriter closes PrintWriter and ResultSet + try (TSVGridWriter writer = new TSVGridWriter(factory)) + { + writer.setApplyFormats(false); + writer.setFilenamePrefix("ParticipantTransforms"); + writer.setColumnHeaderType(ColumnHeaderType.DisplayFieldKey); // CONSIDER: Use FieldKey instead + writer.write(getViewContext().getResponse()); + } + + return true; + } + else + throw new IllegalStateException("A study does not exist in this folder"); + } + + @Override + public URLHelper getSuccessURL(Object o) + { + return null; + } + } + + public static ChangeAlternateIdsForm getChangeAlternateIdForm(StudyImpl study) + { + ChangeAlternateIdsForm changeAlternateIdsForm = new ChangeAlternateIdsForm(); + changeAlternateIdsForm.setPrefix(study.getAlternateIdPrefix()); + changeAlternateIdsForm.setNumDigits(study.getAlternateIdDigits()); + if (study.getParticipantAliasDatasetId() != null) + { + changeAlternateIdsForm.setAliasDatasetId(study.getParticipantAliasDatasetId()); + changeAlternateIdsForm.setAliasColumn(study.getParticipantAliasProperty()); + changeAlternateIdsForm.setSourceColumn(study.getParticipantAliasSourceProperty()); + } + + return changeAlternateIdsForm; + } + + private void setAlternateIdProperties(StudyImpl study, String prefix, int numDigits) + { + study = study.createMutable(); + study.setAlternateIdPrefix(prefix); + study.setAlternateIdDigits(numDigits); + StudyManager.getInstance().updateStudy(getUser(), study); + } + + private void setAliasMappingProperties(StudyImpl study, int datasetId, String aliasColumn, String sourceColumn) + { + study = study.createMutable(); + study.setParticipantAliasDatasetId(datasetId); + study.setParticipantAliasProperty(aliasColumn); + study.setParticipantAliasSourceProperty(sourceColumn); + StudyManager.getInstance().updateStudy(getUser(), study); + } + + @RequiresPermission(ManageStudyPermission.class) + public class ImportAlternateIdMappingAction extends AbstractQueryImportAction + { + private Study _study; + private int _requestId = -1; + + @Override + protected void initRequest(IdForm form) throws ServletException + { + _requestId = form.getId(); + setHasColumnHeaders(true); + if (null != getStudy()) + { + _study = getStudy(); + setImportMessage("Upload a mapping of " + _study.getSubjectNounPlural() + " to Alternate IDs and date offsets from a TXT, CSV or Excel file or paste the mapping directly into the text box below. " + + "There must be a header row, which must contain ParticipantId and either AlternateId, DateOffset or both. Click the button below to export the current mapping."); + } + setTarget(StudySchema.getInstance().getTableInfoParticipant()); + setHideTsvCsvCombo(true); + setSuccessMessageSuffix("uploaded"); + } + + @Override + public ModelAndView getView(IdForm form, BindException errors) throws Exception + { + _study = getStudyThrowIfNull(); + initRequest(form); + return getDefaultImportView(form, errors); + } + + @Override + protected boolean skipInsertOptionValidation() + { + return true; // allow QueryUpdateService.InsertOption.INSERT for study.participant + } + + @Override + protected void validatePermission(User user, BindException errors) + { + checkPermissions(); + } + + @Override + protected boolean canInsert(User user) + { + return getContainer().hasPermission(user, ManageStudyPermission.class); + } + + @Override + protected int importData(DataLoader dl, FileStream file, String originalName, BatchValidationException errors, @Nullable AuditBehaviorType auditBehaviorType, TransactionAuditProvider.@Nullable TransactionAuditEvent auditEvent, @Nullable String auditUserComment) throws IOException + { + if (null == _study) + return 0; + int rows = StudyManager.getInstance().setImportedAlternateParticipantIds(_study, dl, errors); + + // Insert a clear warning at the top that the mappings have not been imported, #36517 + if (errors.hasErrors()) + { + List rowErrors = errors.getRowErrors(); + int count = rowErrors.size(); + rowErrors.add(0, new ValidationException("Warning: NONE of participant mappings have been imported because this mapping file contains " + (1 == count ? "an error" : "errors") + "! Please correct the following:")); + } + + return rows; + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("Upload " + _study.getSubjectNounSingular() + " Mapping"); + } + + @Override + protected ActionURL getSuccessURL(IdForm form) + { + return new ActionURL(ManageParticipantsAction.class, getContainer()); + } + } + + @RequiresPermission(AdminPermission.class) + public class SnapshotSettingsAction extends FormViewAction + { + private StudyImpl _study; + + @Override + public ModelAndView getView(SnapshotSettingsForm form, boolean reshow, BindException errors) + { + _study = getStudyRedirectIfNull(); + StudySnapshot snapshot = StudyManager.getInstance().getStudySnapshot(_study.getStudySnapshot()); + + if (null == snapshot) + { + errors.reject(null, "This is not a published study"); + return new SimpleErrorView(errors); + } + else + { + return new JspView<>("/org/labkey/study/view/snapshotSettings.jsp", snapshot); + } + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("studyPubRefresh"); + _addManageStudy(root); + root.addChild((_study.getStudySnapshotType() != null ? _study.getStudySnapshotType().getTitle() : "") + " Study Settings"); + } + + @Override + public void validateCommand(SnapshotSettingsForm form, Errors errors) + { + } + + @Override + public boolean handlePost(SnapshotSettingsForm form, BindException errors) + { + StudyImpl study = getStudyRedirectIfNull(); + StudySnapshot snapshot = StudyManager.getInstance().getStudySnapshot(study.getStudySnapshot()); + assert null != snapshot; + snapshot.setRefresh(form.isRefresh()); + StudyManager.getInstance().updateStudySnapshot(snapshot, getUser()); + return false; + } + + @Override + public URLHelper getSuccessURL(SnapshotSettingsForm form) + { + return new ActionURL(getClass(), getContainer()); + } + } + + public static class SnapshotSettingsForm + { + private boolean _refresh = false; + + public boolean isRefresh() + { + return _refresh; + } + + public void setRefresh(boolean refresh) + { + _refresh = refresh; + } + } + + /** + * Set up the site wide settings for a master patient provider + */ + @RequiresPermission(AdminPermission.class) + public static class MasterPatientProviderAction extends FormViewAction + { + @Override + public void validateCommand(MasterPatientProviderSettings form, Errors errors) + { + if (!form.isValid()) + errors.reject(ERROR_MSG, "All required fields are not specified"); + } + + @Override + public ModelAndView getView(MasterPatientProviderSettings form, boolean reshow, BindException errors) throws Exception + { + return new JspView<>("/org/labkey/study/view/masterPatientProvider.jsp", form, errors); + } + + @Override + public boolean handlePost(MasterPatientProviderSettings form, BindException errors) throws Exception + { + if (form.getType() != null) + { + try (DbScope.Transaction transaction = StudySchema.getInstance().getScope().ensureTransaction()) + { + MasterPatientIndexService svc = MasterPatientIndexService.getProvider(form.getType()); + if (svc != null) + { + WritablePropertyMap map = PropertyManager.getNormalStore().getWritableProperties(MasterPatientProviderSettings.CATEGORY, true); + + map.put(MasterPatientProviderSettings.TYPE, form.getType()); + map.save(); + + svc.setServerSettings(form); + transaction.commit(); + } + } + } + return true; + } + + @Override + public URLHelper getSuccessURL(MasterPatientProviderSettings form) + { + return urlProvider(AdminUrls.class).getAdminConsoleURL(); + } + + @Override + public void addNavTrail(NavTree root) + { + urlProvider(AdminUrls.class).addAdminNavTrail(root, "Configure Master Patient Index", getClass(), getContainer()); + } + } + + @RequiresPermission(AdminPermission.class) + public static class TestMasterPatientProviderAction extends MutatingApiAction + { + @Override + public void validateForm(MasterPatientProviderSettings form, Errors errors) + { + if (!form.isValid()) + errors.reject(ERROR_MSG, "All required fields are not specified"); + } + + @Override + public Object execute(MasterPatientProviderSettings form, BindException errors) throws Exception + { + ApiSimpleResponse response = new ApiSimpleResponse(); + + if (form.getType() != null) + { + MasterPatientIndexService svc = MasterPatientIndexService.getProvider(form.getType()); + if (svc != null) + { + if (svc.checkServerSettings(form)) + { + response.put("success", true); + response.put("message", "The specified settings are valid."); + } + else + { + response.put("success", false); + response.put("message", "The specified settings are not valid."); + } + } + } + return response; + } + } + + public static class MasterPatientProviderSettings extends MasterPatientIndexService.ServerSettings + { + public static final String CATEGORY = "MASTER_PATIENT_PROVIDER"; + public static final String TYPE = "TYPE"; + + private String _type; + + public String getType() + { + return _type; + } + + public void setType(String type) + { + _type = type; + } + } + + @RequiresPermission(AdminPermission.class) + public class ConfigureMasterPatientSettingsAction extends FormViewAction + { + private MasterPatientIndexService _svc; + + @Override + public void validateCommand(MasterPatientIndexService.FolderSettings form, Errors errors) + { + if (!form.isValid()) + errors.reject(ERROR_MSG, "All required fields are not specified"); + } + + @Override + public ModelAndView getView(MasterPatientIndexService.FolderSettings form, boolean reshow, BindException errors) throws Exception + { + return new JspView<>("/org/labkey/study/view/manageMasterPatientConfig.jsp", getService(), errors); + } + + @Override + public boolean handlePost(MasterPatientIndexService.FolderSettings form, BindException errors) throws Exception + { + MasterPatientIndexService svc = getService(); + if (svc != null) + { + form.setReloadUser(getUser().getUserId()); + svc.setFolderSettings(getContainer(), form); + } + return true; + } + + @Override + public URLHelper getSuccessURL(MasterPatientIndexService.FolderSettings form) + { + return new ActionURL(ManageStudyAction.class, getContainer()); + } + + @Override + public void addNavTrail(NavTree root) + { + MasterPatientIndexService svc = getService(); + if (svc != null) + root.addChild("Manage " + svc.getName() + " Configuration"); + else + root.addChild("Manage Master Patient Index Configuration"); + } + + private MasterPatientIndexService getService() + { + if (_svc == null) + { + _svc = MasterPatientIndexMaintenanceTask.getConfiguredService(); + } + return _svc; + } + } + + @RequiresPermission(AdminPermission.class) + public static class RefreshMasterPatientIndexAction extends MutatingApiAction + { + @Override + public ApiResponse execute(Object o, BindException errors) throws Exception + { + ApiSimpleResponse response = new ApiSimpleResponse(); + try + { + ViewBackgroundInfo info = new ViewBackgroundInfo(getContainer(), getUser(), getViewContext().getActionURL()); + MasterPatientIndexService svc = MasterPatientIndexMaintenanceTask.getConfiguredService(); + + MasterPatientIndexService.FolderSettings settings = svc.getFolderSettings(getContainer()); + if (settings.isEnabled()) + { + PipelineJob job = new MasterPatientIndexUpdateTask(info, PipelineService.get().findPipelineRoot(getContainer()), svc); + + PipelineService.get().queueJob(job); + + response.put("success", true); + response.put(ActionURL.Param.returnUrl.name(), urlProvider(PipelineUrls.class).urlBegin(getContainer())); + } + else + { + response.put("success", false); + response.put("message", "The specified configuration is not enabled."); + } + } + catch (PipelineValidationException e) + { + throw new IOException(e); + } + return response; + } + } + + @RequiresPermission(AdminPermission.class) + public static class DeleteMasterPatientRecordsAction extends MutatingApiAction + { + @Override + public ApiResponse execute(DeleteMPIForm form, BindException errors) throws Exception + { + ApiSimpleResponse response = new ApiSimpleResponse(); + + List> params = form.getParams(); + MasterPatientIndexService svc = MasterPatientIndexMaintenanceTask.getConfiguredService(); + if (svc != null && !params.isEmpty()) + { + int count = svc.deleteMatchingRecords(params); + + response.put("success", true); + response.put("count", count); + } + return response; + } + } + + public static class DeleteMPIForm implements ApiJsonForm + { + private final List> _params = new ArrayList<>(); + + public List> getParams() + { + return _params; + } + + @Override + public void bindJson(JSONObject json) + { + for (String key : json.keySet()) + { + _params.add(new Pair<>(key, String.valueOf(json.get(key)))); + } + } + } + + // Render the HTML description if a study exists in this folder. Used by the client-side CSP validator. + @RequiresPermission(ReadPermission.class) + public static class DescriptionAction extends SimpleViewAction + { + private StudyImpl _study; + + @Override + public ModelAndView getView(Object o, BindException errors) throws Exception + { + _study = getStudy(getContainer()); + return null != _study ? new HtmlView(_study.getDescriptionHtml()) : new EmptyView(); + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild(_study != null ? "Overview: " + _study.getLabel() : "No Study"); + } + } +} diff --git a/study/src/org/labkey/study/importer/CreateChildStudyPipelineJob.java b/study/src/org/labkey/study/importer/CreateChildStudyPipelineJob.java index c6e959829d7..5c98fa3eee2 100644 --- a/study/src/org/labkey/study/importer/CreateChildStudyPipelineJob.java +++ b/study/src/org/labkey/study/importer/CreateChildStudyPipelineJob.java @@ -327,7 +327,7 @@ public boolean run(ViewContext context) } finally { - if (!success && _destFolderCreated) + if (!success && _destFolderCreated && getDstContainer() != null) ContainerManager.delete(getDstContainer(), getUser()); } diff --git a/study/src/org/labkey/study/pipeline/StudyPipeline.java b/study/src/org/labkey/study/pipeline/StudyPipeline.java index 5510bd4d54c..c9432b1e0dc 100644 --- a/study/src/org/labkey/study/pipeline/StudyPipeline.java +++ b/study/src/org/labkey/study/pipeline/StudyPipeline.java @@ -1,126 +1,126 @@ -/* - * Copyright (c) 2008-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.labkey.study.pipeline; - -import org.labkey.api.module.Module; -import org.labkey.api.pipeline.PipeRoot; -import org.labkey.api.pipeline.PipelineAction; -import org.labkey.api.pipeline.PipelineDirectory; -import org.labkey.api.pipeline.PipelineProvider; -import org.labkey.api.security.permissions.InsertPermission; -import org.labkey.api.study.Study; -import org.labkey.api.util.Path; -import org.labkey.api.view.ActionURL; -import org.labkey.api.view.ViewContext; -import org.labkey.study.controllers.StudyController; -import org.labkey.study.model.StudyManager; -import org.labkey.vfs.FileLike; - -import java.io.File; -import java.util.ArrayList; -import java.util.List; - - -/** - * User: Matthew - * Date: Jan 12, 2006 - * Time: 1:16:44 PM - */ - -public class StudyPipeline extends PipelineProvider -{ - public StudyPipeline(Module owningModule) - { - super("Study", owningModule); - } - - @Override - public void updateFileProperties(final ViewContext context, PipeRoot pr, PipelineDirectory directory, boolean includeAll) - { - if (!context.getContainer().hasPermission(context.getUser(), InsertPermission.class)) - return; - - if (context.getContainer().isDataspace()) - return; - - Study study = StudyManager.getInstance().getStudy(context.getContainer()); - - if (study == null) - return; - - File[] files = directory.listFiles(new FileEntryFilter() { - @Override - public boolean accept(File f) - { - return f.getName().endsWith(".dataset"); - } - }); - - handleDatasetFiles(context, study, directory, files, includeAll); - } - - public static File lockForDataset(Study study, File f) - { - String path = f.getPath(); - return new File(path + "." + "_" + study.getContainer().getRowId() + ".lock"); - } - - public static File lockForDataset(Study study, Path path) - { - return new File(path + "." + "_" + study.getContainer().getRowId() + ".lock"); - } - - private void handleDatasetFiles(ViewContext context, Study study, PipelineDirectory directory, File[] files, boolean includeAll) - { - List lockFiles = new ArrayList<>(); - List datasetFiles = new ArrayList<>(); - - for (File f : files) - { - File lock = lockForDataset(study, f); - if (lock.exists()) - { - if (lock.canRead() && lock.canWrite()) - { - lockFiles.add(lock); - } - } - else - { - datasetFiles.add(f); - } - } - - if (!lockFiles.isEmpty()) - { - ActionURL urlReset = directory.cloneHref(); - urlReset.setAction(StudyController.ResetPipelineAction.class); - urlReset.replaceParameter("redirect", context.getActionURL().getLocalURIString()); - urlReset.replaceParameter("path", directory.getPathParameter()); - - String actionId = StudyController.ResetPipelineAction.class.getName() + ":Delete lock"; - directory.addAction(new PipelineAction(actionId, "Delete lock", urlReset, lockFiles.toArray(new File[0]), true)); - } - - files = new File[0]; - if (!datasetFiles.isEmpty()) - files = datasetFiles.toArray(new File[0]); - - String actionId = createActionId(StudyController.ImportStudyBatchAction.class, "Import Datasets"); - addAction(actionId, StudyController.ImportStudyBatchAction.class, "Import Datasets", directory, files, false, false, includeAll); - } -} +/* + * Copyright (c) 2008-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.labkey.study.pipeline; + +import org.labkey.api.module.Module; +import org.labkey.api.pipeline.PipeRoot; +import org.labkey.api.pipeline.PipelineAction; +import org.labkey.api.pipeline.PipelineDirectory; +import org.labkey.api.pipeline.PipelineProvider; +import org.labkey.api.security.permissions.InsertPermission; +import org.labkey.api.study.Study; +import org.labkey.api.util.Path; +import org.labkey.api.view.ActionURL; +import org.labkey.api.view.ViewContext; +import org.labkey.study.controllers.StudyController; +import org.labkey.study.model.StudyManager; +import org.labkey.vfs.FileLike; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + + +/** + * User: Matthew + * Date: Jan 12, 2006 + * Time: 1:16:44 PM + */ + +public class StudyPipeline extends PipelineProvider +{ + public StudyPipeline(Module owningModule) + { + super("Study", owningModule); + } + + @Override + public void updateFileProperties(final ViewContext context, PipeRoot pr, PipelineDirectory directory, boolean includeAll) + { + if (!context.getContainer().hasPermission(context.getUser(), InsertPermission.class)) + return; + + if (context.getContainer().isDataspace()) + return; + + Study study = StudyManager.getInstance().getStudy(context.getContainer()); + + if (study == null) + return; + + File[] files = directory.listFiles(new FileEntryFilter() { + @Override + public boolean accept(File f) + { + return f.getName().endsWith(".dataset"); + } + }); + + handleDatasetFiles(context, study, directory, files, includeAll); + } + + public static File lockForDataset(Study study, File f) + { + String path = f.getPath(); + return new File(path + "." + "_" + study.getContainer().getRowId() + ".lock"); + } + + public static File lockForDataset(Study study, Path path) + { + return new File(path + "." + "_" + study.getContainer().getRowId() + ".lock"); + } + + private void handleDatasetFiles(ViewContext context, Study study, PipelineDirectory directory, File[] files, boolean includeAll) + { + List lockFiles = new ArrayList<>(); + List datasetFiles = new ArrayList<>(); + + for (File f : files) + { + File lock = lockForDataset(study, f); + if (lock.exists()) + { + if (lock.canRead() && lock.canWrite()) + { + lockFiles.add(lock); + } + } + else + { + datasetFiles.add(f); + } + } + + if (!lockFiles.isEmpty()) + { + ActionURL urlReset = directory.cloneHref(); + urlReset.setAction(StudyController.ResetPipelineAction.class); + urlReset.replaceParameter("redirect", context.getActionURL().getLocalURIString()); + urlReset.replaceParameter("path", directory.getPathParameter()); + + String actionId = StudyController.ResetPipelineAction.class.getName() + ":Delete lock"; + directory.addAction(new PipelineAction(actionId, "Delete lock", urlReset, lockFiles.toArray(new File[0]), true)); + } + + files = new File[0]; + if (!datasetFiles.isEmpty()) + files = datasetFiles.toArray(new File[0]); + + String actionId = createActionId(StudyController.ImportStudyBatchAction.class, "Import Datasets"); + addAction(actionId, StudyController.ImportStudyBatchAction.class, "Import Datasets", directory, files, false, false, includeAll); + } +} From 7943797c17fcd9aad132a7d618731596804bdfd7 Mon Sep 17 00:00:00 2001 From: labkey-jeckels Date: Mon, 20 Oct 2025 14:01:35 -0700 Subject: [PATCH 05/16] Test fixes --- .../labkey/api/cloud/CloudStoreService.java | 7 + .../file/AbstractFileAnalysisJob.java | 32 +- .../AbstractFileAnalysisProtocolFactory.java | 11 +- .../pipeline/file/FileAnalysisJobSupport.java | 11 +- api/src/org/labkey/vfs/FileLike.java | 5 +- .../pipeline/analysis/AnalysisController.java | 1578 ++++++++--------- .../pipeline/analysis/FileAnalysisJob.java | 442 ++--- .../analysis/FileAnalysisProtocol.java | 148 +- .../org/labkey/pipeline/api/PipeRootImpl.java | 11 + .../labkey/study/pipeline/StudyPipeline.java | 1 - 10 files changed, 1131 insertions(+), 1115 deletions(-) diff --git a/api/src/org/labkey/api/cloud/CloudStoreService.java b/api/src/org/labkey/api/cloud/CloudStoreService.java index 6d517d6fabb..e477d71ecad 100644 --- a/api/src/org/labkey/api/cloud/CloudStoreService.java +++ b/api/src/org/labkey/api/cloud/CloudStoreService.java @@ -23,6 +23,7 @@ import org.labkey.api.services.ServiceRegistry; import org.labkey.api.util.Pair; import org.labkey.api.webdav.WebdavResource; +import org.labkey.vfs.FileLike; import java.nio.file.Path; import java.util.Collection; @@ -141,6 +142,12 @@ default Collection getEnabledCloudStores(Container container, boolean ex @Nullable Path getPath(Container container, String storeName, org.labkey.api.util.Path path); + /** + * Return nio.Path to cloud file/directory + */ + @Nullable + FileLike getFileLike(Container container, String storeName, org.labkey.api.util.Path path); + /** * Return path relative to cloud store */ diff --git a/api/src/org/labkey/api/pipeline/file/AbstractFileAnalysisJob.java b/api/src/org/labkey/api/pipeline/file/AbstractFileAnalysisJob.java index 95696e8dc7c..4ab4d5e1f1a 100644 --- a/api/src/org/labkey/api/pipeline/file/AbstractFileAnalysisJob.java +++ b/api/src/org/labkey/api/pipeline/file/AbstractFileAnalysisJob.java @@ -15,6 +15,7 @@ */ package org.labkey.api.pipeline.file; +import io.micrometer.common.util.StringUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.NotNull; @@ -262,15 +263,9 @@ public String getBaseNameForFileType(FileType fileType) } @Override - public File getDataDirectory() + public FileLike getDataDirectoryFileLike() { - return _dirData.toNioPathForRead().toFile(); - } - - @Override - public Path getDataDirectoryPath() - { - return _dirData.toNioPathForRead(); + return _dirData; } @Override @@ -403,7 +398,7 @@ public ParamParser createParamParser() @Override public String getDescription() { - return getDataDescription(getDataDirectoryPath(), getBaseName(), getJoinedBaseName(), getProtocolName(), getInputFilePaths()); + return getDataDescription(getDataDirectoryFileLike(), getBaseName(), getJoinedBaseName(), getProtocolName(), _filesInput); } @Override @@ -418,25 +413,19 @@ public ActionURL getStatusHref() return null; } - @Deprecated //prefer Path version - public static String getDataDescription(File dirData, String baseName, String joinedBaseName, String protocolName) - { - return getDataDescription(dirData.toPath(), baseName, joinedBaseName, protocolName, Collections.emptyList()); - } - - public static String getDataDescription(Path dirData, String baseName, String joinedBaseName, String protocolName, List inputFiles) + public static String getDataDescription(FileLike dirData, String baseName, String joinedBaseName, String protocolName, List inputFiles) { String dataName = ""; if (dirData != null) { - dataName = dirData.getFileName().toString(); + dataName = dirData.getName(); // Can't remember why we would ever need the "xml" check. We may get an extra "." in the path, // so check for that and remove it. if (".".equals(dataName) || "xml".equals(dataName)) { dirData = dirData.getParent(); if (dirData != null) - dataName = dirData.getFileName().toString(); + dataName = dirData.getName(); } } @@ -448,14 +437,17 @@ public static String getDataDescription(Path dirData, String baseName, String jo description.append("/"); description.append(baseName); } - description.append(" (").append(protocolName).append(")"); + if (!StringUtils.isEmpty(protocolName)) + { + description.append(" (").append(protocolName).append(")"); + } // input files if (!inputFiles.isEmpty()) { description.append(" ("); //p.getFileName returns the full S3 path -- S3fs bug? - description.append(inputFiles.stream().map(FileUtil::getFileName).collect(Collectors.joining(","))); + description.append(inputFiles.stream().map(FileLike::getName).collect(Collectors.joining(","))); description.append(")"); } return description.toString(); diff --git a/api/src/org/labkey/api/pipeline/file/AbstractFileAnalysisProtocolFactory.java b/api/src/org/labkey/api/pipeline/file/AbstractFileAnalysisProtocolFactory.java index 3a6a2147689..339d8021272 100644 --- a/api/src/org/labkey/api/pipeline/file/AbstractFileAnalysisProtocolFactory.java +++ b/api/src/org/labkey/api/pipeline/file/AbstractFileAnalysisProtocolFactory.java @@ -356,9 +356,16 @@ public AbstractFileAnalysisProtocol getProtocol(PipeRoot root, FileLike dirDa } else { - protocolFile = getProtocolFile(root, protocolName, archived); - if (protocolFile == null || !protocolFile.exists()) + try + { + protocolFile = getProtocolFile(root, protocolName, archived); + if (protocolFile == null || !protocolFile.exists()) + return null; + } + catch (InvalidPathException e) + { return null; + } result = load(root, protocolName, archived); } diff --git a/api/src/org/labkey/api/pipeline/file/FileAnalysisJobSupport.java b/api/src/org/labkey/api/pipeline/file/FileAnalysisJobSupport.java index ff6f6ee0056..532a7150922 100644 --- a/api/src/org/labkey/api/pipeline/file/FileAnalysisJobSupport.java +++ b/api/src/org/labkey/api/pipeline/file/FileAnalysisJobSupport.java @@ -65,15 +65,14 @@ public interface FileAnalysisJobSupport /** * @return the directory in which the original input file resides. */ - @Deprecated //Prefer the getDataDirectoryPath version as File return type doesn't support full URIs very well - File getDataDirectory(); - default Path getDataDirectoryPath() + @Deprecated //Prefer the getDataDirectoryFileLike version as File return type doesn't support full URIs very well + default File getDataDirectory() { - // TODO This needs implementation in derived classes... - // This is typically safe but may cause an error if FileSystem provider isn't configured - return getDataDirectory().toPath(); + return getDataDirectoryFileLike().toNioPathForWrite().toFile(); } + FileLike getDataDirectoryFileLike(); + /** * @return the directory where the input files reside, and where the * final analysis should end up. diff --git a/api/src/org/labkey/vfs/FileLike.java b/api/src/org/labkey/vfs/FileLike.java index 63d79f8d883..4f5ffcf9518 100644 --- a/api/src/org/labkey/vfs/FileLike.java +++ b/api/src/org/labkey/vfs/FileLike.java @@ -20,6 +20,7 @@ import java.io.InputStream; import java.io.OutputStream; import java.net.URI; +import java.nio.file.InvalidPathException; import java.util.List; import java.util.function.Predicate; @@ -76,10 +77,10 @@ default FileLike resolveChild(Path.Part name) default FileLike resolveChild(String name) { if (".".equals(name) || "..".equals(name)) - throw new IllegalArgumentException("Cannot resolve child '" + name + "'"); + throw new InvalidPathException(name, "Cannot resolve child"); Path path = Path.parse(name); if (1 != path.size()) - throw new IllegalArgumentException("Cannot resolve child '" + name + "'"); + throw new InvalidPathException(name, "Cannot resolve child"); return resolveFile(path); } diff --git a/pipeline/src/org/labkey/pipeline/analysis/AnalysisController.java b/pipeline/src/org/labkey/pipeline/analysis/AnalysisController.java index 5fb8f55ff11..0a53f7d1062 100644 --- a/pipeline/src/org/labkey/pipeline/analysis/AnalysisController.java +++ b/pipeline/src/org/labkey/pipeline/analysis/AnalysisController.java @@ -1,789 +1,789 @@ -/* - * Copyright (c) 2008-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.pipeline.analysis; - -import com.google.common.base.Function; -import com.google.common.collect.Collections2; -import org.apache.commons.beanutils.BeanUtils; -import org.apache.commons.io.input.ReaderInputStream; -import org.apache.commons.lang3.StringUtils; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.jetbrains.annotations.Nullable; -import org.json.JSONArray; -import org.json.JSONObject; -import org.labkey.api.action.ApiResponse; -import org.labkey.api.action.ApiSimpleResponse; -import org.labkey.api.action.ApiUsageException; -import org.labkey.api.action.FormHandlerAction; -import org.labkey.api.action.MutatingApiAction; -import org.labkey.api.action.ReturnUrlForm; -import org.labkey.api.action.SimpleViewAction; -import org.labkey.api.action.SpringActionController; -import org.labkey.api.admin.AdminUrls; -import org.labkey.api.audit.AuditLogService; -import org.labkey.api.data.Container; -import org.labkey.api.data.DataRegionSelection; -import org.labkey.api.pipeline.AnalyzeForm; -import org.labkey.api.pipeline.ParamParser; -import org.labkey.api.pipeline.PipeRoot; -import org.labkey.api.pipeline.PipelineJobService; -import org.labkey.api.pipeline.PipelineProtocolFactory; -import org.labkey.api.pipeline.PipelineService; -import org.labkey.api.pipeline.PipelineValidationException; -import org.labkey.api.pipeline.TaskFactory; -import org.labkey.api.pipeline.TaskId; -import org.labkey.api.pipeline.TaskPipeline; -import org.labkey.api.pipeline.file.AbstractFileAnalysisProtocol; -import org.labkey.api.pipeline.file.AbstractFileAnalysisProtocolFactory; -import org.labkey.api.security.RequiresPermission; -import org.labkey.api.security.permissions.AdminPermission; -import org.labkey.api.security.permissions.DeletePermission; -import org.labkey.api.security.permissions.InsertPermission; -import org.labkey.api.security.permissions.ReadPermission; -import org.labkey.api.util.DOM; -import org.labkey.api.util.DotRunner; -import org.labkey.api.util.FileUtil; -import org.labkey.api.util.HtmlString; -import org.labkey.api.util.NetworkDrive; -import org.labkey.api.util.PageFlowUtil; -import org.labkey.api.util.ReturnURLString; -import org.labkey.api.util.URLHelper; -import org.labkey.api.view.ActionURL; -import org.labkey.api.view.HtmlView; -import org.labkey.api.view.JspView; -import org.labkey.api.view.NavTree; -import org.labkey.api.view.NotFoundException; -import org.labkey.api.view.ViewForm; -import org.labkey.api.writer.ContainerUser; -import org.labkey.vfs.FileLike; -import org.springframework.validation.BindException; -import org.springframework.validation.Errors; -import org.springframework.web.servlet.ModelAndView; - -import java.io.File; -import java.io.IOException; -import java.io.StringReader; -import java.nio.charset.Charset; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.TreeMap; - -import static org.labkey.api.util.DOM.TD; -import static org.labkey.api.util.DOM.TR; -import static org.labkey.api.util.DOM.at; -import static org.labkey.api.util.DOM.cl; - -/** - * AnalysisController - */ -public class AnalysisController extends SpringActionController -{ - private static final Logger LOG = LogManager.getLogger(AnalysisController.class); - private static final DefaultActionResolver _resolver = new DefaultActionResolver(AnalysisController.class); - - public AnalysisController() - { - setActionResolver(_resolver); - } - - public static ActionURL urlAnalyze(Container container, TaskId tid, String path, @Nullable ReturnURLString returnUrl) - { - ActionURL result = new ActionURL(AnalyzeAction.class, container) - .addParameter(AnalyzeForm.Params.taskId, tid.toString()) - .addParameter(AnalyzeForm.Params.path, path); - if (returnUrl != null) - { - result.addParameter(ActionURL.Param.returnUrl, returnUrl.toString()); - } - return result; - } - - @RequiresPermission(InsertPermission.class) - public static class AnalyzeAction extends SimpleViewAction - { - private TaskPipeline _taskPipeline; - - @Override - public ModelAndView getView(AnalyzeForm analyzeForm, BindException errors) - { - try - { - getPageConfig().setIncludePostParameters(true); - if (analyzeForm.getTaskId() == null || "".equals(analyzeForm.getTaskId())) - throw new NotFoundException("taskId required"); - - _taskPipeline = PipelineJobService.get().getTaskPipeline(new TaskId(analyzeForm.getTaskId())); - if (_taskPipeline == null) - throw new NotFoundException("Task pipeline not found: " + analyzeForm.getTaskId()); - - return new JspView<>("/org/labkey/pipeline/analysis/analyze.jsp", getViewContext().getActionURL()); - } - catch (ClassNotFoundException e) - { - throw new NotFoundException("Could not find task pipeline: " + analyzeForm.getTaskId()); - } - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild(_taskPipeline.getDescription()); - } - } - - private static TaskPipeline getTaskPipeline(String taskIdString) - { - return PipelineJobService.get().getTaskPipeline(taskIdString); - } - - private static AbstractFileAnalysisProtocolFactory getProtocolFactory(TaskPipeline taskPipeline) - { - return PipelineJobService.get().getProtocolFactory(taskPipeline); - } - - /** - * Called from LABKEY.Pipeline.startAnalysis() - */ - @RequiresPermission(InsertPermission.class) - public static class StartAnalysisAction extends MutatingApiAction - { - @Override - public ApiResponse execute(AnalyzeForm form, BindException errors) - { - try - { - String jobGUID = PipelineService.get().startFileAnalysis(form, null, getViewContext()); - Map resultProperties = new HashMap<>(); - resultProperties.put("status", "success"); - resultProperties.put("jobGUID", jobGUID); - - return new ApiSimpleResponse(resultProperties); - } - catch (IOException | PipelineValidationException e) - { - throw new ApiUsageException(e); - } - } - } - - /** - * Called from LABKEY.Pipeline.getFileStatus(). - */ - @RequiresPermission(ReadPermission.class) - public static class GetFileStatusAction extends MutatingApiAction - { - @Override - public ApiResponse execute(AnalyzeForm form, BindException errors) - { - if (form.getProtocolName() == null || "".equals(form.getProtocolName())) - { - throw new NotFoundException("No protocol specified"); - } - PipelineService.PathAnalysisProperties props = PipelineService.get().getFileAnalysisProperties(getContainer(), form.getTaskId(), form.getPath()); - AbstractFileAnalysisProtocol protocol = props.getFactory().getProtocol(props.getPipeRoot(), props.getDirData(), form.getProtocolName(), false); - //NOTE: if protocol if null, initFileStatus() will return a result of UNKNOWN - FileLike dirAnalysis = props.getFactory().getAnalysisDir(props.getDirData(), form.getProtocolName(), props.getPipeRoot()); - form.initStatus(protocol, props.getDirData(), dirAnalysis); - - boolean isRetry = false; - - JSONArray files = new JSONArray(); - for (int i = 0; i < form.getFile().length; i++) - { - JSONObject o = new JSONObject(); - o.put("name", form.getFile()[i]); - o.put("status", JSONObject.wrap(form.getFileInputStatus()[i])); // Wrap to allow 'null' status - isRetry |= form.getFileInputStatus()[i] != null; - files.put(o); - } - JSONObject result = new JSONObject(); - result.put("files", files); - if (!form.isActiveJobs()) - { - result.put("submitType", isRetry ? "Retry" : "Analyze"); - } - return new ApiSimpleResponse(result); - } - } - - /** - * Called from LABKEY.Pipeline.getProtocols(). - */ - @RequiresPermission(ReadPermission.class) - public static class GetSavedProtocolsAction extends MutatingApiAction - { - @Override - public ApiResponse execute(AnalyzeForm form, BindException errors) - { - PipelineService.PathAnalysisProperties props = PipelineService.get().getFileAnalysisProperties(getContainer(), form.getTaskId(), form.getPath()); - JSONArray protocols = new JSONArray(); - for (String protocolName : props.getFactory().getProtocolNames(props.getPipeRoot(), props.getDirData(), false)) - { - protocols.put(getProtocolJson(protocolName, props.getPipeRoot(), props.getDirData(), props.getFactory())); - } - - if (form.getIncludeWorkbooks()) - { - for (Container c : getContainer().getChildren()) - { - if (c.isWorkbook()) - { - PipeRoot wbRoot = PipelineService.get().findPipelineRoot(c); - if (wbRoot == null || !wbRoot.isValid()) - continue; - - FileLike wbDirData = null; - if (form.getPath() != null) - { - wbDirData = wbRoot.resolvePathToFileLike(form.getPath()); - if (!NetworkDrive.exists(wbDirData)) - continue; - } - - for (String protocolName : props.getFactory().getProtocolNames(wbRoot, wbDirData, false)) - { - protocols.put(getProtocolJson(protocolName, wbRoot, wbDirData, props.getFactory())); - } - } - } - } - - JSONObject result = new JSONObject(); - result.put("protocols", protocols); - result.put("defaultProtocolName", PipelineService.get().getLastProtocolSetting(props.getFactory(), getContainer(), getUser())); - return new ApiSimpleResponse(result); - } - - protected JSONObject getProtocolJson(String protocolName, PipeRoot root, @Nullable FileLike dirData, AbstractFileAnalysisProtocolFactory factory) throws NotFoundException - { - JSONObject protocol = new JSONObject(); - AbstractFileAnalysisProtocol pipelineProtocol = factory.getProtocol(root, dirData, protocolName, false); - if (pipelineProtocol == null) - { - throw new NotFoundException("Protocol not found: " + protocolName); - } - - protocol.put("name", protocolName); - protocol.put("description", pipelineProtocol.getDescription()); - protocol.put("xmlParameters", pipelineProtocol.getXml()); - protocol.put("containerPath", root.getContainer().getPath()); - ParamParser parser = PipelineJobService.get().createParamParser(); - parser.parse(new ReaderInputStream(new StringReader(pipelineProtocol.getXml()), Charset.defaultCharset())); - if (parser.getErrors() == null || parser.getErrors().length == 0) - { - protocol.put("jsonParameters", new JSONObject(parser.getInputParameters())); - } - - return protocol; - } - } - - /** - * For management of protocol files - */ - public enum ProtocolTask - { - delete - { - @Override - boolean doIt(PipeRoot root, PipelineProtocolFactory factory, String name) - { - return factory.deleteProtocolFile(root, name); - } - }, - archive - { - @Override - boolean doIt(PipeRoot root, PipelineProtocolFactory factory, String name) throws IOException - { - return factory.changeArchiveStatus(root, name, true); - } - }, - unarchive - { - @Override - boolean doIt(PipeRoot root, PipelineProtocolFactory factory, String name) throws IOException - { - return factory.changeArchiveStatus(root, name, false); - } - }; - - abstract boolean doIt(PipeRoot root, PipelineProtocolFactory factory, String name) throws IOException; - - String pastTense() - { - return this + "d"; - } - - boolean run(ContainerUser cu, Map> selected) - { - PipeRoot root = PipelineService.get().findPipelineRoot(cu.getContainer()); - - // selected is a map of taskId -> list of protocol names. - // Find the correct factory for each taskId, then perform operation on list of names. Fail and return on first error - return selected.entrySet().stream().allMatch( entry -> { - PipelineProtocolFactory factory = getProtocolFactory(getTaskPipeline(entry.getKey())); - return entry.getValue().stream().allMatch( name -> { - try - { - if (doIt(root, factory, name)) - { - AuditLogService.get().addEvent(cu.getUser(), - new ProtocolManagementAuditProvider.ProtocolManagementEvent(ProtocolManagementAuditProvider.EVENT, - cu.getContainer(), factory.getName(), name, this.pastTense())); - return true; - } - else - return false; - } - catch (IOException e) - { - throw new RuntimeException("Error during protocol execution", e); - } - }); - }); - } - - public static boolean isInEnum(String value) { - return Arrays.stream(ProtocolTask.values()).anyMatch(e -> e.name().equals(value)); - } - } - - @RequiresPermission(DeletePermission.class) - public static class ProtocolManagementAction extends FormHandlerAction - { - - @Override - public void validateCommand(ProtocolManagementForm form, Errors errors) - { - if (!ProtocolTask.isInEnum(form.getAction())) - errors.reject("An invalid action was passed: " + form.getAction()); - try - { - form.getSelected(); - } - catch (Exception e) - { - errors.reject("Invalid selection"); - } - } - - @Override - public boolean handlePost(ProtocolManagementForm form, BindException errors) - { - try - { - return ProtocolTask.valueOf(form.getAction()).run(getViewContext(), form.getSelected()); - } - catch (Exception e) - { - LOG.error("Error processing protocol management action.", e); - errors.reject("Error processing action. See server log for more details."); - return false; - } - } - - @Override - public URLHelper getSuccessURL(ProtocolManagementForm form) - { - return form.getReturnActionURL(); - } - - - } - - public static class ProtocolManagementForm extends ViewForm - { - String action; - Map> selected = null; - String taskId = null; - String name = null; - - public String getAction() - { - return action; - } - - public void setAction(String action) - { - this.action = action; - } - - public void setTaskId(String taskId) - { - this.taskId = taskId; - } - - public void setName(String name) - { - this.name = name; - } - - public Map> getSelected() - { - if (selected == null) - { - if (null != taskId && null != name) // came in from the Details page, not the grid view - { - selected = new HashMap<>(); - selected.put(taskId, Collections.singletonList(name)); - } - else - { - selected = parseSelected(DataRegionSelection.getSelected(getViewContext(), true)); - } - } - return selected; - } - - /** - * The select set are comma separated pairs of taskId, protocol name - * Split into a map of taskId -> list of names - */ - private Map> parseSelected(Set selected) - { - Map> parsedSelected = new HashMap<>(); - for (String pair : selected) - { - String[] split = pair.split(",", 2); - if (split.length == 2) // silently ignore malformed input - { - List names = parsedSelected.computeIfAbsent(split[0], k -> new ArrayList<>()); - names.add(split[1]); - } - } - return parsedSelected; - } - } - - @RequiresPermission(ReadPermission.class) - public static class ProtocolDetailsAction extends SimpleViewAction - { - private String _protocolName; - - @Override - public void addNavTrail(NavTree root) - { - root.addChild("Protocol: " + _protocolName); - } - - @Override - public ModelAndView getView(ProtocolDetailsForm form, BindException errors) - { - _protocolName = form.getName(); - PipeRoot root = PipelineService.get().findPipelineRoot(getViewContext().getContainer()); - AbstractFileAnalysisProtocolFactory factory = getProtocolFactory(getTaskPipeline(form.getTaskId())); - AbstractFileAnalysisProtocol protocol = factory.getProtocol(root, null, _protocolName, form.isArchived()); - if (null != protocol) - form.setXml(protocol.getXml()); - return new JspView<>("/org/labkey/pipeline/analysis/protocolDetail.jsp", form); - } - } - - public static class ProtocolDetailsForm extends ReturnUrlForm - { - private String _taskId; - private String _name; - private boolean _archived; - private String _xml = null; - - public String getTaskId() - { - return _taskId; - } - - public void setTaskId(String taskId) - { - _taskId = taskId; - } - - public String getName() - { - return _name; - } - - public void setName(String name) - { - _name = name; - } - - public boolean isArchived() - { - return _archived; - } - - public void setArchived(boolean archived) - { - _archived = archived; - } - - public String getXml() - { - return _xml; - } - - public void setXml(String xml) - { - _xml = xml; - } - } - - /** - * Used for debugging task registration. - */ - @RequiresPermission(AdminPermission.class) - public static class InternalListTasksAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object o, BindException errors) - { - return new JspView<>("/org/labkey/pipeline/analysis/internalListTasks.jsp", null, errors); - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild("Internal List Tasks"); - } - } - - /** - * Used for debugging pipeline registration. - */ - @RequiresPermission(AdminPermission.class) - public static class InternalListPipelinesAction extends SimpleViewAction - { - @Override - public ModelAndView getView(Object o, BindException errors) - { - return new JspView<>("/org/labkey/pipeline/analysis/internalListPipelines.jsp", null, errors); - } - - @Override - public void addNavTrail(NavTree root) - { - urlProvider(AdminUrls.class).addAdminNavTrail(root, "Internal List Pipelines", getClass(), getContainer()); - } - } - - public static class TaskForm - { - private String _taskId; - - public String getTaskId() - { - return _taskId; - } - - public void setTaskId(String taskId) - { - _taskId = taskId; - } - } - - /** - * Used for debugging task registration. - */ - @RequiresPermission(AdminPermission.class) - public class InternalDetailsAction extends SimpleViewAction - { - @Override - public ModelAndView getView(TaskForm form, BindException errors) throws Exception - { - String id = form.getTaskId(); - if (id == null) - throw new NotFoundException("taskId required"); - - TaskFactory factory = null; - TaskPipeline pipeline = null; - - Map map = Collections.emptyMap(); - TaskId taskId = TaskId.valueOf(id); - if (taskId.getType() == TaskId.Type.task || taskId.getType() == null) - { - factory = PipelineJobService.get().getTaskFactory(taskId); - map = BeanUtils.describe(factory); - } - - if (factory == null) - { - pipeline = PipelineJobService.get().getTaskPipeline(taskId); - map = BeanUtils.describe(pipeline); - } - - if (map.isEmpty()) - { - return new HtmlView(HtmlString.of("no task or pipeline found")); - } - // Sort the properties alphabetically - map = new TreeMap<>(map); - - return new HtmlView(DOM.DIV( - DOM.TABLE(at(cl("labkey-data-region-legacy", "labkey-show-borders")), map.entrySet().stream().map(e -> TR(TD(e.getKey()), TD(e.getValue())))), - generateGraph(pipeline))); - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild("Internal Details"); - } - } - - @Nullable - private DOM.Renderable generateGraph(@Nullable TaskPipeline pipeline) - { - if (pipeline == null) - { - return null; - } - - File svgFile = null; - try - { - File dir = FileUtil.getTempDirectory(); - String dot = buildDigraph(pipeline); - svgFile = FileUtil.createTempFile("pipeline", ".svg", dir); - DotRunner runner = new DotRunner(dir, dot); - runner.addSvgOutput(svgFile); - runner.execute(); - return HtmlString.unsafe(PageFlowUtil.getFileContentsAsString(svgFile)); - } - catch (Exception e) - { - LOG.error("Error running dot", e); - } - finally - { - if (svgFile != null) - svgFile.delete(); - } - return null; - } - - /** - * Generate a dot graph of the pipeline. - * Each task is drawn as a box with inputs on the left and outputs on the right: - *
-     * +--------------------+
-     * |      task id       |
-     * +---------+----------+
-     * | in1.xls | out1.txt |
-     * | in2.xls |          |
-     * +---------+----------+
-     * 
- */ - private String buildDigraph(TaskPipeline pipeline) - { - TaskId[] progression = pipeline.getTaskProgression(); - if (progression == null) - return null; - - StringBuilder sb = new StringBuilder(); - sb.append("digraph pipeline {\n"); - - // First, add all the nodes - for (TaskId taskId : progression) - { - String name = taskId.getName(); - if (name == null) - name = taskId.getNamespaceClass().getSimpleName(); - - TaskFactory factory = PipelineJobService.get().getTaskFactory(taskId); - - if (factory == null) - { - // not found - sb.append("\t\"").append(taskId).append("\""); - sb.append(" [label=\"").append(name).append("\""); - sb.append(" color=red"); - sb.append("];"); - } - else - { - sb.append("\t\"").append(taskId).append("\""); - sb.append(" [shape=record label=\"{"); - sb.append(name).append(" | {"); - - // inputs - // TODO: include parameters as inputs - sb.append("{"); - if (factory instanceof CommandTaskImpl.Factory f) - { - sb.append(StringUtils.join( - Collections2.transform(f.getInputPaths().keySet(), (Function) input -> escapeDotFieldLabel(input) + "\\l"), - " | ")); - } - else - { - StringUtils.join(factory.getInputTypes(), " | "); - } - sb.append("}"); // end inputs - - sb.append(" | "); - - // outputs - sb.append("{"); - if (factory instanceof CommandTaskImpl.Factory f) - { - - sb.append(StringUtils.join( - Collections2.transform(f.getOutputPaths().keySet(), (Function) input -> escapeDotFieldLabel(input) + "\\r"), - " | ")); - } - else - { - // CONSIDER: can other tasks have outputs? - } - sb.append("}"); // end outputs - - sb.append("}"); // end body - sb.append("}\""); // end label - sb.append("];"); - } - - sb.append("\n\n"); - } - - sb.append("\n"); - - // Now draw edges - // For now, we draw just a sequence from a->b->c. Eventually, we should connect outputs to inputs and draw splits/joins. - sb.append("\t"); - sb.append(StringUtils.join( - Collections2.transform(Arrays.asList(progression), task -> "\"" + task.toString() + "\""), - " -> ")); - - sb.append("}"); - return sb.toString(); - } - - // Escape a field within a dot record node: - // - backslash escape [] {} <> - // - spaces with '\' - private String escapeDotFieldLabel(String field) - { - field = field.replaceAll("[\\[\\]{}<>]", "\\\\$0"); - return field.replaceAll("\\s", "\"); - } - -} +/* + * Copyright (c) 2008-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.pipeline.analysis; + +import com.google.common.base.Function; +import com.google.common.collect.Collections2; +import org.apache.commons.beanutils.BeanUtils; +import org.apache.commons.io.input.ReaderInputStream; +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.Nullable; +import org.json.JSONArray; +import org.json.JSONObject; +import org.labkey.api.action.ApiResponse; +import org.labkey.api.action.ApiSimpleResponse; +import org.labkey.api.action.ApiUsageException; +import org.labkey.api.action.FormHandlerAction; +import org.labkey.api.action.MutatingApiAction; +import org.labkey.api.action.ReturnUrlForm; +import org.labkey.api.action.SimpleViewAction; +import org.labkey.api.action.SpringActionController; +import org.labkey.api.admin.AdminUrls; +import org.labkey.api.audit.AuditLogService; +import org.labkey.api.data.Container; +import org.labkey.api.data.DataRegionSelection; +import org.labkey.api.pipeline.AnalyzeForm; +import org.labkey.api.pipeline.ParamParser; +import org.labkey.api.pipeline.PipeRoot; +import org.labkey.api.pipeline.PipelineJobService; +import org.labkey.api.pipeline.PipelineProtocolFactory; +import org.labkey.api.pipeline.PipelineService; +import org.labkey.api.pipeline.PipelineValidationException; +import org.labkey.api.pipeline.TaskFactory; +import org.labkey.api.pipeline.TaskId; +import org.labkey.api.pipeline.TaskPipeline; +import org.labkey.api.pipeline.file.AbstractFileAnalysisProtocol; +import org.labkey.api.pipeline.file.AbstractFileAnalysisProtocolFactory; +import org.labkey.api.security.RequiresPermission; +import org.labkey.api.security.permissions.AdminPermission; +import org.labkey.api.security.permissions.DeletePermission; +import org.labkey.api.security.permissions.InsertPermission; +import org.labkey.api.security.permissions.ReadPermission; +import org.labkey.api.util.DOM; +import org.labkey.api.util.DotRunner; +import org.labkey.api.util.FileUtil; +import org.labkey.api.util.HtmlString; +import org.labkey.api.util.NetworkDrive; +import org.labkey.api.util.PageFlowUtil; +import org.labkey.api.util.ReturnURLString; +import org.labkey.api.util.URLHelper; +import org.labkey.api.view.ActionURL; +import org.labkey.api.view.HtmlView; +import org.labkey.api.view.JspView; +import org.labkey.api.view.NavTree; +import org.labkey.api.view.NotFoundException; +import org.labkey.api.view.ViewForm; +import org.labkey.api.writer.ContainerUser; +import org.labkey.vfs.FileLike; +import org.springframework.validation.BindException; +import org.springframework.validation.Errors; +import org.springframework.web.servlet.ModelAndView; + +import java.io.File; +import java.io.IOException; +import java.io.StringReader; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +import static org.labkey.api.util.DOM.TD; +import static org.labkey.api.util.DOM.TR; +import static org.labkey.api.util.DOM.at; +import static org.labkey.api.util.DOM.cl; + +/** + * AnalysisController + */ +public class AnalysisController extends SpringActionController +{ + private static final Logger LOG = LogManager.getLogger(AnalysisController.class); + private static final DefaultActionResolver _resolver = new DefaultActionResolver(AnalysisController.class); + + public AnalysisController() + { + setActionResolver(_resolver); + } + + public static ActionURL urlAnalyze(Container container, TaskId tid, String path, @Nullable ReturnURLString returnUrl) + { + ActionURL result = new ActionURL(AnalyzeAction.class, container) + .addParameter(AnalyzeForm.Params.taskId, tid.toString()) + .addParameter(AnalyzeForm.Params.path, path); + if (returnUrl != null) + { + result.addParameter(ActionURL.Param.returnUrl, returnUrl.toString()); + } + return result; + } + + @RequiresPermission(InsertPermission.class) + public static class AnalyzeAction extends SimpleViewAction + { + private TaskPipeline _taskPipeline; + + @Override + public ModelAndView getView(AnalyzeForm analyzeForm, BindException errors) + { + try + { + getPageConfig().setIncludePostParameters(true); + if (analyzeForm.getTaskId() == null || "".equals(analyzeForm.getTaskId())) + throw new NotFoundException("taskId required"); + + _taskPipeline = PipelineJobService.get().getTaskPipeline(new TaskId(analyzeForm.getTaskId())); + if (_taskPipeline == null) + throw new NotFoundException("Task pipeline not found: " + analyzeForm.getTaskId()); + + return new JspView<>("/org/labkey/pipeline/analysis/analyze.jsp", getViewContext().getActionURL()); + } + catch (ClassNotFoundException e) + { + throw new NotFoundException("Could not find task pipeline: " + analyzeForm.getTaskId()); + } + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild(_taskPipeline.getDescription()); + } + } + + private static TaskPipeline getTaskPipeline(String taskIdString) + { + return PipelineJobService.get().getTaskPipeline(taskIdString); + } + + private static AbstractFileAnalysisProtocolFactory getProtocolFactory(TaskPipeline taskPipeline) + { + return PipelineJobService.get().getProtocolFactory(taskPipeline); + } + + /** + * Called from LABKEY.Pipeline.startAnalysis() + */ + @RequiresPermission(InsertPermission.class) + public static class StartAnalysisAction extends MutatingApiAction + { + @Override + public ApiResponse execute(AnalyzeForm form, BindException errors) + { + try + { + String jobGUID = PipelineService.get().startFileAnalysis(form, null, getViewContext()); + Map resultProperties = new HashMap<>(); + resultProperties.put("status", "success"); + resultProperties.put("jobGUID", jobGUID); + + return new ApiSimpleResponse(resultProperties); + } + catch (IOException | PipelineValidationException e) + { + throw new ApiUsageException(e); + } + } + } + + /** + * Called from LABKEY.Pipeline.getFileStatus(). + */ + @RequiresPermission(ReadPermission.class) + public static class GetFileStatusAction extends MutatingApiAction + { + @Override + public ApiResponse execute(AnalyzeForm form, BindException errors) + { + if (form.getProtocolName() == null || "".equals(form.getProtocolName())) + { + throw new NotFoundException("No protocol specified"); + } + PipelineService.PathAnalysisProperties props = PipelineService.get().getFileAnalysisProperties(getContainer(), form.getTaskId(), form.getPath()); + AbstractFileAnalysisProtocol protocol = props.getFactory().getProtocol(props.getPipeRoot(), props.getDirData(), form.getProtocolName(), false); + //NOTE: if protocol if null, initFileStatus() will return a result of UNKNOWN + FileLike dirAnalysis = props.getFactory().getAnalysisDir(props.getDirData(), form.getProtocolName(), props.getPipeRoot()); + form.initStatus(protocol, props.getDirData(), dirAnalysis); + + boolean isRetry = false; + + JSONArray files = new JSONArray(); + for (int i = 0; i < form.getFile().length; i++) + { + JSONObject o = new JSONObject(); + o.put("name", form.getFile()[i]); + o.put("status", JSONObject.wrap(form.getFileInputStatus()[i])); // Wrap to allow 'null' status + isRetry |= form.getFileInputStatus()[i] != null; + files.put(o); + } + JSONObject result = new JSONObject(); + result.put("files", files); + if (!form.isActiveJobs()) + { + result.put("submitType", isRetry ? "Retry" : "Analyze"); + } + return new ApiSimpleResponse(result); + } + } + + /** + * Called from LABKEY.Pipeline.getProtocols(). + */ + @RequiresPermission(ReadPermission.class) + public static class GetSavedProtocolsAction extends MutatingApiAction + { + @Override + public ApiResponse execute(AnalyzeForm form, BindException errors) + { + PipelineService.PathAnalysisProperties props = PipelineService.get().getFileAnalysisProperties(getContainer(), form.getTaskId(), form.getPath()); + JSONArray protocols = new JSONArray(); + for (String protocolName : props.getFactory().getProtocolNames(props.getPipeRoot(), props.getDirData(), false)) + { + protocols.put(getProtocolJson(protocolName, props.getPipeRoot(), props.getDirData(), props.getFactory())); + } + + if (form.getIncludeWorkbooks()) + { + for (Container c : getContainer().getChildren()) + { + if (c.isWorkbook()) + { + PipeRoot wbRoot = PipelineService.get().findPipelineRoot(c); + if (wbRoot == null || !wbRoot.isValid()) + continue; + + FileLike wbDirData = null; + if (form.getPath() != null) + { + wbDirData = wbRoot.resolvePathToFileLike(form.getPath()); + if (!NetworkDrive.exists(wbDirData)) + continue; + } + + for (String protocolName : props.getFactory().getProtocolNames(wbRoot, wbDirData, false)) + { + protocols.put(getProtocolJson(protocolName, wbRoot, wbDirData, props.getFactory())); + } + } + } + } + + JSONObject result = new JSONObject(); + result.put("protocols", protocols); + result.put("defaultProtocolName", PipelineService.get().getLastProtocolSetting(props.getFactory(), getContainer(), getUser())); + return new ApiSimpleResponse(result); + } + + protected JSONObject getProtocolJson(String protocolName, PipeRoot root, @Nullable FileLike dirData, AbstractFileAnalysisProtocolFactory factory) throws NotFoundException + { + JSONObject protocol = new JSONObject(); + AbstractFileAnalysisProtocol pipelineProtocol = factory.getProtocol(root, dirData, protocolName, false); + if (pipelineProtocol == null) + { + throw new NotFoundException("Protocol not found: " + protocolName); + } + + protocol.put("name", protocolName); + protocol.put("description", pipelineProtocol.getDescription()); + protocol.put("xmlParameters", pipelineProtocol.getXml()); + protocol.put("containerPath", root.getContainer().getPath()); + ParamParser parser = PipelineJobService.get().createParamParser(); + parser.parse(new ReaderInputStream(new StringReader(pipelineProtocol.getXml()), Charset.defaultCharset())); + if (parser.getErrors() == null || parser.getErrors().length == 0) + { + protocol.put("jsonParameters", new JSONObject(parser.getInputParameters())); + } + + return protocol; + } + } + + /** + * For management of protocol files + */ + public enum ProtocolTask + { + delete + { + @Override + boolean doIt(PipeRoot root, PipelineProtocolFactory factory, String name) + { + return factory.deleteProtocolFile(root, name); + } + }, + archive + { + @Override + boolean doIt(PipeRoot root, PipelineProtocolFactory factory, String name) throws IOException + { + return factory.changeArchiveStatus(root, name, true); + } + }, + unarchive + { + @Override + boolean doIt(PipeRoot root, PipelineProtocolFactory factory, String name) throws IOException + { + return factory.changeArchiveStatus(root, name, false); + } + }; + + abstract boolean doIt(PipeRoot root, PipelineProtocolFactory factory, String name) throws IOException; + + String pastTense() + { + return this + "d"; + } + + boolean run(ContainerUser cu, Map> selected) + { + PipeRoot root = PipelineService.get().findPipelineRoot(cu.getContainer()); + + // selected is a map of taskId -> list of protocol names. + // Find the correct factory for each taskId, then perform operation on list of names. Fail and return on first error + return selected.entrySet().stream().allMatch( entry -> { + PipelineProtocolFactory factory = getProtocolFactory(getTaskPipeline(entry.getKey())); + return entry.getValue().stream().allMatch( name -> { + try + { + if (doIt(root, factory, name)) + { + AuditLogService.get().addEvent(cu.getUser(), + new ProtocolManagementAuditProvider.ProtocolManagementEvent(ProtocolManagementAuditProvider.EVENT, + cu.getContainer(), factory.getName(), name, this.pastTense())); + return true; + } + else + return false; + } + catch (IOException e) + { + throw new RuntimeException("Error during protocol execution", e); + } + }); + }); + } + + public static boolean isInEnum(String value) { + return Arrays.stream(ProtocolTask.values()).anyMatch(e -> e.name().equals(value)); + } + } + + @RequiresPermission(DeletePermission.class) + public static class ProtocolManagementAction extends FormHandlerAction + { + + @Override + public void validateCommand(ProtocolManagementForm form, Errors errors) + { + if (!ProtocolTask.isInEnum(form.getAction())) + errors.reject("An invalid action was passed: " + form.getAction()); + try + { + form.getSelected(); + } + catch (Exception e) + { + errors.reject("Invalid selection"); + } + } + + @Override + public boolean handlePost(ProtocolManagementForm form, BindException errors) + { + try + { + return ProtocolTask.valueOf(form.getAction()).run(getViewContext(), form.getSelected()); + } + catch (Exception e) + { + LOG.error("Error processing protocol management action.", e); + errors.reject("Error processing action. See server log for more details."); + return false; + } + } + + @Override + public URLHelper getSuccessURL(ProtocolManagementForm form) + { + return form.getReturnActionURL(); + } + + + } + + public static class ProtocolManagementForm extends ViewForm + { + String action; + Map> selected = null; + String taskId = null; + String name = null; + + public String getAction() + { + return action; + } + + public void setAction(String action) + { + this.action = action; + } + + public void setTaskId(String taskId) + { + this.taskId = taskId; + } + + public void setName(String name) + { + this.name = name; + } + + public Map> getSelected() + { + if (selected == null) + { + if (null != taskId && null != name) // came in from the Details page, not the grid view + { + selected = new HashMap<>(); + selected.put(taskId, Collections.singletonList(name)); + } + else + { + selected = parseSelected(DataRegionSelection.getSelected(getViewContext(), true)); + } + } + return selected; + } + + /** + * The select set are comma separated pairs of taskId, protocol name + * Split into a map of taskId -> list of names + */ + private Map> parseSelected(Set selected) + { + Map> parsedSelected = new HashMap<>(); + for (String pair : selected) + { + String[] split = pair.split(",", 2); + if (split.length == 2) // silently ignore malformed input + { + List names = parsedSelected.computeIfAbsent(split[0], k -> new ArrayList<>()); + names.add(split[1]); + } + } + return parsedSelected; + } + } + + @RequiresPermission(ReadPermission.class) + public static class ProtocolDetailsAction extends SimpleViewAction + { + private String _protocolName; + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("Protocol: " + _protocolName); + } + + @Override + public ModelAndView getView(ProtocolDetailsForm form, BindException errors) + { + _protocolName = form.getName(); + PipeRoot root = PipelineService.get().findPipelineRoot(getViewContext().getContainer()); + AbstractFileAnalysisProtocolFactory factory = getProtocolFactory(getTaskPipeline(form.getTaskId())); + AbstractFileAnalysisProtocol protocol = factory.getProtocol(root, null, _protocolName, form.isArchived()); + if (null != protocol) + form.setXml(protocol.getXml()); + return new JspView<>("/org/labkey/pipeline/analysis/protocolDetail.jsp", form); + } + } + + public static class ProtocolDetailsForm extends ReturnUrlForm + { + private String _taskId; + private String _name; + private boolean _archived; + private String _xml = null; + + public String getTaskId() + { + return _taskId; + } + + public void setTaskId(String taskId) + { + _taskId = taskId; + } + + public String getName() + { + return _name; + } + + public void setName(String name) + { + _name = name; + } + + public boolean isArchived() + { + return _archived; + } + + public void setArchived(boolean archived) + { + _archived = archived; + } + + public String getXml() + { + return _xml; + } + + public void setXml(String xml) + { + _xml = xml; + } + } + + /** + * Used for debugging task registration. + */ + @RequiresPermission(AdminPermission.class) + public static class InternalListTasksAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object o, BindException errors) + { + return new JspView<>("/org/labkey/pipeline/analysis/internalListTasks.jsp", null, errors); + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("Internal List Tasks"); + } + } + + /** + * Used for debugging pipeline registration. + */ + @RequiresPermission(AdminPermission.class) + public static class InternalListPipelinesAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object o, BindException errors) + { + return new JspView<>("/org/labkey/pipeline/analysis/internalListPipelines.jsp", null, errors); + } + + @Override + public void addNavTrail(NavTree root) + { + urlProvider(AdminUrls.class).addAdminNavTrail(root, "Internal List Pipelines", getClass(), getContainer()); + } + } + + public static class TaskForm + { + private String _taskId; + + public String getTaskId() + { + return _taskId; + } + + public void setTaskId(String taskId) + { + _taskId = taskId; + } + } + + /** + * Used for debugging task registration. + */ + @RequiresPermission(AdminPermission.class) + public class InternalDetailsAction extends SimpleViewAction + { + @Override + public ModelAndView getView(TaskForm form, BindException errors) throws Exception + { + String id = form.getTaskId(); + if (id == null) + throw new NotFoundException("taskId required"); + + TaskFactory factory = null; + TaskPipeline pipeline = null; + + Map map = Collections.emptyMap(); + TaskId taskId = TaskId.valueOf(id); + if (taskId.getType() == TaskId.Type.task || taskId.getType() == null) + { + factory = PipelineJobService.get().getTaskFactory(taskId); + map = BeanUtils.describe(factory); + } + + if (factory == null) + { + pipeline = PipelineJobService.get().getTaskPipeline(taskId); + map = BeanUtils.describe(pipeline); + } + + if (map.isEmpty()) + { + return new HtmlView(HtmlString.of("no task or pipeline found")); + } + // Sort the properties alphabetically + map = new TreeMap<>(map); + + return new HtmlView(DOM.DIV( + DOM.TABLE(at(cl("labkey-data-region-legacy", "labkey-show-borders")), map.entrySet().stream().map(e -> TR(TD(e.getKey()), TD(e.getValue())))), + generateGraph(pipeline))); + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("Internal Details"); + } + } + + @Nullable + private DOM.Renderable generateGraph(@Nullable TaskPipeline pipeline) + { + if (pipeline == null) + { + return null; + } + + File svgFile = null; + try + { + File dir = FileUtil.getTempDirectory(); + String dot = buildDigraph(pipeline); + svgFile = FileUtil.createTempFile("pipeline", ".svg", dir); + DotRunner runner = new DotRunner(dir, dot); + runner.addSvgOutput(svgFile); + runner.execute(); + return HtmlString.unsafe(PageFlowUtil.getFileContentsAsString(svgFile)); + } + catch (Exception e) + { + LOG.error("Error running dot", e); + } + finally + { + if (svgFile != null) + svgFile.delete(); + } + return null; + } + + /** + * Generate a dot graph of the pipeline. + * Each task is drawn as a box with inputs on the left and outputs on the right: + *
+     * +--------------------+
+     * |      task id       |
+     * +---------+----------+
+     * | in1.xls | out1.txt |
+     * | in2.xls |          |
+     * +---------+----------+
+     * 
+ */ + private String buildDigraph(TaskPipeline pipeline) + { + TaskId[] progression = pipeline.getTaskProgression(); + if (progression == null) + return null; + + StringBuilder sb = new StringBuilder(); + sb.append("digraph pipeline {\n"); + + // First, add all the nodes + for (TaskId taskId : progression) + { + String name = taskId.getName(); + if (name == null) + name = taskId.getNamespaceClass().getSimpleName(); + + TaskFactory factory = PipelineJobService.get().getTaskFactory(taskId); + + if (factory == null) + { + // not found + sb.append("\t\"").append(taskId).append("\""); + sb.append(" [label=\"").append(name).append("\""); + sb.append(" color=red"); + sb.append("];"); + } + else + { + sb.append("\t\"").append(taskId).append("\""); + sb.append(" [shape=record label=\"{"); + sb.append(name).append(" | {"); + + // inputs + // TODO: include parameters as inputs + sb.append("{"); + if (factory instanceof CommandTaskImpl.Factory f) + { + sb.append(StringUtils.join( + Collections2.transform(f.getInputPaths().keySet(), (Function) input -> escapeDotFieldLabel(input) + "\\l"), + " | ")); + } + else + { + StringUtils.join(factory.getInputTypes(), " | "); + } + sb.append("}"); // end inputs + + sb.append(" | "); + + // outputs + sb.append("{"); + if (factory instanceof CommandTaskImpl.Factory f) + { + + sb.append(StringUtils.join( + Collections2.transform(f.getOutputPaths().keySet(), (Function) input -> escapeDotFieldLabel(input) + "\\r"), + " | ")); + } + else + { + // CONSIDER: can other tasks have outputs? + } + sb.append("}"); // end outputs + + sb.append("}"); // end body + sb.append("}\""); // end label + sb.append("];"); + } + + sb.append("\n\n"); + } + + sb.append("\n"); + + // Now draw edges + // For now, we draw just a sequence from a->b->c. Eventually, we should connect outputs to inputs and draw splits/joins. + sb.append("\t"); + sb.append(StringUtils.join( + Collections2.transform(Arrays.asList(progression), task -> "\"" + task.toString() + "\""), + " -> ")); + + sb.append("}"); + return sb.toString(); + } + + // Escape a field within a dot record node: + // - backslash escape [] {} <> + // - spaces with '\' + private String escapeDotFieldLabel(String field) + { + field = field.replaceAll("[\\[\\]{}<>]", "\\\\$0"); + return field.replaceAll("\\s", "\"); + } + +} diff --git a/pipeline/src/org/labkey/pipeline/analysis/FileAnalysisJob.java b/pipeline/src/org/labkey/pipeline/analysis/FileAnalysisJob.java index dcaa823d0af..aba982a95fc 100644 --- a/pipeline/src/org/labkey/pipeline/analysis/FileAnalysisJob.java +++ b/pipeline/src/org/labkey/pipeline/analysis/FileAnalysisJob.java @@ -1,221 +1,221 @@ -/* - * Copyright (c) 2008-2018 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.pipeline.analysis; - -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.jetbrains.annotations.Nullable; -import org.labkey.api.pipeline.PipeRoot; -import org.labkey.api.pipeline.TaskId; -import org.labkey.api.pipeline.TaskPipeline; -import org.labkey.api.pipeline.file.AbstractFileAnalysisJob; -import org.labkey.api.pipeline.file.FileAnalysisTaskPipeline; -import org.labkey.api.util.FileType; -import org.labkey.api.util.NetworkDrive; -import org.labkey.api.view.ViewBackgroundInfo; -import org.labkey.vfs.FileLike; - -import java.io.File; -import java.io.IOException; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -/** - * FileAnalysisJob - */ -public class FileAnalysisJob extends AbstractFileAnalysisJob -{ - private TaskId _taskPipelineId; - private Map _variableMap; - - private static final Logger LOG = LogManager.getLogger(FileAnalysisJob.class); - - // For serialization - protected FileAnalysisJob() {} - - public FileAnalysisJob(FileAnalysisProtocol protocol, - String providerName, - ViewBackgroundInfo info, - PipeRoot root, - TaskId taskPipelineId, - String protocolName, - FileLike fileParameters, - List filesInput, - @Nullable Map variableMap, - boolean splittable) throws IOException - { - super(protocol, providerName, info, root, protocolName, fileParameters, filesInput, splittable); - - _taskPipelineId = taskPipelineId; - _variableMap = variableMap; - } - - public FileAnalysisJob(FileAnalysisJob job, FileLike fileInput) - { - super(job, fileInput); - - _taskPipelineId = job._taskPipelineId; - _variableMap = job._variableMap; - } - - @Override - public String getDescription() - { - String description = getParameters().get("pipelineDescription"); - if(description != null) - return description; - - return super.getDescription(); - } - - @Override - public Map getParameters() - { - Map parameters = new HashMap<>(super.getParameters()); - if (_variableMap != null && !_variableMap.isEmpty()) - parameters.putAll(_variableMap); - - return Collections.unmodifiableMap(parameters); - } - - @Override - public TaskId getTaskPipelineId() - { - return _taskPipelineId; - } - - @Override - public AbstractFileAnalysisJob createSingleFileJob(FileLike file) - { - return new FileAnalysisJob(this, file); - } - - @Override - public FileAnalysisTaskPipeline getTaskPipeline() - { - TaskPipeline tp = super.getTaskPipeline(); - if (tp == null) - { - LOG.warn("Task pipeline " + _taskPipelineId + " not found."); - } - return (FileAnalysisTaskPipeline) tp; - } - - @Override - public File findInputFile(String name) - { - return findFile(name); - } - - @Override - public File findOutputFile(String name) - { - return findFile(name); - } - - /** - * Look at the specified type hierarchy to see if the requested file is an - * ancestor to this processing job, residing outside the analysis directory. - * - * @param name The name of the file to be located - * @return The file location outside the analysis directory, or null, if no such match is found. - */ - public File findFile(String name) - { - File dirAnalysis = getAnalysisDirectory(); - - for (Map.Entry> entry : getTaskPipeline().getTypeHierarchy().entrySet()) - { - if (entry.getKey().isType(name)) - { - // TODO: Eventually we will need to actually consult the parameters files - // in order to find files. - - // First try to go two directories up - File dir = dirAnalysis.getParentFile(); - if (dir != null) - { - dir = dir.getParentFile(); - } - - List derivedTypes = entry.getValue(); - for (int i = derivedTypes.size() - 1; i >= 0; i--) - { - // Go two directories up for each level of derivation - if (dir != null) - { - dir = dir.getParentFile(); - } - if (dir != null) - { - dir = dir.getParentFile(); - } - } - - String relativePath = getPipeRoot().relativePath(dir); - File expectedFile = getPipeRoot().resolvePath(relativePath + "/" + name); - - if (!NetworkDrive.exists(expectedFile)) - { - // If the file isn't where we would expect it, check other directories in the same hierarchy - File alternateFile = findFileInAlternateDirectory(expectedFile.getParentFile(), dirAnalysis, name); - if (alternateFile != null) - { - // If we found a file that matches, use it - return alternateFile; - } - } - return expectedFile; - } - } - - // Path of last resort is always to look in the current directory. - return new File(dirAnalysis, name); - } - - /** - * Starting from the expectedDir, look up the chain until getting to the final directory. Return the first - * file that matches by name. - * @param expectedDir where we would have expected the file to be, but it wasn't there - * @param dir must be a descendant of expectedDir, this is the deepest directory that will be inspected - * @param name name of the file to look for - * @return matching file, or null if nothing was found - */ - private File findFileInAlternateDirectory(File expectedDir, File dir, String name) - { - // Bail out if we've gotten all the way down to the originally expected file location - if (dir == null || dir.equals(expectedDir)) - { - return null; - } - // Recurse through the parent directories to find it in the place closest to the expected directory - File result = findFileInAlternateDirectory(expectedDir, dir.getParentFile(), name); - if (result != null) - { - // If we found a match, use it - return result; - } - - result = new File(dir, name); - if (NetworkDrive.exists(result)) - { - return result; - } - return null; - } -} +/* + * Copyright (c) 2008-2018 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.pipeline.analysis; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.Nullable; +import org.labkey.api.pipeline.PipeRoot; +import org.labkey.api.pipeline.TaskId; +import org.labkey.api.pipeline.TaskPipeline; +import org.labkey.api.pipeline.file.AbstractFileAnalysisJob; +import org.labkey.api.pipeline.file.FileAnalysisTaskPipeline; +import org.labkey.api.util.FileType; +import org.labkey.api.util.NetworkDrive; +import org.labkey.api.view.ViewBackgroundInfo; +import org.labkey.vfs.FileLike; + +import java.io.File; +import java.io.IOException; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * FileAnalysisJob + */ +public class FileAnalysisJob extends AbstractFileAnalysisJob +{ + private TaskId _taskPipelineId; + private Map _variableMap; + + private static final Logger LOG = LogManager.getLogger(FileAnalysisJob.class); + + // For serialization + protected FileAnalysisJob() {} + + public FileAnalysisJob(FileAnalysisProtocol protocol, + String providerName, + ViewBackgroundInfo info, + PipeRoot root, + TaskId taskPipelineId, + String protocolName, + FileLike fileParameters, + List filesInput, + @Nullable Map variableMap, + boolean splittable) throws IOException + { + super(protocol, providerName, info, root, protocolName, fileParameters, filesInput, splittable); + + _taskPipelineId = taskPipelineId; + _variableMap = variableMap; + } + + public FileAnalysisJob(FileAnalysisJob job, FileLike fileInput) + { + super(job, fileInput); + + _taskPipelineId = job._taskPipelineId; + _variableMap = job._variableMap; + } + + @Override + public String getDescription() + { + String description = getParameters().get("pipelineDescription"); + if(description != null) + return description; + + return super.getDescription(); + } + + @Override + public Map getParameters() + { + Map parameters = new HashMap<>(super.getParameters()); + if (_variableMap != null && !_variableMap.isEmpty()) + parameters.putAll(_variableMap); + + return Collections.unmodifiableMap(parameters); + } + + @Override + public TaskId getTaskPipelineId() + { + return _taskPipelineId; + } + + @Override + public AbstractFileAnalysisJob createSingleFileJob(FileLike file) + { + return new FileAnalysisJob(this, file); + } + + @Override + public FileAnalysisTaskPipeline getTaskPipeline() + { + TaskPipeline tp = super.getTaskPipeline(); + if (tp == null) + { + LOG.warn("Task pipeline " + _taskPipelineId + " not found."); + } + return (FileAnalysisTaskPipeline) tp; + } + + @Override + public File findInputFile(String name) + { + return findFile(name); + } + + @Override + public File findOutputFile(String name) + { + return findFile(name); + } + + /** + * Look at the specified type hierarchy to see if the requested file is an + * ancestor to this processing job, residing outside the analysis directory. + * + * @param name The name of the file to be located + * @return The file location outside the analysis directory, or null, if no such match is found. + */ + public File findFile(String name) + { + File dirAnalysis = getAnalysisDirectory(); + + for (Map.Entry> entry : getTaskPipeline().getTypeHierarchy().entrySet()) + { + if (entry.getKey().isType(name)) + { + // TODO: Eventually we will need to actually consult the parameters files + // in order to find files. + + // First try to go two directories up + File dir = dirAnalysis.getParentFile(); + if (dir != null) + { + dir = dir.getParentFile(); + } + + List derivedTypes = entry.getValue(); + for (int i = derivedTypes.size() - 1; i >= 0; i--) + { + // Go two directories up for each level of derivation + if (dir != null) + { + dir = dir.getParentFile(); + } + if (dir != null) + { + dir = dir.getParentFile(); + } + } + + String relativePath = getPipeRoot().relativePath(dir); + File expectedFile = getPipeRoot().resolvePath(relativePath + "/" + name); + + if (!NetworkDrive.exists(expectedFile)) + { + // If the file isn't where we would expect it, check other directories in the same hierarchy + File alternateFile = findFileInAlternateDirectory(expectedFile.getParentFile(), dirAnalysis, name); + if (alternateFile != null) + { + // If we found a file that matches, use it + return alternateFile; + } + } + return expectedFile; + } + } + + // Path of last resort is always to look in the current directory. + return new File(dirAnalysis, name); + } + + /** + * Starting from the expectedDir, look up the chain until getting to the final directory. Return the first + * file that matches by name. + * @param expectedDir where we would have expected the file to be, but it wasn't there + * @param dir must be a descendant of expectedDir, this is the deepest directory that will be inspected + * @param name name of the file to look for + * @return matching file, or null if nothing was found + */ + private File findFileInAlternateDirectory(File expectedDir, File dir, String name) + { + // Bail out if we've gotten all the way down to the originally expected file location + if (dir == null || dir.equals(expectedDir)) + { + return null; + } + // Recurse through the parent directories to find it in the place closest to the expected directory + File result = findFileInAlternateDirectory(expectedDir, dir.getParentFile(), name); + if (result != null) + { + // If we found a match, use it + return result; + } + + result = new File(dir, name); + if (NetworkDrive.exists(result)) + { + return result; + } + return null; + } +} diff --git a/pipeline/src/org/labkey/pipeline/analysis/FileAnalysisProtocol.java b/pipeline/src/org/labkey/pipeline/analysis/FileAnalysisProtocol.java index 97350c89b17..f79a53aad95 100644 --- a/pipeline/src/org/labkey/pipeline/analysis/FileAnalysisProtocol.java +++ b/pipeline/src/org/labkey/pipeline/analysis/FileAnalysisProtocol.java @@ -1,74 +1,74 @@ -/* - * Copyright (c) 2008-2017 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.pipeline.analysis; - -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.labkey.api.pipeline.PipeRoot; -import org.labkey.api.pipeline.TaskId; -import org.labkey.api.pipeline.file.AbstractFileAnalysisJob; -import org.labkey.api.pipeline.file.AbstractFileAnalysisProtocol; -import org.labkey.api.util.FileType; -import org.labkey.api.view.ViewBackgroundInfo; -import org.labkey.vfs.FileLike; - -import java.io.IOException; -import java.util.List; -import java.util.Map; - -/** - * FileAnalysisProtocol - */ -public class FileAnalysisProtocol extends AbstractFileAnalysisProtocol -{ - private FileAnalysisProtocolFactory _factory; - - public FileAnalysisProtocol(String name, String description, String xml) - { - super(name, description, xml); - } - - @Override - @NotNull - public List getInputTypes() - { - return _factory.getPipeline().getInitialFileTypes(); - } - - @Override - public FileAnalysisProtocolFactory getFactory() - { - return _factory; - } - - public void setFactory(FileAnalysisProtocolFactory factory) - { - _factory = factory; - } - - @Override - public AbstractFileAnalysisJob createPipelineJob(ViewBackgroundInfo info, PipeRoot root, List filesInput, - FileLike fileParameters, @Nullable Map variableMap - ) throws IOException - { - TaskId id = _factory.getPipeline().getId(); - - boolean splittable = _factory.getPipeline().isSplittable(); - - return new FileAnalysisJob(this, FileAnalysisPipelineProvider.name, info, root, - id, getName(), fileParameters, filesInput, variableMap, splittable); - } -} +/* + * Copyright (c) 2008-2017 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.pipeline.analysis; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.labkey.api.pipeline.PipeRoot; +import org.labkey.api.pipeline.TaskId; +import org.labkey.api.pipeline.file.AbstractFileAnalysisJob; +import org.labkey.api.pipeline.file.AbstractFileAnalysisProtocol; +import org.labkey.api.util.FileType; +import org.labkey.api.view.ViewBackgroundInfo; +import org.labkey.vfs.FileLike; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +/** + * FileAnalysisProtocol + */ +public class FileAnalysisProtocol extends AbstractFileAnalysisProtocol +{ + private FileAnalysisProtocolFactory _factory; + + public FileAnalysisProtocol(String name, String description, String xml) + { + super(name, description, xml); + } + + @Override + @NotNull + public List getInputTypes() + { + return _factory.getPipeline().getInitialFileTypes(); + } + + @Override + public FileAnalysisProtocolFactory getFactory() + { + return _factory; + } + + public void setFactory(FileAnalysisProtocolFactory factory) + { + _factory = factory; + } + + @Override + public AbstractFileAnalysisJob createPipelineJob(ViewBackgroundInfo info, PipeRoot root, List filesInput, + FileLike fileParameters, @Nullable Map variableMap + ) throws IOException + { + TaskId id = _factory.getPipeline().getId(); + + boolean splittable = _factory.getPipeline().isSplittable(); + + return new FileAnalysisJob(this, FileAnalysisPipelineProvider.name, info, root, + id, getName(), fileParameters, filesInput, variableMap, splittable); + } +} diff --git a/pipeline/src/org/labkey/pipeline/api/PipeRootImpl.java b/pipeline/src/org/labkey/pipeline/api/PipeRootImpl.java index 51af9631832..05fe8256843 100644 --- a/pipeline/src/org/labkey/pipeline/api/PipeRootImpl.java +++ b/pipeline/src/org/labkey/pipeline/api/PipeRootImpl.java @@ -384,6 +384,17 @@ public File resolvePath(org.labkey.api.util.Path path) { var parsedPath = org.labkey.api.util.Path.parse(relativePath); + if (ROOT_BASE.cloud.equals(_defaultRoot)) + { + // Return the path to the default location + var combinedPath = StringUtils.isNotBlank(_uris.get(0).getPath()) ? + org.labkey.api.util.Path.parse(_uris.get(0).getPath()).append(parsedPath) : + parsedPath; + return CloudStoreService.get().getFileLike(getContainer(), _cloudStoreName, combinedPath); + // TODO: Do we need? Check that it's under the root to protect against ../../ type paths + } + + var pair = _resolveRoot(parsedPath); if (null == pair) return null; diff --git a/study/src/org/labkey/study/pipeline/StudyPipeline.java b/study/src/org/labkey/study/pipeline/StudyPipeline.java index c9432b1e0dc..9755a5e8bc7 100644 --- a/study/src/org/labkey/study/pipeline/StudyPipeline.java +++ b/study/src/org/labkey/study/pipeline/StudyPipeline.java @@ -28,7 +28,6 @@ import org.labkey.api.view.ViewContext; import org.labkey.study.controllers.StudyController; import org.labkey.study.model.StudyManager; -import org.labkey.vfs.FileLike; import java.io.File; import java.util.ArrayList; From 5c847421dc7cdc8056b8e7f0c77507c43236005a Mon Sep 17 00:00:00 2001 From: labkey-jeckels Date: Mon, 20 Oct 2025 17:07:41 -0700 Subject: [PATCH 06/16] More FileLike --- .../labkey/api/admin/FolderImportContext.java | 17 +++--- .../org/labkey/api/admin/ImportOptions.java | 7 ++- .../api/admin/InvalidFileException.java | 17 ++---- .../cloud/CloudArchiveImporterSupport.java | 2 +- .../org/labkey/api/data/TSVGridWriter.java | 15 +++-- api/src/org/labkey/api/pipeline/PipeRoot.java | 24 ++------ .../api/pipeline/PipelineProtocolFactory.java | 2 +- .../labkey/api/pipeline/PipelineService.java | 2 +- .../org/labkey/api/pipeline/PipelineUrls.java | 3 +- .../api/pipeline/browse/PipelinePathForm.java | 3 - .../pipeline/file/FileAnalysisJobSupport.java | 29 +++++++--- api/src/org/labkey/api/util/FileUtil.java | 11 +++- .../org/labkey/api/writer/FileSystemFile.java | 6 ++ .../labkey/core/admin/AdminController.java | 32 ++++++----- .../admin/ValidateDomainsPipelineJob.java | 4 +- .../controllers/exp/ExperimentController.java | 2 +- .../pipeline/ExperimentPipelineProvider.java | 2 +- .../experiment/pipeline/SampleReloadTask.java | 2 +- .../labkey/pipeline/PipelineController.java | 29 +++++----- .../org/labkey/pipeline/api/PipeRootImpl.java | 38 ++++--------- .../labkey/pipeline/api/PipelineManager.java | 57 +++++++++---------- .../pipeline/api/PipelineServiceImpl.java | 10 ++-- .../pipeline/importer/FolderImportJob.java | 7 ++- .../pipeline/importer/FolderImportTask.java | 8 +-- .../specimen/pipeline/SpecimenReloadJob.java | 2 +- .../study/importer/StudyImporterFactory.java | 2 +- 26 files changed, 157 insertions(+), 176 deletions(-) diff --git a/api/src/org/labkey/api/admin/FolderImportContext.java b/api/src/org/labkey/api/admin/FolderImportContext.java index 006b704a66a..786f5a31650 100644 --- a/api/src/org/labkey/api/admin/FolderImportContext.java +++ b/api/src/org/labkey/api/admin/FolderImportContext.java @@ -29,11 +29,10 @@ import org.labkey.api.util.XmlValidationException; import org.labkey.api.writer.VirtualFile; import org.labkey.folder.xml.FolderDocument; +import org.labkey.vfs.FileLike; import java.io.IOException; import java.io.InputStream; -import java.nio.file.Files; -import java.nio.file.Path; import java.util.HashMap; import java.util.HashSet; import java.util.Map; @@ -47,7 +46,7 @@ */ public class FolderImportContext extends AbstractFolderContext { - private Path _folderXml; + private FileLike _folderXml; private String _xarJobId; @@ -66,7 +65,7 @@ public FolderImportContext() super(null, null, null, null, null, null); } - public FolderImportContext(User user, Container c, Path folderXml, Set dataTypes, LoggerGetter logger, VirtualFile root) + public FolderImportContext(User user, Container c, FileLike folderXml, Set dataTypes, LoggerGetter logger, VirtualFile root) { super(user, c, null, dataTypes, logger, root); _folderXml = folderXml; @@ -112,17 +111,17 @@ public synchronized FolderDocument getDocument() throws ImportException return folderDoc; } - private FolderDocument readFolderDocument(Path folderXml) throws ImportException, IOException + private FolderDocument readFolderDocument(FileLike folderXml) throws ImportException, IOException { - if (!Files.exists(folderXml)) - throw new ImportException(folderXml.getFileName() + " file does not exist."); + if (!folderXml.exists()) + throw new ImportException(folderXml.getName() + " file does not exist."); FolderDocument folderDoc; - try (InputStream inputStream = Files.newInputStream(folderXml)) + try (InputStream inputStream = folderXml.openInputStream()) { folderDoc = FolderDocument.Factory.parse(inputStream, XmlBeansUtil.getDefaultParseOptions()); - XmlBeansUtil.validateXmlDocument(folderDoc, folderXml.getFileName().toString()); + XmlBeansUtil.validateXmlDocument(folderDoc, folderXml.getName()); } catch (XmlException | XmlValidationException e) { diff --git a/api/src/org/labkey/api/admin/ImportOptions.java b/api/src/org/labkey/api/admin/ImportOptions.java index 020a175dbc3..ce40a2f73cd 100644 --- a/api/src/org/labkey/api/admin/ImportOptions.java +++ b/api/src/org/labkey/api/admin/ImportOptions.java @@ -19,6 +19,7 @@ import org.labkey.api.data.Activity; import org.labkey.api.security.User; import org.labkey.api.security.UserManager; +import org.labkey.vfs.FileLike; import java.nio.file.Path; import java.util.Collection; @@ -42,7 +43,7 @@ public class ImportOptions private final Collection _messages = new LinkedList<>(); private Set _dataTypes; private Activity _activity; - private Path _analysisDir; + private FileLike _analysisDir; private String _folderArchiveSourceName = null; private boolean _isNewFolderImport; // if we know the target folder is empty, can skip certain merge logic @@ -153,12 +154,12 @@ public void setActivity(Activity activity) _activity = activity; } - public Path getAnalysisDir() + public FileLike getAnalysisDir() { return _analysisDir; } - public void setAnalysisDir(Path analysisDir) + public void setAnalysisDir(FileLike analysisDir) { _analysisDir = analysisDir; } diff --git a/api/src/org/labkey/api/admin/InvalidFileException.java b/api/src/org/labkey/api/admin/InvalidFileException.java index c156765b723..e5182175593 100644 --- a/api/src/org/labkey/api/admin/InvalidFileException.java +++ b/api/src/org/labkey/api/admin/InvalidFileException.java @@ -20,18 +20,19 @@ import org.apache.xmlbeans.XmlException; import org.labkey.api.util.XmlValidationException; import org.labkey.api.writer.VirtualFile; +import org.labkey.vfs.FileLike; import java.io.File; import java.nio.file.Path; public class InvalidFileException extends ImportException { - @Deprecated // prefer the Path version - public InvalidFileException(File root, File file, Throwable t) + public InvalidFileException(FileLike root, FileLike file, Throwable t) { - this(root.toPath(), file.toPath(), t); + this(root.toNioPathForRead(), file.toNioPathForRead(), t); } + @Deprecated // prefer the FileLike version public InvalidFileException(Path root, Path file, Throwable t) { super(getErrorString(root, file, t.getMessage())); @@ -42,21 +43,11 @@ public InvalidFileException(VirtualFile root, File file, Throwable t) super(getErrorString(root.getRelativePath(file.getName()), t.getMessage())); } - public InvalidFileException(File root, File file, XmlException e) - { - super(getErrorString(root, file, e)); - } - public InvalidFileException(VirtualFile root, File file, XmlException e) { super(getErrorString(root, file, e)); } - public InvalidFileException(File root, File file, XmlValidationException e) - { - super(getErrorString(root, file, (String)null), e); - } - public InvalidFileException(VirtualFile root, File file, XmlValidationException e) { super(getErrorString(root.getRelativePath(file.getName()), null), e); diff --git a/api/src/org/labkey/api/cloud/CloudArchiveImporterSupport.java b/api/src/org/labkey/api/cloud/CloudArchiveImporterSupport.java index a55bfee6af7..856eb9837f1 100644 --- a/api/src/org/labkey/api/cloud/CloudArchiveImporterSupport.java +++ b/api/src/org/labkey/api/cloud/CloudArchiveImporterSupport.java @@ -26,7 +26,7 @@ public interface CloudArchiveImporterSupport default void downloadCloudArchive(@NotNull PipelineJob job, @NotNull Path studyXml, BindException errors) throws UnsupportedOperationException { //check if cloud based pipeline root, and study xml hasn't been downloaded already - if (!studyXml.startsWith(job.getPipeRoot().getImportDirectory().toPath().toAbsolutePath())) + if (!studyXml.startsWith(job.getPipeRoot().getImportDirectory().toNioPathForRead().toAbsolutePath())) { if (CloudStoreService.get() != null) //proxy of is Cloud Module enabled for the current job/container { diff --git a/api/src/org/labkey/api/data/TSVGridWriter.java b/api/src/org/labkey/api/data/TSVGridWriter.java index a678c886d39..70f54014457 100644 --- a/api/src/org/labkey/api/data/TSVGridWriter.java +++ b/api/src/org/labkey/api/data/TSVGridWriter.java @@ -22,10 +22,9 @@ import org.jetbrains.annotations.Nullable; import org.labkey.api.collections.ResultSetRowMapFactory; import org.labkey.api.query.FieldKey; -import org.labkey.api.util.FileUtil; import org.labkey.api.view.HttpView; +import org.labkey.vfs.FileLike; -import java.io.File; import java.io.IOException; import java.sql.SQLException; import java.util.ArrayList; @@ -198,7 +197,7 @@ private int writeBody(Results results) * @return List of the output Files. */ @NotNull - public List writeBatchFiles(@NotNull File outputDir, @NotNull String baseName, @Nullable String extension, int batchSize, @Nullable FieldKey batchColumn) + public List writeBatchFiles(@NotNull FileLike outputDir, @NotNull String baseName, @Nullable String extension, int batchSize, @Nullable FieldKey batchColumn) { extension = StringUtils.trimToEmpty(extension); String ext = "".equals(extension) || extension.startsWith(".") ? extension : "." + extension; @@ -211,13 +210,13 @@ public List writeBatchFiles(@NotNull File outputDir, @NotNull String baseN } @NotNull - private List writeResultSetBatches(Results results, File outputDir, String baseName, String extension, int batchSize, @Nullable FieldKey batchColumn) throws IOException + private List writeResultSetBatches(Results results, FileLike outputDir, String baseName, String extension, int batchSize, @Nullable FieldKey batchColumn) throws IOException { int currentBatchSize = 0; int totalBatches = 1; Object previousBatchColumnValue = null; Object newBatchColumnValue; - List outputFiles = new ArrayList<>(); + List outputFiles = new ArrayList<>(); outputFiles.add(startBatchFile(outputDir, baseName, extension, batchSize, totalBatches)); RenderContext ctx = getRenderContext(); ctx.setResults(results); @@ -264,11 +263,11 @@ private List writeResultSetBatches(Results results, File outputDir, String } @NotNull - private File startBatchFile(File outputDir, String baseName, String extension, int batchSize, int totalBatches) throws IOException + private FileLike startBatchFile(FileLike outputDir, String baseName, String extension, int batchSize, int totalBatches) throws IOException { String batchId = batchSize == 0 ? "" : "-" + totalBatches; - File file = FileUtil.appendName(outputDir, baseName + batchId + extension); - prepare(file); + FileLike file = outputDir.resolveChild(baseName + batchId + extension); + prepare(file.openOutputStream()); writeFileHeader(); if (isHeaderRowVisible()) writeColumnHeaders(); diff --git a/api/src/org/labkey/api/pipeline/PipeRoot.java b/api/src/org/labkey/api/pipeline/PipeRoot.java index d71491f9586..80138b4b122 100644 --- a/api/src/org/labkey/api/pipeline/PipeRoot.java +++ b/api/src/org/labkey/api/pipeline/PipeRoot.java @@ -25,7 +25,6 @@ import org.labkey.api.security.User; import org.labkey.api.security.permissions.Permission; import org.labkey.vfs.FileLike; -import org.labkey.vfs.FileSystemLike; import java.io.File; import java.net.URI; @@ -89,21 +88,16 @@ public interface PipeRoot extends SecurableResource @Nullable FileLike resolvePathToFileLike(String relativePath); - /** - * Get a local directory that can be used for importing (Read/Write) - * - * Cloud: Uses a temp directory - * Default: Uses folder within the file root - */ @NotNull - File getImportDirectory(); + FileLike getImportDirectory(); /** * Delete the import directory and its contents + * * @return File object for import directory * @throws DirectoryNotDeletedException if import directory exists and cannot be deleted */ - Path deleteImportDirectory(@Nullable Logger log) throws DirectoryNotDeletedException; + FileLike deleteImportDirectory(@Nullable Logger log) throws DirectoryNotDeletedException; /** @return relative path to the file from the root. null if the file isn't under the root. Does not include a leading slash */ String relativePath(File file); @@ -125,18 +119,8 @@ public interface PipeRoot extends SecurableResource /** Creates a .labkey directory if it's not present and returns it. Used for things like protocol definition files, * log files for some upgrade tasks, etc. Its contents are generally not exposed directly to the user */ - @Deprecated // prefer ensureSystemFileLike() @NotNull - File ensureSystemDirectory(); - - @Deprecated // prefer ensureSystemFileLike() - @NotNull - Path ensureSystemDirectoryPath(); - - default FileLike ensureSystemFileLike() - { - return new FileSystemLike.Builder(ensureSystemDirectory()).readwrite().root(); - } + FileLike ensureSystemDirectory(); /** @return the entityId for this pipeline root, used to store permissions */ String getEntityId(); diff --git a/api/src/org/labkey/api/pipeline/PipelineProtocolFactory.java b/api/src/org/labkey/api/pipeline/PipelineProtocolFactory.java index 96ad3285f33..94dced631af 100644 --- a/api/src/org/labkey/api/pipeline/PipelineProtocolFactory.java +++ b/api/src/org/labkey/api/pipeline/PipelineProtocolFactory.java @@ -47,7 +47,7 @@ public abstract class PipelineProtocolFactory public static FileLike getProtocolRootDir(PipeRoot root) { - FileLike systemDir = root.ensureSystemFileLike(); + FileLike systemDir = root.ensureSystemDirectory(); return systemDir.resolveChild(_pipelineProtocolDir); } diff --git a/api/src/org/labkey/api/pipeline/PipelineService.java b/api/src/org/labkey/api/pipeline/PipelineService.java index 823ab57515c..f87384fb70d 100644 --- a/api/src/org/labkey/api/pipeline/PipelineService.java +++ b/api/src/org/labkey/api/pipeline/PipelineService.java @@ -196,7 +196,7 @@ void rememberLastProtocolSetting(PipelineProtocolFactory factory, Container c TableInfo getJobsTable(User user, Container container, @Nullable ContainerFilter cf); - boolean runFolderImportJob(Container c, User user, ActionURL url, Path folderXml, String originalFilename, PipeRoot pipelineRoot, ImportOptions options); + boolean runFolderImportJob(Container c, User user, ActionURL url, FileLike folderXml, String originalFilename, PipeRoot pipelineRoot, ImportOptions options); /** * Register a folder archive source implementation. A FolderArchiveSource creates folder artifacts that can be diff --git a/api/src/org/labkey/api/pipeline/PipelineUrls.java b/api/src/org/labkey/api/pipeline/PipelineUrls.java index e292f64de05..c0c098db078 100644 --- a/api/src/org/labkey/api/pipeline/PipelineUrls.java +++ b/api/src/org/labkey/api/pipeline/PipelineUrls.java @@ -22,6 +22,7 @@ import org.labkey.api.data.Container; import org.labkey.api.util.URLHelper; import org.labkey.api.view.ActionURL; +import org.labkey.vfs.FileLike; import java.nio.file.Path; @@ -41,7 +42,7 @@ public interface PipelineUrls extends UrlProvider ActionURL urlActions(Container container); - ActionURL urlStartFolderImport(Container container, @NotNull Path archiveFile, @Nullable ImportOptions options, boolean fromTemplateSourceFolder); + ActionURL urlStartFolderImport(Container container, @NotNull FileLike archiveFile, @Nullable ImportOptions options, boolean fromTemplateSourceFolder); ActionURL urlCreatePipelineTrigger(Container container, String pipelineId, @Nullable ActionURL returnUrl); diff --git a/api/src/org/labkey/api/pipeline/browse/PipelinePathForm.java b/api/src/org/labkey/api/pipeline/browse/PipelinePathForm.java index 60e98c6e642..7db45135681 100644 --- a/api/src/org/labkey/api/pipeline/browse/PipelinePathForm.java +++ b/api/src/org/labkey/api/pipeline/browse/PipelinePathForm.java @@ -21,14 +21,11 @@ import org.labkey.api.pipeline.PipeRoot; import org.labkey.api.pipeline.PipelineService; import org.labkey.api.security.permissions.ReadPermission; -import org.labkey.api.util.FileUtil; import org.labkey.api.util.NetworkDrive; import org.labkey.api.view.NotFoundException; import org.labkey.api.view.ViewForm; import org.labkey.vfs.FileLike; -import java.io.File; -import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; diff --git a/api/src/org/labkey/api/pipeline/file/FileAnalysisJobSupport.java b/api/src/org/labkey/api/pipeline/file/FileAnalysisJobSupport.java index 532a7150922..0a23361774e 100644 --- a/api/src/org/labkey/api/pipeline/file/FileAnalysisJobSupport.java +++ b/api/src/org/labkey/api/pipeline/file/FileAnalysisJobSupport.java @@ -19,10 +19,12 @@ import org.jetbrains.annotations.Nullable; import org.labkey.api.pipeline.ParamParser; import org.labkey.api.util.FileType; +import org.labkey.api.util.UnexpectedException; import org.labkey.vfs.FileLike; import org.labkey.vfs.FileSystemLike; import java.io.File; +import java.io.IOException; import java.nio.file.Path; import java.util.List; import java.util.Map; @@ -77,14 +79,8 @@ default File getDataDirectory() * @return the directory where the input files reside, and where the * final analysis should end up. */ - @Deprecated // Please use getAnalysisDirectoryPath instead, as File objects may have issues with full URIs + @Deprecated // Please use getAnalysisDirectoryFileLike File getAnalysisDirectory(); - default Path getAnalysisDirectoryPath() - { - // TODO This needs implementation in derived classes... - // This is typically safe but may cause an error if FileSystem provider isn't configured - return getAnalysisDirectory().toPath(); - } default FileLike getAnalysisDirectoryFileLike() { @@ -98,14 +94,31 @@ default FileLike getAnalysisDirectoryFileLike() * This allows the task definitions to name files they require as input, * and the pipeline definition to specify where those files should come from. */ - @Deprecated // Please use findInputPath instead, as File objects may have issues with full URIs + @Deprecated // Please use findInputFileLike instead, as File objects may have issues with full URIs File findInputFile(String name); + @Deprecated // Please use findInputFileLike instead, as File objects may have issues with full URIs default Path findInputPath(String filepath) { // TODO This needs implementation in derived classes... // This is typically safe but may cause an error if FileSystem provider isn't configured return findInputFile(filepath).toPath(); } + default FileLike findInputFileLike(String filepath) + { + File file = findInputFile(filepath); + if (file != null) + { + try + { + return FileSystemLike.wrapFile(getDataDirectory(), file); + } + catch (IOException e) + { + throw UnexpectedException.wrap(e); + } + } + return null; + } /** * Returns a file for use as output in the pipeline, given its name. diff --git a/api/src/org/labkey/api/util/FileUtil.java b/api/src/org/labkey/api/util/FileUtil.java index eba329b567c..7529d5712c2 100644 --- a/api/src/org/labkey/api/util/FileUtil.java +++ b/api/src/org/labkey/api/util/FileUtil.java @@ -212,19 +212,24 @@ public static boolean deleteDir(File dir) return deleteDir(dir, null); } - public static boolean deleteDir(FileLike dir) + public static boolean deleteDir(@NotNull FileLike dir) { return deleteDir(dir.toNioPathForWrite(), null); } + public static boolean deleteDir(@NotNull FileLike dir, @Nullable Logger log) + { + return deleteDir(dir.toNioPathForWrite(), log); + } + @Deprecated - public static boolean deleteDir(@NotNull File dir, Logger log) + public static boolean deleteDir(@NotNull File dir, @Nullable Logger log) { return deleteDir(dir.toPath(), log); } - public static boolean deleteDir(Path dir, Logger log) + public static boolean deleteDir(@NotNull Path dir, @Nullable Logger log) { //TODO seems like this could be reworked to use Files.walkFileTree log = log == null ? LOG : log; diff --git a/api/src/org/labkey/api/writer/FileSystemFile.java b/api/src/org/labkey/api/writer/FileSystemFile.java index 298c2f0b42f..2ec36c79d15 100644 --- a/api/src/org/labkey/api/writer/FileSystemFile.java +++ b/api/src/org/labkey/api/writer/FileSystemFile.java @@ -23,6 +23,7 @@ import org.labkey.api.util.MinorConfigurationException; import org.labkey.api.util.XmlBeansUtil; import org.labkey.api.util.XmlValidationException; +import org.labkey.vfs.FileLike; import java.io.BufferedInputStream; import java.io.File; @@ -59,6 +60,11 @@ public FileSystemFile(File root) this(root.toPath()); } + public FileSystemFile(FileLike root) + { + this(root.toNioPathForWrite()); + } + public FileSystemFile(Path root) { try diff --git a/core/src/org/labkey/core/admin/AdminController.java b/core/src/org/labkey/core/admin/AdminController.java index a24b9b65076..35f962bac26 100644 --- a/core/src/org/labkey/core/admin/AdminController.java +++ b/core/src/org/labkey/core/admin/AdminController.java @@ -333,6 +333,7 @@ import org.labkey.data.xml.TablesDocument; import org.labkey.filters.ContentSecurityPolicyFilter; import org.labkey.security.xml.GroupEnumType; +import org.labkey.vfs.FileLike; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.validation.BindException; import org.springframework.validation.Errors; @@ -5336,7 +5337,7 @@ public boolean handlePost(ImportFolderForm form, BindException errors) throws Ex User user = getUser(); Container container = getContainer(); PipeRoot pipelineRoot; - Path pipelineUnzipDir; // Should be local & writable + FileLike pipelineUnzipDir; // Should be local & writable PipelineUrls pipelineUrlProvider; if (form.getOrigin() == null) @@ -5386,8 +5387,8 @@ public boolean handlePost(ImportFolderForm form, BindException errors) throws Ex } // get the folder.xml file from the unzipped import archive - Path archiveXml = pipelineUnzipDir.resolve("folder.xml"); - if (!Files.exists(archiveXml)) + FileLike archiveXml = pipelineUnzipDir.resolveChild("folder.xml"); + if (!archiveXml.exists()) { errors.reject("folderImport", "This archive doesn't contain a folder.xml file."); return false; @@ -5415,7 +5416,7 @@ public boolean handlePost(ImportFolderForm form, BindException errors) throws Ex return !errors.hasErrors(); } - private @Nullable FolderImportConfig getFolderFromZipArchive(Path pipelineUnzipDir, BindException errors) + private @Nullable FolderImportConfig getFolderFromZipArchive(FileLike pipelineUnzipDir, BindException errors) { // user chose to import from a zip file Map map = getFileMap(); @@ -5439,17 +5440,17 @@ public boolean handlePost(ImportFolderForm form, BindException errors) throws Ex // copy and unzip the uploaded import archive zip file to the pipeline unzip dir try { - Path pipelineUnzipFile = pipelineUnzipDir.resolve(originalFilename); + FileLike pipelineUnzipFile = pipelineUnzipDir.resolveFile(org.labkey.api.util.Path.parse(originalFilename)); // Check that the resolved file is under the pipelineUnzipDir - if (!pipelineUnzipFile.normalize().startsWith(pipelineUnzipDir.normalize())) + if (!pipelineUnzipFile.toNioPathForRead().normalize().startsWith(pipelineUnzipDir.toNioPathForRead().normalize())) { errors.reject("folderImport", "Invalid file path - must be within the unzip directory"); return null; } FileUtil.createDirectories(pipelineUnzipFile.getParent()); // Non-pipeline import sometimes fails here on Windows (shrug) - FileUtil.createFile(pipelineUnzipFile); - try (OutputStream os = Files.newOutputStream(pipelineUnzipFile)) + FileUtil.createNewFile(pipelineUnzipFile, true); + try (OutputStream os = pipelineUnzipFile.openOutputStream()) { FileUtil.copyData(zipFile.getInputStream(), os); } @@ -5476,7 +5477,7 @@ public boolean handlePost(ImportFolderForm form, BindException errors) throws Ex } } - private FolderImportConfig getFolderImportConfigFromTemplateFolder(final ImportFolderForm form, final Path pipelineUnzipDir, final BindException errors) throws Exception + private FolderImportConfig getFolderImportConfigFromTemplateFolder(final ImportFolderForm form, final FileLike pipelineUnzipDir, final BindException errors) throws Exception { // user choose to import from a template source folder Container sourceContainer = form.getSourceTemplateFolderContainer(); @@ -5488,7 +5489,8 @@ private FolderImportConfig getFolderImportConfigFromTemplateFolder(final ImportF PHI.NotPHI, false, false, false, new StaticLoggerGetter(LogManager.getLogger(FolderWriterImpl.class))); FolderWriterImpl writer = new FolderWriterImpl(); String zipFileName = FileUtil.makeFileNameWithTimestamp(sourceContainer.getName(), "folder.zip"); - try (ZipFile zip = new ZipFile(pipelineUnzipDir, zipFileName)) + try (OutputStream out = pipelineUnzipDir.openOutputStream(); + ZipFile zip = new ZipFile(out, false)) { writer.write(sourceContainer, ctx, zip); } @@ -5496,26 +5498,26 @@ private FolderImportConfig getFolderImportConfigFromTemplateFolder(final ImportF { errors.reject(SpringActionController.ERROR_MSG, e.getMessage()); } - Path implicitZipFile = pipelineUnzipDir.resolve(zipFileName); + FileLike implicitZipFile = pipelineUnzipDir.resolveChild(zipFileName); // To support the simple import option unzip the zip file to the pipeline unzip dir of the current container ZipUtil.unzipToDirectory(implicitZipFile, pipelineUnzipDir); return new FolderImportConfig( StringUtils.isNotEmpty(form.getSourceTemplateFolderId()), - implicitZipFile.getFileName().toString(), + implicitZipFile.getName(), implicitZipFile, null ); } private static class FolderImportConfig { - Path pipelineUnzipFile; + FileLike pipelineUnzipFile; String originalFileName; - Path archiveFile; + FileLike archiveFile; boolean fromTemplateSourceFolder; - public FolderImportConfig(boolean fromTemplateSourceFolder, String originalFileName, Path archiveFile, @Nullable Path pipelineUnzipFile) + public FolderImportConfig(boolean fromTemplateSourceFolder, String originalFileName, FileLike archiveFile, @Nullable FileLike pipelineUnzipFile) { this.originalFileName = originalFileName; this.archiveFile = archiveFile; diff --git a/core/src/org/labkey/core/admin/ValidateDomainsPipelineJob.java b/core/src/org/labkey/core/admin/ValidateDomainsPipelineJob.java index eff12e58b64..6cdc7d51b3f 100644 --- a/core/src/org/labkey/core/admin/ValidateDomainsPipelineJob.java +++ b/core/src/org/labkey/core/admin/ValidateDomainsPipelineJob.java @@ -23,8 +23,8 @@ import org.labkey.api.util.URLHelper; import org.labkey.api.util.UnexpectedException; import org.labkey.api.view.ViewBackgroundInfo; +import org.labkey.vfs.FileLike; -import java.io.File; import java.io.IOException; /** @@ -43,7 +43,7 @@ public ValidateDomainsPipelineJob(ViewBackgroundInfo info, PipeRoot root) try { - File logFile = FileUtil.createTempFile("validateDomains", ".log", root.ensureSystemDirectory()); + FileLike logFile = FileUtil.createTempFile("validateDomains", ".log", root.ensureSystemDirectory()); setLogFile(logFile); } catch (IOException e) diff --git a/experiment/src/org/labkey/experiment/controllers/exp/ExperimentController.java b/experiment/src/org/labkey/experiment/controllers/exp/ExperimentController.java index 74d36678560..52e31d54d21 100644 --- a/experiment/src/org/labkey/experiment/controllers/exp/ExperimentController.java +++ b/experiment/src/org/labkey/experiment/controllers/exp/ExperimentController.java @@ -6744,7 +6744,7 @@ public Object execute(Object o, BindException errors) throws Exception } PipeRoot pipeRoot = PipelineService.get().findPipelineRoot(getContainer()); - FileLike systemDir = pipeRoot.ensureSystemFileLike(); + FileLike systemDir = pipeRoot.ensureSystemDirectory(); FileLike uploadDir = systemDir.resolveChild("UploadedXARs"); FileUtil.createDirectories(uploadDir); if (!uploadDir.isDirectory()) diff --git a/experiment/src/org/labkey/experiment/pipeline/ExperimentPipelineProvider.java b/experiment/src/org/labkey/experiment/pipeline/ExperimentPipelineProvider.java index 4d797234e81..307413bbb58 100644 --- a/experiment/src/org/labkey/experiment/pipeline/ExperimentPipelineProvider.java +++ b/experiment/src/org/labkey/experiment/pipeline/ExperimentPipelineProvider.java @@ -49,7 +49,7 @@ public static Path getMoveDirectory(PipeRoot pr) private static Path getExperimentDirectory(PipeRoot pr, String name) { - Path systemDir = pr.ensureSystemDirectoryPath(); + Path systemDir = pr.ensureSystemDirectory().toNioPathForRead(); return systemDir.resolve(DIR_NAME_EXPERIMENT).resolve(name); } diff --git a/experiment/src/org/labkey/experiment/pipeline/SampleReloadTask.java b/experiment/src/org/labkey/experiment/pipeline/SampleReloadTask.java index 5237faa9b48..ff7594d64c9 100644 --- a/experiment/src/org/labkey/experiment/pipeline/SampleReloadTask.java +++ b/experiment/src/org/labkey/experiment/pipeline/SampleReloadTask.java @@ -65,7 +65,7 @@ public RecordedActionSet run() { PipelineJob job = getJob(); FileAnalysisJobSupport support = job.getJobSupport(FileAnalysisJobSupport.class); - job.setLogFile(FileUtil.appendName(support.getDataDirectory(), FileUtil.makeFileNameWithTimestamp("triggered_sample_reload", "log"))); + job.setLogFile(support.getDataDirectoryFileLike().resolveChild(FileUtil.makeFileNameWithTimestamp("triggered_sample_reload", "log"))); Map params = support.getParameters(); job.setStatus("RELOADING", "Job started at: " + DateUtil.nowISO()); diff --git a/pipeline/src/org/labkey/pipeline/PipelineController.java b/pipeline/src/org/labkey/pipeline/PipelineController.java index c2e296f1c4b..f47a38279c9 100644 --- a/pipeline/src/org/labkey/pipeline/PipelineController.java +++ b/pipeline/src/org/labkey/pipeline/PipelineController.java @@ -115,6 +115,7 @@ import org.labkey.pipeline.api.PipelineServiceImpl; import org.labkey.pipeline.api.PipelineStatusManager; import org.labkey.pipeline.status.StatusController; +import org.labkey.vfs.FileLike; import org.springframework.beans.MutablePropertyValues; import org.springframework.validation.BindException; import org.springframework.validation.Errors; @@ -1172,10 +1173,10 @@ public class ImportFolderFromPipelineAction extends SimpleRedirectAction _importContainers = new ArrayList<>(); private String _navTrail = "Import Folder"; - private java.nio.file.Path _archiveFile; + private FileLike _archiveFile; @Override public void validateCommand(StartFolderImportForm form, Errors errors) @@ -1221,7 +1222,8 @@ else if (form.getFilePath() == null) } else { - _archiveFile = PipelineManager.validateFolderImportFileNioPath(form.getFilePath(), currentPipelineRoot, errors); + // We no longer support absolute paths - should be relative to the pipeline root + _archiveFile = currentPipelineRoot.resolvePathToFileLike(form.getFilePath()); if (OptionalFeatureService.get().isFeatureEnabled(PipelineModule.ADVANCED_IMPORT_FLAG)) { @@ -1298,14 +1300,14 @@ public boolean handlePost(StartFolderImportForm form, BindException errors) thro { User user = getUser(); boolean success = true; - Map containerArchiveXmlMap = new HashMap<>(); + Map containerArchiveXmlMap = new HashMap<>(); - if (Files.exists(_archiveFile)) + if (_archiveFile.exists()) { // iterate over the selected containers, or just the current container in the default case, and unzip the archive if necessary for (Container container : _importContainers) { - java.nio.file.Path archiveXml = PipelineManager.getArchiveXmlFile(container, _archiveFile, "folder.xml", errors); + FileLike archiveXml = PipelineManager.getArchiveXmlFile(container, _archiveFile, "folder.xml", errors); if (errors.hasErrors()) return false; @@ -1342,12 +1344,12 @@ public boolean handlePost(StartFolderImportForm form, BindException errors) thro return success; } - private boolean createImportPipelineJob(Container container, User user, ImportOptions options, java.nio.file.Path archiveXml) + private boolean createImportPipelineJob(Container container, User user, ImportOptions options, FileLike archiveXml) { PipeRoot pipelineRoot = PipelineService.get().findPipelineRoot(container); ActionURL url = getViewContext().getActionURL(); - return PipelineService.get().runFolderImportJob(container, user, url, archiveXml, _archiveFile.getFileName().toString(), pipelineRoot, options); + return PipelineService.get().runFolderImportJob(container, user, url, archiveXml, _archiveFile.getName(), pipelineRoot, options); } @Override @@ -1721,11 +1723,11 @@ public ActionURL urlActions(Container container) } @Override - public ActionURL urlStartFolderImport(Container container, @NotNull java.nio.file.Path archiveFile, @Nullable ImportOptions options, boolean fromTemplateSourceFolder) + public ActionURL urlStartFolderImport(Container container, @NotNull FileLike archiveFile, @Nullable ImportOptions options, boolean fromTemplateSourceFolder) { ActionURL url = new ActionURL(StartFolderImportAction.class, container); - return addStartImportParameters(url, archiveFile, options, fromTemplateSourceFolder); + return addStartImportParameters(container, url, archiveFile, options, fromTemplateSourceFolder); } @Override @@ -1742,9 +1744,10 @@ public ActionURL urlCreatePipelineTrigger(Container container, String pipelineId return url; } - private ActionURL addStartImportParameters(ActionURL url, @NotNull java.nio.file.Path file, @Nullable ImportOptions options, boolean fromTemplateSourceFolder) + private ActionURL addStartImportParameters(Container container, ActionURL url, @NotNull FileLike file, @Nullable ImportOptions options, boolean fromTemplateSourceFolder) { - url.addParameter("filePath", file.toAbsolutePath().toString()); + PipeRoot pipelineRoot = PipelineService.get().findPipelineRoot(container); + url.addParameter("filePath", pipelineRoot.relativePath(file)); url.addParameter("validateQueries", options == null || !options.isSkipQueryValidation()); url.addParameter("createSharedDatasets", options == null || options.isCreateSharedDatasets()); if (options != null) diff --git a/pipeline/src/org/labkey/pipeline/api/PipeRootImpl.java b/pipeline/src/org/labkey/pipeline/api/PipeRootImpl.java index 05fe8256843..afac2badb02 100644 --- a/pipeline/src/org/labkey/pipeline/api/PipeRootImpl.java +++ b/pipeline/src/org/labkey/pipeline/api/PipeRootImpl.java @@ -146,17 +146,7 @@ public PipeRootImpl(PipelineRoot root) @Override @NotNull - public File ensureSystemDirectory() - { - Path path = ensureSystemDirectoryPath(); - if (FileUtil.hasCloudScheme(path)) - throw new RuntimeException("System Dir is not on file system."); - return path.toFile(); - } - - @Override - @NotNull - public Path ensureSystemDirectoryPath() + public FileLike ensureSystemDirectory() { Path root = getRootNioPath(); Path systemDir = root.resolve(SYSTEM_DIRECTORY_NAME); @@ -184,7 +174,9 @@ public Path ensureSystemDirectoryPath() } } - return systemDir; + if (FileUtil.hasCloudScheme(systemDir)) + throw new RuntimeException("System Dir is not on file system."); + return new FileSystemLike.Builder(systemDir).readwrite().root(); } @Override @@ -490,28 +482,22 @@ public Path resolveToNioPathFromUrl(String url) return null; } - /** - * Get a local directory that can be used for importing (Read/Write) - * - * Cloud: Uses temp directory - * Default: Uses file root - */ @Override @NotNull - public File getImportDirectory() + public FileLike getImportDirectory() { // If pipeline root is in File system, return that; otherwise return temp directory - File root = isCloudRoot() ? - FileUtil.getTempDirectory() : - getRootPath(); - return FileUtil.appendName(root, PipelineService.UNZIP_DIR); + FileLike root = isCloudRoot() ? + FileUtil.getTempDirectoryFileLike() : + getRootFileLike(); + return root.resolveChild(PipelineService.UNZIP_DIR); } @Override - public Path deleteImportDirectory(@Nullable Logger logger) throws DirectoryNotDeletedException + public FileLike deleteImportDirectory(@Nullable Logger logger) throws DirectoryNotDeletedException { - Path importDir = getImportDirectory().toPath(); - if (Files.exists(importDir) && !FileUtil.deleteDir(importDir, logger)) + FileLike importDir = getImportDirectory(); + if (importDir.exists() && !FileUtil.deleteDir(importDir, logger)) { throw new DirectoryNotDeletedException("Could not delete the directory \"" + PipelineService.UNZIP_DIR + "\""); } diff --git a/pipeline/src/org/labkey/pipeline/api/PipelineManager.java b/pipeline/src/org/labkey/pipeline/api/PipelineManager.java index c1b2f30b6e3..44f2b9682bb 100644 --- a/pipeline/src/org/labkey/pipeline/api/PipelineManager.java +++ b/pipeline/src/org/labkey/pipeline/api/PipelineManager.java @@ -82,10 +82,10 @@ import org.labkey.folder.xml.FolderDocument; import org.labkey.pipeline.query.TriggerConfigurationsTable; import org.labkey.pipeline.status.StatusController; +import org.labkey.vfs.FileLike; import org.springframework.validation.BindException; import org.springframework.validation.Errors; -import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; @@ -149,11 +149,11 @@ public static PipelineRoot findPipelineRoot(@NotNull Container container, String } - static public PipelineRoot[] getPipelineRoots(String type) + static public List getPipelineRoots(String type) { SimpleFilter filter = new SimpleFilter(FieldKey.fromParts("Type"), type); - return new TableSelector(pipeline.getTableInfoPipelineRoots(), filter, null).getArray(PipelineRoot.class); + return new TableSelector(pipeline.getTableInfoPipelineRoots(), filter, null).getArrayList(PipelineRoot.class); } static public void setPipelineRoot(User user, Container container, URI[] roots, String type, @@ -809,32 +809,27 @@ else if (!pipeRoot.isCloudRoot() && !pipeRoot.isUnderRoot(archiveFile)) // T } @Nullable - private static Path expandZipLocally(PipeRoot pipelineRoot, Path archiveFile, BindException errors) + private static FileLike expandZipLocally(PipeRoot pipelineRoot, FileLike archiveFile, BindException errors) { try { // check if the archive file already exists in the unzip dir of this pipeline root - Path importDir = pipelineRoot.getImportDirectory().toPath(); - if (!archiveFile.getParent().toAbsolutePath().toString().equalsIgnoreCase(importDir.toAbsolutePath().toString())) + FileLike importDir = pipelineRoot.getImportDirectory(); + if (!archiveFile.getParent().equals(importDir)) importDir = pipelineRoot.deleteImportDirectory(null); - boolean shouldUnzip = Files.notExists(importDir); + boolean shouldUnzip = !importDir.exists(); if (!shouldUnzip) { - try (Stream pathStream = Files.list(importDir)) - { - shouldUnzip = pathStream.noneMatch(s -> s.getFileName().toString().equalsIgnoreCase(archiveFile.getFileName().toString())); - } + Stream pathStream = importDir.getChildren().stream(); + shouldUnzip = pathStream.noneMatch(s -> s.getName().equalsIgnoreCase(archiveFile.getName())); } if (shouldUnzip) { // Only unzip once - try (InputStream is = Files.newInputStream(archiveFile)) - { - ZipUtil.unzipToDirectory(is, importDir); - } + ZipUtil.unzipToDirectory(archiveFile, importDir); } return importDir; @@ -856,13 +851,13 @@ private static Path expandZipLocally(PipeRoot pipelineRoot, Path archiveFile, Bi return null; } - private static Path getImportXmlFile(@NotNull PipeRoot pipelineRoot, @NotNull Path archiveFile, @NotNull String xmlFileName, BindException errors) throws InvalidFileException + private static FileLike getImportXmlFile(@NotNull PipeRoot pipelineRoot, @NotNull FileLike archiveFile, @NotNull String xmlFileName, BindException errors) throws InvalidFileException { - Path xmlFile = archiveFile; + FileLike xmlFile = archiveFile; - if (archiveFile.getFileName().toString().toLowerCase().endsWith(".zip")) + if (archiveFile.getName().toLowerCase().endsWith(".zip")) { - Path importDir = expandZipLocally(pipelineRoot, archiveFile, errors); + FileLike importDir = expandZipLocally(pipelineRoot, archiveFile, errors); if (importDir != null) { xmlFile = getXmlFilePathFromArchive(importDir, archiveFile, xmlFileName); @@ -874,42 +869,42 @@ private static Path getImportXmlFile(@NotNull PipeRoot pipelineRoot, @NotNull Pa return xmlFile; } - public static @NotNull Path getXmlFilePathFromArchive(@NotNull Path importDir, Path archiveFile, @NotNull String xmlFileName) throws InvalidFileException + public static @NotNull FileLike getXmlFilePathFromArchive(@NotNull FileLike importDir, FileLike archiveFile, @NotNull String xmlFileName) throws InvalidFileException { // when importing a folder archive for a study, the study.xml file may not be at the root - if ("study.xml".equalsIgnoreCase(xmlFileName) && archiveFile.getFileName().toString().toLowerCase().endsWith(".folder.zip")) + if ("study.xml".equalsIgnoreCase(xmlFileName) && archiveFile.getName().toLowerCase().endsWith(".folder.zip")) { - File folderXml = new File(importDir.toFile(), "folder.xml"); + FileLike folderXml = importDir.resolveChild("folder.xml"); FolderDocument folderDoc; - try + try (InputStream in = folderXml.openInputStream()) { - folderDoc = FolderDocument.Factory.parse(folderXml, XmlBeansUtil.getDefaultParseOptions()); + folderDoc = FolderDocument.Factory.parse(in, XmlBeansUtil.getDefaultParseOptions()); XmlBeansUtil.validateXmlDocument(folderDoc, xmlFileName); } catch (Exception e) { - throw new InvalidFileException(folderXml.getParentFile().toPath(), folderXml.toPath(), e); + throw new InvalidFileException(folderXml.toString(), e); } if (folderDoc.getFolder().isSetStudy()) { - importDir = importDir.resolve(folderDoc.getFolder().getStudy().getDir()); + importDir = importDir.resolveFile(org.labkey.api.util.Path.parse(folderDoc.getFolder().getStudy().getDir())); } } - return importDir.toAbsolutePath().resolve(xmlFileName); + return importDir.resolveChild(xmlFileName); } - public static Path getArchiveXmlFile(Container container, Path archiveFile, String xmlFileName, BindException errors) throws InvalidFileException + public static FileLike getArchiveXmlFile(Container container, FileLike archiveFile, String xmlFileName, BindException errors) throws InvalidFileException { PipeRoot pipelineRoot = PipelineService.get().findPipelineRoot(container); - Path xmlFile = getImportXmlFile(pipelineRoot, archiveFile, xmlFileName, errors); + FileLike xmlFile = getImportXmlFile(pipelineRoot, archiveFile, xmlFileName, errors); // if this is an import from a source template folder that has been previously implicitly exported // to the unzip dir (without ever creating a zip file) then just look there for the xmlFile. - if (pipelineRoot != null && Files.isDirectory(archiveFile)) + if (pipelineRoot != null && archiveFile.isDirectory()) { - xmlFile = java.nio.file.Path.of(archiveFile.toString(), xmlFileName); + xmlFile = archiveFile.resolveChild(xmlFileName); } return xmlFile; diff --git a/pipeline/src/org/labkey/pipeline/api/PipelineServiceImpl.java b/pipeline/src/org/labkey/pipeline/api/PipelineServiceImpl.java index 1a0fc0f7bc0..41a86ec5bfb 100644 --- a/pipeline/src/org/labkey/pipeline/api/PipelineServiceImpl.java +++ b/pipeline/src/org/labkey/pipeline/api/PipelineServiceImpl.java @@ -321,10 +321,8 @@ public boolean hasValidPipelineRoot(Container container) @Override public Map getAllPipelineRoots() { - PipelineRoot[] pipelines = PipelineManager.getPipelineRoots(PRIMARY_ROOT); - Map result = new HashMap<>(); - for (PipelineRoot pipeline : pipelines) + for (PipelineRoot pipeline : PipelineManager.getPipelineRoots(PRIMARY_ROOT)) { PipeRoot p = new PipeRootImpl(pipeline); if (p.getContainer() != null) @@ -457,7 +455,7 @@ public List getClusterStartupArguments() { List args = new ArrayList<>(); args.add(System.getProperty("java.home") + "/bin/java" + (SystemUtils.IS_OS_WINDOWS ? ".exe" : "")); - File labkeyBootstrap = new File(new File(System.getProperty("catalina.home")), "labkeyBootstrap.jar"); + File labkeyBootstrap = FileUtil.appendName(new File(System.getProperty("catalina.home")), "labkeyBootstrap.jar"); if (!labkeyBootstrap.exists()) { @@ -755,7 +753,7 @@ public void setTriggeredTime(Container container, User user, int triggerConfigId } @Override - public boolean runFolderImportJob(Container c, User user, ActionURL url, Path folderXml, String originalFilename, PipeRoot pipelineRoot, ImportOptions options) + public boolean runFolderImportJob(Container c, User user, ActionURL url, FileLike folderXml, String originalFilename, PipeRoot pipelineRoot, ImportOptions options) { try { @@ -815,7 +813,7 @@ public boolean runGenerateFolderArchiveAndImportJob(Container c, User user, Acti public boolean runGenerateFolderArchiveAndImportJob(Container c, User user, ActionURL url, ImportOptions options) { PipeRoot pipelineRoot = PipelineService.get().findPipelineRoot(c); - Path folderXml = new File(pipelineRoot.getRootPath(), "folder.xml").toPath(); + FileLike folderXml = pipelineRoot.resolvePathToFileLike("folder.xml"); return runFolderImportJob(c, user, null, folderXml, "folder.xml", pipelineRoot, options); } diff --git a/pipeline/src/org/labkey/pipeline/importer/FolderImportJob.java b/pipeline/src/org/labkey/pipeline/importer/FolderImportJob.java index 3e61809e97c..3be6e519445 100644 --- a/pipeline/src/org/labkey/pipeline/importer/FolderImportJob.java +++ b/pipeline/src/org/labkey/pipeline/importer/FolderImportJob.java @@ -39,6 +39,7 @@ import org.labkey.api.view.ViewBackgroundInfo; import org.labkey.api.writer.FileSystemFile; import org.labkey.api.writer.VirtualFile; +import org.labkey.vfs.FileLike; import java.nio.file.Path; @@ -69,10 +70,10 @@ protected FolderImportJob(@JsonProperty("_ctx") FolderImportContext ctx, @JsonPr _ctx.setLoggerGetter(new PipelineJobLoggerGetter(this)); } - public FolderImportJob(Container c, User user, ActionURL url, Path folderXml, String originalFilename, PipeRoot pipeRoot, ImportOptions options) + public FolderImportJob(Container c, User user, ActionURL url, FileLike folderXml, String originalFilename, PipeRoot pipeRoot, ImportOptions options) { super("FolderImport", new ViewBackgroundInfo(c, user, url), pipeRoot); - _root = new FileSystemFile(folderXml.getParent()); + _root = new FileSystemFile(folderXml.getParent().toNioPathForRead()); _originalFilename = originalFilename; _folderArchiveSourceName = options.getFolderArchiveSourceName(); // Optional FolderArchiveSource name. If non-null, will be invoked to generate the archive before import. setupLocalDirectoryAndJobLog(pipeRoot, "FolderImport", FolderImportProvider.generateLogFilename("folder_load")); @@ -112,7 +113,7 @@ public String getFolderArchiveSourceName() } @Override - public TaskPipeline getTaskPipeline() + public TaskPipeline getTaskPipeline() { return PipelineJobService.get().getTaskPipeline(new TaskId(FolderImportJob.class)); } diff --git a/pipeline/src/org/labkey/pipeline/importer/FolderImportTask.java b/pipeline/src/org/labkey/pipeline/importer/FolderImportTask.java index e31bf15e830..2d703a034ef 100644 --- a/pipeline/src/org/labkey/pipeline/importer/FolderImportTask.java +++ b/pipeline/src/org/labkey/pipeline/importer/FolderImportTask.java @@ -75,13 +75,13 @@ public RecordedActionSet run() throws PipelineJobException { FileAnalysisJobSupport support = job.getJobSupport(FileAnalysisJobSupport.class); ImportOptions options = new ImportOptions(job.getContainerId(), job.getUser().getUserId()); - options.setAnalysisDir(support.getDataDirectory().toPath()); + options.setAnalysisDir(support.getDataDirectoryFileLike()); - job = new FolderImportJob(job.getContainer(), job.getUser(), null, support.findInputPath(FOLDER_XML), FOLDER_XML, job.getPipeRoot(), options); + job = new FolderImportJob(job.getContainer(), job.getUser(), null, support.findInputFileLike(FOLDER_XML), FOLDER_XML, job.getPipeRoot(), options); job.setStatus(PipelineJob.TaskStatus.running.toString(), "Starting folder import job", true); importContext = ((FolderImportJob) job).getImportContext(); - vf = new FileSystemFile(support.getDataDirectory()); + vf = new FileSystemFile(support.getDataDirectoryFileLike()); } /* Standard Pipeline triggered job */ else @@ -165,7 +165,7 @@ public Factory() } @Override - public PipelineJob.Task createTask(PipelineJob job) + public FolderImportTask createTask(PipelineJob job) { return new FolderImportTask(this, job); } diff --git a/specimen/src/org/labkey/specimen/pipeline/SpecimenReloadJob.java b/specimen/src/org/labkey/specimen/pipeline/SpecimenReloadJob.java index ad69c897659..4ddbbb6e6ac 100644 --- a/specimen/src/org/labkey/specimen/pipeline/SpecimenReloadJob.java +++ b/specimen/src/org/labkey/specimen/pipeline/SpecimenReloadJob.java @@ -42,7 +42,7 @@ public SpecimenReloadJob(ViewBackgroundInfo info, PipeRoot root, String transfor { super(info, null, root, false); - File logFile = new File(root.getRootPath(), FileUtil.makeFileNameWithTimestamp("specimen_reload", "log")); + FileLike logFile = root.resolvePathToFileLike(FileUtil.makeFileNameWithTimestamp("specimen_reload", "log")); setLogFile(logFile); _transformName = transformName; } diff --git a/study/src/org/labkey/study/importer/StudyImporterFactory.java b/study/src/org/labkey/study/importer/StudyImporterFactory.java index 8519052b6d5..2692e0d40ad 100644 --- a/study/src/org/labkey/study/importer/StudyImporterFactory.java +++ b/study/src/org/labkey/study/importer/StudyImporterFactory.java @@ -155,7 +155,7 @@ public void process(@Nullable PipelineJob job, FolderImportContext ctx, VirtualF if (useLocalImportDir) { //TODO this should be done from the import context getSpecimenArchive specimenFile = job.getPipeRoot().getRootNioPath().relativize(specimenFile); - specimenFile = job.getPipeRoot().getImportDirectory().toPath().resolve(specimenFile); + specimenFile = job.getPipeRoot().getImportDirectory().toNioPathForRead().resolve(specimenFile); } SpecimenMigrationService.get().importSpecimenArchive(specimenFile, job, studyImportContext, false, false); From 0d1736f952389053953e5f8b3159d5976b0c9d93 Mon Sep 17 00:00:00 2001 From: labkey-jeckels Date: Mon, 20 Oct 2025 17:31:33 -0700 Subject: [PATCH 07/16] Fix build --- .../labkey/api/pipeline/file/AbstractFileAnalysisJob.java | 6 ------ 1 file changed, 6 deletions(-) diff --git a/api/src/org/labkey/api/pipeline/file/AbstractFileAnalysisJob.java b/api/src/org/labkey/api/pipeline/file/AbstractFileAnalysisJob.java index 4ab4d5e1f1a..6ff08013dc0 100644 --- a/api/src/org/labkey/api/pipeline/file/AbstractFileAnalysisJob.java +++ b/api/src/org/labkey/api/pipeline/file/AbstractFileAnalysisJob.java @@ -274,12 +274,6 @@ public File getAnalysisDirectory() return _dirAnalysis.toNioPathForWrite().toFile(); } - @Override - public Path getAnalysisDirectoryPath() - { - return _dirAnalysis.toNioPathForWrite(); - } - @Override public File findOutputFile(@NotNull String outputDir, @NotNull String fileName) { From 1bb78a4e2e9d123425ec266b914aed3a66742e7b Mon Sep 17 00:00:00 2001 From: labkey-jeckels Date: Tue, 21 Oct 2025 09:57:22 -0700 Subject: [PATCH 08/16] Test fixes --- api/src/org/labkey/api/util/FileUtil.java | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/api/src/org/labkey/api/util/FileUtil.java b/api/src/org/labkey/api/util/FileUtil.java index 7529d5712c2..ac99f34f802 100644 --- a/api/src/org/labkey/api/util/FileUtil.java +++ b/api/src/org/labkey/api/util/FileUtil.java @@ -33,9 +33,11 @@ import org.labkey.api.cloud.CloudStoreService; import org.labkey.api.data.Container; import org.labkey.api.files.FileContentService; +import org.labkey.api.pipeline.PipelineService; import org.labkey.api.security.Crypt; import org.labkey.api.settings.AppProps; import org.labkey.api.util.logging.LogHelper; +import org.labkey.api.view.NotFoundException; import org.labkey.api.view.UnauthorizedException; import org.labkey.vfs.FileLike; import org.labkey.vfs.FileSystemLike; @@ -951,7 +953,22 @@ public static Path stringToPath(Container container, String str) public static Path stringToPath(Container container, String str, boolean isEncoded) { if (!FileUtil.hasCloudScheme(str)) - return new File(createUri(str, isEncoded)).toPath(); + { + URI uri = createUri(str, isEncoded); + if (!uri.isAbsolute()) + { + return PipelineService.get().findPipelineRoot(container).resolveToNioPath(str); + } + else + { + Path result = new File(uri).toPath(); + if (PipelineService.get().findPipelineRoot(container).isUnderRoot(result)) + { + return result; + } + throw new NotFoundException("Path is not under pipeline root: " + result); + } + } else return Objects.requireNonNull(CloudStoreService.get()).getPathFromUrl(container, PageFlowUtil.decode(str)/*decode everything not just the space*/); } From 10eb9b1fcd5208ce88ceb1122b7cb9aaea958a67 Mon Sep 17 00:00:00 2001 From: labkey-jeckels Date: Tue, 21 Oct 2025 10:52:10 -0700 Subject: [PATCH 09/16] Get rid of new validation causing StackOverflowError --- api/src/org/labkey/api/util/FileUtil.java | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/api/src/org/labkey/api/util/FileUtil.java b/api/src/org/labkey/api/util/FileUtil.java index ac99f34f802..62cf597b352 100644 --- a/api/src/org/labkey/api/util/FileUtil.java +++ b/api/src/org/labkey/api/util/FileUtil.java @@ -961,12 +961,7 @@ public static Path stringToPath(Container container, String str, boolean isEncod } else { - Path result = new File(uri).toPath(); - if (PipelineService.get().findPipelineRoot(container).isUnderRoot(result)) - { - return result; - } - throw new NotFoundException("Path is not under pipeline root: " + result); + return new File(uri).toPath(); } } else From b3cd207d43fb8e5cc1e96097c57dc22d5387a970 Mon Sep 17 00:00:00 2001 From: labkey-matthewb Date: Tue, 21 Oct 2025 14:59:23 -0700 Subject: [PATCH 10/16] CloudStoreService.getFileLike() --- core/src/org/labkey/core/CoreController.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/src/org/labkey/core/CoreController.java b/core/src/org/labkey/core/CoreController.java index 96068322b9a..5837de0c470 100644 --- a/core/src/org/labkey/core/CoreController.java +++ b/core/src/org/labkey/core/CoreController.java @@ -1936,7 +1936,8 @@ private List> getCloudArchiveImporters(FolderImporterForm fo private boolean isCloudArchive(FolderImporterForm form) { - return FileUtil.hasCloudScheme(form.getArchiveFilePath()); + String path = form.getArchiveFilePath(); + return StringUtils.isNotBlank(path) && FileUtil.hasCloudScheme(form.getArchiveFilePath()); } private List> getSelectableImporters(FolderImporterForm form, List registeredImporters) throws Exception From 0ccca7508839763c98e9b7120b89112bf61ba5ce Mon Sep 17 00:00:00 2001 From: labkey-matthewb Date: Tue, 21 Oct 2025 16:00:47 -0700 Subject: [PATCH 11/16] zip to the .zip --- core/src/org/labkey/core/admin/AdminController.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/core/src/org/labkey/core/admin/AdminController.java b/core/src/org/labkey/core/admin/AdminController.java index 35f962bac26..3bf1c68c95e 100644 --- a/core/src/org/labkey/core/admin/AdminController.java +++ b/core/src/org/labkey/core/admin/AdminController.java @@ -5489,7 +5489,11 @@ private FolderImportConfig getFolderImportConfigFromTemplateFolder(final ImportF PHI.NotPHI, false, false, false, new StaticLoggerGetter(LogManager.getLogger(FolderWriterImpl.class))); FolderWriterImpl writer = new FolderWriterImpl(); String zipFileName = FileUtil.makeFileNameWithTimestamp(sourceContainer.getName(), "folder.zip"); - try (OutputStream out = pipelineUnzipDir.openOutputStream(); + FileLike implicitZipFile = pipelineUnzipDir.resolveChild(zipFileName); + if (!pipelineUnzipDir.isDirectory()) + pipelineUnzipDir.mkdirs(); + implicitZipFile.createFile(); + try (OutputStream out = implicitZipFile.openOutputStream(); ZipFile zip = new ZipFile(out, false)) { writer.write(sourceContainer, ctx, zip); @@ -5498,7 +5502,6 @@ private FolderImportConfig getFolderImportConfigFromTemplateFolder(final ImportF { errors.reject(SpringActionController.ERROR_MSG, e.getMessage()); } - FileLike implicitZipFile = pipelineUnzipDir.resolveChild(zipFileName); // To support the simple import option unzip the zip file to the pipeline unzip dir of the current container ZipUtil.unzipToDirectory(implicitZipFile, pipelineUnzipDir); From 9ddb5c1e687fe95adebc0673a9f2524f5d926cb2 Mon Sep 17 00:00:00 2001 From: labkey-matthewb Date: Tue, 21 Oct 2025 16:47:56 -0700 Subject: [PATCH 12/16] PipeRootImpl.ensureSystemDirectory() --- .../labkey/api/pipeline/PipelineProvider.java | 7 ----- .../org/labkey/pipeline/api/PipeRootImpl.java | 26 +++++++++---------- 2 files changed, 12 insertions(+), 21 deletions(-) diff --git a/api/src/org/labkey/api/pipeline/PipelineProvider.java b/api/src/org/labkey/api/pipeline/PipelineProvider.java index a5cadc1a1a3..618b46eed5a 100644 --- a/api/src/org/labkey/api/pipeline/PipelineProvider.java +++ b/api/src/org/labkey/api/pipeline/PipelineProvider.java @@ -208,13 +208,6 @@ public String getName() * @param rootDir the pipeline root directory on disk * @param systemDir the system directory itself */ - @Deprecated - public void initSystemDirectory(File rootDir, File systemDir) - { - if (null != rootDir && null != systemDir) - initSystemDirectory(rootDir.toPath(), systemDir.toPath()); - } - public void initSystemDirectory(Path rootDir, Path systemDir) { } diff --git a/pipeline/src/org/labkey/pipeline/api/PipeRootImpl.java b/pipeline/src/org/labkey/pipeline/api/PipeRootImpl.java index afac2badb02..8942eacc47f 100644 --- a/pipeline/src/org/labkey/pipeline/api/PipeRootImpl.java +++ b/pipeline/src/org/labkey/pipeline/api/PipeRootImpl.java @@ -55,6 +55,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Objects; public class PipeRootImpl implements PipeRoot { @@ -148,35 +149,32 @@ public PipeRootImpl(PipelineRoot root) @NotNull public FileLike ensureSystemDirectory() { - Path root = getRootNioPath(); - Path systemDir = root.resolve(SYSTEM_DIRECTORY_NAME); - if (!Files.exists(systemDir)) + FileLike root = getRootFileLike(); + FileLike systemDir = root.resolveChild(SYSTEM_DIRECTORY_NAME); + if (systemDir.exists()) { try { FileUtil.createDirectories(systemDir); - - Path systemDirLegacy = root.resolve(SYSTEM_DIRECTORY_LEGACY); - if (Files.exists(systemDirLegacy)) + FileLike systemDirLegacy = root.resolveChild(SYSTEM_DIRECTORY_LEGACY); + if (systemDirLegacy.exists() && !isCloudRoot()) { // Legacy means it must be on file system - File legacyDir = systemDirLegacy.toFile(); - for (File f : legacyDir.listFiles()) - f.renameTo(systemDir.toFile()); + File sysDir = systemDirLegacy.toNioPathForRead().toFile(); + File legacyDir = systemDirLegacy.toNioPathForWrite().toFile(); + for (File f : Objects.requireNonNullElse(legacyDir.listFiles(),new File[0])) + f.renameTo(sysDir); } for (PipelineProvider provider : PipelineService.get().getPipelineProviders()) - provider.initSystemDirectory(root, systemDir); + provider.initSystemDirectory(root.toNioPathForWrite(), systemDir.toNioPathForWrite()); } catch (IOException e) { throw new RuntimeException(e); } } - - if (FileUtil.hasCloudScheme(systemDir)) - throw new RuntimeException("System Dir is not on file system."); - return new FileSystemLike.Builder(systemDir).readwrite().root(); + return systemDir; } @Override From 513396e82720a0b197b01ce0369a73b6b12ecf83 Mon Sep 17 00:00:00 2001 From: labkey-matthewb Date: Tue, 21 Oct 2025 17:57:01 -0700 Subject: [PATCH 13/16] Try returning S3 FileLike from getRootFileLike(). Are we ready? --- pipeline/src/org/labkey/pipeline/api/PipeRootImpl.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/pipeline/src/org/labkey/pipeline/api/PipeRootImpl.java b/pipeline/src/org/labkey/pipeline/api/PipeRootImpl.java index 8942eacc47f..c5eb52246c4 100644 --- a/pipeline/src/org/labkey/pipeline/api/PipeRootImpl.java +++ b/pipeline/src/org/labkey/pipeline/api/PipeRootImpl.java @@ -217,7 +217,11 @@ public Path getRootNioPath() @Override public @NotNull FileLike getRootFileLike() { - return new FileSystemLike.Builder(getRootPath()).readwrite().root(); + var ret = resolvePathToFileLike(""); + // this should not return null unless there a configuration problem. + if (null == ret) + throw new IllegalStateException("Could not resolve pipeline path."); + return ret; } @Override @@ -381,10 +385,8 @@ public File resolvePath(org.labkey.api.util.Path path) org.labkey.api.util.Path.parse(_uris.get(0).getPath()).append(parsedPath) : parsedPath; return CloudStoreService.get().getFileLike(getContainer(), _cloudStoreName, combinedPath); - // TODO: Do we need? Check that it's under the root to protect against ../../ type paths } - var pair = _resolveRoot(parsedPath); if (null == pair) return null; From a2b65529206aeac3c793f0e5cb5d1b310e04fdac Mon Sep 17 00:00:00 2001 From: labkey-jeckels Date: Wed, 22 Oct 2025 09:09:16 -0700 Subject: [PATCH 14/16] Test fixes --- pipeline/src/org/labkey/pipeline/PipelineModule.java | 4 ++++ study/src/org/labkey/study/model/StudyManager.java | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/pipeline/src/org/labkey/pipeline/PipelineModule.java b/pipeline/src/org/labkey/pipeline/PipelineModule.java index 6482353c6b2..405e7555c65 100644 --- a/pipeline/src/org/labkey/pipeline/PipelineModule.java +++ b/pipeline/src/org/labkey/pipeline/PipelineModule.java @@ -21,12 +21,15 @@ import org.labkey.api.admin.sitevalidation.SiteValidationService; import org.labkey.api.audit.AuditLogService; import org.labkey.api.collections.CaseInsensitiveHashSet; +import org.labkey.api.data.CompareType; import org.labkey.api.data.Container; import org.labkey.api.data.ContainerManager; import org.labkey.api.data.DbSchema; import org.labkey.api.data.RuntimeSQLException; import org.labkey.api.data.SQLFragment; +import org.labkey.api.data.SimpleFilter; import org.labkey.api.data.SqlSelector; +import org.labkey.api.data.TableSelector; import org.labkey.api.data.dialect.SqlDialect; import org.labkey.api.files.FileContentService; import org.labkey.api.files.TableUpdaterFileListener; @@ -267,6 +270,7 @@ protected void startupAfterSpringConfig(ModuleContext moduleContext) result.put("jmsType", PipelineService.get().getJmsType().toString()); result.put("pipelineRootCount", PipelineService.get().getAllPipelineRoots().size()); + result.put("supplementalDirectories", new TableSelector(PipelineSchema.getInstance().getTableInfoPipelineRoots(), new SimpleFilter("SupplementalPath", null, CompareType.NONBLANK), null).getRowCount()); return result; }); diff --git a/study/src/org/labkey/study/model/StudyManager.java b/study/src/org/labkey/study/model/StudyManager.java index 207daa48924..409da6b5536 100644 --- a/study/src/org/labkey/study/model/StudyManager.java +++ b/study/src/org/labkey/study/model/StudyManager.java @@ -2795,9 +2795,9 @@ private void deleteStudyDesignData(Container c, User user, List study { for (TableInfo tinfo : studyDesignTables) { - if (tinfo instanceof FilteredTable) + if (tinfo instanceof FilteredTable ft) { - Table.delete(((FilteredTable)tinfo).getRealTable(), new SimpleFilter(FieldKey.fromParts("Container"), c)); + Table.delete(ft.getRealTable(), new SimpleFilter(FieldKey.fromParts("Container"), c)); } } } From 7d84a252f1e2b3d39d8a5023f9b430d7d22aa8e7 Mon Sep 17 00:00:00 2001 From: labkey-jeckels Date: Wed, 22 Oct 2025 09:49:12 -0700 Subject: [PATCH 15/16] Avoid File conversion --- api/src/org/labkey/api/util/FileUtil.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/api/src/org/labkey/api/util/FileUtil.java b/api/src/org/labkey/api/util/FileUtil.java index 62cf597b352..fd358042a19 100644 --- a/api/src/org/labkey/api/util/FileUtil.java +++ b/api/src/org/labkey/api/util/FileUtil.java @@ -494,8 +494,7 @@ public static void createDirectories(FileLike file) throws IOException { if (!file.getFileSystem().canWriteFiles()) throw new UnauthorizedException(); - File target = toFileForWrite(file); - createDirectories(target.toPath(), AppProps.getInstance().isInvalidFilenameBlocked()); + createDirectories(file.toNioPathForWrite(), AppProps.getInstance().isInvalidFilenameBlocked()); } From 5eb6e29e355fce9f75be8da92d8a16379d2cfe8f Mon Sep 17 00:00:00 2001 From: labkey-jeckels Date: Wed, 22 Oct 2025 10:35:39 -0700 Subject: [PATCH 16/16] Try to support OutputStream --- .../AbstractFileAnalysisProtocolFactory.java | 24 ------------------- 1 file changed, 24 deletions(-) diff --git a/api/src/org/labkey/api/pipeline/file/AbstractFileAnalysisProtocolFactory.java b/api/src/org/labkey/api/pipeline/file/AbstractFileAnalysisProtocolFactory.java index 339d8021272..da9df32ae1b 100644 --- a/api/src/org/labkey/api/pipeline/file/AbstractFileAnalysisProtocolFactory.java +++ b/api/src/org/labkey/api/pipeline/file/AbstractFileAnalysisProtocolFactory.java @@ -25,9 +25,6 @@ import org.labkey.api.pipeline.PipelineJob; import org.labkey.api.pipeline.PipelineJobService; import org.labkey.api.pipeline.PipelineProtocolFactory; -import org.labkey.api.pipeline.PipelineProvider; -import org.labkey.api.pipeline.PipelineService; -import org.labkey.api.pipeline.TaskPipeline; import org.labkey.api.reader.Readers; import org.labkey.api.util.FileUtil; import org.labkey.api.util.NetworkDrive; @@ -45,8 +42,6 @@ import java.io.Reader; import java.io.StringReader; import java.nio.file.InvalidPathException; -import java.nio.file.Path; -import java.util.List; /** * Base class for protocol factories that are primarily focused on analyzing data files (as opposed to other types of resources) @@ -321,25 +316,6 @@ public void setDefaultParametersXML(PipeRoot root, String xml) throws IOExceptio } } - public static >, F extends AbstractFileAnalysisProtocolFactory> - F fromFile(Class clazz, File file) - { - List providers = PipelineService.get().getPipelineProviders(); - for (PipelineProvider provider : providers) - { - if (!(clazz.isInstance(provider))) - continue; - - T mprovider = (T) provider; - F factory = mprovider.getProtocolFactory(file); - if (factory != null) - return factory; - } - - // TODO: Return some default? - return null; - } - @Nullable public AbstractFileAnalysisProtocol getProtocol(PipeRoot root, FileLike dirData, String protocolName, boolean archived) {