This is an automated email from the ASF dual-hosted git repository.
bamaer 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 6b6ca44bcf Fixes #8516: inconsistencies in PDI import dialog and CLI
(#8587)
6b6ca44bcf is described below
commit 6b6ca44bcfe7886d45ec7ec7a7a7c7aa35c76866
Author: Bart Maertens <[email protected]>
AuthorDate: Sat Sep 26 14:37:39 2026 +0200
Fixes #8516: inconsistencies in PDI import dialog and CLI (#8587)
* Align hop-import CLI options with the Hop GUI import dialog
Keep the run configuration named in the source file when no default is
given, instead of blanking it. Add --pipeline-run-configuration,
--workflow-run-configuration and --project to hop-import, an opt-out for
naming schemes to the import dialog, and reconcile the --skip-folders
default between the two front-ends.
Also sets the hop-import exit code, which was always 1.
Fixes #8516
* Share the ProjectHome lookup between hop-import and the import dialog
Three copies of the ProjectHome extension point protocol collapse into
HopImportBase.projectHome(). No behaviour change: the dialog keeps its
pre-seeded fallback and its error on an unresolvable project.
Part of #8516
* Register the import project only once the import can run
Follow-up on the review of #8587: resolveTargetProject() wrote
hop-config.json
and project-config.json before the options were validated, so an import that
failed on a missing input folder or an unknown plugin type still left a
project
registered at a folder nothing was imported into.
The lookup of an existing project stays where it was - that project is
where the
output folder comes from - but registering a new one now happens after the
import plugin validated both folders and before anything is written, and a
--project that cannot be registered fails the run instead of exiting 0.
The folder stored as the new project's home is the absolute one when the -o
was
relative, so a later 'hop-import --project' does not resolve it against its
own
working directory. A folder that is already absolute, variables included, is
stored as given and stays portable.
---
.../hop/core/extension/HopExtensionPoint.java | 3 +
.../main/java/org/apache/hop/imp/HopImport.java | 157 ++++++++++-
.../java/org/apache/hop/imp/HopImportBase.java | 25 ++
.../main/java/org/apache/hop/imp/IHopImport.java | 20 ++
.../org/apache/hop/imp/HopImportProjectTest.java | 139 ++++++++++
integration-tests/import/README.md | 22 ++
integration-tests/import/dev-env-config.json | 9 +
integration-tests/import/hop-config.json | 290 +++++++++++++++++++++
...in-0001-import-preserves-run-configurations.hwf | 164 ++++++++++++
...2-import-applies-default-run-configurations.hwf | 165 ++++++++++++
.../main-0003-import-registers-a-project.hwf | 181 +++++++++++++
.../metadata/pipeline-run-configuration/local.json | 17 ++
.../metadata/workflow-run-configuration/local.json | 9 +
integration-tests/import/project-config.json | 13 +
integration-tests/import/subject/pdi/parent.kjb | 67 +++++
integration-tests/import/subject/pdi/sub/child.ktr | 35 +++
.../import/subject/pdi/sub/nested.kjb | 35 +++
plugins/misc/import/pom.xml | 8 +
.../apache/hop/imports/kettle/KettleImport.java | 29 ++-
.../hop/imports/kettle/KettleImportDialog.java | 92 ++++---
.../kettle/messages/messages_en_US.properties | 1 +
.../kettle/KettleImportDialogLayoutTest.java | 238 +++++++++++++++++
.../hop/imports/kettle/KettleImportTest.java | 96 +++++++
.../xp/HopImportCreateProjectIfNotExists.java | 29 ++-
.../xp/HopImportCreateProjectIfNotExistsTest.java | 71 ++++-
25 files changed, 1863 insertions(+), 52 deletions(-)
diff --git
a/core/src/main/java/org/apache/hop/core/extension/HopExtensionPoint.java
b/core/src/main/java/org/apache/hop/core/extension/HopExtensionPoint.java
index c65e4e0240..45329d9134 100644
--- a/core/src/main/java/org/apache/hop/core/extension/HopExtensionPoint.java
+++ b/core/src/main/java/org/apache/hop/core/extension/HopExtensionPoint.java
@@ -203,6 +203,9 @@ public enum HopExtensionPoint {
HopImportRewriteMetadata(
"Imported files and connections have been written; rewrite metadata
names (HopImportBase)"),
HopImportEnd("Executed at the end of the 'hop-import' command line tool"),
+ HopImportCreateProject(
+ "Register a project for an import target folder: {folder} or {folder,
project name}"),
+ ProjectHome("Calculates the home folder of a project: {project name, home
folder}"),
;
public String id;
diff --git a/engine/src/main/java/org/apache/hop/imp/HopImport.java
b/engine/src/main/java/org/apache/hop/imp/HopImport.java
index ef027eec35..5978cf4b62 100644
--- a/engine/src/main/java/org/apache/hop/imp/HopImport.java
+++ b/engine/src/main/java/org/apache/hop/imp/HopImport.java
@@ -17,10 +17,12 @@
package org.apache.hop.imp;
+import java.io.File;
import java.util.Map;
import lombok.Getter;
import lombok.Setter;
import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.vfs2.FileName;
import org.apache.hop.core.Const;
import org.apache.hop.core.HopEnvironment;
import org.apache.hop.core.HopVersionProvider;
@@ -37,6 +39,7 @@ import org.apache.hop.core.plugins.IPlugin;
import org.apache.hop.core.plugins.PluginRegistry;
import org.apache.hop.core.variables.IVariables;
import org.apache.hop.core.variables.Variables;
+import org.apache.hop.core.vfs.HopVfs;
import org.apache.hop.hop.Hop;
import org.apache.hop.hop.plugin.HopCommand;
import org.apache.hop.hop.plugin.IHopCommand;
@@ -136,6 +139,27 @@ public class HopImport implements Runnable,
IHasHopMetadataProvider, IHopCommand
"Do not apply a naming scheme; connection names are still aligned to
one spelling")
private boolean noApplyNamingSchemes;
+ @Option(
+ names = {"-r", "--pipeline-run-configuration"},
+ description =
+ "The run configuration to set on every imported pipeline. "
+ + "Without it the name found in the source file is kept.")
+ private String defaultPipelineRunConfiguration;
+
+ @Option(
+ names = {"-w", "--workflow-run-configuration"},
+ description =
+ "The run configuration to set on every imported workflow. "
+ + "Without it the name found in the source file is kept.")
+ private String defaultWorkflowRunConfiguration;
+
+ @Option(
+ names = {"-P", "--project"},
+ description =
+ "Import into this project. An existing project's home folder is used
as the target "
+ + "folder, an unknown project is registered at the target
folder.")
+ private String projectName;
+
private MultiMetadataProvider metadataProvider;
private IVariables variables;
private CommandLine cmd;
@@ -166,9 +190,12 @@ public class HopImport implements Runnable,
IHasHopMetadataProvider, IHopCommand
if (listPluginTypes != null && listPluginTypes) {
printPluginTypes();
+ finishedWithoutError = true;
return;
}
+ resolveExistingProject();
+
if (!validateOptions()) {
cmd.usage(System.err);
return;
@@ -185,6 +212,13 @@ public class HopImport implements Runnable,
IHasHopMetadataProvider, IHopCommand
//
hopImport.setValidateInputFolder(inputFolderName);
hopImport.setValidateOutputFolder(outputFolderName);
+
+ // Only now that both folders are known to be good is a new project
registered. Registering
+ // it any earlier left a project behind that pointed at a folder nothing
was imported into.
+ if (!registerTargetProject(hopImport.getOutputFolderName())) {
+ return;
+ }
+
hopImport.setKettlePropertiesFilename(kettlePropertiesFilename);
hopImport.setJdbcPropertiesFilename(jdbcPropertiesFilename);
hopImport.setSharedXmlFilename(sharedXmlFilename);
@@ -209,6 +243,10 @@ public class HopImport implements Runnable,
IHasHopMetadataProvider, IHopCommand
hopImport.setTargetConfigFilename(targetConfigFilename);
hopImport.setApplyNamingSchemes(!noApplyNamingSchemes);
hopImport.setNamingSchemeName(namingSchemeName);
+
hopImport.setDefaultPipelineRunConfiguration(defaultPipelineRunConfiguration);
+
hopImport.setDefaultWorkflowRunConfiguration(defaultWorkflowRunConfiguration);
+ warnAboutMissingRunConfiguration("pipeline",
defaultPipelineRunConfiguration);
+ warnAboutMissingRunConfiguration("workflow",
defaultWorkflowRunConfiguration);
// Allow plugins to modify the elements loaded so far, before a pipeline
or workflow is even
// loaded
@@ -242,6 +280,10 @@ public class HopImport implements Runnable,
IHasHopMetadataProvider, IHopCommand
ExtensionPointHandler.callExtensionPoint(
log, variables, HopExtensionPoint.HopImportEnd.id, this);
+ // main() turns this into the process exit code. Without it every
import, successful or not,
+ // exited 1 and no script could tell the difference.
+ finishedWithoutError = true;
+
} catch (Exception e) {
throw new ExecutionException(cmd, "There was an error during import", e);
}
@@ -260,6 +302,114 @@ public class HopImport implements Runnable,
IHasHopMetadataProvider, IHopCommand
return hi;
}
+ /**
+ * A missing run configuration name no longer blanks the one the source file
carried, but the
+ * source may not have carried one either. Say so, the way the import dialog
does.
+ */
+ private void warnAboutMissingRunConfiguration(String subject, String
runConfigurationName) {
+ if (StringUtils.isEmpty(runConfigurationName)) {
+ log.logBasic(
+ "No default "
+ + subject
+ + " run configuration was specified. Imported "
+ + subject
+ + "s keep the run configuration named in the source file, which
can be empty.");
+ }
+ }
+
+ /**
+ * An existing project contributes its home folder as the target, so this
runs before the options
+ * are validated: the project is where the output folder comes from. An
unknown project is left to
+ * {@link #registerTargetProject(String)}. A no-op without the projects
plugin.
+ */
+ private void resolveExistingProject() {
+ if (StringUtils.isEmpty(projectName)) {
+ return;
+ }
+ String projectHome = findProjectHome(projectName);
+ if (StringUtils.isEmpty(projectHome)) {
+ return;
+ }
+ if (StringUtils.isNotEmpty(outputFolderName) &&
!projectHome.equals(outputFolderName)) {
+ log.logBasic(
+ "Ignoring output folder '"
+ + outputFolderName
+ + "': project '"
+ + projectName
+ + "' is imported into its own home folder");
+ }
+ outputFolderName = projectHome;
+ log.logBasic("Importing into project '" + projectName + "' at " +
projectHome);
+ }
+
+ /**
+ * Register the target folder as a new project, through the same extension
point the import dialog
+ * uses. Called once the import plugin has validated both folders, so a
failing import never
+ * leaves a project behind, and before anything is imported, so a project
that cannot be
+ * registered fails the run before files are written.
+ *
+ * @param validatedOutputFolder the output folder as the import plugin
normalized it
+ * @return false when {@code --project} was asked for and could not be
honoured
+ */
+ private boolean registerTargetProject(String validatedOutputFolder) throws
HopException {
+ if (StringUtils.isEmpty(projectName) ||
StringUtils.isNotEmpty(findProjectHome(projectName))) {
+ // No project asked for, or resolveExistingProject() already found it.
+ return true;
+ }
+ String projectHome = projectHomeToStore(validatedOutputFolder);
+ ExtensionPointHandler.callExtensionPoint(
+ log,
+ variables,
+ HopExtensionPoint.HopImportCreateProject.id,
+ new Object[] {projectHome, projectName});
+ if (StringUtils.isEmpty(findProjectHome(projectName))) {
+ log.logError(
+ "Unable to register project '"
+ + projectName
+ + "' at "
+ + projectHome
+ + ". Is the projects plugin available? Nothing was imported.");
+ return false;
+ }
+ log.logBasic("Registered project '" + projectName + "' at " + projectHome);
+ return true;
+ }
+
+ /**
+ * The folder to store as the new project's home. A folder that resolves to
an absolute path is
+ * stored as it was given, so that a home written as
'${SOME_VARIABLE}/folder' stays portable -
+ * the ProjectHome extension point resolves it on every read. A relative
folder has to be pinned
+ * down now: the next 'hop-import --project' can run from any working
directory.
+ */
+ private String projectHomeToStore(String validatedOutputFolder) throws
HopException {
+ if (isAbsolute(variables.resolve(outputFolderName))) {
+ return outputFolderName;
+ }
+ try {
+ FileName folder = HopVfs.getFileObject(validatedOutputFolder).getName();
+ // Keep a plain path plain, the way a project home typed in the GUI
looks, but never drop the
+ // scheme of a folder that has one: s3://bucket/folder is not
/bucket/folder.
+ return "file".equals(folder.getScheme()) ? folder.getPathDecoded() :
folder.getURI();
+ } catch (Exception e) {
+ throw new HopException("Error resolving the home folder of project " +
projectName, e);
+ }
+ }
+
+ /** Whether a folder name stands on its own, rather than depending on the
working directory. */
+ private boolean isAbsolute(String folderName) {
+ return folderName.contains("://") || new File(folderName).isAbsolute();
+ }
+
+ /** The home folder of a registered project, or null when it is unknown. */
+ private String findProjectHome(String name) {
+ try {
+ return HopImportBase.projectHome(log, variables, name, null);
+ } catch (Exception e) {
+ // The projects plugin throws when the project isn't registered yet.
+ return null;
+ }
+ }
+
private void printPluginTypes() {
System.err.println("Here are the available import plugins:");
for (IPlugin importPlugin :
PluginRegistry.getInstance().getPlugins(ImportPluginType.class)) {
@@ -284,7 +434,12 @@ public class HopImport implements Runnable,
IHasHopMetadataProvider, IHopCommand
ok = false;
}
if (StringUtils.isEmpty(outputFolderName)) {
- log.logBasic("Please specify an output folder to write to");
+ log.logBasic(
+ StringUtils.isEmpty(projectName)
+ ? "Please specify an output folder to write to"
+ : "Please specify an output folder to write to: project '"
+ + projectName
+ + "' is not registered, so it has no home folder to import
into");
ok = false;
}
if (StringUtils.isEmpty(type)) {
diff --git a/engine/src/main/java/org/apache/hop/imp/HopImportBase.java
b/engine/src/main/java/org/apache/hop/imp/HopImportBase.java
index 15bfdf4cc1..89a3bd08db 100644
--- a/engine/src/main/java/org/apache/hop/imp/HopImportBase.java
+++ b/engine/src/main/java/org/apache/hop/imp/HopImportBase.java
@@ -514,18 +514,22 @@ public abstract class HopImportBase implements IHopImport
{
this.targetConfigFilename = targetConfigFilename;
}
+ @Override
public String getDefaultPipelineRunConfiguration() {
return defaultPipelineRunConfiguration;
}
+ @Override
public void setDefaultPipelineRunConfiguration(String
defaultPipelineRunConfiguration) {
this.defaultPipelineRunConfiguration = defaultPipelineRunConfiguration;
}
+ @Override
public String getDefaultWorkflowRunConfiguration() {
return defaultWorkflowRunConfiguration;
}
+ @Override
public void setDefaultWorkflowRunConfiguration(String
defaultWorkflowRunConfiguration) {
this.defaultWorkflowRunConfiguration = defaultWorkflowRunConfiguration;
}
@@ -618,6 +622,27 @@ public abstract class HopImportBase implements IHopImport {
return connectionNameMapper != null ? connectionNameMapper :
UnaryOperator.identity();
}
+ /**
+ * The home folder of the project named {@code projectName}, as the {@code
ProjectHome} extension
+ * point resolves it.
+ *
+ * <p>Returns {@code fallback} when nothing resolved it - there is no
projects plugin - and throws
+ * when the project is not registered. Callers that treat an unknown project
as "not a project
+ * yet" pass {@code null} and catch; callers that want to keep the folder
they already have pass
+ * it as the fallback.
+ *
+ * @param projectName the project to look up
+ * @param fallback the folder to return when no extension point answered
+ */
+ public static String projectHome(
+ ILogChannel log, IVariables variables, String projectName, String
fallback)
+ throws HopException {
+ Object[] objects = new Object[] {projectName, fallback};
+ ExtensionPointHandler.callExtensionPoint(
+ log, variables, HopExtensionPoint.ProjectHome.id, objects);
+ return (String) objects[1];
+ }
+
/**
* {@code <folder>/metadata}, with trailing slashes and backslashes
normalized so VFS URIs do not
* pick up a double slash.
diff --git a/engine/src/main/java/org/apache/hop/imp/IHopImport.java
b/engine/src/main/java/org/apache/hop/imp/IHopImport.java
index ca8d55ba28..9aaa418777 100644
--- a/engine/src/main/java/org/apache/hop/imp/IHopImport.java
+++ b/engine/src/main/java/org/apache/hop/imp/IHopImport.java
@@ -186,4 +186,24 @@ public interface IHopImport {
default String getNamingSchemeName() {
return null;
}
+
+ /**
+ * The run configuration name to set on every imported pipeline. When left
empty the name found in
+ * the source file is preserved.
+ */
+ default void setDefaultPipelineRunConfiguration(String
defaultPipelineRunConfiguration) {}
+
+ default String getDefaultPipelineRunConfiguration() {
+ return null;
+ }
+
+ /**
+ * The run configuration name to set on every imported workflow. When left
empty the name found in
+ * the source file is preserved.
+ */
+ default void setDefaultWorkflowRunConfiguration(String
defaultWorkflowRunConfiguration) {}
+
+ default String getDefaultWorkflowRunConfiguration() {
+ return null;
+ }
}
diff --git a/engine/src/test/java/org/apache/hop/imp/HopImportProjectTest.java
b/engine/src/test/java/org/apache/hop/imp/HopImportProjectTest.java
new file mode 100644
index 0000000000..ab322fc4ac
--- /dev/null
+++ b/engine/src/test/java/org/apache/hop/imp/HopImportProjectTest.java
@@ -0,0 +1,139 @@
+/*
+ * 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.imp;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import org.apache.hop.core.HopClientEnvironment;
+import org.apache.hop.core.logging.LogChannel;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.core.variables.Variables;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * The folder {@code hop-import --project} registers a new project at, and
what happens when it
+ * cannot register one (issue #8516).
+ */
+class HopImportProjectTest {
+
+ private HopImport hopImport;
+ private IVariables variables;
+
+ @BeforeAll
+ static void setUpBeforeClass() throws Exception {
+ HopClientEnvironment.init();
+ }
+
+ @BeforeEach
+ void setUp() throws Exception {
+ hopImport = new HopImport();
+ variables = new Variables();
+ set("variables", variables);
+ set("log", new LogChannel("HopImportProjectTest"));
+ }
+
+ /**
+ * A relative output folder is pinned down before it is stored: the next
{@code hop-import
+ * --project} can run from any working directory.
+ */
+ @Test
+ void relativeOutputFolderBecomesAnAbsoluteProjectHome() throws Exception {
+ set("outputFolderName", "target/an-import-folder");
+
+ String home = projectHomeToStore("file:///tmp/an-import-folder");
+
+ assertEquals("/tmp/an-import-folder", home);
+ }
+
+ /** An absolute folder is stored as it was given, rather than as a VFS URI.
*/
+ @Test
+ void absoluteOutputFolderIsStoredAsGiven() throws Exception {
+ set("outputFolderName", "/tmp/an-import-folder/");
+
+ assertEquals("/tmp/an-import-folder/",
projectHomeToStore("file:///tmp/an-import-folder"));
+ }
+
+ /**
+ * A folder written with a variable stays portable: the ProjectHome
extension point resolves it on
+ * every read, so freezing it here would tie the project to this machine.
+ */
+ @Test
+ void variableInTheOutputFolderSurvives() throws Exception {
+ variables.setVariable("IMPORT_TARGET", "/tmp");
+ set("outputFolderName", "${IMPORT_TARGET}/an-import-folder");
+
+ assertEquals(
+ "${IMPORT_TARGET}/an-import-folder",
projectHomeToStore("file:///tmp/an-import-folder"));
+ }
+
+ /** A folder on a VFS location keeps its scheme: s3://bucket/folder is not
/bucket/folder. */
+ @Test
+ void schemeOfANonLocalFolderIsKept() throws Exception {
+ set("outputFolderName", "relative-so-it-gets-resolved");
+
+ assertEquals("ram:///an-import-folder",
projectHomeToStore("ram:///an-import-folder"));
+ }
+
+ /** Without a {@code --project} there is nothing to register and nothing to
fail on. */
+ @Test
+ void noProjectRequestedAlwaysSucceeds() throws Exception {
+ assertTrue(registerTargetProject("file:///tmp/an-import-folder"));
+ }
+
+ /**
+ * Nothing registers the project without the projects plugin, which used to
be logged and
+ * forgotten: the import ran and the process still exited 0. Fail the run
instead.
+ */
+ @Test
+ void unregisteredProjectFailsTheRun() throws Exception {
+ set("projectName", "Never Registered");
+ set("outputFolderName", "/tmp/an-import-folder");
+
+ assertFalse(registerTargetProject("file:///tmp/an-import-folder"));
+ }
+
+ private String projectHomeToStore(String validatedOutputFolder) throws
Exception {
+ return (String) invoke("projectHomeToStore", validatedOutputFolder);
+ }
+
+ private boolean registerTargetProject(String validatedOutputFolder) throws
Exception {
+ return (Boolean) invoke("registerTargetProject", validatedOutputFolder);
+ }
+
+ private Object invoke(String methodName, String validatedOutputFolder)
throws Exception {
+ Method method = HopImport.class.getDeclaredMethod(methodName,
String.class);
+ method.setAccessible(true);
+ try {
+ return method.invoke(hopImport, validatedOutputFolder);
+ } catch (InvocationTargetException e) {
+ throw (Exception) e.getCause();
+ }
+ }
+
+ private void set(String fieldName, Object value) throws Exception {
+ Field field = HopImport.class.getDeclaredField(fieldName);
+ field.setAccessible(true);
+ field.set(hopImport, value);
+ }
+}
diff --git a/integration-tests/import/README.md
b/integration-tests/import/README.md
new file mode 100644
index 0000000000..15e24d4a3f
--- /dev/null
+++ b/integration-tests/import/README.md
@@ -0,0 +1,22 @@
+# hop-import integration tests
+
+Exercises the `hop-import` command line tool end to end, by shelling out to
`hop-import.sh` from
+a workflow and asserting on what lands in the target folder. These cover the
CLI half of the
+front-end parity work in [#8516]; the import dialog half is covered by
+`KettleImportDialogLayoutTest` in the `hop-misc-import` plugin.
+
+| Test | What it proves |
+| --- | --- |
+| `main-0001-import-preserves-run-configurations` | Without
`--pipeline-run-configuration` / `--workflow-run-configuration`, the run
configuration names in the PDI source survive the import instead of being
blanked ([#3814], [#8516]). Also checks that sub-folders are imported by
default. |
+| `main-0002-import-applies-default-run-configurations` | With both options,
every imported pipeline and workflow gets the given run configuration. |
+| `main-0003-import-registers-a-project` | `--project` registers the target
folder as a Hop project, and a second run resolves that project's home folder
without a `-o`. An import that fails on its options registers nothing. |
+
+`subject/pdi` holds the PDI job, transformation and nested job the tests
import. Both entries of
+`parent.kjb` name a run configuration, which is what tests 0001 and 0002
assert on.
+
+Each test imports into its own folder under `output/`, so they can run in any
order. Test 0003
+points `HOP_CONFIG_FOLDER` at a throwaway folder as well: registering a
project writes
+`hop-config.json`, which must not be the one this suite itself runs on.
+
+[#3814]: https://github.com/apache/hop/issues/3814
+[#8516]: https://github.com/apache/hop/issues/8516
diff --git a/integration-tests/import/dev-env-config.json
b/integration-tests/import/dev-env-config.json
new file mode 100644
index 0000000000..a1ca51bc29
--- /dev/null
+++ b/integration-tests/import/dev-env-config.json
@@ -0,0 +1,9 @@
+{
+ "variables": [
+ {
+ "name": "SAMPLE",
+ "value": "sampleValue",
+ "description": "A sample variable value"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/integration-tests/import/hop-config.json
b/integration-tests/import/hop-config.json
new file mode 100644
index 0000000000..d9e1e6562e
--- /dev/null
+++ b/integration-tests/import/hop-config.json
@@ -0,0 +1,290 @@
+{
+ "variables": [
+ {
+ "name": "HOP_LENIENT_STRING_TO_NUMBER_CONVERSION",
+ "value": "N",
+ "description": "System wide flag to allow lenient string to number
conversion for backward compatibility. If this setting is set to \"Y\", an
string starting with digits will be converted successfully into a number.
(example: 192.168.1.1 will be converted into 192 or 192.168 or 192168 depending
on the decimal and grouping symbol). The default (N) will be to throw an error
if non-numeric symbols are found in the string."
+ },
+ {
+ "name": "HOP_COMPATIBILITY_DB_IGNORE_TIMEZONE",
+ "value": "N",
+ "description": "System wide flag to ignore timezone while writing
date/timestamp value to the database."
+ },
+ {
+ "name": "HOP_LOG_SIZE_LIMIT",
+ "value": "0",
+ "description": "The log size limit for all pipelines and workflows that
don't have the \"log size limit\" property set in their respective properties."
+ },
+ {
+ "name": "HOP_EMPTY_STRING_DIFFERS_FROM_NULL",
+ "value": "N",
+ "description": "NULL vs Empty String. If this setting is set to Y, an
empty string and null are different. Otherwise they are not."
+ },
+ {
+ "name": "HOP_MAX_LOG_SIZE_IN_LINES",
+ "value": "0",
+ "description": "The maximum number of log lines that are kept internally
by Hop. Set to 0 to keep all rows (default)"
+ },
+ {
+ "name": "HOP_MAX_LOG_TIMEOUT_IN_MINUTES",
+ "value": "1440",
+ "description": "The maximum age (in minutes) of a log line while being
kept internally by Hop. Set to 0 to keep all rows indefinitely (default)"
+ },
+ {
+ "name": "HOP_MAX_WORKFLOW_TRACKER_SIZE",
+ "value": "5000",
+ "description": "The maximum number of workflow trackers kept in memory"
+ },
+ {
+ "name": "HOP_MAX_ACTIONS_LOGGED",
+ "value": "5000",
+ "description": "The maximum number of action results kept in memory for
logging purposes."
+ },
+ {
+ "name": "HOP_MAX_LOGGING_REGISTRY_SIZE",
+ "value": "10000",
+ "description": "The maximum number of logging registry entries kept in
memory for logging purposes."
+ },
+ {
+ "name": "HOP_LOG_TAB_REFRESH_DELAY",
+ "value": "1000",
+ "description": "The hop log tab refresh delay."
+ },
+ {
+ "name": "HOP_LOG_TAB_REFRESH_PERIOD",
+ "value": "1000",
+ "description": "The hop log tab refresh period."
+ },
+ {
+ "name": "HOP_PLUGIN_CLASSES",
+ "value": null,
+ "description": "A comma delimited list of classes to scan for plugin
annotations"
+ },
+ {
+ "name": "HOP_PLUGIN_PACKAGES",
+ "value": null,
+ "description": "A comma delimited list of packages to scan for plugin
annotations (warning: slow!!)"
+ },
+ {
+ "name": "HOP_TRANSFORM_PERFORMANCE_SNAPSHOT_LIMIT",
+ "value": "0",
+ "description": "The maximum number of transform performance snapshots to
keep in memory. Set to 0 to keep all snapshots indefinitely (default)"
+ },
+ {
+ "name": "HOP_ROWSET_GET_TIMEOUT",
+ "value": "50",
+ "description": "The name of the variable that optionally contains an
alternative rowset get timeout (in ms). This only makes a difference for
extremely short lived pipelines."
+ },
+ {
+ "name": "HOP_ROWSET_PUT_TIMEOUT",
+ "value": "50",
+ "description": "The name of the variable that optionally contains an
alternative rowset put timeout (in ms). This only makes a difference for
extremely short lived pipelines."
+ },
+ {
+ "name": "HOP_CORE_TRANSFORMS_FILE",
+ "value": null,
+ "description": "The name of the project variable that will contain the
alternative location of the hop-transforms.xml file. You can use this to
customize the list of available internal transforms outside of the codebase."
+ },
+ {
+ "name": "HOP_CORE_WORKFLOW_ACTIONS_FILE",
+ "value": null,
+ "description": "The name of the project variable that will contain the
alternative location of the hop-workflow-actions.xml file."
+ },
+ {
+ "name": "HOP_SERVER_OBJECT_TIMEOUT_MINUTES",
+ "value": "1440",
+ "description": "This project variable will set a time-out after which
waiting, completed or stopped pipelines and workflows will be automatically
cleaned up. The default value is 1440 (one day)."
+ },
+ {
+ "name": "HOP_PIPELINE_PAN_JVM_EXIT_CODE",
+ "value": null,
+ "description": "Set this variable to an integer that will be returned as
the Pan JVM exit code."
+ },
+ {
+ "name": "HOP_DISABLE_CONSOLE_LOGGING",
+ "value": "N",
+ "description": "Set this variable to Y to disable standard Hop logging
to the console. (stdout)"
+ },
+ {
+ "name": "HOP_REDIRECT_STDERR",
+ "value": "N",
+ "description": "Set this variable to Y to redirect stderr to Hop
logging."
+ },
+ {
+ "name": "HOP_REDIRECT_STDOUT",
+ "value": "N",
+ "description": "Set this variable to Y to redirect stdout to Hop
logging."
+ },
+ {
+ "name": "HOP_DEFAULT_NUMBER_FORMAT",
+ "value": null,
+ "description": "The name of the variable containing an alternative
default number format"
+ },
+ {
+ "name": "HOP_DEFAULT_BIGNUMBER_FORMAT",
+ "value": null,
+ "description": "The name of the variable containing an alternative
default bignumber format"
+ },
+ {
+ "name": "HOP_DEFAULT_INTEGER_FORMAT",
+ "value": null,
+ "description": "The name of the variable containing an alternative
default integer format"
+ },
+ {
+ "name": "HOP_DEFAULT_DATE_FORMAT",
+ "value": null,
+ "description": "The name of the variable containing an alternative
default date format"
+ },
+ {
+ "name": "HOP_DEFAULT_TIMESTAMP_FORMAT",
+ "value": null,
+ "description": "The name of the variable containing an alternative
default timestamp format"
+ },
+ {
+ "name": "HOP_DEFAULT_SERVLET_ENCODING",
+ "value": null,
+ "description": "Defines the default encoding for servlets, leave it
empty to use Java default encoding"
+ },
+ {
+ "name": "HOP_FAIL_ON_LOGGING_ERROR",
+ "value": "N",
+ "description": "Set this variable to Y when you want the
workflow/pipeline fail with an error when the related logging process (e.g. to
a database) fails."
+ },
+ {
+ "name": "HOP_AGGREGATION_MIN_NULL_IS_VALUED",
+ "value": "N",
+ "description": "Set this variable to Y to set the minimum to NULL if
NULL is within an aggregate. Otherwise by default NULL is ignored by the MIN
aggregate and MIN is set to the minimum value that is not NULL. See also the
variable HOP_AGGREGATION_ALL_NULLS_ARE_ZERO."
+ },
+ {
+ "name": "HOP_AGGREGATION_ALL_NULLS_ARE_ZERO",
+ "value": "N",
+ "description": "Set this variable to Y to return 0 when all values
within an aggregate are NULL. Otherwise by default a NULL is returned when all
values are NULL."
+ },
+ {
+ "name": "HOP_COMPATIBILITY_TEXT_FILE_OUTPUT_APPEND_NO_HEADER",
+ "value": "N",
+ "description": "Set this variable to Y for backward compatibility for
the Text File Output transform. Setting this to Ywill add no header row at all
when the append option is enabled, regardless if the file is existing or not."
+ },
+ {
+ "name": "HOP_PASSWORD_ENCODER_PLUGIN",
+ "value": "Hop",
+ "description": "Specifies the password encoder plugin to use by ID (Hop
is the default)."
+ },
+ {
+ "name": "HOP_SYSTEM_HOSTNAME",
+ "value": null,
+ "description": "You can use this variable to speed up hostname lookup.
Hostname lookup is performed by Hop so that it is capable of logging the server
on which a workflow or pipeline is executed."
+ },
+ {
+ "name": "HOP_SERVER_JETTY_ACCEPTORS",
+ "value": null,
+ "description": "A variable to configure jetty option: acceptors for
Carte"
+ },
+ {
+ "name": "HOP_SERVER_JETTY_ACCEPT_QUEUE_SIZE",
+ "value": null,
+ "description": "A variable to configure jetty option: acceptQueueSize
for Carte"
+ },
+ {
+ "name": "HOP_SERVER_JETTY_RES_MAX_IDLE_TIME",
+ "value": null,
+ "description": "A variable to configure jetty option:
lowResourcesMaxIdleTime for Carte"
+ },
+ {
+ "name":
"HOP_COMPATIBILITY_MERGE_ROWS_USE_REFERENCE_STREAM_WHEN_IDENTICAL",
+ "value": "N",
+ "description": "Set this variable to Y for backward compatibility for
the Merge Rows (diff) transform. Setting this to Y will use the data from the
reference stream (instead of the comparison stream) in case the compared rows
are identical."
+ },
+ {
+ "name": "HOP_SPLIT_FIELDS_REMOVE_ENCLOSURE",
+ "value": "false",
+ "description": "Set this variable to false to preserve enclosure symbol
after splitting the string in the Split fields transform. Changing it to true
will remove first and last enclosure symbol from the resulting string chunks."
+ },
+ {
+ "name": "HOP_ALLOW_EMPTY_FIELD_NAMES_AND_TYPES",
+ "value": "false",
+ "description": "Set this variable to TRUE to allow your pipeline to pass
'null' fields and/or empty types."
+ },
+ {
+ "name": "HOP_GLOBAL_LOG_VARIABLES_CLEAR_ON_EXPORT",
+ "value": "false",
+ "description": "Set this variable to false to preserve global log
variables defined in pipeline / workflow Properties -> Log panel. Changing it
to true will clear it when export pipeline / workflow."
+ },
+ {
+ "name": "HOP_FILE_OUTPUT_MAX_STREAM_COUNT",
+ "value": "1024",
+ "description": "This project variable is used by the Text File Output
transform. It defines the max number of simultaneously open files within the
transform. The transform will close/reopen files as necessary to insure the max
is not exceeded"
+ },
+ {
+ "name": "HOP_FILE_OUTPUT_MAX_STREAM_LIFE",
+ "value": "0",
+ "description": "This project variable is used by the Text File Output
transform. It defines the max number of milliseconds between flushes of files
opened by the transform."
+ },
+ {
+ "name": "HOP_USE_NATIVE_FILE_DIALOG",
+ "value": "N",
+ "description": "Set this value to Y if you want to use the system file
open/save dialog when browsing files"
+ },
+ {
+ "name": "HOP_AUTO_CREATE_CONFIG",
+ "value": "Y",
+ "description": "Set this value to N if you don't want to automatically
create a hop configuration file (hop-config.json) when it's missing"
+ }
+ ],
+ "LocaleDefault": "en_BE",
+ "guiProperties": {
+ "FontFixedSize": "13",
+ "MaxUndo": "100",
+ "DarkMode": "Y",
+ "FontNoteSize": "13",
+ "ShowOSLook": "Y",
+ "FontFixedStyle": "0",
+ "FontNoteName": ".AppleSystemUIFont",
+ "FontFixedName": "Monospaced",
+ "FontGraphStyle": "0",
+ "FontDefaultSize": "13",
+ "GraphColorR": "255",
+ "FontGraphSize": "13",
+ "IconSize": "32",
+ "BackgroundColorB": "255",
+ "FontNoteStyle": "0",
+ "FontGraphName": ".AppleSystemUIFont",
+ "FontDefaultName": ".AppleSystemUIFont",
+ "GraphColorG": "255",
+ "UseGlobalFileBookmarks": "Y",
+ "FontDefaultStyle": "0",
+ "GraphColorB": "255",
+ "BackgroundColorR": "255",
+ "BackgroundColorG": "255",
+ "WorkflowDialogStyle": "RESIZE,MAX,MIN",
+ "LineWidth": "1",
+ "ContextDialogShowCategories": "Y"
+ },
+ "projectsConfig": {
+ "enabled": true,
+ "projectMandatory": true,
+ "environmentMandatory": false,
+ "defaultProject": "default",
+ "defaultEnvironment": null,
+ "standardParentProject": "default",
+ "standardProjectsFolder": null,
+ "projectConfigurations": [
+ {
+ "projectName": "default",
+ "projectHome": "${HOP_CONFIG_FOLDER}",
+ "configFilename": "project-config.json"
+ }
+ ],
+ "lifecycleEnvironments": [
+ {
+ "name": "dev",
+ "purpose": "Testing",
+ "projectName": "default",
+ "configurationFiles": [
+ "${PROJECT_HOME}/dev-env-config.json"
+ ]
+ }
+ ],
+ "projectLifecycles": []
+ }
+}
\ No newline at end of file
diff --git
a/integration-tests/import/main-0001-import-preserves-run-configurations.hwf
b/integration-tests/import/main-0001-import-preserves-run-configurations.hwf
new file mode 100644
index 0000000000..3cfe02eb44
--- /dev/null
+++ b/integration-tests/import/main-0001-import-preserves-run-configurations.hwf
@@ -0,0 +1,164 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+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.
+
+-->
+<workflow>
+ <name>0001-import-preserves-run-configurations</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description>hop-import keeps the run configuration names of the source
files when no defaults are given</description>
+ <extended_description/>
+ <workflow_version/>
+ <created_user>-</created_user>
+ <created_date>2026/09/24 16:00:00.000</created_date>
+ <modified_user>-</modified_user>
+ <modified_date>2026/09/24 16:00:00.000</modified_date>
+ <parameters>
+ </parameters>
+ <actions>
+ <action>
+ <name>Start</name>
+ <description/>
+ <type>SPECIAL</type>
+ <attributes/>
+ <DayOfMonth>1</DayOfMonth>
+ <hour>12</hour>
+ <intervalMinutes>60</intervalMinutes>
+ <intervalSeconds>0</intervalSeconds>
+ <minutes>0</minutes>
+ <repeat>N</repeat>
+ <schedulerType>0</schedulerType>
+ <weekDay>1</weekDay>
+ <parallel>N</parallel>
+ <xloc>112</xloc>
+ <yloc>96</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>Import without run configurations</name>
+ <description/>
+ <type>SHELL</type>
+ <attributes/>
+ <filename/>
+ <work_directory/>
+ <arg_from_previous>N</arg_from_previous>
+ <exec_per_row>N</exec_per_row>
+ <set_logfile>N</set_logfile>
+ <logfile/>
+ <set_append_logfile>N</set_append_logfile>
+ <logext/>
+ <add_date>N</add_date>
+ <add_time>N</add_time>
+ <insertScript>Y</insertScript>
+ <script>set -e
+SRC="${PROJECT_HOME}/subject/pdi"
+OUT="${PROJECT_HOME}/output/0001-preserved"
+
+rm -rf "$OUT"
+mkdir -p "$OUT"
+
+sh hop-import.sh -i "$SRC" -o "$OUT"
+
+# Naming no defaults used to blank every run configuration, leaving imported
workflows unable to
+# run (issues #3814 and #8516). The names the PDI files carried have to
survive instead.
+grep -q '<run_configuration>Kettle pipeline
runner</run_configuration>' "$OUT/parent.hwf"
+grep -q '<run_configuration>Kettle workflow
runner</run_configuration>' "$OUT/parent.hwf"
+
+# --skip-folders defaults to false, so the sub-folder was imported as well.
+test -f "$OUT/sub/child.hpl"
+test -f "$OUT/sub/nested.hwf"</script>
+ <loglevel>Basic</loglevel>
+ <parallel>N</parallel>
+ <xloc>288</xloc>
+ <yloc>96</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>Success</name>
+ <description/>
+ <type>SUCCESS</type>
+ <attributes/>
+ <parallel>N</parallel>
+ <xloc>464</xloc>
+ <yloc>96</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>Failure: the source run configurations did not survive the
import</name>
+ <description/>
+ <type>ABORT</type>
+ <attributes/>
+ <always_log_rows>N</always_log_rows>
+ <message>Failure: the source run configurations did not survive the
import</message>
+ <parallel>N</parallel>
+ <xloc>464</xloc>
+ <yloc>192</yloc>
+ <attributes_hac/>
+ </action>
+ </actions>
+ <hops>
+ <hop>
+ <from>Start</from>
+ <to>Import without run configurations</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>Y</unconditional>
+ </hop>
+ <hop>
+ <from>Import without run configurations</from>
+ <to>Success</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ <hop>
+ <from>Import without run configurations</from>
+ <to>Failure: the source run configurations did not survive the
import</to>
+ <enabled>Y</enabled>
+ <evaluation>N</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ </hops>
+ <notepads>
+ <notepad>
+ <backgroundcolorblue>210</backgroundcolorblue>
+ <backgroundcolorgreen>136</backgroundcolorgreen>
+ <backgroundcolorred>15</backgroundcolorred>
+ <bordercolorblue>250</bordercolorblue>
+ <bordercolorgreen>231</bordercolorgreen>
+ <bordercolorred>200</bordercolorred>
+ <fontbold>N</fontbold>
+ <fontcolorblue>250</fontcolorblue>
+ <fontcolorgreen>231</fontcolorgreen>
+ <fontcolorred>200</fontcolorred>
+ <fontitalic>N</fontitalic>
+ <fontname>Noto Sans</fontname>
+ <fontsize>10</fontsize>
+ <height>116</height>
+ <xloc>80</xloc>
+ <yloc>256</yloc>
+ <note>The PDI job under subject/pdi names a run configuration on both of
its entries.
+
+hop-import is run without --pipeline-run-configuration /
--workflow-run-configuration, so those
+names have to come through the import unchanged. Before issue #8516 they were
replaced with an
+empty element and the imported workflow failed at execution time with
+"You need to specify a workflow run configuration to execute this
workflow".</note>
+ <width>560</width>
+ </notepad>
+ </notepads>
+ <attributes/>
+</workflow>
diff --git
a/integration-tests/import/main-0002-import-applies-default-run-configurations.hwf
b/integration-tests/import/main-0002-import-applies-default-run-configurations.hwf
new file mode 100644
index 0000000000..a1c9b03f9b
--- /dev/null
+++
b/integration-tests/import/main-0002-import-applies-default-run-configurations.hwf
@@ -0,0 +1,165 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+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.
+
+-->
+<workflow>
+ <name>0002-import-applies-default-run-configurations</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description>hop-import overwrites the source run configuration names when
defaults are given</description>
+ <extended_description/>
+ <workflow_version/>
+ <created_user>-</created_user>
+ <created_date>2026/09/24 16:00:00.000</created_date>
+ <modified_user>-</modified_user>
+ <modified_date>2026/09/24 16:00:00.000</modified_date>
+ <parameters>
+ </parameters>
+ <actions>
+ <action>
+ <name>Start</name>
+ <description/>
+ <type>SPECIAL</type>
+ <attributes/>
+ <DayOfMonth>1</DayOfMonth>
+ <hour>12</hour>
+ <intervalMinutes>60</intervalMinutes>
+ <intervalSeconds>0</intervalSeconds>
+ <minutes>0</minutes>
+ <repeat>N</repeat>
+ <schedulerType>0</schedulerType>
+ <weekDay>1</weekDay>
+ <parallel>N</parallel>
+ <xloc>112</xloc>
+ <yloc>96</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>Import with run configurations</name>
+ <description/>
+ <type>SHELL</type>
+ <attributes/>
+ <filename/>
+ <work_directory/>
+ <arg_from_previous>N</arg_from_previous>
+ <exec_per_row>N</exec_per_row>
+ <set_logfile>N</set_logfile>
+ <logfile/>
+ <set_append_logfile>N</set_append_logfile>
+ <logext/>
+ <add_date>N</add_date>
+ <add_time>N</add_time>
+ <insertScript>Y</insertScript>
+ <script>set -e
+SRC="${PROJECT_HOME}/subject/pdi"
+OUT="${PROJECT_HOME}/output/0002-defaults"
+
+rm -rf "$OUT"
+mkdir -p "$OUT"
+
+sh hop-import.sh -i "$SRC" -o "$OUT" \
+ --pipeline-run-configuration 'Imported pipelines' \
+ --workflow-run-configuration 'Imported workflows'
+
+grep -q '<run_configuration>Imported
pipelines</run_configuration>' "$OUT/parent.hwf"
+grep -q '<run_configuration>Imported
workflows</run_configuration>' "$OUT/parent.hwf"
+
+# The names from the source files must be gone.
+if grep -q 'Kettle pipeline runner' "$OUT/parent.hwf"; then
+ echo "The source pipeline run configuration was not replaced"
+ exit 1
+fi</script>
+ <loglevel>Basic</loglevel>
+ <parallel>N</parallel>
+ <xloc>288</xloc>
+ <yloc>96</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>Success</name>
+ <description/>
+ <type>SUCCESS</type>
+ <attributes/>
+ <parallel>N</parallel>
+ <xloc>464</xloc>
+ <yloc>96</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>Failure: the default run configurations were not applied</name>
+ <description/>
+ <type>ABORT</type>
+ <attributes/>
+ <always_log_rows>N</always_log_rows>
+ <message>Failure: the default run configurations were not
applied</message>
+ <parallel>N</parallel>
+ <xloc>464</xloc>
+ <yloc>192</yloc>
+ <attributes_hac/>
+ </action>
+ </actions>
+ <hops>
+ <hop>
+ <from>Start</from>
+ <to>Import with run configurations</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>Y</unconditional>
+ </hop>
+ <hop>
+ <from>Import with run configurations</from>
+ <to>Success</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ <hop>
+ <from>Import with run configurations</from>
+ <to>Failure: the default run configurations were not applied</to>
+ <enabled>Y</enabled>
+ <evaluation>N</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ </hops>
+ <notepads>
+ <notepad>
+ <backgroundcolorblue>210</backgroundcolorblue>
+ <backgroundcolorgreen>136</backgroundcolorgreen>
+ <backgroundcolorred>15</backgroundcolorred>
+ <bordercolorblue>250</bordercolorblue>
+ <bordercolorgreen>231</bordercolorgreen>
+ <bordercolorred>200</bordercolorred>
+ <fontbold>N</fontbold>
+ <fontcolorblue>250</fontcolorblue>
+ <fontcolorgreen>231</fontcolorgreen>
+ <fontcolorred>200</fontcolorred>
+ <fontitalic>N</fontitalic>
+ <fontname>Noto Sans</fontname>
+ <fontsize>10</fontsize>
+ <height>100</height>
+ <xloc>80</xloc>
+ <yloc>256</yloc>
+ <note>The counterpart of test 0001: given --pipeline-run-configuration
and
+--workflow-run-configuration, hop-import replaces whatever the source files
named.
+
+These are the CLI equivalents of the two run configuration fields on the
Metadata tab of the
+import dialog in Hop GUI.</note>
+ <width>560</width>
+ </notepad>
+ </notepads>
+ <attributes/>
+</workflow>
diff --git a/integration-tests/import/main-0003-import-registers-a-project.hwf
b/integration-tests/import/main-0003-import-registers-a-project.hwf
new file mode 100644
index 0000000000..63796695e9
--- /dev/null
+++ b/integration-tests/import/main-0003-import-registers-a-project.hwf
@@ -0,0 +1,181 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+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.
+
+-->
+<workflow>
+ <name>0003-import-registers-a-project</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description>hop-import --project registers the target folder as a Hop
project</description>
+ <extended_description/>
+ <workflow_version/>
+ <created_user>-</created_user>
+ <created_date>2026/09/24 16:00:00.000</created_date>
+ <modified_user>-</modified_user>
+ <modified_date>2026/09/24 16:00:00.000</modified_date>
+ <parameters>
+ </parameters>
+ <actions>
+ <action>
+ <name>Start</name>
+ <description/>
+ <type>SPECIAL</type>
+ <attributes/>
+ <DayOfMonth>1</DayOfMonth>
+ <hour>12</hour>
+ <intervalMinutes>60</intervalMinutes>
+ <intervalSeconds>0</intervalSeconds>
+ <minutes>0</minutes>
+ <repeat>N</repeat>
+ <schedulerType>0</schedulerType>
+ <weekDay>1</weekDay>
+ <parallel>N</parallel>
+ <xloc>112</xloc>
+ <yloc>96</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>Import into a new project</name>
+ <description/>
+ <type>SHELL</type>
+ <attributes/>
+ <filename/>
+ <work_directory/>
+ <arg_from_previous>N</arg_from_previous>
+ <exec_per_row>N</exec_per_row>
+ <set_logfile>N</set_logfile>
+ <logfile/>
+ <set_append_logfile>N</set_append_logfile>
+ <logext/>
+ <add_date>N</add_date>
+ <add_time>N</add_time>
+ <insertScript>Y</insertScript>
+ <script>set -e
+SRC="${PROJECT_HOME}/subject/pdi"
+OUT="${PROJECT_HOME}/output/0003-project"
+CONF="${PROJECT_HOME}/output/0003-project-config"
+
+rm -rf "$OUT" "$CONF"
+mkdir -p "$OUT" "$CONF"
+
+# A throwaway HOP_CONFIG_FOLDER: registering a project writes hop-config.json,
which must not be
+# the one this test suite itself runs on.
+HOP_CONFIG_FOLDER="$CONF" sh hop-import.sh -i "$SRC" -o "$OUT" --project
'Imported PDI Project'
+
+test -f "$CONF/hop-config.json"
+grep -q 'Imported PDI Project' "$CONF/hop-config.json"
+grep -q "$OUT" "$CONF/hop-config.json"
+
+# Importing again into the same project resolves its home folder, so no target
folder is needed.
+rm -rf "$OUT"
+HOP_CONFIG_FOLDER="$CONF" sh hop-import.sh -i "$SRC" --project 'Imported PDI
Project'
+test -f "$OUT/parent.hwf"
+
+# An import that never runs must not leave a project behind, pointing at a
folder nothing was
+# imported into. This one has no input folder, so it fails on its options.
+FAILED_CONF="${PROJECT_HOME}/output/0003-failed-config"
+rm -rf "$FAILED_CONF"
+mkdir -p "$FAILED_CONF"
+if HOP_CONFIG_FOLDER="$FAILED_CONF" sh hop-import.sh -o "$OUT" --project
'Never Imported'; then
+ echo "hop-import without an input folder should have failed"
+ exit 1
+fi
+if test -f "$FAILED_CONF/hop-config.json" \
+ && grep -q 'Never Imported' "$FAILED_CONF/hop-config.json"; then
+ echo "a failed hop-import registered project 'Never Imported'"
+ exit 1
+fi</script>
+ <loglevel>Basic</loglevel>
+ <parallel>N</parallel>
+ <xloc>288</xloc>
+ <yloc>96</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>Success</name>
+ <description/>
+ <type>SUCCESS</type>
+ <attributes/>
+ <parallel>N</parallel>
+ <xloc>464</xloc>
+ <yloc>96</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>Failure: hop-import did not register the target project</name>
+ <description/>
+ <type>ABORT</type>
+ <attributes/>
+ <always_log_rows>N</always_log_rows>
+ <message>Failure: hop-import did not register the target
project</message>
+ <parallel>N</parallel>
+ <xloc>464</xloc>
+ <yloc>192</yloc>
+ <attributes_hac/>
+ </action>
+ </actions>
+ <hops>
+ <hop>
+ <from>Start</from>
+ <to>Import into a new project</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>Y</unconditional>
+ </hop>
+ <hop>
+ <from>Import into a new project</from>
+ <to>Success</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ <hop>
+ <from>Import into a new project</from>
+ <to>Failure: hop-import did not register the target project</to>
+ <enabled>Y</enabled>
+ <evaluation>N</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ </hops>
+ <notepads>
+ <notepad>
+ <backgroundcolorblue>210</backgroundcolorblue>
+ <backgroundcolorgreen>136</backgroundcolorgreen>
+ <backgroundcolorred>15</backgroundcolorred>
+ <bordercolorblue>250</bordercolorblue>
+ <bordercolorgreen>231</bordercolorgreen>
+ <bordercolorred>200</bordercolorred>
+ <fontbold>N</fontbold>
+ <fontcolorblue>250</fontcolorblue>
+ <fontcolorgreen>231</fontcolorgreen>
+ <fontcolorred>200</fontcolorred>
+ <fontitalic>N</fontitalic>
+ <fontname>Noto Sans</fontname>
+ <fontsize>10</fontsize>
+ <height>100</height>
+ <xloc>80</xloc>
+ <yloc>256</yloc>
+ <note>Without --project, hop-import leaves files on disk that belong to
no registered project and
+hop-conf has to be run afterwards. The import dialog in Hop GUI has always
registered or resolved
+a project; this is the CLI equivalent (issue #8516).
+
+The second run passes no target folder at all: the project's home folder is
used.</note>
+ <width>560</width>
+ </notepad>
+ </notepads>
+ <attributes/>
+</workflow>
diff --git
a/integration-tests/import/metadata/pipeline-run-configuration/local.json
b/integration-tests/import/metadata/pipeline-run-configuration/local.json
new file mode 100644
index 0000000000..63794efcaf
--- /dev/null
+++ b/integration-tests/import/metadata/pipeline-run-configuration/local.json
@@ -0,0 +1,17 @@
+{
+ "engineRunConfiguration": {
+ "Local": {
+ "feedback_size": "50000",
+ "sample_size": "100",
+ "sample_type_in_gui": "Last",
+ "rowset_size": "10000",
+ "safe_mode": false,
+ "show_feedback": false,
+ "topo_sort": false,
+ "gather_metrics": false
+ }
+ },
+ "configurationVariables": [],
+ "name": "local",
+ "description": "Runs your pipelines locally with the standard local Hop
pipeline engine"
+}
\ No newline at end of file
diff --git
a/integration-tests/import/metadata/workflow-run-configuration/local.json
b/integration-tests/import/metadata/workflow-run-configuration/local.json
new file mode 100644
index 0000000000..e37a93039a
--- /dev/null
+++ b/integration-tests/import/metadata/workflow-run-configuration/local.json
@@ -0,0 +1,9 @@
+{
+ "engineRunConfiguration": {
+ "Local": {
+ "safe_mode": false
+ }
+ },
+ "name": "local",
+ "description": "Runs your workflows locally with the standard local Hop
workflow engine"
+}
\ No newline at end of file
diff --git a/integration-tests/import/project-config.json
b/integration-tests/import/project-config.json
new file mode 100644
index 0000000000..6a91171e1c
--- /dev/null
+++ b/integration-tests/import/project-config.json
@@ -0,0 +1,13 @@
+{
+ "metadataBaseFolder" : "${PROJECT_HOME}/metadata",
+ "unitTestsBasePath" : "${PROJECT_HOME}",
+ "dataSetsCsvFolder" : "${PROJECT_HOME}/datasets",
+ "enforcingExecutionInHome" : true,
+ "config" : {
+ "variables" : [ {
+ "name" : "HOP_LICENSE_HEADER_FILE",
+ "value" : "${PROJECT_HOME}/../asf-header.txt",
+ "description" : "This will automatically serialize the ASF license
header into pipelines and workflows in the integration test projects"
+ } ]
+ }
+}
\ No newline at end of file
diff --git a/integration-tests/import/subject/pdi/parent.kjb
b/integration-tests/import/subject/pdi/parent.kjb
new file mode 100644
index 0000000000..6fb109a465
--- /dev/null
+++ b/integration-tests/import/subject/pdi/parent.kjb
@@ -0,0 +1,67 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+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.
+
+-->
+<job>
+ <name>parent</name>
+ <description/>
+ <job_version/>
+ <parameters/>
+ <entries>
+ <entry>
+ <name>START</name>
+ <type>SPECIAL</type>
+ <start>Y</start>
+ <dummy>N</dummy>
+ <repeat>N</repeat>
+ <xloc>96</xloc>
+ <yloc>96</yloc>
+ </entry>
+ <entry>
+ <name>Run the child transformation</name>
+ <type>TRANS</type>
+ <specification_method>filename</specification_method>
+ <filename>${Internal.Entry.Current.Directory}/sub/child.ktr</filename>
+ <run_configuration>Kettle pipeline runner</run_configuration>
+ <xloc>240</xloc>
+ <yloc>96</yloc>
+ </entry>
+ <entry>
+ <name>Run the nested job</name>
+ <type>JOB</type>
+ <specification_method>filename</specification_method>
+ <filename>${Internal.Entry.Current.Directory}/sub/nested.kjb</filename>
+ <run_configuration>Kettle workflow runner</run_configuration>
+ <xloc>400</xloc>
+ <yloc>96</yloc>
+ </entry>
+ </entries>
+ <hops>
+ <hop>
+ <from>START</from>
+ <to>Run the child transformation</to>
+ <unconditional>Y</unconditional>
+ </hop>
+ <hop>
+ <from>Run the child transformation</from>
+ <to>Run the nested job</to>
+ <unconditional>N</unconditional>
+ <evaluation>Y</evaluation>
+ </hop>
+ </hops>
+</job>
diff --git a/integration-tests/import/subject/pdi/sub/child.ktr
b/integration-tests/import/subject/pdi/sub/child.ktr
new file mode 100644
index 0000000000..3e2e55c64d
--- /dev/null
+++ b/integration-tests/import/subject/pdi/sub/child.ktr
@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+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.
+
+-->
+<transformation>
+ <info>
+ <name>child</name>
+ <description/>
+ </info>
+ <step>
+ <name>Generate rows</name>
+ <type>RowGenerator</type>
+ <limit>1</limit>
+ <fields/>
+ <GUI>
+ <xloc>96</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </step>
+</transformation>
diff --git a/integration-tests/import/subject/pdi/sub/nested.kjb
b/integration-tests/import/subject/pdi/sub/nested.kjb
new file mode 100644
index 0000000000..09be1de993
--- /dev/null
+++ b/integration-tests/import/subject/pdi/sub/nested.kjb
@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+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.
+
+-->
+<job>
+ <name>nested</name>
+ <description/>
+ <entries>
+ <entry>
+ <name>START</name>
+ <type>SPECIAL</type>
+ <start>Y</start>
+ <dummy>N</dummy>
+ <repeat>N</repeat>
+ <xloc>96</xloc>
+ <yloc>96</yloc>
+ </entry>
+ </entries>
+ <hops/>
+</job>
diff --git a/plugins/misc/import/pom.xml b/plugins/misc/import/pom.xml
index 3388315d05..4bb60f6d4c 100644
--- a/plugins/misc/import/pom.xml
+++ b/plugins/misc/import/pom.xml
@@ -35,6 +35,14 @@
<version>${project.version}</version>
<scope>test</scope>
</dependency>
+ <!-- Registers the "naming-scheme" metadata type so the import
dialog's naming-scheme
+ selector exists in the SWTBot layout test. -->
+ <dependency>
+ <groupId>org.apache.hop</groupId>
+ <artifactId>hop-misc-naming</artifactId>
+ <version>${project.version}</version>
+ <scope>test</scope>
+ </dependency>
<!-- Only to verify that a Kettle SFTPPut step imports into a real
SFTP connection. -->
<dependency>
<groupId>org.apache.hop</groupId>
diff --git
a/plugins/misc/import/src/main/java/org/apache/hop/imports/kettle/KettleImport.java
b/plugins/misc/import/src/main/java/org/apache/hop/imports/kettle/KettleImport.java
index 09e71a80dc..ae4610a96e 100644
---
a/plugins/misc/import/src/main/java/org/apache/hop/imports/kettle/KettleImport.java
+++
b/plugins/misc/import/src/main/java/org/apache/hop/imports/kettle/KettleImport.java
@@ -970,10 +970,15 @@ public class KettleImport extends HopImportBase
implements IHopImport {
if ((entryType == EntryType.JOB || entryType == EntryType.TRANS)
&& currentNode.getNodeName().equals("run_configuration")) {
- if (entryType == EntryType.JOB)
- currentNode.setTextContent(defaultWorkflowRunConfiguration);
- else if (entryType == EntryType.TRANS)
- currentNode.setTextContent(defaultPipelineRunConfiguration);
+ String defaultRunConfiguration =
+ entryType == EntryType.JOB
+ ? defaultWorkflowRunConfiguration
+ : defaultPipelineRunConfiguration;
+ // Without a default, keep the name the source file carried.
Blanking it leaves the
+ // imported workflow or pipeline without a run configuration to
execute with.
+ if (StringUtils.isNotEmpty(defaultRunConfiguration)) {
+ currentNode.setTextContent(defaultRunConfiguration);
+ }
}
// rename Kettle elements to Hop elements
@@ -1009,13 +1014,15 @@ public class KettleImport extends HopImportBase
implements IHopImport {
migrateTransformationReference(doc, currentNode);
- // add the default pipeline run configuration.
- String runConfigElementName =
- entryType == EntryType.METAINJECT ? "run_configuration" :
"runConfiguration";
- if (getChildElement(currentNode, runConfigElementName) == null) {
- Element runConfigElement = doc.createElement(runConfigElementName);
-
runConfigElement.appendChild(doc.createTextNode(defaultPipelineRunConfiguration));
- currentNode.appendChild(runConfigElement);
+ // Add the default pipeline run configuration. Metadata Injection
reads a differently
+ // named element than a mapping does. Without a default, keep what the
source carried:
+ // an empty element leaves the step without a run configuration to
execute with.
+ if (StringUtils.isNotEmpty(defaultPipelineRunConfiguration)) {
+ setChildElement(
+ doc,
+ currentNode,
+ entryType == EntryType.METAINJECT ? "run_configuration" :
"runConfiguration",
+ defaultPipelineRunConfiguration);
}
}
diff --git
a/plugins/misc/import/src/main/java/org/apache/hop/imports/kettle/KettleImportDialog.java
b/plugins/misc/import/src/main/java/org/apache/hop/imports/kettle/KettleImportDialog.java
index 44071ce90d..b9f982680d 100644
---
a/plugins/misc/import/src/main/java/org/apache/hop/imports/kettle/KettleImportDialog.java
+++
b/plugins/misc/import/src/main/java/org/apache/hop/imports/kettle/KettleImportDialog.java
@@ -27,6 +27,7 @@ import org.apache.hop.core.Props;
import org.apache.hop.core.encryption.Encr;
import org.apache.hop.core.exception.HopException;
import org.apache.hop.core.extension.ExtensionPointHandler;
+import org.apache.hop.core.extension.HopExtensionPoint;
import org.apache.hop.core.logging.LogChannel;
import org.apache.hop.core.util.SingletonUtil;
import org.apache.hop.core.util.Utils;
@@ -90,8 +91,10 @@ public class KettleImportDialog extends Dialog {
public static final String LAST_USED_IMPORT_WORKFLOW_RUN_CONFIGURATION =
"ImportWorkflowRunConfiguration";
public static final String LAST_USED_IMPORT_NAMING_SCHEME =
"ImportNamingScheme";
+ public static final String LAST_USED_IMPORT_APPLY_NAMING_SCHEMES =
"ImportApplyNamingSchemes";
public static final String NAMING_SCHEME_METADATA_KEY = "naming-scheme";
public static final String CONST_FALSE = "false";
+ public static final String CONST_TRUE = "true";
public static final String CONST_ALL_FILES = "All Files (*.*)";
public static final String CONST_KETTLE_IMPORT_DIALOG_BUTTON_BROWSE =
"KettleImportDialog.Button.Browse";
@@ -133,6 +136,7 @@ public class KettleImportDialog extends Dialog {
private Button wSkipExisting;
private Button wSkipHidden;
private Button wSkipFolders;
+ private Button wApplyNamingSchemes;
private int margin;
private int middle;
@@ -150,14 +154,22 @@ public class KettleImportDialog extends Dialog {
this.dialogMetadataProvider =
new MultiMetadataProvider(Encr.getEncoder(), List.of(scratchMetadata),
variables);
+ this.projectNames = listProjectNames();
+ }
+
+ /**
+ * Without the projects plugin there are no projects to import into, but
importing into a plain
+ * target folder still works. List nothing rather than refusing to open the
dialog.
+ */
+ private static List<String> listProjectNames() {
try {
- projectNames =
- SingletonUtil.getValuesList(
- "org.apache.hop.projects.gui.ProjectsGuiPlugin",
- "org.apache.hop.projects.config.ProjectsConfigSingleton",
- "listProjectNames");
+ return SingletonUtil.getValuesList(
+ "org.apache.hop.projects.gui.ProjectsGuiPlugin",
+ "org.apache.hop.projects.config.ProjectsConfigSingleton",
+ "listProjectNames");
} catch (HopException e) {
- throw new HopException("Error getting project names list", e);
+ LogChannel.UI.logDetailed("No projects available to import into: " +
e.getMessage());
+ return List.of();
}
}
@@ -206,8 +218,10 @@ public class KettleImportDialog extends Dialog {
kettleImport.getInputFolderName(),
AuditManagerGuiUtil.getLastUsedValue(LAST_USED_IMPORT_SOURCE_FOLDER)));
wImportInExisting.setSelection(
- !CONST_FALSE.equalsIgnoreCase(
-
AuditManagerGuiUtil.getLastUsedValue(LAST_USED_IMPORT_INTO_PROJECT)));
+ !projectNames.isEmpty()
+ && !CONST_FALSE.equalsIgnoreCase(
+
AuditManagerGuiUtil.getLastUsedValue(LAST_USED_IMPORT_INTO_PROJECT)));
+ wImportInExisting.setEnabled(!projectNames.isEmpty());
wImportProject.setText(
Const.NVL(AuditManagerGuiUtil.getLastUsedValue(LAST_USED_IMPORT_TARGET_PROJECT),
""));
wImportPath.setText(
@@ -236,8 +250,9 @@ public class KettleImportDialog extends Dialog {
wSkipHidden.setSelection(
!CONST_FALSE.equalsIgnoreCase(
AuditManagerGuiUtil.getLastUsedValue(LAST_USED_IMPORT_SKIP_HIDDEN)));
+ // Defaults to false, like 'hop-import --skip-folders': a source tree is
imported whole.
wSkipFolders.setSelection(
- !CONST_FALSE.equalsIgnoreCase(
+ CONST_TRUE.equalsIgnoreCase(
AuditManagerGuiUtil.getLastUsedValue(LAST_USED_IMPORT_SKIP_FOLDERS)));
showHideProjectFields(null);
@@ -248,10 +263,14 @@ public class KettleImportDialog extends Dialog {
wWorkflowRunConfiguration.setText(
Const.NVL(
AuditManagerGuiUtil.getLastUsedValue(LAST_USED_IMPORT_WORKFLOW_RUN_CONFIGURATION),
""));
+ wApplyNamingSchemes.setSelection(
+ !CONST_FALSE.equalsIgnoreCase(
+
AuditManagerGuiUtil.getLastUsedValue(LAST_USED_IMPORT_APPLY_NAMING_SCHEMES)));
if (wNamingScheme != null) {
wNamingScheme.setText(
Const.NVL(AuditManagerGuiUtil.getLastUsedValue(LAST_USED_IMPORT_NAMING_SCHEME),
""));
}
+ enableNamingScheme();
wImportFrom.setFocus();
@@ -265,7 +284,7 @@ public class KettleImportDialog extends Dialog {
AuditManagerGuiUtil.addLastUsedValue(LAST_USED_IMPORT_SOURCE_FOLDER,
sourceFolder);
}
AuditManagerGuiUtil.addLastUsedValue(
- LAST_USED_IMPORT_INTO_PROJECT, wImportInExisting.getSelection() ?
"true" : CONST_FALSE);
+ LAST_USED_IMPORT_INTO_PROJECT, wImportInExisting.getSelection() ?
CONST_TRUE : CONST_FALSE);
AuditManagerGuiUtil.addLastUsedValue(LAST_USED_IMPORT_TARGET_PROJECT,
wImportProject.getText());
AuditManagerGuiUtil.addLastUsedValue(LAST_USED_IMPORT_TARGET_FOLDER,
wImportPath.getText());
AuditManagerGuiUtil.addLastUsedValue(LAST_USED_IMPORT_PROPS_FILE,
wKettleProps.getText());
@@ -276,15 +295,18 @@ public class KettleImportDialog extends Dialog {
LAST_USED_IMPORT_PIPELINE_RUN_CONFIGURATION,
wPipelineRunConfiguration.getText());
AuditManagerGuiUtil.addLastUsedValue(
LAST_USED_IMPORT_WORKFLOW_RUN_CONFIGURATION,
wWorkflowRunConfiguration.getText());
+ AuditManagerGuiUtil.addLastUsedValue(
+ LAST_USED_IMPORT_APPLY_NAMING_SCHEMES,
+ wApplyNamingSchemes.getSelection() ? CONST_TRUE : CONST_FALSE);
if (wNamingScheme != null) {
AuditManagerGuiUtil.addLastUsedValue(LAST_USED_IMPORT_NAMING_SCHEME,
wNamingScheme.getText());
}
AuditManagerGuiUtil.addLastUsedValue(
- LAST_USED_IMPORT_SKIP_EXISTING, wSkipExisting.getSelection() ? "true"
: CONST_FALSE);
+ LAST_USED_IMPORT_SKIP_EXISTING, wSkipExisting.getSelection() ?
CONST_TRUE : CONST_FALSE);
AuditManagerGuiUtil.addLastUsedValue(
- LAST_USED_IMPORT_SKIP_HIDDEN, wSkipHidden.getSelection() ? "true" :
CONST_FALSE);
+ LAST_USED_IMPORT_SKIP_HIDDEN, wSkipHidden.getSelection() ? CONST_TRUE
: CONST_FALSE);
AuditManagerGuiUtil.addLastUsedValue(
- LAST_USED_IMPORT_SKIP_FOLDERS, wSkipFolders.getSelection() ? "true" :
CONST_FALSE);
+ LAST_USED_IMPORT_SKIP_FOLDERS, wSkipFolders.getSelection() ?
CONST_TRUE : CONST_FALSE);
shell.dispose();
}
@@ -397,7 +419,7 @@ public class KettleImportDialog extends Dialog {
wSkipHidden =
addCheckboxRow(parent, wJdbcProps,
"KettleImportDialog.Label.SkipHiddenFiles", true);
wSkipFolders =
- addCheckboxRow(parent, wSkipHidden,
"KettleImportDialog.Label.SkipFolders", true);
+ addCheckboxRow(parent, wSkipHidden,
"KettleImportDialog.Label.SkipFolders", false);
}
private void addTargetTab(CTabFolder folder) {
@@ -458,6 +480,11 @@ public class KettleImportDialog extends Dialog {
BaseMessages.getString(PKG,
"KettleImportDialog.RunConfiguration.Tooltip"));
wWorkflowRunConfiguration.addToConnectionLine(parent,
wPipelineRunConfiguration, null, null);
+ wApplyNamingSchemes =
+ addCheckboxRow(
+ parent, wWorkflowRunConfiguration,
"KettleImportDialog.Label.ApplyNamingSchemes", true);
+ wApplyNamingSchemes.addListener(SWT.Selection, event ->
enableNamingScheme());
+
wNamingScheme =
MetaSelectionLine.forMetadataKey(
variables,
@@ -468,7 +495,14 @@ public class KettleImportDialog extends Dialog {
BaseMessages.getString(PKG,
"KettleImportDialog.NamingScheme.Label"),
BaseMessages.getString(PKG,
"KettleImportDialog.NamingScheme.Tooltip"));
if (wNamingScheme != null) {
- wNamingScheme.addToConnectionLine(parent, wWorkflowRunConfiguration,
null, null);
+ wNamingScheme.addToConnectionLine(parent, wApplyNamingSchemes, null,
null);
+ }
+ }
+
+ /** The scheme to apply is only a choice while schemes are applied at all. */
+ private void enableNamingScheme() {
+ if (wNamingScheme != null && !wNamingScheme.isDisposed()) {
+ wNamingScheme.setEnabled(wApplyNamingSchemes.getSelection());
}
}
@@ -562,7 +596,7 @@ public class KettleImportDialog extends Dialog {
ExtensionPointHandler.callExtensionPoint(
HopGui.getInstance().getLog(),
variables,
- "HopImportCreateProject",
+ HopExtensionPoint.HopImportCreateProject.id,
wImportPath.getText());
} catch (HopException e) {
throw new HopException("Error creating project", e);
@@ -579,15 +613,12 @@ public class KettleImportDialog extends Dialog {
// See if we can pick up the target folder from a project reference...
//
if (wImportInExisting.getSelection()) {
- Object[] objects = new Object[2];
- objects[0] = projectName;
- objects[1] = targetFolder;
try {
- ExtensionPointHandler.callExtensionPoint(
- HopGui.getInstance().getLog(), variables, "ProjectHome",
objects);
-
- // Grab it back (or leave unchanged)
- targetFolder = (String) objects[1];
+ // The project's own home folder wins, but leave the folder
unchanged when nothing
+ // resolves it.
+ targetFolder =
+ HopImportBase.projectHome(
+ HopGui.getInstance().getLog(), variables, projectName,
targetFolder);
} catch (HopException e) {
throw new HopException("Error getting home folder of project " +
projectName, e);
}
@@ -607,9 +638,10 @@ public class KettleImportDialog extends Dialog {
kettleImport.setDefaultPipelineRunConfiguration(defaultPRC);
String defaultWRC = Const.NVL(wWorkflowRunConfiguration.getText(), "");
kettleImport.setDefaultWorkflowRunConfiguration(defaultWRC);
- kettleImport.setApplyNamingSchemes(true);
+ kettleImport.setApplyNamingSchemes(wApplyNamingSchemes.getSelection());
if (wNamingScheme != null) {
- kettleImport.setNamingSchemeName(Const.NVL(wNamingScheme.getText(),
""));
+ kettleImport.setNamingSchemeName(
+ wApplyNamingSchemes.getSelection() ?
Const.NVL(wNamingScheme.getText(), "") : "");
}
boolean goForImport = true;
@@ -715,11 +747,9 @@ public class KettleImportDialog extends Dialog {
if (Utils.isEmpty(projectName)) {
return null;
}
- Object[] objects = new Object[] {projectName, ""};
try {
- ExtensionPointHandler.callExtensionPoint(
- HopGui.getInstance().getLog(), variables, "ProjectHome", objects);
- return (String) objects[1];
+ return HopImportBase.projectHome(
+ HopGui.getInstance().getLog(), variables, projectName, null);
} catch (Exception e) {
return null;
}
@@ -770,7 +800,7 @@ public class KettleImportDialog extends Dialog {
json,
WorkflowRunConfiguration.class,
wWorkflowRunConfiguration.getText());
- if (wNamingScheme != null) {
+ if (wNamingScheme != null && wApplyNamingSchemes.getSelection()) {
copyNamedByKey(
dialogMetadataProvider, json, NAMING_SCHEME_METADATA_KEY,
wNamingScheme.getText());
}
diff --git
a/plugins/misc/import/src/main/resources/org/apache/hop/imports/kettle/messages/messages_en_US.properties
b/plugins/misc/import/src/main/resources/org/apache/hop/imports/kettle/messages/messages_en_US.properties
index 8118c266df..9c0870d820 100644
---
a/plugins/misc/import/src/main/resources/org/apache/hop/imports/kettle/messages/messages_en_US.properties
+++
b/plugins/misc/import/src/main/resources/org/apache/hop/imports/kettle/messages/messages_en_US.properties
@@ -41,6 +41,7 @@ KettleImportDialog.NoDefaultRCPrc.Message=No default run
configuration has been
KettleImportDialog.NoDefaultRCWrc.Message=No default run configuration has
been specified for workflows. This can cause errors\nduring the execution of
your process in any case where, in the original PDI process, a run
configuration\n was needed but was not specified.
KettleImportDialog.Pipeline.RunConfiguration.Label=Pipeline default run
configuration
KettleImportDialog.RunConfiguration.Tooltip=Sets the default run configuration
in case it was not specified. New and Edit stay in memory until Import, then
the selected object is written to the target project''s metadata folder.
+KettleImportDialog.Label.ApplyNamingSchemes=Apply a naming scheme to
connections?
KettleImportDialog.NamingScheme.Label=Naming scheme for connections
KettleImportDialog.NamingScheme.Tooltip=Applied to imported relational
connection names. New and Edit stay in memory until Import, then the selected
scheme is written to the target project''s metadata. Leave empty to use the
unique Hop-metadata (or General) scheme.
KettleImportDialog.Shell.Name=Import code to Hop
diff --git
a/plugins/misc/import/src/test/java/org/apache/hop/imports/kettle/KettleImportDialogLayoutTest.java
b/plugins/misc/import/src/test/java/org/apache/hop/imports/kettle/KettleImportDialogLayoutTest.java
new file mode 100644
index 0000000000..7a839af20a
--- /dev/null
+++
b/plugins/misc/import/src/test/java/org/apache/hop/imports/kettle/KettleImportDialogLayoutTest.java
@@ -0,0 +1,238 @@
+/*
+ * 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.imports.kettle;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Consumer;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.ui.core.widget.MetaSelectionLine;
+import org.apache.hop.ui.testing.SwtBotTestBase;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.graphics.Point;
+import org.eclipse.swt.graphics.Rectangle;
+import org.eclipse.swt.widgets.Button;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Label;
+import org.eclipse.swt.widgets.Shell;
+import org.eclipse.swtbot.swt.finder.widgets.SWTBotShell;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+/**
+ * SWTBot coverage for the two front-end parity fixes of issue #8516: the
import dialog now imports
+ * sub-folders by default (like {@code hop-import --skip-folders}) and can opt
out of naming schemes
+ * (like {@code hop-import --no-apply-naming-schemes}).
+ *
+ * <p>The dialog runs its own blocking event loop in {@code open()}, so {@link
+ * SwtBotTestBase#withDialog} pumps it on the UI thread while the assertions
run on a worker thread.
+ * Nothing is ever imported: the dialog is closed, never confirmed.
+ *
+ * <p>Tagged {@code uitest} so it is skipped without a display. On a desktop
wrap Maven with {@code
+ * tools/with-isolated-display.sh} so the dialog does not steal focus.
+ */
+@Tag("uitest")
+class KettleImportDialogLayoutTest extends SwtBotTestBase {
+
+ private static final Class<?> PKG = KettleImportDialog.class;
+
+ private static final String SKIP_FOLDERS =
"KettleImportDialog.Label.SkipFolders";
+ private static final String APPLY_NAMING_SCHEMES =
"KettleImportDialog.Label.ApplyNamingSchemes";
+ private static final String NAMING_SCHEME =
"KettleImportDialog.NamingScheme.Label";
+ private static final String PIPELINE_RUN_CONFIGURATION =
+ "KettleImportDialog.Pipeline.RunConfiguration.Label";
+ private static final String WORKFLOW_RUN_CONFIGURATION =
+ "KettleImportDialog.Workflow.RunConfiguration.Label";
+
+ /**
+ * The CLI has always recursed into sub-folders while the dialog checkbox
came up selected, so the
+ * same source tree imported differently depending on which front-end
launched it.
+ */
+ @Test
+ void subFoldersAreImportedByDefault() {
+ List<Boolean> skipping = new ArrayList<>();
+
+ inDialog(shell -> skipping.add(checkBox(shell,
SKIP_FOLDERS).getSelection()));
+
+ assertFalse(
+ skipping.get(0),
+ "sub-folders must be imported by default, like 'hop-import
--skip-folders'");
+ }
+
+ /** {@code --no-apply-naming-schemes} had no expression in the dialog at
all. */
+ @Test
+ void turningNamingSchemesOffDisablesTheSchemeSelector() {
+ List<Boolean> states = new ArrayList<>();
+
+ inDialog(
+ shell -> {
+ Button apply = checkBox(shell, APPLY_NAMING_SCHEMES);
+ MetaSelectionLine<?> scheme = metaSelectionLine(shell,
NAMING_SCHEME);
+ assertNotNull(scheme, "the naming-scheme selector is missing from
the Metadata tab");
+
+ states.add(apply.getSelection());
+ states.add(scheme.getComboWidget().isEnabled());
+
+ // setSelection() does not fire the listener, so drive it the way a
click would.
+ apply.setSelection(false);
+ apply.notifyListeners(SWT.Selection, null);
+ states.add(scheme.getComboWidget().isEnabled());
+ });
+
+ assertTrue(states.get(0), "naming schemes must stay on by default");
+ assertTrue(states.get(1), "the scheme selector is usable while schemes are
applied");
+ assertFalse(states.get(2), "turning naming schemes off must disable the
scheme selector");
+ }
+
+ /**
+ * The Metadata tab rows are stacked with FormAttachments. Inserting the
opt-out checkbox between
+ * the workflow run configuration and the scheme selector means attaching
both to their new
+ * neighbour; attaching to the old one draws the rows on top of each other.
+ */
+ @Test
+ void theMetadataTabRowsAreStackedWithoutOverlapping() {
+ List<Rectangle> rows = new ArrayList<>();
+
+ inDialog(
+ shell -> {
+ rows.add(displayBounds(metaSelectionLine(shell,
PIPELINE_RUN_CONFIGURATION)));
+ rows.add(displayBounds(metaSelectionLine(shell,
WORKFLOW_RUN_CONFIGURATION)));
+ rows.add(displayBounds(checkBox(shell, APPLY_NAMING_SCHEMES)));
+ rows.add(displayBounds(metaSelectionLine(shell, NAMING_SCHEME)));
+ });
+
+ for (Rectangle row : rows) {
+ assertTrue(row.width > 0 && row.height > 0, "a Metadata tab row was
never laid out");
+ }
+ for (int i = 1; i < rows.size(); i++) {
+ assertFalse(
+ rows.get(i).intersects(rows.get(i - 1)),
+ "Metadata tab row " + i + " overlaps the row above it");
+ assertTrue(
+ rows.get(i - 1).y < rows.get(i).y,
+ "Metadata tab row " + i + " is not below the row above it");
+ }
+ }
+
+ /**
+ * Opens the real import dialog, runs {@code assertions} against its widget
tree on the UI thread,
+ * then closes it. Every tab is laid out up front so the Metadata tab has
real bounds.
+ */
+ private void inDialog(Consumer<Shell> assertions) {
+ withDialog(
+ parent -> {
+ try {
+ new KettleImportDialog(parent, new Variables(), new
KettleImport()).open();
+ } catch (HopException e) {
+ throw new IllegalStateException("Unable to open the Kettle import
dialog", e);
+ }
+ },
+ bot -> {
+ SWTBotShell dialog = bot.shell(shellTitle());
+ dialog.activate();
+ display.syncExec(
+ () -> {
+ Shell shell = dialog.widget;
+ layoutEveryTab(shell);
+ assertions.accept(shell);
+ });
+ dialog.close();
+ });
+ }
+
+ /**
+ * A CTabFolder only lays out the selected tab, so select each one before
measuring. Leaves the
+ * Metadata tab showing, which is where the assertions look.
+ */
+ private static void layoutEveryTab(Shell shell) {
+ shell.layout(true, true);
+ for (Control control : shell.getChildren()) {
+ if (control instanceof org.eclipse.swt.custom.CTabFolder folder) {
+ for (int i = 0; i < folder.getItemCount(); i++) {
+ folder.setSelection(i);
+ shell.layout(true, true);
+ }
+ }
+ }
+ }
+
+ /**
+ * The checkbox of the row labelled {@code labelKey}: the Button created
right after its Label.
+ */
+ private static Button checkBox(Composite parent, String labelKey) {
+ String text = message(labelKey);
+ Control[] children = parent.getChildren();
+ for (int i = 0; i < children.length; i++) {
+ if (children[i] instanceof Label label
+ && text.equals(label.getText())
+ && i + 1 < children.length
+ && children[i + 1] instanceof Button button
+ && (button.getStyle() & SWT.CHECK) != 0) {
+ return button;
+ }
+ if (children[i] instanceof Composite composite && !(composite instanceof
MetaSelectionLine)) {
+ Button found = checkBox(composite, labelKey);
+ if (found != null) {
+ return found;
+ }
+ }
+ }
+ return null;
+ }
+
+ /** The {@link MetaSelectionLine} carrying {@code labelKey}, or null when
its plugin is absent. */
+ private static MetaSelectionLine<?> metaSelectionLine(Composite parent,
String labelKey) {
+ String text = message(labelKey);
+ for (Control child : parent.getChildren()) {
+ if (child instanceof MetaSelectionLine<?> line
+ && text.equals(line.getLabelWidget().getText())) {
+ return line;
+ }
+ if (child instanceof Composite composite) {
+ MetaSelectionLine<?> found = metaSelectionLine(composite, labelKey);
+ if (found != null) {
+ return found;
+ }
+ }
+ }
+ return null;
+ }
+
+ /** A control's position in display coordinates, so rows in different
parents are comparable. */
+ private static Rectangle displayBounds(Control control) {
+ assertNotNull(control, "a Metadata tab row is missing from the dialog");
+ Rectangle bounds = control.getBounds();
+ Point origin = control.getParent().toDisplay(bounds.x, bounds.y);
+ return new Rectangle(origin.x, origin.y, bounds.width, bounds.height);
+ }
+
+ private static String shellTitle() {
+ return message("KettleImportDialog.Shell.Name");
+ }
+
+ private static String message(String key) {
+ return BaseMessages.getString(PKG, key);
+ }
+}
diff --git
a/plugins/misc/import/src/test/java/org/apache/hop/imports/kettle/KettleImportTest.java
b/plugins/misc/import/src/test/java/org/apache/hop/imports/kettle/KettleImportTest.java
index fec37a624e..4bd2b3e321 100644
---
a/plugins/misc/import/src/test/java/org/apache/hop/imports/kettle/KettleImportTest.java
+++
b/plugins/misc/import/src/test/java/org/apache/hop/imports/kettle/KettleImportTest.java
@@ -34,6 +34,7 @@ import org.apache.hop.core.xml.XmlParserFactoryProducer;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.w3c.dom.Document;
+import org.w3c.dom.Node;
class KettleImportTest {
@@ -128,6 +129,82 @@ class KettleImportTest {
assertEquals("Database",
kettleImport.getConnectionsList().get(0).getName());
}
+ /**
+ * Without a default run configuration the importer used to blank every
{@code run_configuration}
+ * element, so imported workflows refused to run (#3814, #8516). Keep what
the source carried.
+ */
+ @Test
+ void runConfigurationOfSourceSurvivesWithoutADefault() throws Exception {
+ Document doc =
+ parse(
+ "<job>"
+ + "<entry><type>TRANS</type><run_configuration>Local
pipeline</run_configuration></entry>"
+ + "<entry><type>JOB</type><run_configuration>Local
workflow</run_configuration></entry>"
+ + "</job>");
+
+ invokeProcessNode(new KettleImport(), doc);
+
+ assertEquals("Local pipeline", runConfigurationAt(doc, 0));
+ assertEquals("Local workflow", runConfigurationAt(doc, 1));
+ }
+
+ @Test
+ void defaultRunConfigurationsReplaceTheSourceNames() throws Exception {
+ Document doc =
+ parse(
+ "<job>"
+ + "<entry><type>TRANS</type><run_configuration>Local
pipeline</run_configuration></entry>"
+ + "<entry><type>JOB</type><run_configuration>Local
workflow</run_configuration></entry>"
+ + "</job>");
+
+ KettleImport kettleImport = new KettleImport();
+ kettleImport.setDefaultPipelineRunConfiguration("Target pipeline RC");
+ kettleImport.setDefaultWorkflowRunConfiguration("Target workflow RC");
+ invokeProcessNode(kettleImport, doc);
+
+ assertEquals("Target pipeline RC", runConfigurationAt(doc, 0));
+ assertEquals("Target workflow RC", runConfigurationAt(doc, 1));
+ }
+
+ /**
+ * A Simple Mapping step has no run configuration in PDI, so the importer
appends one. With no
+ * default to append it used to add an empty element; leave the transform
alone instead.
+ */
+ @Test
+ void simpleMappingGetsNoEmptyRunConfigurationElement() throws Exception {
+ Document doc = parse(simpleMappingTransformation());
+
+ invokeProcessNode(new KettleImport(), doc);
+
+ assertEquals(0, doc.getElementsByTagName("runConfiguration").getLength());
+ }
+
+ @Test
+ void simpleMappingGetsTheDefaultPipelineRunConfiguration() throws Exception {
+ Document doc = parse(simpleMappingTransformation());
+
+ KettleImport kettleImport = new KettleImport();
+ kettleImport.setDefaultPipelineRunConfiguration("Target pipeline RC");
+ invokeProcessNode(kettleImport, doc);
+
+ assertEquals(1, doc.getElementsByTagName("runConfiguration").getLength());
+ assertEquals(
+ "Target pipeline RC",
+ doc.getElementsByTagName("runConfiguration").item(0).getTextContent());
+ }
+
+ private static String simpleMappingTransformation() {
+ return "<transformation>"
+ + "<step>"
+ + "<name>Sub-pipeline</name>"
+ + "<type>Mapping</type>"
+ + "<trans_name>child</trans_name>"
+ + "<directory_path>/sub</directory_path>"
+ + "<filename/>"
+ + "</step>"
+ + "</transformation>";
+ }
+
@Test
void csvFieldQuotesCommasAndDoublesQuotes() {
assertEquals("plain", KettleImport.csvField("plain"));
@@ -136,6 +213,25 @@ class KettleImportTest {
assertEquals("", KettleImport.csvField(null));
}
+ private static String runConfigurationAt(Document doc, int entryIndex) {
+ return
doc.getElementsByTagName("run_configuration").item(entryIndex).getTextContent();
+ }
+
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ private static void invokeProcessNode(KettleImport kettleImport, Document
doc) throws Exception {
+ Class<?> entryType =
Class.forName("org.apache.hop.imports.kettle.KettleImport$EntryType");
+ Method method =
+ KettleImport.class.getDeclaredMethod(
+ "processNode", Document.class, Node.class, entryType, int.class);
+ method.setAccessible(true);
+ method.invoke(
+ kettleImport,
+ doc,
+ doc.getDocumentElement(),
+ Enum.valueOf((Class<Enum>) entryType, "OTHER"),
+ 0);
+ }
+
private static Document parse(String xml) throws Exception {
try (ByteArrayInputStream in = new
ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))) {
return XmlParserFactoryProducer.createSecureDocBuilderFactory()
diff --git
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/HopImportCreateProjectIfNotExists.java
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/HopImportCreateProjectIfNotExists.java
index 5e2f9a2721..a4692a0ed4 100644
---
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/HopImportCreateProjectIfNotExists.java
+++
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/HopImportCreateProjectIfNotExists.java
@@ -36,14 +36,29 @@ import org.apache.hop.projects.project.ProjectConfig;
id = "HopImportCreateProject",
description = "Creates a new project for a project path specified in Hop
Import",
extensionPointId = "HopImportCreateProject")
-public class HopImportCreateProjectIfNotExists implements
IExtensionPoint<String> {
+public class HopImportCreateProjectIfNotExists implements
IExtensionPoint<Object> {
static final String IMPORT_PROJECT_NAME = "Hop Import Project";
+ /**
+ * The payload is either the project path on its own (the import dialog,
which always registers
+ * the same "Hop Import Project"), or an {@code Object[]} of {project path,
project name} for
+ * {@code hop-import --project}.
+ */
@Override
- public void callExtensionPoint(ILogChannel iLogChannel, IVariables
variables, String projectPath)
+ public void callExtensionPoint(ILogChannel iLogChannel, IVariables
variables, Object payload)
throws HopException {
- createImportProject(variables, projectPath, true);
+ String projectPath;
+ String projectName = IMPORT_PROJECT_NAME;
+ if (payload instanceof Object[] objects) {
+ projectPath = objects.length > 0 ? (String) objects[0] : null;
+ if (objects.length > 1 && StringUtils.isNotBlank((String) objects[1])) {
+ projectName = (String) objects[1];
+ }
+ } else {
+ projectPath = (String) payload;
+ }
+ createImportProject(variables, projectPath, projectName, true);
}
/**
@@ -56,7 +71,8 @@ public class HopImportCreateProjectIfNotExists implements
IExtensionPoint<String
* @return the registered project config, or {@code null} when {@code
projectPath} is empty
*/
static ProjectConfig createImportProject(
- IVariables variables, String projectPath, boolean persistHopConfig)
throws HopException {
+ IVariables variables, String projectPath, String projectName, boolean
persistHopConfig)
+ throws HopException {
if (StringUtil.isEmpty(projectPath)) {
return null;
}
@@ -67,7 +83,10 @@ public class HopImportCreateProjectIfNotExists implements
IExtensionPoint<String
defaultProjectConfigFilename =
ProjectsConfig.DEFAULT_PROJECT_CONFIG_FILENAME;
}
ProjectConfig projectConfig =
- new ProjectConfig(IMPORT_PROJECT_NAME, projectPath,
defaultProjectConfigFilename);
+ new ProjectConfig(
+ StringUtils.isBlank(projectName) ? IMPORT_PROJECT_NAME :
projectName,
+ projectPath,
+ defaultProjectConfigFilename);
Project project = new Project();
project.getDescribedVariables().clear();
diff --git
a/plugins/misc/projects/src/test/java/org/apache/hop/projects/xp/HopImportCreateProjectIfNotExistsTest.java
b/plugins/misc/projects/src/test/java/org/apache/hop/projects/xp/HopImportCreateProjectIfNotExistsTest.java
index 55ed6e5dbb..1da6ce3e43 100644
---
a/plugins/misc/projects/src/test/java/org/apache/hop/projects/xp/HopImportCreateProjectIfNotExistsTest.java
+++
b/plugins/misc/projects/src/test/java/org/apache/hop/projects/xp/HopImportCreateProjectIfNotExistsTest.java
@@ -96,7 +96,10 @@ class HopImportCreateProjectIfNotExistsTest {
ProjectConfig created =
HopImportCreateProjectIfNotExists.createImportProject(
- variables, importHome.toString(), false);
+ variables,
+ importHome.toString(),
+ HopImportCreateProjectIfNotExists.IMPORT_PROJECT_NAME,
+ false);
assertEquals(
originalHome.toString(),
variables.getVariable(ProjectsUtil.VARIABLE_PROJECT_HOME));
@@ -114,6 +117,59 @@ class HopImportCreateProjectIfNotExistsTest {
.findProjectConfig(HopImportCreateProjectIfNotExists.IMPORT_PROJECT_NAME));
}
+ /** {@code hop-import --project <name>} registers the target folder under
that name (#8516). */
+ @Test
+ void anExplicitProjectNameIsUsedInsteadOfTheDefault() throws Exception {
+ Path importHome = tempRoot.resolve("named");
+ Files.createDirectories(importHome);
+ ProjectsConfig config = ProjectsConfigSingleton.getConfig();
+ try {
+ ProjectConfig created =
+ HopImportCreateProjectIfNotExists.createImportProject(
+ new Variables(), importHome.toString(), "Migrated PDI", false);
+
+ assertNotNull(created);
+ assertEquals("Migrated PDI", created.getProjectName());
+ assertNotNull(config.findProjectConfig("Migrated PDI"));
+
assertNull(config.findProjectConfig(HopImportCreateProjectIfNotExists.IMPORT_PROJECT_NAME));
+ } finally {
+ config.removeProjectConfig("Migrated PDI");
+ }
+ }
+
+ /**
+ * The extension point takes the folder alone from the import dialog, and
{folder, project name}
+ * from {@code hop-import --project}. Both shapes have to keep working.
+ */
+ @Test
+ void theExtensionPointAcceptsAFolderOnItsOwnAndAFolderWithAName() throws
Exception {
+ Path fromDialog = tempRoot.resolve("from-dialog");
+ Path fromCli = tempRoot.resolve("from-cli");
+ Files.createDirectories(fromDialog);
+ Files.createDirectories(fromCli);
+ HopImportCreateProjectIfNotExists extensionPoint = new
HopImportCreateProjectIfNotExists();
+ ProjectsConfig config = ProjectsConfigSingleton.getConfig();
+ try {
+ extensionPoint.callExtensionPoint(
+ HopLogStore.getLogChannelFactory().create("test"),
+ new Variables(),
+ fromDialog.toString());
+ assertEquals(
+ fromDialog.toString(),
+ config
+
.findProjectConfig(HopImportCreateProjectIfNotExists.IMPORT_PROJECT_NAME)
+ .getProjectHome());
+
+ extensionPoint.callExtensionPoint(
+ HopLogStore.getLogChannelFactory().create("test"),
+ new Variables(),
+ new Object[] {fromCli.toString(), "Migrated PDI"});
+ assertEquals(fromCli.toString(), config.findProjectConfig("Migrated
PDI").getProjectHome());
+ } finally {
+ config.removeProjectConfig("Migrated PDI");
+ }
+ }
+
@Test
void emptyDefaultProjectConfigFileFallsBackToProjectConfigJson() throws
Exception {
Path importHome = tempRoot.resolve("empty-config-name");
@@ -122,7 +178,10 @@ class HopImportCreateProjectIfNotExistsTest {
ProjectConfig created =
HopImportCreateProjectIfNotExists.createImportProject(
- new Variables(), importHome.toString(), false);
+ new Variables(),
+ importHome.toString(),
+ HopImportCreateProjectIfNotExists.IMPORT_PROJECT_NAME,
+ false);
assertNotNull(created);
assertEquals(ProjectsConfig.DEFAULT_PROJECT_CONFIG_FILENAME,
created.getConfigFilename());
@@ -135,8 +194,12 @@ class HopImportCreateProjectIfNotExistsTest {
IVariables variables = new Variables();
variables.setVariable(ProjectsUtil.VARIABLE_PROJECT_HOME,
"/original-home");
-
assertNull(HopImportCreateProjectIfNotExists.createImportProject(variables, "",
false));
-
assertNull(HopImportCreateProjectIfNotExists.createImportProject(variables,
null, false));
+ assertNull(
+ HopImportCreateProjectIfNotExists.createImportProject(
+ variables, "",
HopImportCreateProjectIfNotExists.IMPORT_PROJECT_NAME, false));
+ assertNull(
+ HopImportCreateProjectIfNotExists.createImportProject(
+ variables, null,
HopImportCreateProjectIfNotExists.IMPORT_PROJECT_NAME, false));
assertEquals("/original-home",
variables.getVariable(ProjectsUtil.VARIABLE_PROJECT_HOME));
}