This is an automated email from the ASF dual-hosted git repository.
hansva pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git
The following commit(s) were added to refs/heads/main by this push:
new 394947fbcb issues #7585, #7586, #7116 : Projects improvements (#7588)
394947fbcb is described below
commit 394947fbcb88981a494646ae03e8fb5cd37ece38
Author: Matt Casters <[email protected]>
AuthorDate: Tue Jul 21 11:02:10 2026 +0200
issues #7585, #7586, #7116 : Projects improvements (#7588)
* issue #7585 : Open File dialog browse inside archives (zip/jar/tar)
Add a Drill into toolbar button on the VFS file browser so users can
open supported Commons VFS archives without hand-editing URIs.
* issue #7586: read-only projects and as-is zip export (#7585)
Add a GUI option to open projects in read-only mode (no project-config.json
writes), auto-enabled for archive URIs, with validation that the config file
exists. Export can write projects as-is to the zip root for that workflow.
Also extend VFS directory/file browsing so archives appear when choosing a
folder, double-click drills into zip/jar/tar, and OK still selects the
archive
as a file. Fixes FileTree checkbox cascade so export selection works.
Closes #7586
Closes #7585
* Issue #7116 : improve project organization in Apache Hop Project Manager
---
.../modules/ROOT/assets/images/icons/zipfile.svg | 21 +
.../modules/ROOT/pages/hop-gui/file-dialog.adoc | 1 +
.../apache/hop/projects/config/ProjectsConfig.java | 45 ++
.../hop/projects/gui/ProjectConfigDialog.java | 305 ++++++++++++++
.../apache/hop/projects/gui/ProjectsGuiPlugin.java | 460 +++++++++++++--------
.../hop/projects/gui/SelectProjectsDialog.java | 394 ++++++++++++++++++
.../apache/hop/projects/project/ProjectConfig.java | 171 ++++++++
.../apache/hop/projects/project/ProjectDialog.java | 108 ++++-
.../gui/messages/messages_en_US.properties | 32 ++
.../project/messages/messages_en_US.properties | 3 +
.../hop/projects/gui/ProjectsGuiPluginZipTest.java | 53 +++
.../apache/hop/projects/project/ProjectTest.java | 135 ++++++
.../apache/hop/ui/core/vfs/HopVfsFileDialog.java | 221 ++++++++--
.../org/apache/hop/ui/core/widget/FileTree.java | 136 ++++--
.../ui/core/vfs/messages/messages_en_US.properties | 2 +
.../ui/core/vfs/HopVfsFileDialogArchiveTest.java | 101 +++++
16 files changed, 1929 insertions(+), 259 deletions(-)
diff --git a/docs/hop-user-manual/modules/ROOT/assets/images/icons/zipfile.svg
b/docs/hop-user-manual/modules/ROOT/assets/images/icons/zipfile.svg
new file mode 100644
index 0000000000..ed483c82c6
--- /dev/null
+++ b/docs/hop-user-manual/modules/ROOT/assets/images/icons/zipfile.svg
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="42" height="42">
+<g>
+ <path fill="#FFECCE"
d="M27.858,2.565H12.894v2.252h-1.813V2.565h-3.43v36.867h26.632V8.997h-6.426V2.565z
M11.08,8.479h1.813
+ v3.548H11.08V8.479z M11.08,15.689h1.813v3.548H11.08V15.689z
M11.08,22.879h1.813v3.546H11.08V22.879z M14.871,35.685h-3.933
+ v-8.365h3.933V35.685z M14.98,22.842h-1.812v-3.547h1.812V22.842z
M14.98,15.632h-1.812v-3.548h1.812V15.632z M14.98,8.422h-1.812
+ V4.875h1.812V8.422z"/>
+ <path fill="#FFECCE"
d="M12.058,34.907h1.694c0.275,0,0.495-0.22,0.495-0.494v-0.764c0-0.274-0.22-0.495-0.495-0.495h-1.694
+
c-0.273,0-0.495,0.221-0.495,0.495v0.764C11.563,34.685,11.786,34.907,12.058,34.907z"/>
+ <path fill="#FF9C04"
d="M34.271,6.429h-3.847V2.583L27.843,0H5.085v42h31.767V9.008L34.271,6.429z
M34.284,39.433H7.652V2.565h3.43
+ v2.252h1.813V2.565h14.964v6.431h6.426V39.433z"/>
+ <rect x="11.08" y="8.479" fill="#FF9C04" width="1.813" height="3.548"/>
+ <rect x="11.08" y="15.689" fill="#FF9C04" width="1.813" height="3.548"/>
+ <rect x="13.168" y="4.875" fill="#FF9C04" width="1.812" height="3.547"/>
+ <rect x="13.168" y="12.085" fill="#FF9C04" width="1.812" height="3.549"/>
+ <rect x="13.168" y="19.294" fill="#FF9C04" width="1.812" height="3.547"/>
+ <rect x="11.08" y="22.879" fill="#FF9C04" width="1.813" height="3.546"/>
+ <path fill="#FF9C04" d="M10.938,35.685h3.933v-8.365h-3.933V35.685z
M11.563,33.649c0-0.274,0.222-0.495,0.495-0.495h1.694
+
c0.275,0,0.495,0.221,0.495,0.495v0.764c0,0.274-0.22,0.494-0.495,0.494h-1.694c-0.273,0-0.495-0.22-0.495-0.494V33.649z"/>
+</g>
+</svg>
diff --git a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/file-dialog.adoc
b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/file-dialog.adoc
index 6b6b611eea..0456b1635c 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/file-dialog.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/file-dialog.adoc
@@ -62,6 +62,7 @@ Tools -> Options contains an option to specify whether you
want or don't want gl
.. image:icons/delete.svg[width="25px"]Remove the selected bookmark
. **File/folder browser**: Browse your current file system, create folders and
toggle hidden files and folders.
.. image:icons/folder-add.svg[width="25px"]Create folder
+.. image:icons/zipfile.svg[width="25px"]Drill into a selected archive (zip,
jar, tar, tgz, tbz2) using VFS. Double-clicking an archive does the same; use
*OK* if you need to select the archive file itself. Directory browsers also
list these archives so you can open a folder inside a zip (for example a
project home).
.. image:icons/preview.svg[width="25px"]Show or hide hidden files and
directories
. **File/folder information**
.. file and folder name
diff --git
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/config/ProjectsConfig.java
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/config/ProjectsConfig.java
index 5e538c83d3..0f0cf6c6bc 100644
---
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/config/ProjectsConfig.java
+++
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/config/ProjectsConfig.java
@@ -117,9 +117,54 @@ public class ProjectsConfig {
existing.setProjectName(projectConfig.getProjectName());
existing.setProjectHome(projectConfig.getProjectHome());
existing.setConfigFilename(projectConfig.getConfigFilename());
+ existing.setReadOnly(projectConfig.isReadOnly());
+ existing.setGroup(projectConfig.getGroup());
+ existing.setTags(projectConfig.getTags());
}
}
+ /**
+ * Update an existing project registration, including rename of the project
name.
+ *
+ * @param originalName name currently stored in hop-config
+ * @param projectConfig updated registration (may have a new projectName)
+ */
+ public void updateProjectConfig(String originalName, ProjectConfig
projectConfig) {
+ if (StringUtils.isEmpty(originalName)
+ || originalName.equalsIgnoreCase(projectConfig.getProjectName())) {
+ addProjectConfig(projectConfig);
+ return;
+ }
+ ProjectConfig existing = findProjectConfig(originalName);
+ if (existing == null) {
+ addProjectConfig(projectConfig);
+ return;
+ }
+ existing.setProjectName(projectConfig.getProjectName());
+ existing.setProjectHome(projectConfig.getProjectHome());
+ existing.setConfigFilename(projectConfig.getConfigFilename());
+ existing.setReadOnly(projectConfig.isReadOnly());
+ existing.setGroup(projectConfig.getGroup());
+ existing.setTags(projectConfig.getTags());
+ }
+
+ /**
+ * Distinct non-empty group names from registered projects, sorted
case-insensitively.
+ *
+ * @return list of group names
+ */
+ public List<String> listProjectGroups() {
+ List<String> groups = new ArrayList<>();
+ for (ProjectConfig projectConfig : projectConfigurations) {
+ String group = projectConfig.getGroup();
+ if (StringUtils.isNotEmpty(group) && !groups.contains(group)) {
+ groups.add(group);
+ }
+ }
+ Collections.sort(groups, String.CASE_INSENSITIVE_ORDER);
+ return groups;
+ }
+
public int indexOfProjectConfig(String projectName) {
return projectConfigurations.indexOf(
new ProjectConfig(projectName, null, null)); // Only considers the name
diff --git
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectConfigDialog.java
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectConfigDialog.java
new file mode 100644
index 0000000000..e60739e903
--- /dev/null
+++
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectConfigDialog.java
@@ -0,0 +1,305 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.apache.hop.projects.gui;
+
+import java.util.List;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.projects.config.ProjectsConfig;
+import org.apache.hop.projects.config.ProjectsConfigSingleton;
+import org.apache.hop.projects.project.ProjectConfig;
+import org.apache.hop.ui.core.ConstUi;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.dialog.BaseDialog;
+import org.apache.hop.ui.core.dialog.ErrorDialog;
+import org.apache.hop.ui.core.gui.GuiResource;
+import org.apache.hop.ui.core.gui.WindowProperty;
+import org.apache.hop.ui.core.widget.ComboVar;
+import org.apache.hop.ui.core.widget.TextVar;
+import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.layout.FormAttachment;
+import org.eclipse.swt.layout.FormData;
+import org.eclipse.swt.layout.FormLayout;
+import org.eclipse.swt.widgets.Button;
+import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Dialog;
+import org.eclipse.swt.widgets.Label;
+import org.eclipse.swt.widgets.Shell;
+import org.eclipse.swt.widgets.Text;
+
+/**
+ * Small dialog to edit a project <em>registration</em> ({@link ProjectConfig}
in hop-config.json):
+ * name, home, config path, group, tags, and read-only. Does not write
project-config.json.
+ */
+public class ProjectConfigDialog extends Dialog {
+ private static final Class<?> PKG = ProjectConfigDialog.class;
+
+ private final PropsUi props;
+ private final IVariables variables;
+ private final ProjectConfig projectConfig;
+ private final String originalName;
+
+ private Shell shell;
+ private Text wName;
+ private TextVar wHome;
+ private TextVar wConfigFile;
+ private ComboVar wGroup;
+ private Text wTags;
+ private Button wReadOnly;
+
+ private boolean ok;
+
+ public ProjectConfigDialog(Shell parent, ProjectConfig projectConfig,
IVariables variables) {
+ super(parent, SWT.DIALOG_TRIM | SWT.APPLICATION_MODAL | SWT.RESIZE);
+ this.projectConfig = projectConfig;
+ this.variables = variables;
+ this.originalName = projectConfig.getProjectName();
+ this.props = PropsUi.getInstance();
+ }
+
+ /**
+ * Open the dialog.
+ *
+ * @return true if the user confirmed changes
+ */
+ public boolean open() {
+ Shell parent = getParent();
+ shell = new Shell(parent, SWT.DIALOG_TRIM | SWT.APPLICATION_MODAL |
SWT.RESIZE);
+ shell.setText(BaseMessages.getString(PKG,
"ProjectConfigDialog.Shell.Name"));
+ shell.setImage(
+ GuiResource.getInstance()
+ .getImage(
+ "project.svg",
+ PKG.getClassLoader(),
+ ConstUi.SMALL_ICON_SIZE,
+ ConstUi.SMALL_ICON_SIZE));
+ PropsUi.setLook(shell);
+
+ int margin = PropsUi.getMargin();
+ int middle = props.getMiddlePct();
+
+ FormLayout formLayout = new FormLayout();
+ formLayout.marginWidth = PropsUi.getFormMargin();
+ formLayout.marginHeight = PropsUi.getFormMargin();
+ shell.setLayout(formLayout);
+
+ Button wOk = new Button(shell, SWT.PUSH);
+ wOk.setText(BaseMessages.getString("System.Button.OK"));
+ wOk.addListener(SWT.Selection, e -> ok());
+ Button wCancel = new Button(shell, SWT.PUSH);
+ wCancel.setText(BaseMessages.getString("System.Button.Cancel"));
+ wCancel.addListener(SWT.Selection, e -> cancel());
+ BaseTransformDialog.positionBottomButtons(shell, new Button[] {wOk,
wCancel}, margin * 3, null);
+
+ Label wlName = new Label(shell, SWT.RIGHT);
+ PropsUi.setLook(wlName);
+ wlName.setText(BaseMessages.getString(PKG,
"ProjectConfigDialog.Label.Name"));
+ FormData fdlName = new FormData();
+ fdlName.left = new FormAttachment(0, 0);
+ fdlName.right = new FormAttachment(middle, 0);
+ fdlName.top = new FormAttachment(0, margin * 2);
+ wlName.setLayoutData(fdlName);
+ wName = new Text(shell, SWT.SINGLE | SWT.BORDER);
+ PropsUi.setLook(wName);
+ FormData fdName = new FormData();
+ fdName.left = new FormAttachment(middle, margin);
+ fdName.right = new FormAttachment(100, 0);
+ fdName.top = new FormAttachment(wlName, 0, SWT.CENTER);
+ wName.setLayoutData(fdName);
+ Control lastControl = wName;
+
+ Label wlHome = new Label(shell, SWT.RIGHT);
+ PropsUi.setLook(wlHome);
+ wlHome.setText(BaseMessages.getString(PKG,
"ProjectConfigDialog.Label.Home"));
+ FormData fdlHome = new FormData();
+ fdlHome.left = new FormAttachment(0, 0);
+ fdlHome.right = new FormAttachment(middle, 0);
+ fdlHome.top = new FormAttachment(lastControl, margin);
+ wlHome.setLayoutData(fdlHome);
+ Button wbHome = new Button(shell, SWT.PUSH);
+ PropsUi.setLook(wbHome);
+ wbHome.setText(BaseMessages.getString(PKG,
"ProjectConfigDialog.Button.Browse"));
+ FormData fdbHome = new FormData();
+ fdbHome.right = new FormAttachment(100, 0);
+ fdbHome.top = new FormAttachment(wlHome, 0, SWT.CENTER);
+ wbHome.setLayoutData(fdbHome);
+ wbHome.addListener(
+ SWT.Selection, e -> BaseDialog.presentDirectoryDialog(shell, wHome,
variables));
+ wHome = new TextVar(variables, shell, SWT.SINGLE | SWT.BORDER);
+ PropsUi.setLook(wHome);
+ FormData fdHome = new FormData();
+ fdHome.left = new FormAttachment(middle, margin);
+ fdHome.right = new FormAttachment(wbHome, -margin);
+ fdHome.top = new FormAttachment(wlHome, 0, SWT.CENTER);
+ wHome.setLayoutData(fdHome);
+ lastControl = wHome;
+
+ Label wlConfigFile = new Label(shell, SWT.RIGHT);
+ PropsUi.setLook(wlConfigFile);
+ wlConfigFile.setText(BaseMessages.getString(PKG,
"ProjectConfigDialog.Label.ConfigFile"));
+ FormData fdlConfigFile = new FormData();
+ fdlConfigFile.left = new FormAttachment(0, 0);
+ fdlConfigFile.right = new FormAttachment(middle, 0);
+ fdlConfigFile.top = new FormAttachment(lastControl, margin);
+ wlConfigFile.setLayoutData(fdlConfigFile);
+ wConfigFile = new TextVar(variables, shell, SWT.SINGLE | SWT.BORDER);
+ PropsUi.setLook(wConfigFile);
+ FormData fdConfigFile = new FormData();
+ fdConfigFile.left = new FormAttachment(middle, margin);
+ fdConfigFile.right = new FormAttachment(100, 0);
+ fdConfigFile.top = new FormAttachment(wlConfigFile, 0, SWT.CENTER);
+ wConfigFile.setLayoutData(fdConfigFile);
+ lastControl = wConfigFile;
+
+ Label wlGroup = new Label(shell, SWT.RIGHT);
+ PropsUi.setLook(wlGroup);
+ wlGroup.setText(BaseMessages.getString(PKG,
"ProjectConfigDialog.Label.Group"));
+ FormData fdlGroup = new FormData();
+ fdlGroup.left = new FormAttachment(0, 0);
+ fdlGroup.right = new FormAttachment(middle, 0);
+ fdlGroup.top = new FormAttachment(lastControl, margin);
+ wlGroup.setLayoutData(fdlGroup);
+ wGroup = new ComboVar(variables, shell, SWT.SINGLE | SWT.BORDER |
SWT.LEFT);
+ PropsUi.setLook(wGroup);
+ FormData fdGroup = new FormData();
+ fdGroup.left = new FormAttachment(middle, margin);
+ fdGroup.right = new FormAttachment(100, 0);
+ fdGroup.top = new FormAttachment(wlGroup, 0, SWT.CENTER);
+ wGroup.setLayoutData(fdGroup);
+ lastControl = wGroup;
+
+ Label wlTags = new Label(shell, SWT.RIGHT);
+ PropsUi.setLook(wlTags);
+ wlTags.setText(BaseMessages.getString(PKG,
"ProjectConfigDialog.Label.Tags"));
+ FormData fdlTags = new FormData();
+ fdlTags.left = new FormAttachment(0, 0);
+ fdlTags.right = new FormAttachment(middle, 0);
+ fdlTags.top = new FormAttachment(lastControl, margin);
+ wlTags.setLayoutData(fdlTags);
+ wTags = new Text(shell, SWT.SINGLE | SWT.BORDER);
+ PropsUi.setLook(wTags);
+ wTags.setToolTipText(BaseMessages.getString(PKG,
"ProjectConfigDialog.Label.Tags.Tooltip"));
+ FormData fdTags = new FormData();
+ fdTags.left = new FormAttachment(middle, margin);
+ fdTags.right = new FormAttachment(100, 0);
+ fdTags.top = new FormAttachment(wlTags, 0, SWT.CENTER);
+ wTags.setLayoutData(fdTags);
+ lastControl = wTags;
+
+ Label wlReadOnly = new Label(shell, SWT.RIGHT);
+ PropsUi.setLook(wlReadOnly);
+ wlReadOnly.setText(BaseMessages.getString(PKG,
"ProjectConfigDialog.Label.ReadOnly"));
+ FormData fdlReadOnly = new FormData();
+ fdlReadOnly.left = new FormAttachment(0, 0);
+ fdlReadOnly.right = new FormAttachment(middle, 0);
+ fdlReadOnly.top = new FormAttachment(lastControl, margin);
+ wlReadOnly.setLayoutData(fdlReadOnly);
+ wReadOnly = new Button(shell, SWT.CHECK | SWT.LEFT);
+ PropsUi.setLook(wReadOnly);
+ FormData fdReadOnly = new FormData();
+ fdReadOnly.left = new FormAttachment(middle, margin);
+ fdReadOnly.right = new FormAttachment(100, 0);
+ fdReadOnly.top = new FormAttachment(wlReadOnly, 0, SWT.CENTER);
+ wReadOnly.setLayoutData(fdReadOnly);
+
+ getData();
+
+ shell.setMinimumSize(500, 280);
+ shell.setDefaultButton(wOk);
+ BaseDialog.defaultShellHandling(shell, c -> ok(), c -> cancel());
+ return ok;
+ }
+
+ private void getData() {
+ wName.setText(Const.NVL(projectConfig.getProjectName(), ""));
+ wHome.setText(Const.NVL(projectConfig.getProjectHome(), ""));
+ wConfigFile.setText(Const.NVL(projectConfig.getConfigFilename(), ""));
+ wGroup.setText(Const.NVL(projectConfig.getGroup(), ""));
+ List<String> groups =
ProjectsConfigSingleton.getConfig().listProjectGroups();
+ wGroup.setItems(groups.toArray(new String[0]));
+ wTags.setText(projectConfig.getTagsAsDisplayString());
+ wReadOnly.setSelection(
+ projectConfig.isReadOnly()
+ ||
ProjectConfig.isArchiveUri(variables.resolve(projectConfig.getProjectHome())));
+ }
+
+ private void ok() {
+ try {
+ String name = wName.getText();
+ if (StringUtils.isEmpty(name)) {
+ throw new IllegalArgumentException(
+ BaseMessages.getString(PKG,
"ProjectConfigDialog.Error.NameRequired"));
+ }
+ if (StringUtils.isEmpty(wHome.getText())) {
+ throw new IllegalArgumentException(
+ BaseMessages.getString(PKG,
"ProjectConfigDialog.Error.HomeRequired"));
+ }
+ if (StringUtils.isEmpty(wConfigFile.getText())) {
+ throw new IllegalArgumentException(
+ BaseMessages.getString(PKG,
"ProjectConfigDialog.Error.ConfigRequired"));
+ }
+
+ ProjectsConfig config = ProjectsConfigSingleton.getConfig();
+ if (!name.equals(originalName)) {
+ ProjectConfig clash = config.findProjectConfig(name);
+ if (clash != null) {
+ throw new IllegalArgumentException(
+ BaseMessages.getString(PKG,
"ProjectConfigDialog.Error.NameExists", name));
+ }
+ }
+
+ projectConfig.setProjectName(name);
+ projectConfig.setProjectHome(wHome.getText());
+ projectConfig.setConfigFilename(wConfigFile.getText());
+ projectConfig.setGroup(StringUtils.trimToEmpty(wGroup.getText()));
+ projectConfig.setTags(ProjectConfig.parseTags(wTags.getText()));
+ projectConfig.setReadOnly(wReadOnly.getSelection());
+
+ ok = true;
+ dispose();
+ } catch (Exception e) {
+ new ErrorDialog(
+ shell,
+ BaseMessages.getString(PKG, "ProjectConfigDialog.Error.Header"),
+ BaseMessages.getString(PKG, "ProjectConfigDialog.Error.Message"),
+ e);
+ }
+ }
+
+ private void cancel() {
+ ok = false;
+ dispose();
+ }
+
+ private void dispose() {
+ props.setScreen(new WindowProperty(shell));
+ shell.dispose();
+ }
+
+ /**
+ * Original project name when the dialog opened (for rename detection).
+ *
+ * @return original name
+ */
+ public String getOriginalName() {
+ return originalName;
+ }
+}
diff --git
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectsGuiPlugin.java
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectsGuiPlugin.java
index 68001603d6..94aee6ce3f 100644
---
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectsGuiPlugin.java
+++
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectsGuiPlugin.java
@@ -77,7 +77,6 @@ import org.apache.hop.ui.core.FormDataBuilder;
import org.apache.hop.ui.core.PropsUi;
import org.apache.hop.ui.core.bus.HopGuiEvents;
import org.apache.hop.ui.core.dialog.BaseDialog;
-import org.apache.hop.ui.core.dialog.EnterSelectionDialog;
import org.apache.hop.ui.core.dialog.ErrorDialog;
import org.apache.hop.ui.core.dialog.MessageBox;
import org.apache.hop.ui.core.dialog.ProgressMonitorDialog;
@@ -582,28 +581,25 @@ public class ProjectsGuiPlugin {
}
}
- String projectFolder = projectConfig.getProjectHome();
-
ProjectDialog projectDialog =
new ProjectDialog(
hopGui.getActiveShell(), project, projectConfig,
hopGui.getVariables(), true);
if (projectDialog.open() != null) {
config.addProjectConfig(projectConfig);
- // Project's home changed. Update reference in hop-config.json
- if (!projectFolder.equals(projectConfig.getProjectHome())) {
- // Refresh project reference in hop-config.json
- HopConfig.getInstance().saveToFile();
- }
-
if (!projectName.equals(projectConfig.getProjectName())) {
// Project got renamed
projectName = projectConfig.getProjectName();
- // Refresh project reference in hop-config.json
- HopConfig.getInstance().saveToFile();
}
- project.saveToFile();
+ // Persist project registration (name, home, config path, read-only)
in hop-config.json
+ HopConfig.getInstance().saveToFile();
+
+ // Do not write project-config.json for read-only projects (archives,
HTTP, ...).
+ //
+ if (!projectConfig.isReadOnly()) {
+ project.saveToFile();
+ }
updateProjectToolItem(projectName);
if (projectDialog.isNeedingProjectRefresh()) {
@@ -795,28 +791,9 @@ public class ProjectsGuiPlugin {
}
public void selectProject() {
- List<String> projectNames =
ProjectsConfigSingleton.getConfig().listProjectConfigNames();
- projectNames.sort(String::compareToIgnoreCase);
-
- String[] projects = projectNames.toArray(new String[0]);
-
- EnterSelectionDialog dialog =
- new EnterSelectionDialog(
- HopGui.getInstance().getActiveShell(),
- projects,
- BaseMessages.getString(PKG,
"ProjectGuiPlugin.Dialog.AvailableProjects.Title"),
- BaseMessages.getString(PKG,
"ProjectGuiPlugin.Dialog.AvailableProjects.Message"));
-
- String currentProject = HopNamespace.getNamespace();
- dialog.setCurrentValue(currentProject);
- int index = Const.indexOfString(currentProject, projects);
- if (index >= 0) {
- dialog.setSelectedNrs(
- new int[] {
- index,
- });
- }
-
+ HopGui hopGui = HopGui.getInstance();
+ SelectProjectsDialog dialog =
+ new SelectProjectsDialog(hopGui.getActiveShell(),
hopGui.getVariables());
String name = dialog.open();
if (name != null) {
selectProject(name);
@@ -1015,15 +992,19 @@ public class ProjectsGuiPlugin {
config.addProjectConfig(projectConfig);
HopConfig.getInstance().saveToFile();
- // Save the project-config.json file as well in the project itself
+ // Save or load the project-config.json in the project itself.
+ // Read-only projects (archives, HTTP, ...) never write the config
file: it must already
+ // exist (validated in the project dialog).
//
FileObject configFile =
HopVfs.getFileObject(projectConfig.getActualProjectConfigFilename(variables));
- if (!configFile.exists()) {
- // Create the empty configuration file if it does not exists
+ if (projectConfig.isReadOnly()) {
+ project.readFromFile();
+ } else if (!configFile.exists()) {
+ // Create the empty configuration file if it does not exist
project.saveToFile();
} else {
- // If projects exists load configuration
+ // If project already exists load configuration
MessageBox box = new MessageBox(hopGui.getActiveShell(),
SWT.ICON_QUESTION | SWT.OK);
box.setText(BaseMessages.getString(PKG,
"ProjectGuiPlugin.ProjectExists.Dialog.Header"));
box.setMessage(
@@ -1037,77 +1018,83 @@ public class ProjectsGuiPlugin {
enableHopGuiProject(projectName, project, null);
- // Now see if these project contains any local run configurations.
- // If not we can add those automatically
- //
- // First pipeline
+ // Do not create local run configurations inside a read-only project
(would try to write
+ // metadata).
//
- IHopMetadataSerializer<PipelineRunConfiguration> prcSerializer =
-
hopGui.getMetadataProvider().getSerializer(PipelineRunConfiguration.class);
- List<PipelineRunConfiguration> pipelineRunConfigs =
prcSerializer.loadAll();
- boolean localFound = false;
- for (PipelineRunConfiguration pipelineRunConfig : pipelineRunConfigs) {
- if (pipelineRunConfig.getEngineRunConfiguration()
- instanceof LocalPipelineRunConfiguration) {
- localFound = true;
- break;
+ if (!projectConfig.isReadOnly()) {
+ // Now see if these project contains any local run configurations.
+ // If not we can add those automatically
+ //
+ // First pipeline
+ //
+ IHopMetadataSerializer<PipelineRunConfiguration> prcSerializer =
+
hopGui.getMetadataProvider().getSerializer(PipelineRunConfiguration.class);
+ List<PipelineRunConfiguration> pipelineRunConfigs =
prcSerializer.loadAll();
+ boolean localFound = false;
+ for (PipelineRunConfiguration pipelineRunConfig :
pipelineRunConfigs) {
+ if (pipelineRunConfig.getEngineRunConfiguration()
+ instanceof LocalPipelineRunConfiguration) {
+ localFound = true;
+ break;
+ }
+ }
+ if (!localFound) {
+
PipelineExecutionConfigurationDialog.createLocalPipelineConfiguration(
+ hopGui.getShell(), prcSerializer);
}
- }
- if (!localFound) {
-
PipelineExecutionConfigurationDialog.createLocalPipelineConfiguration(
- hopGui.getShell(), prcSerializer);
- }
- // Then the local workflow run configuration
- //
- IHopMetadataSerializer<WorkflowRunConfiguration> wrcSerializer =
-
hopGui.getMetadataProvider().getSerializer(WorkflowRunConfiguration.class);
- localFound = false;
- List<WorkflowRunConfiguration> workflowRunConfigs =
wrcSerializer.loadAll();
- for (WorkflowRunConfiguration workflowRunConfig : workflowRunConfigs) {
- if (workflowRunConfig.getEngineRunConfiguration()
- instanceof LocalWorkflowRunConfiguration) {
- localFound = true;
- break;
+ // Then the local workflow run configuration
+ //
+ IHopMetadataSerializer<WorkflowRunConfiguration> wrcSerializer =
+
hopGui.getMetadataProvider().getSerializer(WorkflowRunConfiguration.class);
+ localFound = false;
+ List<WorkflowRunConfiguration> workflowRunConfigs =
wrcSerializer.loadAll();
+ for (WorkflowRunConfiguration workflowRunConfig :
workflowRunConfigs) {
+ if (workflowRunConfig.getEngineRunConfiguration()
+ instanceof LocalWorkflowRunConfiguration) {
+ localFound = true;
+ break;
+ }
}
- }
- if (!localFound) {
+ if (!localFound) {
+ MessageBox box =
+ new MessageBox(
+ HopGui.getInstance().getShell(), SWT.YES | SWT.NO |
SWT.ICON_QUESTION);
+ box.setText(
+ BaseMessages.getString(PKG,
"ProjectGuiPlugin.LocalWFRunConfig.Dialog.Header"));
+ box.setMessage(
+ BaseMessages.getString(PKG,
"ProjectGuiPlugin.LocalWFRunConfig.Dialog.Message"));
+ int answer = box.open();
+ if ((answer & SWT.YES) != 0) {
+ LocalWorkflowRunConfiguration localWorkflowRunConfiguration =
+ new LocalWorkflowRunConfiguration();
+ localWorkflowRunConfiguration.setEnginePluginId("Local");
+ WorkflowRunConfiguration local =
+ new WorkflowRunConfiguration(
+ "local",
+ BaseMessages.getString(
+ PKG,
"ProjectGuiPlugin.LocalWFRunConfigDescription.Text"),
+ null,
+ localWorkflowRunConfiguration,
+ true);
+ wrcSerializer.save(local);
+ }
+ }
+
+ // Ask to put the project in a lifecycle environment
+ //
MessageBox box =
new MessageBox(HopGui.getInstance().getShell(), SWT.YES | SWT.NO
| SWT.ICON_QUESTION);
- box.setText(
- BaseMessages.getString(PKG,
"ProjectGuiPlugin.LocalWFRunConfig.Dialog.Header"));
+ box.setText(BaseMessages.getString(PKG,
"ProjectGuiPlugin.Lifecycle.Dialog.Header"));
box.setMessage(
- BaseMessages.getString(PKG,
"ProjectGuiPlugin.LocalWFRunConfig.Dialog.Message"));
+ BaseMessages.getString(PKG,
"ProjectGuiPlugin.Lifecycle.Dialog.Message1")
+ + Const.CR
+ + BaseMessages.getString(PKG,
"ProjectGuiPlugin.Lifecycle.Dialog.Message2"));
int answer = box.open();
if ((answer & SWT.YES) != 0) {
- LocalWorkflowRunConfiguration localWorkflowRunConfiguration =
- new LocalWorkflowRunConfiguration();
- localWorkflowRunConfiguration.setEnginePluginId("Local");
- WorkflowRunConfiguration local =
- new WorkflowRunConfiguration(
- "local",
- BaseMessages.getString(
- PKG,
"ProjectGuiPlugin.LocalWFRunConfigDescription.Text"),
- null,
- localWorkflowRunConfiguration,
- true);
- wrcSerializer.save(local);
- }
- }
-
- // Ask to put the project in a lifecycle environment
- //
- MessageBox box =
- new MessageBox(HopGui.getInstance().getShell(), SWT.YES | SWT.NO |
SWT.ICON_QUESTION);
- box.setText(BaseMessages.getString(PKG,
"ProjectGuiPlugin.Lifecycle.Dialog.Header"));
- box.setMessage(
- BaseMessages.getString(PKG,
"ProjectGuiPlugin.Lifecycle.Dialog.Message1")
- + Const.CR
- + BaseMessages.getString(PKG,
"ProjectGuiPlugin.Lifecycle.Dialog.Message2"));
- int answer = box.open();
- if ((answer & SWT.YES) != 0) {
- addNewEnvironment();
+ addNewEnvironment();
+ }
}
}
@@ -1131,29 +1118,41 @@ public class ProjectsGuiPlugin {
// Use getToolbarItemText() for RAP compatibility (text is stored in
Label, not ToolItem)
String projectName =
HopGui.getInstance().getStatusToolbarWidgets().getToolbarItemText(ID_TOOLBAR_ITEM_PROJECT);
+ deleteRegisteredProject(HopGui.getInstance().getShell(), projectName);
+ }
+
+ /**
+ * Delete a project registration by name (confirmation + parent-project
checks). Used by the
+ * toolbar delete action and the projects browser dialog.
+ *
+ * @param parentShell parent for confirmation/error dialogs (must be the
active modal dialog when
+ * called from SelectProjectsDialog, otherwise message boxes appear
behind it)
+ * @param projectName name of the project to remove from hop-config
+ * @return true if the project was removed
+ */
+ public boolean deleteRegisteredProject(Shell parentShell, String
projectName) {
if (StringUtils.isEmpty(projectName)) {
- return;
+ return false;
}
+ Shell shell = parentShell != null ? parentShell :
HopGui.getInstance().getShell();
ProjectsConfig config = ProjectsConfigSingleton.getConfig();
ProjectConfig currentProjectConfig = config.findProjectConfig(projectName);
if (currentProjectConfig == null) {
- return;
+ return false;
}
String projectHome = currentProjectConfig.getProjectHome();
String configFilename = currentProjectConfig.getConfigFilename();
try {
-
List<String> refs = ProjectsUtil.getParentProjectReferences(projectName);
if (refs.isEmpty()) {
- performProjectDeletion(projectName, config, projectHome,
configFilename);
+ return performProjectDeletion(shell, projectName, config, projectHome,
configFilename);
} else {
String prjReferences = String.join(",", refs);
- MessageBox box =
- new MessageBox(HopGui.getInstance().getShell(), SWT.OK |
SWT.ICON_INFORMATION);
+ MessageBox box = new MessageBox(shell, SWT.OK | SWT.ICON_INFORMATION);
box.setText(
BaseMessages.getString(
PKG,
"ProjectGuiPlugin.DeleteProject.ProjectReferencedAsParent.Header"));
@@ -1166,21 +1165,33 @@ public class ProjectsGuiPlugin {
+ BaseMessages.getString(
PKG,
"ProjectGuiPlugin.DeleteProject.ProjectReferencedAsParent.Message2"));
box.open();
+ return false;
}
} catch (Exception e) {
new ErrorDialog(
- HopGui.getInstance().getShell(),
+ shell,
BaseMessages.getString(PKG,
"ProjectGuiPlugin.DeleteProject.Dialog.Header"),
BaseMessages.getString(
PKG, "ProjectGuiPlugin.DeleteProject.Error.Dialog.Message",
projectName),
e);
+ return false;
}
}
- private void performProjectDeletion(
- String projectName, ProjectsConfig config, String projectHome, String
configFilename) {
- MessageBox box =
- new MessageBox(HopGui.getInstance().getShell(), SWT.YES | SWT.NO |
SWT.ICON_QUESTION);
+ /**
+ * Confirm and remove a project registration from hop-config (does not
delete files on disk).
+ *
+ * @param parentShell parent for confirmation/error dialogs
+ * @return true if the project was removed
+ */
+ public boolean performProjectDeletion(
+ Shell parentShell,
+ String projectName,
+ ProjectsConfig config,
+ String projectHome,
+ String configFilename) {
+ Shell shell = parentShell != null ? parentShell :
HopGui.getInstance().getShell();
+ MessageBox box = new MessageBox(shell, SWT.YES | SWT.NO |
SWT.ICON_QUESTION);
box.setText(BaseMessages.getString(PKG,
"ProjectGuiPlugin.DeleteProject.Dialog.Header"));
box.setMessage(
BaseMessages.getString(PKG,
"ProjectGuiPlugin.DeleteProject.Dialog.Message1", projectName)
@@ -1201,15 +1212,17 @@ public class ProjectsGuiPlugin {
} else {
updateProjectToolItem(config.getDefaultProject());
}
+ return true;
} catch (Exception e) {
new ErrorDialog(
- HopGui.getInstance().getShell(),
+ shell,
BaseMessages.getString(PKG,
"ProjectGuiPlugin.DeleteProject.Error.Dialog.Header"),
BaseMessages.getString(
PKG, "ProjectGuiPlugin.DeleteProject.Error.Dialog.Message",
projectName),
e);
}
}
+ return false;
}
@GuiMenuElement(
@@ -1546,6 +1559,7 @@ public class ProjectsGuiPlugin {
AtomicBoolean includeVariables = new AtomicBoolean(true);
AtomicBoolean includeMetadata = new AtomicBoolean(true);
+ AtomicBoolean exportAsIs = new AtomicBoolean(false);
AtomicBoolean cancel = new AtomicBoolean(true);
// After getting the filename we create a tree dialog to select the items
you wish to include in
@@ -1588,35 +1602,76 @@ public class ProjectsGuiPlugin {
.bottom(okButton, -2 * PropsUi.getMargin())
.result());
+ Button btnIncludeVariables = new Button(treeShell, SWT.CHECK);
+ PropsUi.setLook(btnIncludeVariables);
+ btnIncludeVariables.setText(
+ BaseMessages.getString(PKG,
"ProjectGuiPlugin.IncludeVariables.Label"));
+ btnIncludeVariables.setSelection(true);
+ btnIncludeVariables.addListener(
+ SWT.Selection, event ->
includeVariables.set(btnIncludeVariables.getSelection()));
+ btnIncludeVariables.setLayoutData(
+ new FormDataBuilder().fullWidth().bottom(separator,
-PropsUi.getMargin()).result());
+
+ // Bottom → top: metadata, as-is, variables, file tree
+ //
Button btnIncludeMetadata = new Button(treeShell, SWT.CHECK);
PropsUi.setLook(btnIncludeMetadata);
btnIncludeMetadata.setText(
BaseMessages.getString(PKG,
"ProjectGuiPlugin.IncludeMetadata.Label"));
btnIncludeMetadata.setSelection(true);
btnIncludeMetadata.setLayoutData(
- new FormDataBuilder().fullWidth().bottom(separator, -2 *
PropsUi.getMargin()).result());
+ new FormDataBuilder()
+ .fullWidth()
+ .bottom(btnIncludeVariables, -2 * PropsUi.getMargin())
+ .result());
btnIncludeMetadata.addListener(
SWT.Selection, event ->
includeMetadata.set(btnIncludeMetadata.getSelection()));
- Button btnIncludeVariables = new Button(treeShell, SWT.CHECK);
- PropsUi.setLook(btnIncludeVariables);
- btnIncludeVariables.setText(
- BaseMessages.getString(PKG,
"ProjectGuiPlugin.IncludeVariables.Label"));
- btnIncludeVariables.setSelection(true);
- btnIncludeVariables.addListener(
- SWT.Selection, event ->
includeVariables.set(btnIncludeVariables.getSelection()));
- btnIncludeVariables.setLayoutData(
+ Button btnExportAsIs = new Button(treeShell, SWT.CHECK);
+ PropsUi.setLook(btnExportAsIs);
+ btnExportAsIs.setText(BaseMessages.getString(PKG,
"ProjectGuiPlugin.ExportAsIs.Label"));
+ btnExportAsIs.setToolTipText(
+ BaseMessages.getString(PKG, "ProjectGuiPlugin.ExportAsIs.Tooltip"));
+ btnExportAsIs.setSelection(false);
+ btnExportAsIs.setLayoutData(
new FormDataBuilder()
.fullWidth()
.bottom(btnIncludeMetadata, -PropsUi.getMargin())
.result());
+ // Remember selections so we can restore them when "export as-is" is
turned off.
+ AtomicBoolean savedIncludeVariables = new AtomicBoolean(true);
+ AtomicBoolean savedIncludeMetadata = new AtomicBoolean(true);
+ btnExportAsIs.addListener(
+ SWT.Selection,
+ event -> {
+ boolean asIs = btnExportAsIs.getSelection();
+ exportAsIs.set(asIs);
+ if (asIs) {
+ savedIncludeVariables.set(btnIncludeVariables.getSelection());
+ savedIncludeMetadata.set(btnIncludeMetadata.getSelection());
+ btnIncludeVariables.setSelection(false);
+ btnIncludeMetadata.setSelection(false);
+ includeVariables.set(false);
+ includeMetadata.set(false);
+ btnIncludeVariables.setEnabled(false);
+ btnIncludeMetadata.setEnabled(false);
+ } else {
+ btnIncludeVariables.setEnabled(true);
+ btnIncludeMetadata.setEnabled(true);
+ btnIncludeVariables.setSelection(savedIncludeVariables.get());
+ btnIncludeMetadata.setSelection(savedIncludeMetadata.get());
+ includeVariables.set(savedIncludeVariables.get());
+ includeMetadata.set(savedIncludeMetadata.get());
+ }
+ });
+
tree = new FileTree(treeShell, HopVfs.getFileObject(projectHome),
projectName);
tree.setLayoutData(
new FormDataBuilder()
.top()
.fullWidth()
- .bottom(btnIncludeVariables, -PropsUi.getMargin())
+ .bottom(btnExportAsIs, -PropsUi.getMargin())
.result());
BaseTransformDialog.setSize(treeShell);
@@ -1640,47 +1695,19 @@ public class ProjectsGuiPlugin {
try {
monitor.setTaskName(
BaseMessages.getString(PKG,
"ProjectGuiPlugin.ZipDirectory.Taskname.Text"));
- HashMap<String, String> variablesMap = new HashMap<>();
-
- for (String name : variables.getVariableNames()) {
- if (!name.contains("java.")
- && !name.contains("user.")
- && !name.contains("sun.")
- && !name.contains("os.")
- && !name.contains("file.")
- && !name.contains("jdk.")
- && !name.contains("http.")
- && !name.contains("path.")
- && !name.contains("ftp.")
- && !name.contains("line.")
- && !name.contains("awt.")
- && !name.equals("HOP_METADATA_FOLDER")
- && !name.contains("HOP_ENVIRONMENT_NAME")
- && !name.contains("HOP_AUDIT_FOLDER")
- && !name.contains("HOP_CONFIG_FOLDER")
- && !name.contains("PROJECT_HOME")
- && !name.contains("PARENT_PROJECT_NAME")
- && !name.contains("HOP_PROJECTS")
- && !name.contains("HOP_PLATFORM_OS")
- && !name.contains("HOP_PROJECT_NAME")
- && !name.contains("HOP_SERVER_URL")) {
- String value = variables.getVariable(name);
- variablesMap.put(name, value);
- }
- }
- ObjectMapper objectMapper = new ObjectMapper();
- String variablesJson =
objectMapper.writeValueAsString(variablesMap);
-
- SerializableMetadataProvider metadataProvider =
- new
SerializableMetadataProvider(hopGui.getMetadataProvider());
- String metadataJson = metadataProvider.toJson();
+ boolean asIs = exportAsIs.get();
FileObject zipFile = HopVfs.getFileObject(zipFilename);
OutputStream outputStream = HopVfs.getOutputStream(zipFile,
false);
ZipOutputStream zos = new ZipOutputStream(outputStream);
FileObject projectDirectory = HopVfs.getFileObject(projectHome);
- String projectHomeFolder =
-
HopVfs.getFileObject(projectHome).getParent().getName().getURI();
+ // As-is: strip project home so entries land at the zip root
(compatible with
+ // zip:file://…!/ read-only project open). Normal: strip parent
so entries are under
+ // {projectName}/…
+ String stripBase =
+ asIs
+ ? projectDirectory.getName().getURI()
+ : projectDirectory.getParent().getName().getURI();
// Includes selected files and dependencies
if (!tree.getFileObjects().isEmpty()) {
@@ -1690,30 +1717,71 @@ public class ProjectsGuiPlugin {
continue;
}
monitor.subTask(fileObject.getName().getURI());
- zipFile(fileObject, fileObject.getName().getURI(), zos,
projectHomeFolder);
+ zipFile(fileObject, fileObject.getName().getURI(), zos,
stripBase);
}
}
- // Includes config file
- zipFile(
- HopVfs.getFileObject(
- Const.HOP_CONFIG_FOLDER + Const.FILE_SEPARATOR +
Const.HOP_CONFIG),
- projectDirectory.getName().getBaseName()
- + Const.FILE_SEPARATOR
- + Const.HOP_CONFIG,
- zos,
- projectHomeFolder);
-
- // Includes variables
- if (includeVariables.get()) {
- zipString(
- variablesJson, "variables.json", zos,
projectDirectory.getName().getBaseName());
- }
+ // Sidecars only for the classic (non as-is) export format
+ if (!asIs) {
+ HashMap<String, String> variablesMap = new HashMap<>();
+
+ for (String name : variables.getVariableNames()) {
+ if (!name.contains("java.")
+ && !name.contains("user.")
+ && !name.contains("sun.")
+ && !name.contains("os.")
+ && !name.contains("file.")
+ && !name.contains("jdk.")
+ && !name.contains("http.")
+ && !name.contains("path.")
+ && !name.contains("ftp.")
+ && !name.contains("line.")
+ && !name.contains("awt.")
+ && !name.equals("HOP_METADATA_FOLDER")
+ && !name.contains("HOP_ENVIRONMENT_NAME")
+ && !name.contains("HOP_AUDIT_FOLDER")
+ && !name.contains("HOP_CONFIG_FOLDER")
+ && !name.contains("PROJECT_HOME")
+ && !name.contains("PARENT_PROJECT_NAME")
+ && !name.contains("HOP_PROJECTS")
+ && !name.contains("HOP_PLATFORM_OS")
+ && !name.contains("HOP_PROJECT_NAME")
+ && !name.contains("HOP_SERVER_URL")) {
+ String value = variables.getVariable(name);
+ variablesMap.put(name, value);
+ }
+ }
+ ObjectMapper objectMapper = new ObjectMapper();
+ String variablesJson =
objectMapper.writeValueAsString(variablesMap);
+
+ SerializableMetadataProvider metadataProvider =
+ new
SerializableMetadataProvider(hopGui.getMetadataProvider());
+ String metadataJson = metadataProvider.toJson();
+
+ // Includes config file
+ zipFile(
+ HopVfs.getFileObject(
+ Const.HOP_CONFIG_FOLDER + Const.FILE_SEPARATOR +
Const.HOP_CONFIG),
+ projectDirectory.getName().getBaseName()
+ + Const.FILE_SEPARATOR
+ + Const.HOP_CONFIG,
+ zos,
+ stripBase);
+
+ // Includes variables
+ if (includeVariables.get()) {
+ zipString(
+ variablesJson,
+ "variables.json",
+ zos,
+ projectDirectory.getName().getBaseName());
+ }
- // Includes metadata
- if (includeMetadata.get()) {
- zipString(
- metadataJson, "metadata.json", zos,
projectDirectory.getName().getBaseName());
+ // Includes metadata
+ if (includeMetadata.get()) {
+ zipString(
+ metadataJson, "metadata.json", zos,
projectDirectory.getName().getBaseName());
+ }
}
zos.close();
@@ -1746,6 +1814,36 @@ public class ProjectsGuiPlugin {
}
}
+ /**
+ * Build a zip entry path by stripping a base URI/path prefix from an
absolute file URI.
+ *
+ * @param absoluteUri absolute file URI or path
+ * @param stripBase URI/path prefix to remove (project home or its parent)
+ * @return relative zip entry path without a leading slash
+ */
+ static String relativeZipPath(String absoluteUri, String stripBase) {
+ if (absoluteUri == null) {
+ return "";
+ }
+ String path = absoluteUri;
+ if (StringUtils.isNotEmpty(stripBase)) {
+ String base = stripBase;
+ // Normalize trailing slashes so file://…/project and file://…/project/
both strip cleanly
+ while (base.endsWith("/")) {
+ base = base.substring(0, base.length() - 1);
+ }
+ if (path.startsWith(base)) {
+ path = path.substring(base.length());
+ } else {
+ path = path.replace(stripBase, "");
+ }
+ }
+ while (path.startsWith("/")) {
+ path = path.substring(1);
+ }
+ return path;
+ }
+
/**
* Add file Object to zip file
*
@@ -1766,10 +1864,7 @@ public class ProjectsGuiPlugin {
}
// Build relative filename
- filename = filename.replace(projectHomeParent, "");
- if (filename.startsWith("/")) {
- filename = filename.substring(1);
- }
+ filename = relativeZipPath(filename, projectHomeParent);
zipOutputStream.putNextEntry(new ZipEntry(filename));
if (fileToZip.isFolder()) {
@@ -1777,9 +1872,10 @@ public class ProjectsGuiPlugin {
for (FileObject childFile : fileToZip.getChildren()) {
zipFile(
childFile,
+ // Pass already-relative path so nested calls do not re-strip
incorrectly
filename + "/" + childFile.getName().getBaseName(),
zipOutputStream,
- projectHomeParent);
+ "");
}
} else {
InputStream fis = HopVfs.getInputStream(fileToZip);
diff --git
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/SelectProjectsDialog.java
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/SelectProjectsDialog.java
new file mode 100644
index 0000000000..bb7062ea41
--- /dev/null
+++
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/SelectProjectsDialog.java
@@ -0,0 +1,394 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.apache.hop.projects.gui;
+
+import java.text.DateFormat;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.logging.LogChannel;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.history.AuditEvent;
+import org.apache.hop.history.AuditManager;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.projects.config.ProjectsConfig;
+import org.apache.hop.projects.config.ProjectsConfigSingleton;
+import org.apache.hop.projects.project.ProjectConfig;
+import org.apache.hop.projects.util.ProjectsUtil;
+import org.apache.hop.ui.core.ConstUi;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.dialog.BaseDialog;
+import org.apache.hop.ui.core.dialog.ErrorDialog;
+import org.apache.hop.ui.core.gui.GuiResource;
+import org.apache.hop.ui.core.gui.HopNamespace;
+import org.apache.hop.ui.core.gui.WindowProperty;
+import org.apache.hop.ui.core.widget.ColumnInfo;
+import org.apache.hop.ui.core.widget.TableView;
+import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.layout.FormAttachment;
+import org.eclipse.swt.layout.FormData;
+import org.eclipse.swt.layout.FormLayout;
+import org.eclipse.swt.widgets.Button;
+import org.eclipse.swt.widgets.Dialog;
+import org.eclipse.swt.widgets.Label;
+import org.eclipse.swt.widgets.Shell;
+import org.eclipse.swt.widgets.TableItem;
+import org.eclipse.swt.widgets.Text;
+
+/**
+ * Dialog listing all registered projects with filter, metadata columns, and
the ability to edit a
+ * registration or open the selected project.
+ */
+public class SelectProjectsDialog extends Dialog {
+ private static final Class<?> PKG = SelectProjectsDialog.class;
+
+ /** TableView column index for project name (0 is the row-number column). */
+ private static final int COL_NAME = 1;
+
+ private final PropsUi props;
+ private final IVariables variables;
+
+ private Shell shell;
+ private Text wFilter;
+ private TableView wTable;
+ private Button wEdit;
+ private Button wDelete;
+ private Button wOk;
+
+ private String selectedProjectName;
+
+ /**
+ * Cached last-used timestamps from AuditManager for the lifetime of this
dialog. Loaded once so
+ * filter typing does not re-query audit storage on every keystroke.
+ */
+ private Map<String, String> lastUsedDisplayByProject;
+
+ public SelectProjectsDialog(Shell parent, IVariables variables) {
+ super(parent, SWT.DIALOG_TRIM | SWT.APPLICATION_MODAL | SWT.RESIZE);
+ this.variables = variables;
+ this.props = PropsUi.getInstance();
+ }
+
+ /**
+ * Open the dialog.
+ *
+ * @return selected project name to open, or {@code null} if cancelled
+ */
+ public String open() {
+ Shell parent = getParent();
+ shell = new Shell(parent, SWT.DIALOG_TRIM | SWT.APPLICATION_MODAL |
SWT.RESIZE);
+ shell.setText(BaseMessages.getString(PKG,
"SelectProjectsDialog.Shell.Name"));
+ shell.setImage(
+ GuiResource.getInstance()
+ .getImage(
+ "project.svg",
+ PKG.getClassLoader(),
+ ConstUi.SMALL_ICON_SIZE,
+ ConstUi.SMALL_ICON_SIZE));
+ PropsUi.setLook(shell);
+
+ int margin = PropsUi.getMargin();
+
+ FormLayout formLayout = new FormLayout();
+ formLayout.marginWidth = PropsUi.getFormMargin();
+ formLayout.marginHeight = PropsUi.getFormMargin();
+ shell.setLayout(formLayout);
+
+ shell.setMinimumSize(700, 450);
+
+ wOk = new Button(shell, SWT.PUSH);
+ wOk.setText(BaseMessages.getString("System.Button.OK"));
+ wOk.addListener(SWT.Selection, e -> ok());
+ wOk.setEnabled(false);
+
+ wEdit = new Button(shell, SWT.PUSH);
+ wEdit.setText(BaseMessages.getString(PKG,
"SelectProjectsDialog.Button.Edit"));
+ wEdit.setEnabled(false);
+ wEdit.addListener(SWT.Selection, e -> editSelected());
+
+ wDelete = new Button(shell, SWT.PUSH);
+ wDelete.setText(BaseMessages.getString(PKG,
"SelectProjectsDialog.Button.Delete"));
+ wDelete.setEnabled(false);
+ wDelete.addListener(SWT.Selection, e -> deleteSelected());
+
+ Button wCancel = new Button(shell, SWT.PUSH);
+ wCancel.setText(BaseMessages.getString("System.Button.Cancel"));
+ wCancel.addListener(SWT.Selection, e -> cancel());
+
+ BaseTransformDialog.positionBottomButtons(
+ shell, new Button[] {wOk, wEdit, wDelete, wCancel}, margin * 3, null);
+
+ Label wlFilter = new Label(shell, SWT.LEFT);
+ PropsUi.setLook(wlFilter);
+ wlFilter.setText(BaseMessages.getString(PKG,
"SelectProjectsDialog.Label.Filter"));
+ FormData fdlFilter = new FormData();
+ fdlFilter.left = new FormAttachment(0, 0);
+ fdlFilter.top = new FormAttachment(0, margin);
+ wlFilter.setLayoutData(fdlFilter);
+
+ wFilter = new Text(shell, SWT.SINGLE | SWT.BORDER | SWT.SEARCH |
SWT.ICON_CANCEL);
+ PropsUi.setLook(wFilter);
+ wFilter.setToolTipText(
+ BaseMessages.getString(PKG,
"SelectProjectsDialog.Label.Filter.Tooltip"));
+ FormData fdFilter = new FormData();
+ fdFilter.left = new FormAttachment(wlFilter, margin);
+ fdFilter.right = new FormAttachment(100, 0);
+ fdFilter.top = new FormAttachment(wlFilter, 0, SWT.CENTER);
+ wFilter.setLayoutData(fdFilter);
+ wFilter.addModifyListener(e -> refreshTable());
+
+ ColumnInfo[] columns =
+ new ColumnInfo[] {
+ new ColumnInfo(
+ BaseMessages.getString(PKG,
"SelectProjectsDialog.Table.Col.Name"),
+ ColumnInfo.COLUMN_TYPE_TEXT,
+ false,
+ true),
+ new ColumnInfo(
+ BaseMessages.getString(PKG,
"SelectProjectsDialog.Table.Col.Group"),
+ ColumnInfo.COLUMN_TYPE_TEXT,
+ false,
+ true),
+ new ColumnInfo(
+ BaseMessages.getString(PKG,
"SelectProjectsDialog.Table.Col.Folder"),
+ ColumnInfo.COLUMN_TYPE_TEXT,
+ false,
+ true),
+ new ColumnInfo(
+ BaseMessages.getString(PKG,
"SelectProjectsDialog.Table.Col.Tags"),
+ ColumnInfo.COLUMN_TYPE_TEXT,
+ false,
+ true),
+ new ColumnInfo(
+ BaseMessages.getString(PKG,
"SelectProjectsDialog.Table.Col.LastUsed"),
+ ColumnInfo.COLUMN_TYPE_TEXT,
+ false,
+ true),
+ new ColumnInfo(
+ BaseMessages.getString(PKG,
"SelectProjectsDialog.Table.Col.ReadOnly"),
+ ColumnInfo.COLUMN_TYPE_TEXT,
+ false,
+ true),
+ };
+
+ wTable =
+ new TableView(
+ variables,
+ shell,
+ SWT.BORDER | SWT.FULL_SELECTION | SWT.SINGLE | SWT.H_SCROLL |
SWT.V_SCROLL,
+ columns,
+ 0,
+ null,
+ props);
+ wTable.setReadonly(true);
+ PropsUi.setLook(wTable);
+ FormData fdTable = new FormData();
+ fdTable.left = new FormAttachment(0, 0);
+ fdTable.right = new FormAttachment(100, 0);
+ fdTable.top = new FormAttachment(wFilter, margin);
+ fdTable.bottom = new FormAttachment(wOk, -margin * 2);
+ wTable.setLayoutData(fdTable);
+
+ wTable.table.addListener(SWT.Selection, e -> updateButtons());
+ wTable.table.addListener(SWT.DefaultSelection, e -> ok());
+
+ // Load audit last-used once for this dialog instance (filter uses the
cache).
+ loadLastUsedCache();
+ refreshTable();
+ selectCurrentProject();
+
+ shell.setDefaultButton(wOk);
+ BaseDialog.defaultShellHandling(shell, c -> ok(), c -> cancel());
+ return selectedProjectName;
+ }
+
+ /**
+ * Load project "open" events from AuditManager into {@link
#lastUsedDisplayByProject}. Called
+ * once per dialog open so filter keystrokes stay cheap.
+ */
+ private void loadLastUsedCache() {
+ lastUsedDisplayByProject = new HashMap<>();
+ try {
+ List<AuditEvent> projectOpenEvents =
+ AuditManager.findEvents(
+ ProjectsUtil.STRING_PROJECTS_AUDIT_GROUP,
+ ProjectsUtil.STRING_PROJECT_AUDIT_TYPE,
+ "open",
+ 100,
+ true);
+ DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm");
+ for (AuditEvent event : projectOpenEvents) {
+ if (StringUtils.isEmpty(event.getName()) || event.getDate() == null) {
+ continue;
+ }
+ lastUsedDisplayByProject.put(event.getName(),
dateFormat.format(event.getDate()));
+ }
+ } catch (Exception e) {
+ LogChannel.UI.logError("Error reading last used project dates from the
audit logs", e);
+ }
+ }
+
+ private String formatLastUsed(String projectName) {
+ if (lastUsedDisplayByProject == null || projectName == null) {
+ return "";
+ }
+ return Const.NVL(lastUsedDisplayByProject.get(projectName), "");
+ }
+
+ private void refreshTable() {
+ String previouslySelected = getSelectedProjectName();
+ String filter = wFilter.getText();
+
+ List<ProjectConfig> projects =
+ new
ArrayList<>(ProjectsConfigSingleton.getConfig().getProjectConfigurations());
+ projects.sort(
+ Comparator.comparing(
+ (ProjectConfig p) -> Const.NVL(p.getGroup(), ""),
String.CASE_INSENSITIVE_ORDER)
+ .thenComparing(p -> Const.NVL(p.getProjectName(), ""),
String.CASE_INSENSITIVE_ORDER));
+
+ wTable.table.removeAll();
+ for (ProjectConfig projectConfig : projects) {
+ if (!projectConfig.matchesFilter(filter)) {
+ continue;
+ }
+ TableItem item = new TableItem(wTable.table, SWT.NONE);
+ // Column 0 is the TableView row-number column; name is always column 1
+ item.setText(COL_NAME, Const.NVL(projectConfig.getProjectName(), ""));
+ item.setText(2, Const.NVL(projectConfig.getGroup(), ""));
+ item.setText(3, Const.NVL(projectConfig.getProjectHome(), ""));
+ item.setText(4, projectConfig.getTagsAsDisplayString());
+ item.setText(5, formatLastUsed(projectConfig.getProjectName()));
+ item.setText(
+ 6,
+ projectConfig.isReadOnly()
+ ? BaseMessages.getString(PKG, "SelectProjectsDialog.Table.Yes")
+ : BaseMessages.getString(PKG, "SelectProjectsDialog.Table.No"));
+ }
+ wTable.optimizeTableView();
+
+ if (StringUtils.isNotEmpty(previouslySelected)) {
+ selectProjectByName(previouslySelected);
+ }
+ updateButtons();
+ }
+
+ private void selectCurrentProject() {
+ String current = HopNamespace.getNamespace();
+ if (StringUtils.isNotEmpty(current)) {
+ selectProjectByName(current);
+ }
+ updateButtons();
+ }
+
+ private void selectProjectByName(String name) {
+ for (TableItem item : wTable.table.getItems()) {
+ if (name.equals(item.getText(COL_NAME))) {
+ wTable.table.setSelection(item);
+ wTable.table.showSelection();
+ return;
+ }
+ }
+ }
+
+ /**
+ * Project name from the selected row. Always use column 1 (Name) —
TableView sort can recreate
+ * items so item data is not reliable after header clicks.
+ */
+ private String getSelectedProjectName() {
+ int index = wTable.getSelectionIndex();
+ if (index < 0 || index >= wTable.table.getItemCount()) {
+ return null;
+ }
+ String name = wTable.table.getItem(index).getText(COL_NAME);
+ return StringUtils.isEmpty(name) ? null : name;
+ }
+
+ private void updateButtons() {
+ boolean hasSelection = StringUtils.isNotEmpty(getSelectedProjectName());
+ wOk.setEnabled(hasSelection);
+ wEdit.setEnabled(hasSelection);
+ wDelete.setEnabled(hasSelection);
+ }
+
+ private void editSelected() {
+ String name = getSelectedProjectName();
+ if (name == null) {
+ return;
+ }
+ ProjectsConfig config = ProjectsConfigSingleton.getConfig();
+ ProjectConfig projectConfig = config.findProjectConfig(name);
+ if (projectConfig == null) {
+ return;
+ }
+
+ ProjectConfigDialog editDialog = new ProjectConfigDialog(shell,
projectConfig, variables);
+ if (editDialog.open()) {
+ try {
+ config.updateProjectConfig(editDialog.getOriginalName(),
projectConfig);
+ ProjectsConfigSingleton.saveConfig();
+ refreshTable();
+ selectProjectByName(projectConfig.getProjectName());
+ updateButtons();
+ } catch (Exception e) {
+ new ErrorDialog(
+ shell,
+ BaseMessages.getString(PKG,
"SelectProjectsDialog.Error.Save.Header"),
+ BaseMessages.getString(PKG,
"SelectProjectsDialog.Error.Save.Message"),
+ e);
+ }
+ }
+ }
+
+ private void deleteSelected() {
+ String name = getSelectedProjectName();
+ if (name == null) {
+ return;
+ }
+ // Re-use the same confirmation / parent-reference / hop-config removal as
the toolbar action.
+ // Pass this dialog shell so MessageBox/ErrorDialog are modal children
(not the main Hop shell).
+ boolean deleted = new ProjectsGuiPlugin().deleteRegisteredProject(shell,
name);
+ if (deleted) {
+ refreshTable();
+ updateButtons();
+ }
+ }
+
+ private void ok() {
+ selectedProjectName = getSelectedProjectName();
+ if (selectedProjectName == null) {
+ return;
+ }
+ dispose();
+ }
+
+ private void cancel() {
+ selectedProjectName = null;
+ dispose();
+ }
+
+ private void dispose() {
+ props.setScreen(new WindowProperty(shell));
+ shell.dispose();
+ }
+}
diff --git
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/ProjectConfig.java
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/ProjectConfig.java
index 97aa3ea80c..15bd6104ae 100644
---
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/ProjectConfig.java
+++
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/ProjectConfig.java
@@ -17,8 +17,14 @@
package org.apache.hop.projects.project;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Locale;
import java.util.Objects;
+import java.util.stream.Collectors;
import org.apache.commons.io.FilenameUtils;
+import org.apache.commons.lang3.StringUtils;
import org.apache.commons.vfs2.FileObject;
import org.apache.hop.core.exception.HopException;
import org.apache.hop.core.variables.IVariables;
@@ -26,10 +32,36 @@ import org.apache.hop.core.vfs.HopVfs;
public class ProjectConfig {
+ /**
+ * VFS schemes that provide read-only access to archive contents (Zip, Jar
and Tar family). See
+ *
https://commons.apache.org/proper/commons-vfs/filesystems.html#Zip.2C_Jar_and_Tar
+ */
+ private static final String[] ARCHIVE_URI_SCHEMES = {
+ "zip:", "jar:", "tar:", "tgz:", "tbz2:",
+ };
+
protected String projectName;
protected String projectHome;
protected String configFilename;
+ /**
+ * When true, Hop will not write to the project's configuration file
(project-config.json). Useful
+ * for projects opened from archives (zip/jar/tar), HTTP locations, or other
read-only folders.
+ */
+ protected boolean readOnly;
+
+ /**
+ * Optional organization group for this project registration (stored in
hop-config.json), e.g.
+ * "Clients" or "Legacy". Empty means ungrouped.
+ */
+ protected String group;
+
+ /**
+ * Optional tags for filtering and organization (stored in hop-config.json).
A project may have
+ * multiple tags.
+ */
+ protected List<String> tags = new ArrayList<>();
+
public ProjectConfig() {
super();
}
@@ -41,6 +73,26 @@ public class ProjectConfig {
this.configFilename = configFilename;
}
+ /**
+ * Returns true when the given path is a Commons VFS archive URI (zip, jar,
tar, tgz, tbz2),
+ * including nested forms such as {@code jar:zip:outer.zip!/nested.jar!/dir}.
+ *
+ * @param path project home path or URI (may be null/empty)
+ * @return true if the path uses an archive scheme
+ */
+ public static boolean isArchiveUri(String path) {
+ if (StringUtils.isEmpty(path)) {
+ return false;
+ }
+ String lower = path.trim().toLowerCase(Locale.ROOT);
+ for (String scheme : ARCHIVE_URI_SCHEMES) {
+ if (lower.startsWith(scheme)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
@Override
public boolean equals(Object o) {
if (this == o) {
@@ -79,6 +131,14 @@ public class ProjectConfig {
+ "' does not exist");
}
String actualConfigFilename = variables.resolve(getConfigFilename());
+ // Use VFS resolve so archive/HTTP URIs work (FilenameUtils.concat
mangles schemes).
+ // For plain local paths keep the previous FilenameUtils behaviour for
compatibility.
+ //
+ String scheme = actualHome.getName().getScheme();
+ if (scheme != null && !"file".equalsIgnoreCase(scheme)) {
+ FileObject configFile = actualHome.resolveFile(actualConfigFilename);
+ return configFile.getName().getURI();
+ }
String fullFilename = FilenameUtils.concat(actualHome.toString(),
actualConfigFilename);
if (fullFilename == null) {
throw new HopException(
@@ -165,4 +225,115 @@ public class ProjectConfig {
public void setConfigFilename(String configFilename) {
this.configFilename = configFilename;
}
+
+ /**
+ * Gets readOnly
+ *
+ * @return value of readOnly
+ */
+ public boolean isReadOnly() {
+ return readOnly;
+ }
+
+ /**
+ * @param readOnly The readOnly flag to set
+ */
+ public void setReadOnly(boolean readOnly) {
+ this.readOnly = readOnly;
+ }
+
+ /**
+ * Gets group
+ *
+ * @return value of group
+ */
+ public String getGroup() {
+ return group;
+ }
+
+ /**
+ * @param group The group to set
+ */
+ public void setGroup(String group) {
+ this.group = group;
+ }
+
+ /**
+ * Gets tags
+ *
+ * @return value of tags (never null)
+ */
+ public List<String> getTags() {
+ if (tags == null) {
+ tags = new ArrayList<>();
+ }
+ return tags;
+ }
+
+ /**
+ * @param tags The tags to set
+ */
+ public void setTags(List<String> tags) {
+ this.tags = tags != null ? new ArrayList<>(tags) : new ArrayList<>();
+ }
+
+ /**
+ * Comma-separated display form of tags (for tables and text fields).
+ *
+ * @return joined tags, or empty string
+ */
+ public String getTagsAsDisplayString() {
+ if (tags == null || tags.isEmpty()) {
+ return "";
+ }
+ return String.join(", ", tags);
+ }
+
+ /**
+ * Parse a comma/semicolon-separated tag string into a list of trimmed
non-empty tags.
+ *
+ * @param text free-form tags text
+ * @return list of tags (never null)
+ */
+ public static List<String> parseTags(String text) {
+ if (StringUtils.isEmpty(text)) {
+ return new ArrayList<>();
+ }
+ return Arrays.stream(text.split("[,;]"))
+ .map(String::trim)
+ .filter(StringUtils::isNotEmpty)
+ .collect(Collectors.toCollection(ArrayList::new));
+ }
+
+ /**
+ * Whether this project registration matches a free-text filter (name,
group, home, tags).
+ *
+ * @param filter filter text (null/empty matches all)
+ * @return true if the project should be shown
+ */
+ public boolean matchesFilter(String filter) {
+ if (StringUtils.isEmpty(filter)) {
+ return true;
+ }
+ String needle = filter.trim().toLowerCase(Locale.ROOT);
+ if (containsIgnoreCase(projectName, needle)
+ || containsIgnoreCase(group, needle)
+ || containsIgnoreCase(projectHome, needle)
+ || containsIgnoreCase(configFilename, needle)
+ || containsIgnoreCase(getTagsAsDisplayString(), needle)) {
+ return true;
+ }
+ if (tags != null) {
+ for (String tag : tags) {
+ if (containsIgnoreCase(tag, needle)) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ private static boolean containsIgnoreCase(String value, String needleLower) {
+ return value != null &&
value.toLowerCase(Locale.ROOT).contains(needleLower);
+ }
}
diff --git
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/ProjectDialog.java
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/ProjectDialog.java
index d31d7bd051..188acfd1ff 100644
---
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/ProjectDialog.java
+++
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/ProjectDialog.java
@@ -80,8 +80,10 @@ public class ProjectDialog extends Dialog {
private Text wName;
private TextVar wHome;
+ private Button wReadOnly;
private ComboVar wParentProject;
private TextVar wConfigFile;
+ private Button wbConfigFile;
private Text wDescription;
private Text wCompany;
private Text wDepartment;
@@ -226,6 +228,19 @@ public class ProjectDialog extends Dialog {
wHome.setLayoutData(fdHome);
lastControl = wHome;
+ // Read-only option below the home folder path (auto-enabled for archive
URIs)
+ //
+ wReadOnly = new Button(comp, SWT.CHECK | SWT.LEFT);
+ PropsUi.setLook(wReadOnly);
+ wReadOnly.setText(BaseMessages.getString(PKG,
"ProjectDialog.Label.ReadOnly"));
+ FormData fdReadOnly = new FormData();
+ fdReadOnly.left = new FormAttachment(middle, margin);
+ fdReadOnly.right = new FormAttachment(99, 0);
+ fdReadOnly.top = new FormAttachment(lastControl, margin);
+ wReadOnly.setLayoutData(fdReadOnly);
+ wReadOnly.addListener(SWT.Selection, e -> updateReadOnlyWidgets());
+ lastControl = wReadOnly;
+
Label wlConfigFile = new Label(comp, SWT.RIGHT);
PropsUi.setLook(wlConfigFile);
wlConfigFile.setText(BaseMessages.getString(PKG,
"ProjectDialog.Label.ConfigurationFile"));
@@ -234,7 +249,7 @@ public class ProjectDialog extends Dialog {
fdlConfigFile.right = new FormAttachment(middle, 0);
fdlConfigFile.top = new FormAttachment(lastControl, margin);
wlConfigFile.setLayoutData(fdlConfigFile);
- Button wbConfigFile = new Button(comp, SWT.PUSH);
+ wbConfigFile = new Button(comp, SWT.PUSH);
PropsUi.setLook(wbConfigFile);
wbConfigFile.setText(BaseMessages.getString(PKG,
"ProjectDialog.Button.Browse"));
FormData fdbConfigFile = new FormData();
@@ -463,9 +478,14 @@ public class ProjectDialog extends Dialog {
// See if we need a project refresh/reload
//
wParentProject.addModifyListener(e -> needingProjectRefresh = true);
- wHome.addModifyListener(e -> needingProjectRefresh = true);
+ wHome.addModifyListener(
+ e -> {
+ needingProjectRefresh = true;
+ autoSetReadOnlyFromHome();
+ });
getData();
+ updateReadOnlyWidgets();
comp.pack();
scroll.setContent(comp);
@@ -478,6 +498,43 @@ public class ProjectDialog extends Dialog {
return returnValue;
}
+ /**
+ * Automatically select read-only when the home folder is a VFS archive URI
(zip/jar/tar/...). The
+ * user can still uncheck the option, or check it manually for other cases
(http://, read-only
+ * folders, ...).
+ */
+ private void autoSetReadOnlyFromHome() {
+ if (ProjectConfig.isArchiveUri(variables.resolve(wHome.getText()))) {
+ if (!wReadOnly.getSelection()) {
+ wReadOnly.setSelection(true);
+ updateReadOnlyWidgets();
+ }
+ }
+ }
+
+ /**
+ * Enable or disable project settings that are stored in
project-config.json. When the project is
+ * read-only those cannot be persisted, so the widgets are disabled. Name,
home folder, config
+ * path and the read-only flag remain editable (they live in
hop-config.json).
+ */
+ private void updateReadOnlyWidgets() {
+ boolean editable = !wReadOnly.getSelection();
+
+ // Config file browse is of limited use for archives; keep the relative
path editable.
+ wbConfigFile.setEnabled(editable);
+ wParentProject.setEnabled(editable);
+ wDescription.setEnabled(editable);
+ wCompany.setEnabled(editable);
+ wDepartment.setEnabled(editable);
+ wVersion.setEnabled(editable);
+ wMetadataBaseFolder.setEnabled(editable);
+ wUnitTestsBasePath.setEnabled(editable);
+ wDataSetCsvFolder.setEnabled(editable);
+ wEnforceHomeExecution.setEnabled(editable);
+ wVariables.setEnabled(editable);
+ wVariables.setReadonly(!editable);
+ }
+
private void browseHomeFolder(Event event) {
String homeFolder = BaseDialog.presentDirectoryDialog(shell, wHome,
variables);
@@ -574,6 +631,7 @@ public class ProjectDialog extends Dialog {
String homeFolder = wHome.getText();
boolean projectHomeFolderChanged = this.editMode &&
!oriProjectHome.equals(homeFolder);
+ boolean readOnly = wReadOnly.getSelection();
if (StringUtils.isEmpty(variables.resolve(homeFolder))) {
throw new HopException("Please specify a home folder for your
project");
@@ -593,16 +651,24 @@ public class ProjectDialog extends Dialog {
}
}
- // If the home folder doesn't exist and project is new aks if want it
created
- if (!HopVfs.getFileObject(variables.resolve(homeFolder)).exists()
- && (!this.editMode || projectHomeFolderChanged)) {
- MessageBox box = new MessageBox(shell, SWT.YES | SWT.NO |
SWT.ICON_QUESTION);
- box.setText(BaseMessages.getString(PKG,
"ProjectDialog.CreateHome.Dialog.Header"));
- box.setMessage(
- BaseMessages.getString(PKG,
"ProjectDialog.CreateHome.Dialog.Message", homeFolder));
- int anwser = box.open();
- if ((anwser & SWT.YES) != 0) {
- HopVfs.getFileObject(homeFolder).createFolder();
+ // If the home folder doesn't exist and project is new ask if it should
be created.
+ // Never create folders for a read-only project (archives, HTTP, etc.).
+ //
+ FileObject homeFolderObject =
HopVfs.getFileObject(variables.resolve(homeFolder));
+ if (!homeFolderObject.exists()) {
+ if (readOnly) {
+ throw new HopException(
+ BaseMessages.getString(PKG,
"ProjectDialog.ReadOnly.HomeMissing.Error", homeFolder));
+ }
+ if (!this.editMode || projectHomeFolderChanged) {
+ MessageBox box = new MessageBox(shell, SWT.YES | SWT.NO |
SWT.ICON_QUESTION);
+ box.setText(BaseMessages.getString(PKG,
"ProjectDialog.CreateHome.Dialog.Header"));
+ box.setMessage(
+ BaseMessages.getString(PKG,
"ProjectDialog.CreateHome.Dialog.Message", homeFolder));
+ int anwser = box.open();
+ if ((anwser & SWT.YES) != 0) {
+ HopVfs.getFileObject(homeFolder).createFolder();
+ }
}
}
@@ -620,6 +686,20 @@ public class ProjectDialog extends Dialog {
throw new HopException("Please specify project's configuration file
relative path!");
}
+ // Read-only projects cannot create/write project-config.json: it must
already exist.
+ //
+ if (readOnly) {
+ ProjectConfig verifyConfig =
+ new ProjectConfig(projectName, homeFolder, wConfigFile.getText());
+ String configPath =
verifyConfig.getActualProjectConfigFilename(variables);
+ FileObject configFile = HopVfs.getFileObject(configPath);
+ if (!configFile.exists()) {
+ throw new HopException(
+ BaseMessages.getString(
+ PKG, "ProjectDialog.ReadOnly.ConfigMissing.Error",
configPath));
+ }
+ }
+
if (wParentProject.getText() != null
&& !wParentProject.getText().isEmpty()
&& projectName.equals(wParentProject.getText())) {
@@ -716,6 +796,9 @@ public class ProjectDialog extends Dialog {
wName.setText(Const.NVL(projectConfig.getProjectName(), ""));
wHome.setText(Const.NVL(projectConfig.getProjectHome(), ""));
wConfigFile.setText(Const.NVL(projectConfig.getConfigFilename(), ""));
+ wReadOnly.setSelection(
+ projectConfig.isReadOnly()
+ ||
ProjectConfig.isArchiveUri(variables.resolve(projectConfig.getProjectHome())));
wDescription.setText(Const.NVL(project.getDescription(), ""));
wCompany.setText(Const.NVL(project.getCompany(), ""));
@@ -759,6 +842,7 @@ public class ProjectDialog extends Dialog {
projectConfig.setProjectName(wName.getText());
projectConfig.setProjectHome(wHome.getText());
projectConfig.setConfigFilename(wConfigFile.getText());
+ projectConfig.setReadOnly(wReadOnly.getSelection());
project.setParentProjectName(wParentProject.getText());
project.setDescription(wDescription.getText());
diff --git
a/plugins/misc/projects/src/main/resources/org/apache/hop/projects/gui/messages/messages_en_US.properties
b/plugins/misc/projects/src/main/resources/org/apache/hop/projects/gui/messages/messages_en_US.properties
index 9205198991..e829e8e681 100644
---
a/plugins/misc/projects/src/main/resources/org/apache/hop/projects/gui/messages/messages_en_US.properties
+++
b/plugins/misc/projects/src/main/resources/org/apache/hop/projects/gui/messages/messages_en_US.properties
@@ -66,6 +66,36 @@
ProjectGuiPlugin.DeleteProject.ProjectReferencedAsParent.Message1=Current projec
ProjectGuiPlugin.DeleteProject.ProjectReferencedAsParent.Message2=To perform
the deletion, you MUST firstly remove the parent project reference in the
listed projects and then retry.
ProjectGuiPlugin.Dialog.AvailableProjects.Title=Available projects
ProjectGuiPlugin.Dialog.AvailableProjects.Message=Select a project to activate
+SelectProjectsDialog.Shell.Name=Projects
+SelectProjectsDialog.Label.Filter=Filter:
+SelectProjectsDialog.Label.Filter.Tooltip=Filter by name, group, folder or tags
+SelectProjectsDialog.Button.Edit=Edit...
+SelectProjectsDialog.Button.Delete=Delete...
+SelectProjectsDialog.Table.Col.Name=Name
+SelectProjectsDialog.Table.Col.Group=Group
+SelectProjectsDialog.Table.Col.Folder=Folder
+SelectProjectsDialog.Table.Col.Tags=Tags
+SelectProjectsDialog.Table.Col.LastUsed=Last used
+SelectProjectsDialog.Table.Col.ReadOnly=Read only
+SelectProjectsDialog.Table.Yes=Yes
+SelectProjectsDialog.Table.No=No
+SelectProjectsDialog.Error.Save.Header=Error
+SelectProjectsDialog.Error.Save.Message=Error saving project registration
+ProjectConfigDialog.Shell.Name=Project registration
+ProjectConfigDialog.Label.Name=Name
+ProjectConfigDialog.Label.Home=Home folder
+ProjectConfigDialog.Label.ConfigFile=Configuration file (relative path)
+ProjectConfigDialog.Label.Group=Group
+ProjectConfigDialog.Label.Tags=Tags
+ProjectConfigDialog.Label.Tags.Tooltip=Comma-separated tags (e.g. etl,
customer-a, legacy)
+ProjectConfigDialog.Label.ReadOnly=This project is read only
+ProjectConfigDialog.Button.Browse=Browse...
+ProjectConfigDialog.Error.Header=Error
+ProjectConfigDialog.Error.Message=Error updating project registration
+ProjectConfigDialog.Error.NameRequired=Please give the project a name
+ProjectConfigDialog.Error.HomeRequired=Please specify the project home folder
+ProjectConfigDialog.Error.ConfigRequired=Please specify the project
configuration file path
+ProjectConfigDialog.Error.NameExists=Project ''{0}'' already exists. Project
name must be unique.
ProjectGuiPlugin.EditEnvironment.Error.Dialog.Header=Error
ProjectGuiPlugin.EditEnvironment.Error.Dialog.Message=Error editing
environment ''{0}''
ProjectGuiPlugin.EditProject.Error.Dialog.Header=Error
@@ -90,6 +120,8 @@ ProjectGuiPlugin.ZipDirectory.Error.Dialog.Header=Error
ProjectGuiPlugin.ZipDirectory.Error.Dialog.Message=Error zipping project
ProjectGuiPlugin.ZipDirectory.Taskname.Text=Zipping project directory...
ProjectGuiPlugin.IncludeVariables.Label=Include variables in export
+ProjectGuiPlugin.ExportAsIs.Label=Export project as-is
+ProjectGuiPlugin.ExportAsIs.Tooltip=Copy selected files into the zip root
without a project sub-folder, and without exporting variables or metadata. Use
this to open the zip as a read-only project.
ProjectGuiPlugin.IncludeMetadata.Label=Include metadata in export
EmptyProjectDialog.Shell.Name=Create empty project
EmptyProjectDialog.Label.ProjectName=Project name
diff --git
a/plugins/misc/projects/src/main/resources/org/apache/hop/projects/project/messages/messages_en_US.properties
b/plugins/misc/projects/src/main/resources/org/apache/hop/projects/project/messages/messages_en_US.properties
index c0265b9497..79b0fae76d 100644
---
a/plugins/misc/projects/src/main/resources/org/apache/hop/projects/project/messages/messages_en_US.properties
+++
b/plugins/misc/projects/src/main/resources/org/apache/hop/projects/project/messages/messages_en_US.properties
@@ -42,6 +42,7 @@ ProjectDialog.Label.HomeFolder=Home folder
ProjectDialog.Label.MetadataBaseFolder=Metadata base folder
(HOP_METADATA_FOLDER)
ProjectDialog.Label.ParentProject=Parent project to inherit from
ProjectDialog.Label.ProjectName=Name
+ProjectDialog.Label.ReadOnly=This project is read only
ProjectDialog.Label.UnitTestBaseFolder=Unit tests base path
(HOP_UNIT_TESTS_FOLDER)
ProjectDialog.Label.Version=Version
ProjectDialog.ProjectConfigError.Error.Dialog.Header=Error
@@ -50,6 +51,8 @@ ProjectDialog.ProjectDefinitionError.Error.Dialog.Header=Error
ProjectDialog.ProjectDefinitionError.Error.Dialog.Message=There is an error in
the project definition, variables couldn''t be set
ProjectDialog.ProjectList.Error.Dialog.Header=Error
ProjectDialog.ProjectList.Error.Dialog.Message=Error getting list of project
names
+ProjectDialog.ReadOnly.ConfigMissing.Error=This project is read only, so the
configuration file must already exist.\nFile not found: {0}
+ProjectDialog.ReadOnly.HomeMissing.Error=This project is read only, so the
home folder must already exist.\nFolder not found: {0}
ProjectDialog.Shell.Name=Project Properties
ProjectGuiPlugin.WrongConfigPath.Dialog.Header=Wrong project''s root
ProjectGuiPlugin.WrongConfigPath.Dialog.Message=The path to selected
project-config.json is outside the boundaries of this project and that is not
permitted!
diff --git
a/plugins/misc/projects/src/test/java/org/apache/hop/projects/gui/ProjectsGuiPluginZipTest.java
b/plugins/misc/projects/src/test/java/org/apache/hop/projects/gui/ProjectsGuiPluginZipTest.java
new file mode 100644
index 0000000000..f47ae6ceb4
--- /dev/null
+++
b/plugins/misc/projects/src/test/java/org/apache/hop/projects/gui/ProjectsGuiPluginZipTest.java
@@ -0,0 +1,53 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.apache.hop.projects.gui;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.Test;
+
+public class ProjectsGuiPluginZipTest {
+
+ @Test
+ public void testRelativeZipPathAsIsUsesProjectHomeAsRoot() {
+ String projectHome = "file:///home/user/my-project";
+ String file = "file:///home/user/my-project/pipelines/demo.hpl";
+ assertEquals("pipelines/demo.hpl", ProjectsGuiPlugin.relativeZipPath(file,
projectHome));
+ }
+
+ @Test
+ public void testRelativeZipPathAsIsWithTrailingSlashOnHome() {
+ String projectHome = "file:///home/user/my-project/";
+ String file = "file:///home/user/my-project/project-config.json";
+ assertEquals("project-config.json",
ProjectsGuiPlugin.relativeZipPath(file, projectHome));
+ }
+
+ @Test
+ public void testRelativeZipPathNormalNestsUnderProjectName() {
+ String parent = "file:///home/user";
+ String file = "file:///home/user/my-project/pipelines/demo.hpl";
+ assertEquals("my-project/pipelines/demo.hpl",
ProjectsGuiPlugin.relativeZipPath(file, parent));
+ }
+
+ @Test
+ public void testRelativeZipPathAlreadyRelativeUnchanged() {
+ assertEquals(
+ "my-project/hop-config.json",
+ ProjectsGuiPlugin.relativeZipPath("my-project/hop-config.json",
"file:///home/user"));
+ }
+}
diff --git
a/plugins/misc/projects/src/test/java/org/apache/hop/projects/project/ProjectTest.java
b/plugins/misc/projects/src/test/java/org/apache/hop/projects/project/ProjectTest.java
index a9e67cc5cb..976605048e 100644
---
a/plugins/misc/projects/src/test/java/org/apache/hop/projects/project/ProjectTest.java
+++
b/plugins/misc/projects/src/test/java/org/apache/hop/projects/project/ProjectTest.java
@@ -28,6 +28,8 @@ import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.stream.Stream;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
import org.apache.hop.core.variables.IVariables;
import org.apache.hop.core.variables.Variables;
import org.apache.hop.projects.config.ProjectsConfig;
@@ -174,6 +176,139 @@ public class ProjectTest {
assertEquals(childHome.toString(),
variables.getVariable(ProjectsUtil.VARIABLE_PROJECT_HOME));
}
+ @Test
+ public void testIsArchiveUri() {
+
assertTrue(ProjectConfig.isArchiveUri("zip:file:///tmp/project.zip!/my-project"));
+
assertTrue(ProjectConfig.isArchiveUri("zip:http://example.com/p.zip!/folder"));
+
assertTrue(ProjectConfig.isArchiveUri("jar:file:///tmp/lib.jar!/META-INF"));
+ assertTrue(ProjectConfig.isArchiveUri("tar:file:///tmp/a.tar!/proj"));
+ assertTrue(ProjectConfig.isArchiveUri("tgz:file:///tmp/a.tgz!/proj"));
+ assertTrue(ProjectConfig.isArchiveUri("tbz2:file:///tmp/a.tbz2!/proj"));
+
assertTrue(ProjectConfig.isArchiveUri("jar:zip:outer.zip!/nested.jar!/dir"));
+
assertTrue(ProjectConfig.isArchiveUri("tar:gz:http://host/mytar.tar.gz!/mytar.tar!/path"));
+ assertTrue(ProjectConfig.isArchiveUri(" ZIP:/tmp/a.zip!/proj "));
+
+ assertFalse(ProjectConfig.isArchiveUri(null));
+ assertFalse(ProjectConfig.isArchiveUri(""));
+ assertFalse(ProjectConfig.isArchiveUri("/home/user/project"));
+ assertFalse(ProjectConfig.isArchiveUri("file:///home/user/project"));
+ assertFalse(ProjectConfig.isArchiveUri("http://example.com/project"));
+ assertFalse(ProjectConfig.isArchiveUri("s3://bucket/project"));
+ // A path ending in .zip without an archive scheme is not an archive URI
+ assertFalse(ProjectConfig.isArchiveUri("/tmp/project.zip"));
+ }
+
+ @Test
+ public void testReadOnlyFlagDefaultsAndSetters() {
+ ProjectConfig config =
+ new ProjectConfig("demo", "/tmp/demo",
ProjectsConfig.DEFAULT_PROJECT_CONFIG_FILENAME);
+ assertFalse(config.isReadOnly());
+ config.setReadOnly(true);
+ assertTrue(config.isReadOnly());
+ }
+
+ @Test
+ public void testGroupAndTagsHelpers() {
+ ProjectConfig config =
+ new ProjectConfig(
+ "demo", "/home/user/demo",
ProjectsConfig.DEFAULT_PROJECT_CONFIG_FILENAME);
+ config.setGroup("Clients");
+ config.setTags(ProjectConfig.parseTags("etl, customer-a; legacy"));
+
+ assertEquals("Clients", config.getGroup());
+ assertEquals(3, config.getTags().size());
+ assertEquals("etl, customer-a, legacy", config.getTagsAsDisplayString());
+
+ assertTrue(config.matchesFilter("client"));
+ assertTrue(config.matchesFilter("ETL"));
+ assertTrue(config.matchesFilter("customer-a"));
+ assertTrue(config.matchesFilter("/home/user"));
+ assertFalse(config.matchesFilter("unrelated-xyz"));
+ assertTrue(config.matchesFilter(""));
+ assertTrue(config.matchesFilter(null));
+ }
+
+ @Test
+ public void testAddProjectConfigPreservesGroupTagsAndReadOnly() {
+ ProjectConfig config =
+ new ProjectConfig("meta", "/tmp/meta",
ProjectsConfig.DEFAULT_PROJECT_CONFIG_FILENAME);
+ config.setGroup("Internal");
+ config.setTags(ProjectConfig.parseTags("poc"));
+ config.setReadOnly(true);
+ registerProject(config);
+
+ ProjectConfig update = new ProjectConfig("meta", "/tmp/meta2",
"config/project-config.json");
+ update.setGroup("Clients");
+ update.setTags(ProjectConfig.parseTags("active, critical"));
+ update.setReadOnly(false);
+ ProjectsConfigSingleton.getConfig().addProjectConfig(update);
+
+ ProjectConfig stored =
ProjectsConfigSingleton.getConfig().findProjectConfig("meta");
+ assertEquals("/tmp/meta2", stored.getProjectHome());
+ assertEquals("config/project-config.json", stored.getConfigFilename());
+ assertEquals("Clients", stored.getGroup());
+ assertEquals("active, critical", stored.getTagsAsDisplayString());
+ assertFalse(stored.isReadOnly());
+ }
+
+ @Test
+ public void testUpdateProjectConfigRename() {
+ ProjectConfig config =
+ new ProjectConfig("old-name", "/tmp/old",
ProjectsConfig.DEFAULT_PROJECT_CONFIG_FILENAME);
+ config.setGroup("Legacy");
+ registerProject(config);
+
+ ProjectConfig renamed =
+ new ProjectConfig("new-name", "/tmp/new",
ProjectsConfig.DEFAULT_PROJECT_CONFIG_FILENAME);
+ renamed.setGroup("Legacy");
+ renamed.setTags(ProjectConfig.parseTags("migrated"));
+ ProjectsConfigSingleton.getConfig().updateProjectConfig("old-name",
renamed);
+ registeredProjectNames.remove("old-name");
+ registeredProjectNames.add("new-name");
+
+
assertTrue(ProjectsConfigSingleton.getConfig().findProjectConfig("old-name") ==
null);
+ ProjectConfig stored =
ProjectsConfigSingleton.getConfig().findProjectConfig("new-name");
+ assertEquals("new-name", stored.getProjectName());
+ assertEquals("/tmp/new", stored.getProjectHome());
+ assertEquals("migrated", stored.getTagsAsDisplayString());
+ }
+
+ @Test
+ public void testLoadProjectFromZipArchive() throws Exception {
+ tempRoot = Files.createTempDirectory("hop-project-zip");
+ Path zipFile = tempRoot.resolve("project.zip");
+
+ String configJson =
+ "{\n"
+ + " \"description\" : \"zipped project\",\n"
+ + " \"metadataBaseFolder\" : \"${PROJECT_HOME}/metadata\",\n"
+ + " \"unitTestsBasePath\" : \"${PROJECT_HOME}\",\n"
+ + " \"dataSetsCsvFolder\" : \"${PROJECT_HOME}/datasets\",\n"
+ + " \"enforcingExecutionInHome\" : true,\n"
+ + " \"parentProjectName\" : null,\n"
+ + " \"config\" : {\n"
+ + " \"variables\" : [ ]\n"
+ + " }\n"
+ + "}\n";
+
+ try (ZipOutputStream zos = new
ZipOutputStream(Files.newOutputStream(zipFile))) {
+ ZipEntry entry = new ZipEntry("my-project/" +
ProjectsConfig.DEFAULT_PROJECT_CONFIG_FILENAME);
+ zos.putNextEntry(entry);
+ zos.write(configJson.getBytes(StandardCharsets.UTF_8));
+ zos.closeEntry();
+ }
+
+ String homeUri = "zip:file://" + zipFile.toAbsolutePath() + "!/my-project";
+ ProjectConfig projectConfig =
+ new ProjectConfig("zipped", homeUri,
ProjectsConfig.DEFAULT_PROJECT_CONFIG_FILENAME);
+ projectConfig.setReadOnly(true);
+ registerProject(projectConfig);
+
+ Project project = projectConfig.loadProject(new Variables());
+ assertEquals("zipped project", project.getDescription());
+ assertTrue(projectConfig.isReadOnly());
+ }
+
private void registerProject(ProjectConfig projectConfig) {
ProjectsConfigSingleton.getConfig().addProjectConfig(projectConfig);
registeredProjectNames.add(projectConfig.getProjectName());
diff --git a/ui/src/main/java/org/apache/hop/ui/core/vfs/HopVfsFileDialog.java
b/ui/src/main/java/org/apache/hop/ui/core/vfs/HopVfsFileDialog.java
index 76cd073df0..acdaa075b9 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/vfs/HopVfsFileDialog.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/vfs/HopVfsFileDialog.java
@@ -135,6 +135,7 @@ public class HopVfsFileDialog implements IFileDialog,
IDirectoryDialog {
public static final String BROWSER_TOOLBAR_PARENT_ID =
"HopVfsFileDialog-BrowserToolbar";
private static final String BROWSER_ITEM_ID_CREATE_FOLDER =
"0020-create-folder";
+ private static final String BROWSER_ITEM_ID_DRILL_INTO = "0030-drill-into";
private static final String BROWSER_ITEM_ID_SHOW_HIDDEN = "0200-show-hidden";
private static final String BROWSER_ITEM_ID_DELETE = "0100-delete";
private static final String BROWSER_ITEM_ID_RENAME = "0110-rename";
@@ -704,6 +705,17 @@ public class HopVfsFileDialog implements IFileDialog,
IDirectoryDialog {
return;
}
+ // Directory browser: an archive is not a valid folder selection — enter
it instead so the
+ // user can pick a folder inside (zip:…!/path).
+ //
+ if (this.browsingDirectories && isDrillableArchive(activeFileObject)) {
+ String archiveUri = buildArchiveBrowseUri(activeFileObject);
+ if (archiveUri != null) {
+ navigateTo(archiveUri, true);
+ return;
+ }
+ }
+
ok();
} catch (Exception e) {
showError(
@@ -851,15 +863,26 @@ public class HopVfsFileDialog implements IFileDialog,
IDirectoryDialog {
}
try {
- navigateTo(HopVfs.getFilename(fileObject), true);
-
if (fileObject.isFolder()) {
// Browse into the selected folder...
//
- refreshBrowser();
- } else {
+ navigateTo(HopVfs.getFilename(fileObject), true);
+ return;
+ }
+
+ // Double-click drills into supported archives (zip/jar/tar/...). To
select the archive
+ // itself as a file, select it and press OK.
+ //
+ String archiveUri = buildArchiveBrowseUri(fileObject);
+ if (archiveUri != null) {
+ navigateTo(archiveUri, true);
+ return;
+ }
+
+ if (!browsingDirectories) {
// Take this file as the user choice for this dialog
//
+ navigateTo(HopVfs.getFilename(fileObject), true);
okButton();
}
} catch (Exception e) {
@@ -1028,7 +1051,16 @@ public class HopVfsFileDialog implements IFileDialog,
IDirectoryDialog {
fileObjectsMap.put(getTreeItemPath(childFolderItem), child);
}
}
- if (!browsingDirectories) {
+ if (browsingDirectories) {
+ // Directory mode: still show drillable archives so users can open
project homes inside zip
+ // (and similar) via VFS without typing URIs by hand.
+ //
+ for (final FileObject child : children) {
+ if (child.isFile() && isDrillableArchive(child)) {
+ addBrowserFileItem(folderItem, child);
+ }
+ }
+ } else {
for (final FileObject child : children) {
if (child.isFile()) {
String baseFilename = child.getName().getBaseName();
@@ -1051,33 +1083,59 @@ public class HopVfsFileDialog implements IFileDialog,
IDirectoryDialog {
}
}
- // Hidden file?
+ // Always offer drillable archives even when the active filter would
hide them
+ // (e.g. "Pipelines" only), so nested zip/jar browsing stays
available.
//
- if (selectFile) {
- TreeItem childFileItem = new TreeItem(folderItem, SWT.NONE);
- childFileItem.setImage(getFileImage(child));
- childFileItem.setFont(GuiResource.getInstance().getFontBold());
- childFileItem.setText(0, child.getName().getBaseName());
- childFileItem.setText(1, getFileDate(child));
- childFileItem.setText(2, getFileSize(child));
- fileObjectsMap.put(getTreeItemPath(childFileItem), child);
-
- // Gray out if the file is not readable
- //
- if (!child.isReadable()) {
-
childFileItem.setForeground(GuiResource.getInstance().getColorGray());
- }
+ if (!selectFile && isDrillableArchive(child)) {
+ selectFile = true;
+ }
- if (child.equals(activeFileObject)) {
- wBrowser.setSelection(childFileItem);
- wBrowser.showSelection();
- }
+ if (selectFile) {
+ addBrowserFileItem(folderItem, child);
}
}
}
}
}
+ /**
+ * Add a file row to the browser tree (name, modified, size, image,
selection map).
+ *
+ * @param folderItem parent tree item for the current folder
+ * @param child file to display
+ */
+ private void addBrowserFileItem(TreeItem folderItem, FileObject child)
+ throws FileSystemException {
+ String baseFilename = child.getName().getBaseName();
+ if (!showingHiddenFiles && baseFilename.startsWith(".")) {
+ return;
+ }
+ if (shouldFilterOut(baseFilename)) {
+ return;
+ }
+
+ TreeItem childFileItem = new TreeItem(folderItem, SWT.NONE);
+ childFileItem.setImage(getFileImage(child));
+ childFileItem.setFont(GuiResource.getInstance().getFontBold());
+ childFileItem.setText(0, baseFilename);
+ childFileItem.setText(1, getFileDate(child));
+ childFileItem.setText(2, getFileSize(child));
+ fileObjectsMap.put(getTreeItemPath(childFileItem), child);
+
+ if (!child.isReadable()) {
+ childFileItem.setForeground(GuiResource.getInstance().getColorGray());
+ }
+
+ if (child.equals(activeFileObject)) {
+ wBrowser.setSelection(childFileItem);
+ wBrowser.showSelection();
+ }
+ }
+
+ private static boolean isDrillableArchive(FileObject file) throws
FileSystemException {
+ return file != null && file.isFile() &&
getArchiveScheme(file.getName().getBaseName()) != null;
+ }
+
private static int compareBaseName(FileObject child1, FileObject child2) {
try {
String name1 = child1.getName().getBaseName();
@@ -1417,6 +1475,114 @@ public class HopVfsFileDialog implements IFileDialog,
IDirectoryDialog {
}
}
+ @GuiToolbarElement(
+ root = BROWSER_TOOLBAR_PARENT_ID,
+ id = BROWSER_ITEM_ID_DRILL_INTO,
+ toolTip = "i18n::HopVfsFileDialog.DrillInto.Tooltip.Message",
+ image = "ui/images/zipfile.svg")
+ public void drillIntoArchive() {
+ FileObject file = getSelectedFileObject();
+ if (file == null) {
+ return;
+ }
+ try {
+ String archiveUri = buildArchiveBrowseUri(file);
+ if (archiveUri == null) {
+ return;
+ }
+ navigateTo(archiveUri, true);
+ } catch (Exception e) {
+ showError(
+ BaseMessages.getString(PKG,
"HopVfsFileDialog.DrillInto.Error.Message", file.toString()),
+ e);
+ }
+ }
+
+ /**
+ * Map a file name or path to a Commons VFS archive scheme that supports
browsing with {@code !/}.
+ *
+ * @param nameOrPath file name, path, or URI
+ * @return scheme such as {@code zip}, {@code jar}, {@code tar}, {@code
tgz}, {@code tbz2}, or
+ * {@code null} if the file is not a drillable archive
+ */
+ static String getArchiveScheme(String nameOrPath) {
+ if (StringUtils.isEmpty(nameOrPath)) {
+ return null;
+ }
+ String base = nameOrPath;
+ int bang = base.lastIndexOf('!');
+ if (bang >= 0) {
+ base = base.substring(bang + 1);
+ }
+ int slash = Math.max(base.lastIndexOf('/'), base.lastIndexOf('\\'));
+ if (slash >= 0) {
+ base = base.substring(slash + 1);
+ }
+ String lower = base.toLowerCase();
+ // Compound suffixes before short ones (.tar.gz before .gz)
+ if (lower.endsWith(".tar.gz") || lower.endsWith(".tgz")) {
+ return "tgz";
+ }
+ if (lower.endsWith(".tar.bz2") || lower.endsWith(".tbz2")) {
+ return "tbz2";
+ }
+ if (lower.endsWith(".tar")) {
+ return "tar";
+ }
+ if (lower.endsWith(".zip")) {
+ return "zip";
+ }
+ if (lower.endsWith(".jar")
+ || lower.endsWith(".war")
+ || lower.endsWith(".ear")
+ || lower.endsWith(".par")
+ || lower.endsWith(".sar")
+ || lower.endsWith(".ejb3")) {
+ return "jar";
+ }
+ return null;
+ }
+
+ /**
+ * Build a VFS URI that opens an archive for browsing ({@code
scheme:archFileUri!/}).
+ *
+ * @param scheme VFS archive scheme (zip, jar, tar, tgz, tbz2)
+ * @param archFileUri path or URI of the archive file
+ * @return browse URI, or {@code null} if inputs are incomplete
+ */
+ static String buildArchiveBrowseUri(String scheme, String archFileUri) {
+ if (StringUtils.isEmpty(scheme) || StringUtils.isEmpty(archFileUri)) {
+ return null;
+ }
+ return scheme + ":" + archFileUri + "!/";
+ }
+
+ /**
+ * Build a VFS URI that opens the given archive for browsing (scheme +
archive + {@code !/}).
+ *
+ * @param file archive file object
+ * @return browse URI, or {@code null} if not a supported archive
+ */
+ static String buildArchiveBrowseUri(FileObject file) {
+ if (file == null) {
+ return null;
+ }
+ String scheme = getArchiveScheme(file.getName().getBaseName());
+ if (scheme == null) {
+ return null;
+ }
+ String archFileUri;
+ String root = file.getName().getRootURI();
+ if (root != null && root.startsWith("file:")) {
+ // Local files: match Hop issue style zip:/path/to/archive.zip!/
+ archFileUri = HopVfs.getFilename(file);
+ } else {
+ // Nested / remote: keep full URI (e.g. jar:zip:...!/nested.jar!/)
+ archFileUri = file.getName().getURI();
+ }
+ return buildArchiveBrowseUri(scheme, archFileUri);
+ }
+
@GuiToolbarElement(
root = BROWSER_TOOLBAR_PARENT_ID,
id = BROWSER_ITEM_ID_RENAME,
@@ -1665,10 +1831,14 @@ public class HopVfsFileDialog implements IFileDialog,
IDirectoryDialog {
FileObject file = getSelectedFileObject();
boolean isEnabled = false;
+ boolean canDrillInto = false;
if (file != null) {
try {
// Protect root can be modified
- if (file.getParent() != null) isEnabled = true;
+ if (file.getParent() != null) {
+ isEnabled = true;
+ }
+ canDrillInto = isDrillableArchive(file);
} catch (FileSystemException e) {
// Ignore
}
@@ -1676,5 +1846,6 @@ public class HopVfsFileDialog implements IFileDialog,
IDirectoryDialog {
browserToolbarWidgets.enableToolbarItem(BROWSER_ITEM_ID_DELETE, isEnabled);
browserToolbarWidgets.enableToolbarItem(BROWSER_ITEM_ID_RENAME, isEnabled);
+ browserToolbarWidgets.enableToolbarItem(BROWSER_ITEM_ID_DRILL_INTO,
canDrillInto);
}
}
diff --git a/ui/src/main/java/org/apache/hop/ui/core/widget/FileTree.java
b/ui/src/main/java/org/apache/hop/ui/core/widget/FileTree.java
index de7150104d..c4854ab623 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/widget/FileTree.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/widget/FileTree.java
@@ -20,6 +20,7 @@ package org.apache.hop.ui.core.widget;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Arrays;
+import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.LinkedList;
import java.util.List;
@@ -60,8 +61,16 @@ public class FileTree extends Composite {
private final AtomicBoolean findDependencies;
private static final String FILE_OBJECT = "fileObject";
private static final String FOLDER = "folder";
+ private static final String FILE = "file";
private final Tree tree;
+ /**
+ * Guard against re-entrant SWT CHECK events. Programmatic {@link
TreeItem#setChecked(boolean)}
+ * can fire nested selection events on some platforms (notably GTK); those
must not undo a cascade
+ * that is already in progress.
+ */
+ private boolean ignoreCheckEvents;
+
public FileTree(Composite composite, FileObject rootFolder, String
rootFolderName)
throws FileSystemException {
super(composite, SWT.NONE);
@@ -100,23 +109,54 @@ public class FileTree extends Composite {
tree.addListener(
SWT.Selection,
event -> {
- if (event.detail == SWT.CHECK) {
- TreeItem[] selectedTreeItems = tree.getSelection();
- TreeItem item = (TreeItem) event.item;
- boolean checked = item.getChecked();
- if (selectedTreeItems.length > 0) {
- for (TreeItem treeItem : selectedTreeItems) {
- if (!treeItem.getChecked() == checked) {
- selectionEvent(rootItem, treeItem, checked);
- }
+ if (event.detail != SWT.CHECK || ignoreCheckEvents) {
+ return;
+ }
+ TreeItem item = (TreeItem) event.item;
+ if (item == null || item.isDisposed()) {
+ return;
+ }
+ boolean checked = item.getChecked();
+ // Snapshot multi-selection before we change checkboxes / deselect.
+ TreeItem[] multiSelection = tree.getSelection();
+
+ ignoreCheckEvents = true;
+ try {
+ // Always process the item that was checked/unchecked so folder
selection
+ // propagates to all children.
+ selectionEvent(rootItem, item, checked);
+ // Also apply the same state to any other multi-selected items.
+ for (TreeItem treeItem : multiSelection) {
+ if (treeItem != item && !treeItem.isDisposed()) {
+ treeItem.setChecked(checked);
+ selectionEvent(rootItem, treeItem, checked);
}
- } else {
- selectionEvent(rootItem, item, checked);
}
tree.deselectAll();
+ } finally {
+ ignoreCheckEvents = false;
}
});
- rootItem.setExpanded(true);
+
+ // Select everything by default so export works without manual checkbox
interaction.
+ // Guard ignoreCheckEvents so programmatic setChecked does not fire nested
handlers.
+ ignoreCheckEvents = true;
+ try {
+ cascadeCheck(rootItem, true);
+ } finally {
+ ignoreCheckEvents = false;
+ }
+ expandAll(rootItem);
+ }
+
+ /** Expand the given item and all descendants so the default selection is
visible. */
+ private void expandAll(TreeItem item) {
+ item.setExpanded(true);
+ for (TreeItem child : item.getItems()) {
+ if (FOLDER.equals(child.getData("type"))) {
+ expandAll(child);
+ }
+ }
}
/**
@@ -127,9 +167,14 @@ public class FileTree extends Composite {
* @param checked true when enabling a checkbox, false when disabling
*/
private void selectionEvent(TreeItem rootItem, TreeItem treeItem, boolean
checked) {
- if (treeItem.getData("type").equals(FOLDER)) {
- checkItems(rootItem, treeItem, checked);
- checkGrayedItems(treeItem);
+ Object type = treeItem.getData("type");
+ if (FOLDER.equals(type)) {
+ // Cascade to every descendant. Do not resolve pipeline/workflow
dependencies while
+ // cascading: every file under the folder is included, and dependency
resolution would
+ // re-enter checkbox updates and can undo the cascade (empty export).
+ cascadeCheck(treeItem, checked);
+ // Update grayed/checked state of ancestors only — cascadeCheck already
set this folder.
+ checkGrayedItems(treeItem.getParentItem());
} else {
FileObject fileObject = (FileObject) treeItem.getData(FILE_OBJECT);
if (findDependencies.get()) {
@@ -137,9 +182,11 @@ public class FileTree extends Composite {
}
if (checked) {
fileObjects.add(fileObject);
+ treeItem.setGrayed(false);
treeItem.setChecked(true);
} else {
fileObjects.remove(fileObject);
+ treeItem.setGrayed(false);
treeItem.setChecked(false);
}
checkGrayedItems(treeItem.getParentItem());
@@ -156,7 +203,10 @@ public class FileTree extends Composite {
*/
private void populateFolder(FileObject folder, TreeItem folderItem) throws
FileSystemException {
FileObject[] children = folder.getChildren();
- Arrays.sort(children);
+ Arrays.sort(
+ children,
+ Comparator.comparing(
+ child -> child.getName().getBaseName(),
String.CASE_INSENSITIVE_ORDER));
// Add the folders
for (FileObject child : children) {
@@ -184,7 +234,7 @@ public class FileTree extends Composite {
TreeItem childItem = new TreeItem(folderItem, SWT.NONE);
childItem.setImage(GuiResource.getInstance().getImageFile());
childItem.setText(child.getName().getBaseName());
- childItem.setData("type", "file");
+ childItem.setData("type", FILE);
childItem.setData(FILE_OBJECT, child);
}
}
@@ -213,56 +263,56 @@ public class FileTree extends Composite {
}
/**
- * Scan the Tree to find the TreeItem to select/deselect
+ * Recursively set the checked state of a folder and all descendants, and
update {@link
+ * #fileObjects} for every file under that folder.
*
- * @param rootItem the main tree to update
- * @param item that needs to be enabled/disabled
- * @param checked true when enabled, false when disabled
+ * @param item folder or file tree item
+ * @param checked true when enabling a checkbox, false when disabling
*/
- void checkItems(TreeItem rootItem, TreeItem item, boolean checked) {
+ void cascadeCheck(TreeItem item, boolean checked) {
item.setGrayed(false);
item.setChecked(checked);
- TreeItem[] items = item.getItems();
- for (TreeItem treeItem : items) {
- if (treeItem.getData("type").equals("file")) {
- FileObject fileObject = (FileObject) treeItem.getData(FILE_OBJECT);
- if (findDependencies.get() && item != rootItem) {
- addDependencies(fileObject, rootItem, checked);
- }
+ for (TreeItem child : item.getItems()) {
+ Object type = child.getData("type");
+ if (FILE.equals(type)) {
+ FileObject fileObject = (FileObject) child.getData(FILE_OBJECT);
if (checked) {
fileObjects.add(fileObject);
} else {
fileObjects.remove(fileObject);
}
}
- checkItems(rootItem, treeItem, checked);
+ cascadeCheck(child, checked);
}
}
/**
- * Set Grayed to checkboxes to the parents of the current item
+ * Set Grayed to checkboxes on the given folder and its ancestors, based on
child state.
*
- * @param parentItem The start folder to start checking
+ * @param parentItem The start folder to start checking (typically the
parent of the changed item)
*/
void checkGrayedItems(TreeItem parentItem) {
- if (parentItem == null) {
+ if (parentItem == null || parentItem.isDisposed()) {
return;
}
TreeItem[] items = parentItem.getItems();
- boolean allChecked = true;
+ boolean allChecked = items.length > 0;
boolean atLeastOneChecked = false;
for (TreeItem treeItem : items) {
- if (!treeItem.getChecked() || (treeItem.getChecked() &&
treeItem.getGrayed())) {
+ if (!treeItem.getChecked() || treeItem.getGrayed()) {
allChecked = false;
}
atLeastOneChecked = treeItem.getChecked() || atLeastOneChecked;
}
if (allChecked) {
parentItem.setGrayed(false);
- parentItem.setChecked(atLeastOneChecked);
- } else {
+ parentItem.setChecked(true);
+ } else if (atLeastOneChecked) {
parentItem.setGrayed(true);
- parentItem.setChecked(atLeastOneChecked);
+ parentItem.setChecked(true);
+ } else {
+ parentItem.setGrayed(false);
+ parentItem.setChecked(false);
}
checkGrayedItems(parentItem.getParentItem());
}
@@ -370,13 +420,19 @@ public class FileTree extends Composite {
void checkItemsByFileObject(TreeItem rootItem, String file, boolean checked)
{
TreeItem[] children = rootItem.getItems();
for (TreeItem treeItem : children) {
- if (treeItem.getData("type").equals(FOLDER)) {
+ Object type = treeItem.getData("type");
+ if (FOLDER.equals(type)) {
checkItemsByFileObject(treeItem, file, checked);
}
- if (treeItem.getData("type").equals("file")
+ if (FILE.equals(type)
&& ((FileObject)
treeItem.getData(FILE_OBJECT)).getName().getURI().equals(file)) {
treeItem.setGrayed(false);
treeItem.setChecked(checked);
+ if (checked) {
+ fileObjects.add((FileObject) treeItem.getData(FILE_OBJECT));
+ } else {
+ fileObjects.remove((FileObject) treeItem.getData(FILE_OBJECT));
+ }
checkGrayedItems(treeItem.getParentItem());
}
}
diff --git
a/ui/src/main/resources/org/apache/hop/ui/core/vfs/messages/messages_en_US.properties
b/ui/src/main/resources/org/apache/hop/ui/core/vfs/messages/messages_en_US.properties
index 00d73370a0..328bad2bbc 100644
---
a/ui/src/main/resources/org/apache/hop/ui/core/vfs/messages/messages_en_US.properties
+++
b/ui/src/main/resources/org/apache/hop/ui/core/vfs/messages/messages_en_US.properties
@@ -30,6 +30,8 @@ HopVfsFileDialog.DeleteFile.Confirmation.Header=Delete file?
HopVfsFileDialog.DeleteFile.Confirmation.Message=Are you sure you want to
delete the following file?
HopVfsFileDialog.DeleteFile.Error.Message=Error deleting file: ''{0}''
HopVfsFileDialog.DeleteFile.Tooltip.Message=Delete selected file (DEL)
+HopVfsFileDialog.DrillInto.Error.Message=Error drilling into archive: ''{0}''
+HopVfsFileDialog.DrillInto.Tooltip.Message=Drill into the selected archive
(zip, jar, tar, …). Double-click does the same; use OK to select the archive
file itself.
HopVfsFileDialog.File.Size.Label=Size
HopVfsFileDialog.FileInfo.Tooltip.Attributes=Attributes: {0}
HopVfsFileDialog.FileInfo.Tooltip.Executable=Executable: {0}
diff --git
a/ui/src/test/java/org/apache/hop/ui/core/vfs/HopVfsFileDialogArchiveTest.java
b/ui/src/test/java/org/apache/hop/ui/core/vfs/HopVfsFileDialogArchiveTest.java
new file mode 100644
index 0000000000..a7d17215c6
--- /dev/null
+++
b/ui/src/test/java/org/apache/hop/ui/core/vfs/HopVfsFileDialogArchiveTest.java
@@ -0,0 +1,101 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.apache.hop.ui.core.vfs;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import org.apache.commons.vfs2.FileName;
+import org.apache.commons.vfs2.FileObject;
+import org.junit.jupiter.api.Test;
+
+class HopVfsFileDialogArchiveTest {
+
+ @Test
+ void getArchiveSchemeRecognizesSupportedExtensions() {
+ assertEquals("zip", HopVfsFileDialog.getArchiveScheme("archive.zip"));
+ assertEquals("zip",
HopVfsFileDialog.getArchiveScheme("/home/matt/Downloads/archive.ZIP"));
+ assertEquals("jar", HopVfsFileDialog.getArchiveScheme("lib.jar"));
+ assertEquals("jar", HopVfsFileDialog.getArchiveScheme("app.war"));
+ assertEquals("jar", HopVfsFileDialog.getArchiveScheme("module.ear"));
+ assertEquals("tar", HopVfsFileDialog.getArchiveScheme("data.tar"));
+ assertEquals("tgz", HopVfsFileDialog.getArchiveScheme("data.tgz"));
+ assertEquals("tgz", HopVfsFileDialog.getArchiveScheme("data.tar.gz"));
+ assertEquals("tbz2", HopVfsFileDialog.getArchiveScheme("data.tbz2"));
+ assertEquals("tbz2", HopVfsFileDialog.getArchiveScheme("data.tar.bz2"));
+ }
+
+ @Test
+ void getArchiveSchemeUsesBasenameFromPathAndNestedUri() {
+ assertEquals("zip",
HopVfsFileDialog.getArchiveScheme("/tmp/folder/file.zip"));
+ assertEquals("jar",
HopVfsFileDialog.getArchiveScheme("zip:file:///tmp/outer.zip!/nested.jar"));
+ }
+
+ @Test
+ void getArchiveSchemeRejectsNonDrillableFiles() {
+ assertNull(HopVfsFileDialog.getArchiveScheme(null));
+ assertNull(HopVfsFileDialog.getArchiveScheme(""));
+ assertNull(HopVfsFileDialog.getArchiveScheme("readme.txt"));
+ assertNull(HopVfsFileDialog.getArchiveScheme("data.gz"));
+ assertNull(HopVfsFileDialog.getArchiveScheme("data.bz2"));
+ assertNull(HopVfsFileDialog.getArchiveScheme("archive.7z"));
+ assertNull(HopVfsFileDialog.getArchiveScheme("archive.rar"));
+ }
+
+ @Test
+ void buildArchiveBrowseUriFromSchemeAndPath() {
+ assertEquals(
+ "zip:/home/matt/Downloads/archive.zip!/",
+ HopVfsFileDialog.buildArchiveBrowseUri("zip",
"/home/matt/Downloads/archive.zip"));
+ assertEquals(
+ "jar:zip:file:///tmp/outer.zip!/nested.jar!/",
+ HopVfsFileDialog.buildArchiveBrowseUri("jar",
"zip:file:///tmp/outer.zip!/nested.jar"));
+ assertNull(HopVfsFileDialog.buildArchiveBrowseUri(null, "/tmp/a.zip"));
+ assertNull(HopVfsFileDialog.buildArchiveBrowseUri("zip", null));
+ assertNull(HopVfsFileDialog.buildArchiveBrowseUri("", "/tmp/a.zip"));
+ }
+
+ @Test
+ void buildArchiveBrowseUriFromNestedFileObject() {
+ FileObject nestedJar = mock(FileObject.class);
+ FileName name = mock(FileName.class);
+ when(nestedJar.getName()).thenReturn(name);
+ when(name.getBaseName()).thenReturn("nested.jar");
+ when(name.getRootURI()).thenReturn("zip:file:///tmp/outer.zip!/");
+ when(name.getURI()).thenReturn("zip:file:///tmp/outer.zip!/nested.jar");
+
+ assertEquals(
+ "jar:zip:file:///tmp/outer.zip!/nested.jar!/",
+ HopVfsFileDialog.buildArchiveBrowseUri(nestedJar));
+ }
+
+ @Test
+ void buildArchiveBrowseUriRejectsNonArchives() {
+ assertNull(HopVfsFileDialog.buildArchiveBrowseUri(null));
+
+ FileObject textFile = mock(FileObject.class);
+ FileName name = mock(FileName.class);
+ when(textFile.getName()).thenReturn(name);
+ when(name.getBaseName()).thenReturn("readme.txt");
+ when(name.getRootURI()).thenReturn("file:///");
+
+ assertNull(HopVfsFileDialog.buildArchiveBrowseUri(textFile));
+ }
+}