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 '&lt;run_configuration&gt;Kettle pipeline 
runner&lt;/run_configuration&gt;' "$OUT/parent.hwf"
+grep -q '&lt;run_configuration&gt;Kettle workflow 
runner&lt;/run_configuration&gt;' "$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 '&lt;run_configuration&gt;Imported 
pipelines&lt;/run_configuration&gt;' "$OUT/parent.hwf"
+grep -q '&lt;run_configuration&gt;Imported 
workflows&lt;/run_configuration&gt;' "$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" \
+    &amp;&amp; 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));
   }
 

Reply via email to