This is an automated email from the ASF dual-hosted git repository.

hansva pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git


The following commit(s) were added to refs/heads/main by this push:
     new f43d55807d issue #3553 : auto-export project metadata to a single JSON 
file (#7597)
f43d55807d is described below

commit f43d55807d9e510538079280a31941148104aef4
Author: Matt Casters <[email protected]>
AuthorDate: Wed Jul 22 15:05:50 2026 +0200

    issue #3553 : auto-export project metadata to a single JSON file (#7597)
    
    Optionally write metadata.json (or a user-specified path) when a project
    is enabled or hop metadata is created, updated, or deleted. Opt-in per
    project; reuses extension points and SerializableMetadataProvider.
---
 .../modules/ROOT/pages/projects/metadata.adoc      |  26 +++
 .../project/ManageProjectsOptionPlugin.java        |  22 +--
 .../org/apache/hop/projects/project/Project.java   |  17 ++
 .../apache/hop/projects/project/ProjectDialog.java |  60 ++++++
 .../org/apache/hop/projects/util/Defaults.java     |   6 +
 .../projects/util/ProjectsMetadataExporter.java    | 205 +++++++++++++++++++++
 .../xp/AutoExportMetadataOnMetadataCreated.java    |  38 ++++
 .../xp/AutoExportMetadataOnMetadataDeleted.java    |  38 ++++
 .../xp/AutoExportMetadataOnMetadataUpdated.java    |  38 ++++
 .../xp/AutoExportMetadataOnProjectActivated.java   |  42 +++++
 .../project/messages/messages_en_US.properties     |   3 +
 .../apache/hop/projects/project/ProjectTest.java   |  24 +++
 .../util/ProjectsMetadataExporterTest.java         |  78 ++++++++
 .../hop/ui/core/metadata/MetadataManager.java      |   8 +
 .../perspective/metadata/MetadataPerspective.java  |   7 +
 15 files changed, 594 insertions(+), 18 deletions(-)

diff --git a/docs/hop-user-manual/modules/ROOT/pages/projects/metadata.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/projects/metadata.adoc
index 9a25ce293e..847a3d4fc2 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/projects/metadata.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/projects/metadata.adoc
@@ -35,6 +35,32 @@ E.g. a `CRM` database connection is created in the project, 
using a number of va
 
 A project's metadata is stored in `{openvar}PROJECT_HOME{closevar}/metadata`, 
but can be overruled by setting the project's `metadata base folder 
{openvar}HOP_METADATA_FOLDER{closevar}` property in the project configuration 
dialog or directly in the `project-config.json` file.
 
+=== Auto-export metadata to a single JSON file
+
+Some integrations (for example Apache Airflow calling Hop Server, or 
Beam-style packaging) need the project metadata as a **single JSON file** 
rather than the per-type folder tree under `metadata/`.
+
+You can already create that file manually:
+
+* GUI: **Tools → Export metadata to JSON** (Beam plugin)
+* CLI: `hop-conf --project=<name> --export-metadata=<path/to/metadata.json>`
+
+Optionally, a project can keep that file up to date automatically. In the 
project properties dialog enable **Auto-export metadata** and set a filename 
(default `metadata.json`, relative to the project home). When enabled, Hop 
rewrites the file when:
+
+* the project is enabled / switched to, and
+* a metadata object is created, updated, or deleted in the GUI.
+
+The setting is stored in `project-config.json`:
+
+[source,json]
+----
+{
+  "autoExportMetadata" : true,
+  "autoExportMetadataFilename" : "metadata.json"
+}
+----
+
+Read-only projects never write the export file. Export failures are logged and 
do not block saving metadata.
+
 A basic project metadata folder will look similar to the one below:
 
 [source,bash]
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/ManageProjectsOptionPlugin.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/ManageProjectsOptionPlugin.java
index 6607ddc864..c59d326d18 100644
--- 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/ManageProjectsOptionPlugin.java
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/ManageProjectsOptionPlugin.java
@@ -18,9 +18,7 @@
 package org.apache.hop.projects.project;
 
 import java.io.IOException;
-import java.io.OutputStream;
 import java.lang.reflect.InvocationTargetException;
-import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.List;
 import lombok.Getter;
@@ -32,16 +30,15 @@ import org.apache.hop.core.config.plugin.ConfigPlugin;
 import org.apache.hop.core.config.plugin.IConfigOptions;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.logging.ILogChannel;
-import org.apache.hop.core.metadata.SerializableMetadataProvider;
 import org.apache.hop.core.util.StringUtil;
 import org.apache.hop.core.util.Utils;
 import org.apache.hop.core.variables.DescribedVariable;
 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.metadata.api.IHasHopMetadataProvider;
 import org.apache.hop.projects.config.ProjectsConfig;
 import org.apache.hop.projects.config.ProjectsConfigSingleton;
+import org.apache.hop.projects.util.ProjectsMetadataExporter;
 import org.apache.hop.projects.util.ProjectsUtil;
 import picocli.CommandLine;
 
@@ -447,20 +444,9 @@ public class ManageProjectsOptionPlugin implements 
IConfigOptions {
     String realFilename = variables.resolve(metadataJsonFilename);
     log.logBasic("Exporting project metadata to a single file: " + 
realFilename);
 
-    // This is the metadata to export
-    //
-    SerializableMetadataProvider metadataProvider =
-        new 
SerializableMetadataProvider(hasHopMetadataProvider.getMetadataProvider());
-    String jsonString = metadataProvider.toJson();
-
-    try {
-      try (OutputStream outputStream = HopVfs.getOutputStream(realFilename, 
false)) {
-        outputStream.write(jsonString.getBytes(StandardCharsets.UTF_8));
-      }
-      log.logBasic("Metadata was exported successfully.");
-    } catch (Exception e) {
-      throw new HopException("There was an error exporting metadata to file: " 
+ realFilename, e);
-    }
+    ProjectsMetadataExporter.exportToFile(
+        hasHopMetadataProvider.getMetadataProvider(), realFilename);
+    log.logBasic("Metadata was exported successfully.");
   }
 
   public void listActionTypes(
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/Project.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/Project.java
index f2ceff0df1..829fbf7f7d 100644
--- 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/Project.java
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/Project.java
@@ -85,6 +85,19 @@ public class Project extends ConfigFile implements 
IConfigFile {
   @JsonInclude(JsonInclude.Include.ALWAYS)
   private boolean enforcingExecutionInHome;
 
+  /**
+   * When true, Hop writes a single-file JSON export of the project's metadata 
(connections, run
+   * configurations, etc.) whenever the project is enabled or metadata is 
created/updated/deleted.
+   * See {@link org.apache.hop.projects.util.ProjectsMetadataExporter}.
+   */
+  private boolean autoExportMetadata;
+
+  /**
+   * Target filename for auto-export, relative to the project home (or 
absolute). Empty means the
+   * default {@code metadata.json}.
+   */
+  private String autoExportMetadataFilename;
+
   private String parentProjectName;
   @JsonIgnore private MultiMetadataProvider metadataProvider;
   @JsonIgnore private List<Path> pipelinePaths;
@@ -98,6 +111,8 @@ public class Project extends ConfigFile implements 
IConfigFile {
     dataSetsCsvFolder = "${" + ProjectsUtil.VARIABLE_PROJECT_HOME + 
"}/datasets";
     unitTestsBasePath = "${" + ProjectsUtil.VARIABLE_PROJECT_HOME + "}";
     enforcingExecutionInHome = true;
+    autoExportMetadata = false;
+    autoExportMetadataFilename = "";
   }
 
   public Project(String configFilename) {
@@ -153,6 +168,8 @@ public class Project extends ConfigFile implements 
IConfigFile {
       this.unitTestsBasePath = project.unitTestsBasePath;
       this.dataSetsCsvFolder = project.dataSetsCsvFolder;
       this.enforcingExecutionInHome = project.enforcingExecutionInHome;
+      this.autoExportMetadata = project.autoExportMetadata;
+      this.autoExportMetadataFilename = project.autoExportMetadataFilename;
       this.configMap = project.configMap;
       this.parentProjectName = project.parentProjectName;
     } catch (Exception e) {
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/ProjectDialog.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/ProjectDialog.java
index 188acfd1ff..7bbbe319fd 100644
--- 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/ProjectDialog.java
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/ProjectDialog.java
@@ -36,6 +36,7 @@ import org.apache.hop.i18n.BaseMessages;
 import org.apache.hop.projects.config.ProjectsConfig;
 import org.apache.hop.projects.config.ProjectsConfigSingleton;
 import org.apache.hop.projects.gui.ProjectsGuiPlugin;
+import org.apache.hop.projects.util.Defaults;
 import org.apache.hop.projects.util.ProjectsUtil;
 import org.apache.hop.ui.core.ConstUi;
 import org.apache.hop.ui.core.PropsUi;
@@ -90,6 +91,8 @@ public class ProjectDialog extends Dialog {
   private Text wVersion;
 
   private TextVar wMetadataBaseFolder;
+  private Button wAutoExportMetadata;
+  private TextVar wAutoExportMetadataFilename;
   private TextVar wUnitTestsBasePath;
   private TextVar wDataSetCsvFolder;
   private Button wEnforceHomeExecution;
@@ -370,6 +373,46 @@ public class ProjectDialog extends Dialog {
     wMetadataBaseFolder.addModifyListener(e -> updateIVariables());
     lastControl = wMetadataBaseFolder;
 
+    Label wlAutoExportMetadata = new Label(comp, SWT.RIGHT);
+    PropsUi.setLook(wlAutoExportMetadata);
+    wlAutoExportMetadata.setText(
+        BaseMessages.getString(PKG, "ProjectDialog.Label.AutoExportMetadata"));
+    FormData fdlAutoExportMetadata = new FormData();
+    fdlAutoExportMetadata.left = new FormAttachment(0, 0);
+    fdlAutoExportMetadata.right = new FormAttachment(middle, 0);
+    fdlAutoExportMetadata.top = new FormAttachment(lastControl, margin);
+    wlAutoExportMetadata.setLayoutData(fdlAutoExportMetadata);
+    wAutoExportMetadata = new Button(comp, SWT.CHECK | SWT.LEFT);
+    PropsUi.setLook(wAutoExportMetadata);
+    wAutoExportMetadata.setText(
+        BaseMessages.getString(PKG, 
"ProjectDialog.Label.AutoExportMetadata.Enable"));
+    FormData fdAutoExportMetadata = new FormData();
+    fdAutoExportMetadata.left = new FormAttachment(middle, margin);
+    fdAutoExportMetadata.right = new FormAttachment(99, 0);
+    fdAutoExportMetadata.top = new FormAttachment(wlAutoExportMetadata, 0, 
SWT.CENTER);
+    wAutoExportMetadata.setLayoutData(fdAutoExportMetadata);
+    wAutoExportMetadata.addListener(SWT.Selection, e -> 
updateAutoExportMetadataWidgets());
+    lastControl = wlAutoExportMetadata;
+
+    Label wlAutoExportMetadataFilename = new Label(comp, SWT.RIGHT);
+    PropsUi.setLook(wlAutoExportMetadataFilename);
+    wlAutoExportMetadataFilename.setText(
+        BaseMessages.getString(PKG, 
"ProjectDialog.Label.AutoExportMetadataFilename"));
+    FormData fdlAutoExportMetadataFilename = new FormData();
+    fdlAutoExportMetadataFilename.left = new FormAttachment(0, 0);
+    fdlAutoExportMetadataFilename.right = new FormAttachment(middle, 0);
+    fdlAutoExportMetadataFilename.top = new FormAttachment(lastControl, 
margin);
+    wlAutoExportMetadataFilename.setLayoutData(fdlAutoExportMetadataFilename);
+    wAutoExportMetadataFilename = new TextVar(variables, comp, SWT.SINGLE | 
SWT.BORDER | SWT.LEFT);
+    PropsUi.setLook(wAutoExportMetadataFilename);
+    FormData fdAutoExportMetadataFilename = new FormData();
+    fdAutoExportMetadataFilename.left = new FormAttachment(middle, margin);
+    fdAutoExportMetadataFilename.right = new FormAttachment(99, 0);
+    fdAutoExportMetadataFilename.top =
+        new FormAttachment(wlAutoExportMetadataFilename, 0, SWT.CENTER);
+    wAutoExportMetadataFilename.setLayoutData(fdAutoExportMetadataFilename);
+    lastControl = wAutoExportMetadataFilename;
+
     Label wlUnitTestsBasePath = new Label(comp, SWT.RIGHT);
     PropsUi.setLook(wlUnitTestsBasePath);
     wlUnitTestsBasePath.setText(
@@ -486,6 +529,7 @@ public class ProjectDialog extends Dialog {
 
     getData();
     updateReadOnlyWidgets();
+    updateAutoExportMetadataWidgets();
 
     comp.pack();
     scroll.setContent(comp);
@@ -528,11 +572,19 @@ public class ProjectDialog extends Dialog {
     wDepartment.setEnabled(editable);
     wVersion.setEnabled(editable);
     wMetadataBaseFolder.setEnabled(editable);
+    wAutoExportMetadata.setEnabled(editable);
     wUnitTestsBasePath.setEnabled(editable);
     wDataSetCsvFolder.setEnabled(editable);
     wEnforceHomeExecution.setEnabled(editable);
     wVariables.setEnabled(editable);
     wVariables.setReadonly(!editable);
+    updateAutoExportMetadataWidgets();
+  }
+
+  /** Filename is only meaningful when auto-export is enabled (and the project 
is not read-only). */
+  private void updateAutoExportMetadataWidgets() {
+    boolean editable = !wReadOnly.getSelection() && 
wAutoExportMetadata.getSelection();
+    wAutoExportMetadataFilename.setEnabled(editable);
   }
 
   private void browseHomeFolder(Event event) {
@@ -805,6 +857,12 @@ public class ProjectDialog extends Dialog {
     wDepartment.setText(Const.NVL(project.getDepartment(), ""));
     wVersion.setText(Const.NVL(project.getVersion(), ""));
     wMetadataBaseFolder.setText(Const.NVL(project.getMetadataBaseFolder(), 
""));
+    wAutoExportMetadata.setSelection(project.isAutoExportMetadata());
+    String exportFilename = project.getAutoExportMetadataFilename();
+    if (Utils.isEmpty(exportFilename)) {
+      exportFilename = Defaults.DEFAULT_AUTO_EXPORT_METADATA_FILENAME;
+    }
+    wAutoExportMetadataFilename.setText(exportFilename);
     wUnitTestsBasePath.setText(Const.NVL(project.getUnitTestsBasePath(), ""));
     wDataSetCsvFolder.setText(Const.NVL(project.getDataSetsCsvFolder(), ""));
     wEnforceHomeExecution.setSelection(project.isEnforcingExecutionInHome());
@@ -850,6 +908,8 @@ public class ProjectDialog extends Dialog {
     project.setDepartment(wDepartment.getText());
     project.setVersion(wVersion.getText());
     project.setMetadataBaseFolder(wMetadataBaseFolder.getText());
+    project.setAutoExportMetadata(wAutoExportMetadata.getSelection());
+    
project.setAutoExportMetadataFilename(wAutoExportMetadataFilename.getText());
     project.setUnitTestsBasePath(wUnitTestsBasePath.getText());
     project.setDataSetsCsvFolder(wDataSetCsvFolder.getText());
     project.setEnforcingExecutionInHome(wEnforceHomeExecution.getSelection());
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/util/Defaults.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/util/Defaults.java
index 665fb83df1..0abc0a7833 100644
--- 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/util/Defaults.java
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/util/Defaults.java
@@ -22,4 +22,10 @@ public class Defaults {
   public static final String VARIABLE_HOP_ENVIRONMENT_NAME = 
"HOP_ENVIRONMENT_NAME";
 
   public static final String EXTENSION_POINT_PROJECT_ACTIVATED = 
"ProjectActivated";
+
+  /**
+   * Default filename (relative to project home) used when a project has 
auto-export of metadata
+   * enabled and no custom filename is configured.
+   */
+  public static final String DEFAULT_AUTO_EXPORT_METADATA_FILENAME = 
"metadata.json";
 }
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/util/ProjectsMetadataExporter.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/util/ProjectsMetadataExporter.java
new file mode 100644
index 0000000000..2b7df4a35e
--- /dev/null
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/util/ProjectsMetadataExporter.java
@@ -0,0 +1,205 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.projects.util;
+
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.vfs2.FileName;
+import org.apache.commons.vfs2.FileObject;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.logging.ILogChannel;
+import org.apache.hop.core.metadata.SerializableMetadataProvider;
+import org.apache.hop.core.util.Utils;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.core.vfs.HopVfs;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.metadata.util.HopMetadataInstance;
+import org.apache.hop.projects.config.ProjectsConfig;
+import org.apache.hop.projects.config.ProjectsConfigSingleton;
+import org.apache.hop.projects.project.Project;
+import org.apache.hop.projects.project.ProjectConfig;
+import org.apache.hop.ui.core.gui.HopNamespace;
+
+/**
+ * Writes a single-file JSON export of hop metadata (connections, run 
configurations, etc.) for a
+ * project. Used by {@code hop-conf --export-metadata}, optional project 
auto-export, and similar
+ * packaging flows.
+ */
+public final class ProjectsMetadataExporter {
+
+  private ProjectsMetadataExporter() {
+    // utility
+  }
+
+  /**
+   * If the currently active project has auto-export enabled, write the 
metadata export file. Fail
+   * soft: log errors and never throw to the caller (so metadata saves are not 
blocked).
+   *
+   * @param log log channel
+   * @param variables variable space (for resolving paths)
+   */
+  public static void autoExportActiveProjectIfEnabled(ILogChannel log, 
IVariables variables) {
+    try {
+      String projectName = resolveActiveProjectName(variables);
+      if (StringUtils.isEmpty(projectName)) {
+        return;
+      }
+
+      ProjectsConfig config = ProjectsConfigSingleton.getConfig();
+      ProjectConfig projectConfig = config.findProjectConfig(projectName);
+      if (projectConfig == null) {
+        return;
+      }
+      if (projectConfig.isReadOnly()) {
+        if (log != null && log.isDetailed()) {
+          log.logDetailed(
+              "Skipping metadata auto-export for read-only project '" + 
projectName + "'");
+        }
+        return;
+      }
+
+      Project project = projectConfig.loadProject(variables);
+      if (project == null || !project.isAutoExportMetadata()) {
+        return;
+      }
+
+      IHopMetadataProvider metadataProvider = 
HopMetadataInstance.getMetadataProvider();
+      if (metadataProvider == null && project.getMetadataProvider() != null) {
+        metadataProvider = project.getMetadataProvider();
+      }
+      if (metadataProvider == null) {
+        if (log != null) {
+          log.logError(
+              "Cannot auto-export metadata for project '"
+                  + projectName
+                  + "': no metadata provider is available");
+        }
+        return;
+      }
+
+      String relativeOrAbsolute =
+          Const.NVL(
+              project.getAutoExportMetadataFilename(),
+              Defaults.DEFAULT_AUTO_EXPORT_METADATA_FILENAME);
+      if (Utils.isEmpty(relativeOrAbsolute)) {
+        relativeOrAbsolute = Defaults.DEFAULT_AUTO_EXPORT_METADATA_FILENAME;
+      }
+
+      String projectHome = variables.resolve(projectConfig.getProjectHome());
+      String targetFilename = resolveExportFilename(projectHome, 
relativeOrAbsolute, variables);
+
+      exportToFile(metadataProvider, targetFilename);
+
+      if (log != null && log.isBasic()) {
+        log.logBasic(
+            "Auto-exported project metadata for '" + projectName + "' to: " + 
targetFilename);
+      }
+    } catch (Exception e) {
+      if (log != null) {
+        log.logError(
+            "Error auto-exporting project metadata (continuing without failing 
the save)", e);
+      }
+    }
+  }
+
+  /**
+   * Export all metadata from the provider to a JSON file.
+   *
+   * @param metadataProvider source metadata
+   * @param filename resolved absolute or VFS path of the target file
+   * @throws HopException on serialization or write errors
+   */
+  public static void exportToFile(IHopMetadataProvider metadataProvider, 
String filename)
+      throws HopException {
+    if (metadataProvider == null) {
+      throw new HopException("Metadata provider is required for export");
+    }
+    if (Utils.isEmpty(filename)) {
+      throw new HopException("Export filename is required");
+    }
+
+    SerializableMetadataProvider serializable = new 
SerializableMetadataProvider(metadataProvider);
+    String jsonString = serializable.toJson();
+
+    try {
+      FileObject file = HopVfs.getFileObject(filename);
+      FileObject parent = file.getParent();
+      if (parent != null && !parent.exists()) {
+        parent.createFolder();
+      }
+      try (OutputStream outputStream = HopVfs.getOutputStream(file, false)) {
+        outputStream.write(jsonString.getBytes(StandardCharsets.UTF_8));
+      }
+    } catch (Exception e) {
+      throw new HopException("There was an error exporting metadata to file: " 
+ filename, e);
+    }
+  }
+
+  /**
+   * Resolve the export target path. Absolute paths and VFS URIs are used 
as-is (after variable
+   * resolution); relative paths are rooted at the project home.
+   */
+  public static String resolveExportFilename(
+      String projectHome, String filename, IVariables variables) throws 
HopException {
+    String resolved = variables != null ? variables.resolve(filename) : 
filename;
+    if (Utils.isEmpty(resolved)) {
+      resolved = Defaults.DEFAULT_AUTO_EXPORT_METADATA_FILENAME;
+    }
+
+    // Absolute filesystem path or VFS scheme (e.g. file://, s3://)
+    if (resolved.startsWith("/")
+        || resolved.startsWith("\\")
+        || (resolved.length() > 2 && resolved.charAt(1) == ':')
+        || resolved.contains("://")) {
+      return resolved;
+    }
+
+    String home = variables != null ? variables.resolve(projectHome) : 
projectHome;
+    if (Utils.isEmpty(home)) {
+      throw new HopException("Project home is required to resolve relative 
export filename");
+    }
+
+    try {
+      FileObject homeObject = HopVfs.getFileObject(home);
+      FileObject target = homeObject.resolveFile(resolved);
+      // Prefer a clean path string
+      FileName name = target.getName();
+      return name.getURI();
+    } catch (Exception e) {
+      // Fallback to simple join
+      String separator = home.endsWith("/") || home.endsWith("\\") ? "" : "/";
+      return home + separator + resolved;
+    }
+  }
+
+  private static String resolveActiveProjectName(IVariables variables) {
+    String projectName = HopNamespace.getNamespace();
+    if (StringUtils.isNotEmpty(projectName)) {
+      return projectName;
+    }
+    if (variables != null) {
+      projectName = variables.getVariable(Defaults.VARIABLE_HOP_PROJECT_NAME);
+      if (StringUtils.isNotEmpty(projectName)) {
+        return projectName;
+      }
+    }
+    return System.getProperty(Defaults.VARIABLE_HOP_PROJECT_NAME);
+  }
+}
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/AutoExportMetadataOnMetadataCreated.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/AutoExportMetadataOnMetadataCreated.java
new file mode 100644
index 0000000000..342e1c5a3c
--- /dev/null
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/AutoExportMetadataOnMetadataCreated.java
@@ -0,0 +1,38 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.projects.xp;
+
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.extension.ExtensionPoint;
+import org.apache.hop.core.extension.IExtensionPoint;
+import org.apache.hop.core.logging.ILogChannel;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.metadata.api.IHopMetadata;
+import org.apache.hop.projects.util.ProjectsMetadataExporter;
+
+@ExtensionPoint(
+    id = "AutoExportMetadataOnMetadataCreated",
+    description = "Auto-export project metadata after a metadata object is 
created",
+    extensionPointId = "HopGuiMetadataObjectCreated")
+public class AutoExportMetadataOnMetadataCreated implements 
IExtensionPoint<IHopMetadata> {
+  @Override
+  public void callExtensionPoint(ILogChannel log, IVariables variables, 
IHopMetadata metadata)
+      throws HopException {
+    ProjectsMetadataExporter.autoExportActiveProjectIfEnabled(log, variables);
+  }
+}
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/AutoExportMetadataOnMetadataDeleted.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/AutoExportMetadataOnMetadataDeleted.java
new file mode 100644
index 0000000000..8fa3ca22a3
--- /dev/null
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/AutoExportMetadataOnMetadataDeleted.java
@@ -0,0 +1,38 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.projects.xp;
+
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.extension.ExtensionPoint;
+import org.apache.hop.core.extension.IExtensionPoint;
+import org.apache.hop.core.logging.ILogChannel;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.metadata.api.IHopMetadata;
+import org.apache.hop.projects.util.ProjectsMetadataExporter;
+
+@ExtensionPoint(
+    id = "AutoExportMetadataOnMetadataDeleted",
+    description = "Auto-export project metadata after a metadata object is 
deleted",
+    extensionPointId = "HopGuiMetadataObjectDeleted")
+public class AutoExportMetadataOnMetadataDeleted implements 
IExtensionPoint<IHopMetadata> {
+  @Override
+  public void callExtensionPoint(ILogChannel log, IVariables variables, 
IHopMetadata metadata)
+      throws HopException {
+    ProjectsMetadataExporter.autoExportActiveProjectIfEnabled(log, variables);
+  }
+}
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/AutoExportMetadataOnMetadataUpdated.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/AutoExportMetadataOnMetadataUpdated.java
new file mode 100644
index 0000000000..7179270936
--- /dev/null
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/AutoExportMetadataOnMetadataUpdated.java
@@ -0,0 +1,38 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.projects.xp;
+
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.extension.ExtensionPoint;
+import org.apache.hop.core.extension.IExtensionPoint;
+import org.apache.hop.core.logging.ILogChannel;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.metadata.api.IHopMetadata;
+import org.apache.hop.projects.util.ProjectsMetadataExporter;
+
+@ExtensionPoint(
+    id = "AutoExportMetadataOnMetadataUpdated",
+    description = "Auto-export project metadata after a metadata object is 
updated",
+    extensionPointId = "HopGuiMetadataObjectUpdated")
+public class AutoExportMetadataOnMetadataUpdated implements 
IExtensionPoint<IHopMetadata> {
+  @Override
+  public void callExtensionPoint(ILogChannel log, IVariables variables, 
IHopMetadata metadata)
+      throws HopException {
+    ProjectsMetadataExporter.autoExportActiveProjectIfEnabled(log, variables);
+  }
+}
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/AutoExportMetadataOnProjectActivated.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/AutoExportMetadataOnProjectActivated.java
new file mode 100644
index 0000000000..c861f34d59
--- /dev/null
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/AutoExportMetadataOnProjectActivated.java
@@ -0,0 +1,42 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.projects.xp;
+
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.extension.ExtensionPoint;
+import org.apache.hop.core.extension.IExtensionPoint;
+import org.apache.hop.core.logging.ILogChannel;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.projects.util.Defaults;
+import org.apache.hop.projects.util.ProjectsMetadataExporter;
+
+/**
+ * When a project is enabled, optionally write/update the project's 
single-file metadata export
+ * (issue #3553).
+ */
+@ExtensionPoint(
+    id = "AutoExportMetadataOnProjectActivated",
+    description = "Auto-export project metadata to a single JSON file when a 
project is activated",
+    extensionPointId = Defaults.EXTENSION_POINT_PROJECT_ACTIVATED)
+public class AutoExportMetadataOnProjectActivated implements 
IExtensionPoint<Object> {
+  @Override
+  public void callExtensionPoint(ILogChannel log, IVariables variables, Object 
projectName)
+      throws HopException {
+    ProjectsMetadataExporter.autoExportActiveProjectIfEnabled(log, variables);
+  }
+}
diff --git 
a/plugins/misc/projects/src/main/resources/org/apache/hop/projects/project/messages/messages_en_US.properties
 
b/plugins/misc/projects/src/main/resources/org/apache/hop/projects/project/messages/messages_en_US.properties
index 79b0fae76d..cbe5492b94 100644
--- 
a/plugins/misc/projects/src/main/resources/org/apache/hop/projects/project/messages/messages_en_US.properties
+++ 
b/plugins/misc/projects/src/main/resources/org/apache/hop/projects/project/messages/messages_en_US.properties
@@ -40,6 +40,9 @@ ProjectDialog.Label.Description=Description
 ProjectDialog.Label.EnforceExecutionInHome=Enforce executions in project home
 ProjectDialog.Label.HomeFolder=Home folder
 ProjectDialog.Label.MetadataBaseFolder=Metadata base folder 
(HOP_METADATA_FOLDER)
+ProjectDialog.Label.AutoExportMetadata=Auto-export metadata
+ProjectDialog.Label.AutoExportMetadata.Enable=Write a single metadata JSON 
file when the project is enabled or metadata changes
+ProjectDialog.Label.AutoExportMetadataFilename=Auto-export metadata filename 
(relative to project home)
 ProjectDialog.Label.ParentProject=Parent project to inherit from
 ProjectDialog.Label.ProjectName=Name
 ProjectDialog.Label.ReadOnly=This project is read only
diff --git 
a/plugins/misc/projects/src/test/java/org/apache/hop/projects/project/ProjectTest.java
 
b/plugins/misc/projects/src/test/java/org/apache/hop/projects/project/ProjectTest.java
index 976605048e..bdcdc1003e 100644
--- 
a/plugins/misc/projects/src/test/java/org/apache/hop/projects/project/ProjectTest.java
+++ 
b/plugins/misc/projects/src/test/java/org/apache/hop/projects/project/ProjectTest.java
@@ -88,6 +88,30 @@ public class ProjectTest {
     }
   }
 
+  @Test
+  public void testAutoExportMetadataSerialization() throws Exception {
+    File tempFile = Files.createTempFile("project-config-auto-export", 
".json").toFile();
+    tempFile.deleteOnExit();
+
+    try {
+      Project project = new Project(tempFile.getAbsolutePath());
+      assertFalse(project.isAutoExportMetadata());
+      assertEquals("", project.getAutoExportMetadataFilename());
+
+      project.setAutoExportMetadata(true);
+      project.setAutoExportMetadataFilename("project-metadata.json");
+      project.saveToFile();
+
+      Project readProject = new Project(tempFile.getAbsolutePath());
+      readProject.readFromFile();
+
+      assertTrue(readProject.isAutoExportMetadata());
+      assertEquals("project-metadata.json", 
readProject.getAutoExportMetadataFilename());
+    } finally {
+      tempFile.delete();
+    }
+  }
+
   @Test
   public void testParentProjectVariablesWhenNoParent() throws Exception {
     tempRoot = Files.createTempDirectory("hop-project-no-parent");
diff --git 
a/plugins/misc/projects/src/test/java/org/apache/hop/projects/util/ProjectsMetadataExporterTest.java
 
b/plugins/misc/projects/src/test/java/org/apache/hop/projects/util/ProjectsMetadataExporterTest.java
new file mode 100644
index 0000000000..2fbc20a3d5
--- /dev/null
+++ 
b/plugins/misc/projects/src/test/java/org/apache/hop/projects/util/ProjectsMetadataExporterTest.java
@@ -0,0 +1,78 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.projects.util;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.metadata.serializer.memory.MemoryMetadataProvider;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class ProjectsMetadataExporterTest {
+
+  @TempDir Path tempDir;
+
+  @Test
+  void resolveExportFilenameRelativeToProjectHome() throws Exception {
+    Variables variables = new Variables();
+    String home = tempDir.toAbsolutePath().toString();
+    String resolved =
+        ProjectsMetadataExporter.resolveExportFilename(home, "metadata.json", 
variables);
+    assertTrue(resolved.contains("metadata.json"), "resolved path: " + 
resolved);
+    assertTrue(
+        resolved.contains(tempDir.getFileName().toString()) || 
resolved.startsWith("file:"),
+        "resolved path should include home: " + resolved);
+  }
+
+  @Test
+  void resolveExportFilenameAbsoluteUnchanged() throws Exception {
+    Variables variables = new Variables();
+    Path absolute = tempDir.resolve("out").resolve("export.json");
+    String resolved =
+        ProjectsMetadataExporter.resolveExportFilename(
+            tempDir.toString(), absolute.toString(), variables);
+    assertTrue(resolved.contains("export.json"), "resolved path: " + resolved);
+  }
+
+  @Test
+  void resolveExportFilenameUsesDefaultWhenEmpty() throws Exception {
+    Variables variables = new Variables();
+    String resolved =
+        ProjectsMetadataExporter.resolveExportFilename(tempDir.toString(), "", 
variables);
+    assertTrue(
+        resolved.endsWith(Defaults.DEFAULT_AUTO_EXPORT_METADATA_FILENAME)
+            || 
resolved.contains(Defaults.DEFAULT_AUTO_EXPORT_METADATA_FILENAME),
+        "resolved path: " + resolved);
+  }
+
+  @Test
+  void exportToFileWritesJson() throws Exception {
+    Path target = tempDir.resolve("metadata.json");
+    MemoryMetadataProvider provider = new MemoryMetadataProvider();
+    ProjectsMetadataExporter.exportToFile(provider, target.toString());
+
+    assertTrue(Files.exists(target));
+    String content = Files.readString(target, StandardCharsets.UTF_8);
+    // Empty provider still produces a JSON object
+    assertTrue(content.trim().startsWith("{"));
+  }
+}
diff --git 
a/ui/src/main/java/org/apache/hop/ui/core/metadata/MetadataManager.java 
b/ui/src/main/java/org/apache/hop/ui/core/metadata/MetadataManager.java
index 76a7e7889b..d8218ac6bf 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/metadata/MetadataManager.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/metadata/MetadataManager.java
@@ -354,6 +354,14 @@ public class MetadataManager<T extends IHopMetadata> {
     serializer.save(metadata);
     serializer.delete(oldName);
 
+    // Notify listeners (auto-export, etc.) that metadata changed via rename
+    //
+    ExtensionPointHandler.callExtensionPoint(
+        HopGui.getInstance().getLog(),
+        variables,
+        HopExtensionPoint.HopGuiMetadataObjectUpdated.id,
+        metadata);
+
     return true;
   }
 
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataPerspective.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataPerspective.java
index 3589700252..588a0504cf 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataPerspective.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataPerspective.java
@@ -31,6 +31,8 @@ import lombok.Getter;
 import org.apache.hop.core.Const;
 import org.apache.hop.core.Props;
 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.gui.plugin.GuiPlugin;
 import org.apache.hop.core.gui.plugin.GuiRegistry;
 import org.apache.hop.core.gui.plugin.key.GuiKeyboardShortcut;
@@ -662,6 +664,11 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
               IHopMetadata metadata = serializer.load(name);
               metadata.setVirtualPath(Utils.isEmpty(newVirtualPath) ? "" : 
newVirtualPath);
               serializer.save(metadata);
+              ExtensionPointHandler.callExtensionPoint(
+                  hopGui.getLog(),
+                  hopGui.getVariables(),
+                  HopExtensionPoint.HopGuiMetadataObjectUpdated.id,
+                  metadata);
               
hopGui.getEventsHandler().fire(HopGuiEvents.MetadataChanged.name());
               refresh();
             } catch (Exception e) {


Reply via email to