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) {