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 264c70ddf2 Fixes #8403 : Add a Plugin Catalog transform that exposes
the plugin … (#8408)
264c70ddf2 is described below
commit 264c70ddf23a4bd28bb53ee589c8dc38b28ea397
Author: Bart Maertens <[email protected]>
AuthorDate: Wed Sep 16 21:11:07 2026 +0200
Fixes #8403 : Add a Plugin Catalog transform that exposes the plugin …
(#8408)
* Issue #8403 : Add a Plugin Catalog transform that exposes the plugin
registry as pipeline rows
* Issue #8403 : Address review feedback on the Plugin Catalog transform
---
assemblies/plugins/pom.xml | 6 +
.../images/transforms/icons/plugincatalog.svg | 11 +
docs/hop-user-manual/modules/ROOT/nav.adoc | 1 +
.../pages/pipeline/transforms/plugincatalog.adoc | 123 ++++++++
.../transform/never-consume-at-default-plugins.txt | 1 +
.../transforms/0113-plugin-catalog.hpl | 179 ++++++++++++
.../transforms/datasets/golden-plugin-catalog.csv | 5 +
.../transforms/main-0113-plugin-catalog.hwf | 79 ++++++
.../metadata/dataset/golden-plugin-catalog.json | 39 +++
.../unit-test/0113-plugin-catalog UNIT.json | 43 +++
plugins/transforms/plugincatalog/pom.xml | 32 +++
.../plugincatalog/src/assembly/assembly.xml | 43 +++
.../transforms/plugincatalog/DetailLevel.java | 37 +++
.../transforms/plugincatalog/PluginCatalog.java | 113 ++++++++
.../plugincatalog/PluginCatalogData.java | 31 +++
.../plugincatalog/PluginCatalogDialog.java | 80 ++++++
.../plugincatalog/PluginCatalogMeta.java | 234 ++++++++++++++++
.../plugincatalog/PluginCatalogReader.java | 309 +++++++++++++++++++++
.../transforms/plugincatalog/PluginRecord.java | 42 +++
.../transforms/plugincatalog/PropertyRecord.java | 36 +++
.../messages/messages_en_US.properties | 36 +++
.../src/main/resources/plugincatalog.svg | 11 +
.../plugincatalog/src/main/resources/version.xml | 20 ++
.../samples/transforms/plugin-catalog-basic.hpl | 108 +++++++
.../io/github/thirdparty/ThirdPartyMapping.java | 31 +++
.../plugincatalog/PluginCatalogReaderTest.java | 202 ++++++++++++++
plugins/transforms/pom.xml | 1 +
27 files changed, 1853 insertions(+)
diff --git a/assemblies/plugins/pom.xml b/assemblies/plugins/pom.xml
index 0d3dfe9227..1c53b8f415 100644
--- a/assemblies/plugins/pom.xml
+++ b/assemblies/plugins/pom.xml
@@ -1284,6 +1284,12 @@
<version>${project.version}</version>
<type>zip</type>
</dependency>
+ <dependency>
+ <groupId>org.apache.hop</groupId>
+ <artifactId>hop-transform-plugincatalog</artifactId>
+ <version>${project.version}</version>
+ <type>zip</type>
+ </dependency>
<dependency>
<groupId>org.apache.hop</groupId>
<artifactId>hop-transform-processfiles</artifactId>
diff --git
a/docs/hop-user-manual/modules/ROOT/assets/images/transforms/icons/plugincatalog.svg
b/docs/hop-user-manual/modules/ROOT/assets/images/transforms/icons/plugincatalog.svg
new file mode 100644
index 0000000000..53ec8ed4b5
--- /dev/null
+++
b/docs/hop-user-manual/modules/ROOT/assets/images/transforms/icons/plugincatalog.svg
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg">
+ <rect x="3" y="3" width="8" height="8" rx="1.5" stroke="#2A5D8F"
stroke-width="1.6"/>
+ <rect x="13" y="3" width="8" height="8" rx="1.5" stroke="#2A5D8F"
stroke-width="1.6"/>
+ <rect x="3" y="13" width="8" height="8" rx="1.5" stroke="#2A5D8F"
stroke-width="1.6"/>
+ <rect x="13" y="13" width="8" height="8" rx="1.5" stroke="#7BB661"
stroke-width="1.6"/>
+ <line x1="7" y1="11" x2="7" y2="13" stroke="#2A5D8F" stroke-width="1.6"/>
+ <line x1="17" y1="11" x2="17" y2="13" stroke="#2A5D8F" stroke-width="1.6"/>
+ <line x1="11" y1="7" x2="13" y2="7" stroke="#2A5D8F" stroke-width="1.6"/>
+ <line x1="11" y1="17" x2="13" y2="17" stroke="#2A5D8F" stroke-width="1.6"/>
+</svg>
diff --git a/docs/hop-user-manual/modules/ROOT/nav.adoc
b/docs/hop-user-manual/modules/ROOT/nav.adoc
index 47c2894cbf..9952b52dca 100644
--- a/docs/hop-user-manual/modules/ROOT/nav.adoc
+++ b/docs/hop-user-manual/modules/ROOT/nav.adoc
@@ -249,6 +249,7 @@ under the License.
*** xref:pipeline/transforms/pgpdecryptstream.adoc[PGP decrypt stream]
*** xref:pipeline/transforms/pgpencryptstream.adoc[PGP encrypt stream]
*** xref:pipeline/transforms/pipeline-executor.adoc[Pipeline Executor]
+*** xref:pipeline/transforms/plugincatalog.adoc[Plugin Catalog]
*** xref:pipeline/transforms/postgresbulkloader.adoc[PostgreSQL bulk loader]
*** xref:pipeline/transforms/processfiles.adoc[Process files]
*** xref:pipeline/transforms/propertyinput.adoc[Properties file Input]
diff --git
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/plugincatalog.adoc
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/plugincatalog.adoc
new file mode 100644
index 0000000000..2e2c794a66
--- /dev/null
+++
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/plugincatalog.adoc
@@ -0,0 +1,123 @@
+////
+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.
+////
+:documentationPath: /pipeline/transforms/
+:language: en_US
+:description: The Plugin Catalog transform reads the live Hop plugin registry
and emits the installed transforms, actions and metadata types as rows.
+
+= image:transforms/icons/plugincatalog.svg[Plugin Catalog transform Icon,
role="image-doc-icon"] Plugin Catalog
+
+[%noheader,cols="3a,1a", role="table-no-borders" ]
+|===
+|
+== Description
+
+The Plugin Catalog transform reads the live Hop plugin registry and emits one
row per installed plugin: transforms, workflow actions and metadata types.
+
+For each plugin it also reflects over the plugin's metadata class and reports
the `@HopMetadataProperty` fields it accepts, so the catalog describes not just
which plugins are installed but which properties each one exposes.
+
+Because the catalog comes from the registry of the running Hop installation
rather than from a static list, it always matches the version in use and
includes any third-party or Marketplace plugins that are installed.
+
+This is a source transform: it generates rows and does not read an input
stream.
+
+|
+== Supported Engines
+[%noheader,cols="2,1a",frame=none, role="table-supported-engines"]
+!===
+!Hop Engine! image:check_mark.svg[Supported, 24]
+!Single Threaded! image:check_mark.svg[Supported, 24]
+!Native Spark! image:question_mark.svg[Maybe Supported, 24]
+!Beam Spark! image:question_mark.svg[Maybe Supported, 24]
+!Beam Flink! image:question_mark.svg[Maybe Supported, 24]
+!Beam Dataflow! image:question_mark.svg[Maybe Supported, 24]
+!===
+|===
+
+== Usage
+
+The catalog answers questions about what a given Hop installation actually
contains, for example:
+
+* generating or checking documentation against the plugins that really ship
+* auditing which plugins are installed across environments
+* supplying an AI assistant with real plugin ids and property names instead of
guessed ones
+* building migration or refactoring tooling that needs to resolve property
names
+
+== Options
+
+[options="header"]
+|===
+|Option |Description
+
+|Transform name|Name of the transform, unique within the pipeline.
+|Include transforms|Emit an entry for every registered pipeline transform
plugin.
+|Include actions|Emit an entry for every registered workflow action plugin.
+|Include metadata types|Emit an entry for every registered Hop metadata type,
such as relational database connections or run configurations.
+|Detail level|`PER_PLUGIN` emits one row per plugin with the properties as a
JSON column. `PER_PROPERTY` emits one row per property, repeating the plugin
columns.
+|===
+
+== Output fields
+
+Every row carries these plugin-level fields:
+
+[options="header"]
+|===
+|Field |Type |Description
+
+|`plugin_id`|String|Plugin id, for example `TextFileInput2` or `ABORT`.
+|`plugin_type`|String|`transform`, `action` or `metadata`.
+|`name`|String|Display name, resolved against the Hop locale in use.
+|`description`|String|Description, resolved against the Hop locale in use.
+|`category`|String|Category, resolved against the Hop locale in use.
+|`keywords`|String|Comma-separated keywords, resolved against the Hop locale
in use.
+|`class_name`|String|Fully qualified metadata or implementation class.
+|`english_aliases`|String|Comma-separated English name, category and keyword
aliases, independent of the locale in use.
+|`locale`|String|BCP-47 tag of the locale the resolved labels above came from.
+|===
+
+`PER_PLUGIN` adds one field:
+
+[options="header"]
+|===
+|Field |Type |Description
+
+|`metadata_fields`|String|JSON array of `{field, xml_key, java_type, password,
group, group_key}` objects, one per property.
+|===
+
+`PER_PROPERTY` instead adds one row per property, with:
+
+[options="header"]
+|===
+|Field |Type |Description
+
+|`property_field`|String|Java field name on the metadata class.
+|`property_xml_key`|String|Key the property is serialised under in `.hpl` and
`.hwf` files.
+|`property_java_type`|String|Simple Java type name of the field.
+|`property_password`|Boolean|`true` when the property is marked as a password
and should be redacted.
+|`property_group`|String|Name of the containing property group for a nested
property, empty for top-level properties. This is *nesting*, not the
serialization wrapper.
+|`property_group_key`|String|The `groupKey` wrapper element a list or map is
serialized under, for example `fields` in `<fields><field>…`. Empty when the
property has no wrapper.
+|===
+
+A plugin with no properties still produces one row, with the property fields
empty.
+
+== Notes
+
+`name`, `description`, `category` and `keywords` are resolved by the plugin
registry against the locale Hop is running in. When the output is stored or
indexed, use `english_aliases` for lookups that must not depend on the locale,
and `locale` to record which locale the labels came from.
+
+Property reflection descends one level into complex property groups, which
covers the tabular property groups plugins use. Deeper nesting is not reported.
Group classes are recognised by carrying `@HopMetadataProperty` fields,
whatever package they are declared in, so third-party and Marketplace plugins
are described the same way as the ones shipped with Hop.
+
+Properties marked `isExcludedFromSerialization` are left out, since they never
reach a `.hpl` or `.hwf` file.
+
+Plugins whose metadata class cannot be loaded are logged at detailed logging
level and skipped rather than failing the pipeline, so a single broken plugin
does not stop the catalog.
diff --git
a/engine/src/test/resources/org/apache/hop/pipeline/transform/never-consume-at-default-plugins.txt
b/engine/src/test/resources/org/apache/hop/pipeline/transform/never-consume-at-default-plugins.txt
index c207f9f2b9..cb0c03cb1a 100644
---
a/engine/src/test/resources/org/apache/hop/pipeline/transform/never-consume-at-default-plugins.txt
+++
b/engine/src/test/resources/org/apache/hop/pipeline/transform/never-consume-at-default-plugins.txt
@@ -57,6 +57,7 @@ MetadataInput
MongoDbInput
ODataInput
PipelineLogging
+PluginCatalog
PropertyInput
RowGenerator
RowsFromResult
diff --git a/integration-tests/transforms/0113-plugin-catalog.hpl
b/integration-tests/transforms/0113-plugin-catalog.hpl
new file mode 100644
index 0000000000..fbfc2320c2
--- /dev/null
+++ b/integration-tests/transforms/0113-plugin-catalog.hpl
@@ -0,0 +1,179 @@
+<?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.
+
+-->
+<pipeline>
+ <info>
+ <name>0113-plugin-catalog</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description>Plugin Catalog reads the live plugin registry. The golden set
is restricted to the
+ Plugin Catalog transform's own row so that installing, removing or
moving any other plugin to
+ the Marketplace cannot break this test.</description>
+ <extended_description/>
+ <pipeline_version/>
+ <pipeline_type>Normal</pipeline_type>
+ <pipeline_status>0</pipeline_status>
+ <parameters>
+ </parameters>
+ <capture_transform_performance>N</capture_transform_performance>
+
<transform_performance_capturing_delay>1000</transform_performance_capturing_delay>
+
<transform_performance_capturing_size_limit>100</transform_performance_capturing_size_limit>
+ <created_user>-</created_user>
+ <created_date>2026/09/16 08:00:00.000</created_date>
+ <modified_user>-</modified_user>
+ <modified_date>2026/09/16 08:00:00.000</modified_date>
+ <key_for_session_key/>
+ <is_key_private>N</is_key_private>
+ </info>
+ <notepads>
+ </notepads>
+ <order>
+ <hop> <from>Plugin Catalog</from> <to>Only this transform</to>
<enabled>Y</enabled> </hop>
+ <hop> <from>Only this transform</from> <to>Sort by property</to>
<enabled>Y</enabled> </hop>
+ <hop> <from>Only this transform</from> <to>DISCARD</to>
<enabled>Y</enabled> </hop>
+ <hop> <from>Sort by property</from> <to>OUTPUT</to> <enabled>Y</enabled>
</hop>
+ </order>
+ <transform>
+ <name>Plugin Catalog</name>
+ <type>PluginCatalog</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <includeTransforms>Y</includeTransforms>
+ <includeActions>N</includeActions>
+ <includeMetadataTypes>N</includeMetadataTypes>
+ <detailLevel>PER_PROPERTY</detailLevel>
+ <attributes/>
+ <GUI>
+ <xloc>96</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>Only this transform</name>
+ <type>FilterRows</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <send_true_to>Sort by property</send_true_to>
+ <send_false_to>DISCARD</send_false_to>
+ <compare>
+ <condition>
+ <negated>N</negated>
+ <leftvalue>plugin_id</leftvalue>
+ <function>=</function>
+ <rightvalue/>
+ <value>
+ <name>constant</name>
+ <type>String</type>
+ <text>PluginCatalog</text>
+ <length>-1</length>
+ <precision>-1</precision>
+ <isnull>N</isnull>
+ <mask/>
+ </value>
+ </condition>
+ </compare>
+ <attributes/>
+ <GUI>
+ <xloc>288</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>Sort by property</name>
+ <type>SortRows</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <directory>${java.io.tmpdir}</directory>
+ <prefix>out</prefix>
+ <sort_size>1000000</sort_size>
+ <free_memory/>
+ <compress>N</compress>
+ <compress_variable/>
+ <unique_rows>N</unique_rows>
+ <fields>
+ <field>
+ <name>property_xml_key</name>
+ <ascending>Y</ascending>
+ <case_sensitive>Y</case_sensitive>
+ <collator_enabled>N</collator_enabled>
+ <collator_strength>0</collator_strength>
+ <presorted>N</presorted>
+ </field>
+ </fields>
+ <attributes/>
+ <GUI>
+ <xloc>496</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>OUTPUT</name>
+ <type>Dummy</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <attributes/>
+ <GUI>
+ <xloc>688</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>DISCARD</name>
+ <type>Dummy</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <attributes/>
+ <GUI>
+ <xloc>288</xloc>
+ <yloc>224</yloc>
+ </GUI>
+ </transform>
+ <transform_error_handling>
+ </transform_error_handling>
+ <attributes/>
+</pipeline>
diff --git a/integration-tests/transforms/datasets/golden-plugin-catalog.csv
b/integration-tests/transforms/datasets/golden-plugin-catalog.csv
new file mode 100644
index 0000000000..5fa4ec4366
--- /dev/null
+++ b/integration-tests/transforms/datasets/golden-plugin-catalog.csv
@@ -0,0 +1,5 @@
+plugin_id,plugin_type,property_xml_key,property_java_type
+PluginCatalog,transform,detailLevel,DetailLevel
+PluginCatalog,transform,includeActions,boolean
+PluginCatalog,transform,includeMetadataTypes,boolean
+PluginCatalog,transform,includeTransforms,boolean
diff --git a/integration-tests/transforms/main-0113-plugin-catalog.hwf
b/integration-tests/transforms/main-0113-plugin-catalog.hwf
new file mode 100644
index 0000000000..ccb052114d
--- /dev/null
+++ b/integration-tests/transforms/main-0113-plugin-catalog.hwf
@@ -0,0 +1,79 @@
+<?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>main-0113-plugin-catalog</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description/>
+ <extended_description/>
+ <workflow_version/>
+ <created_user>-</created_user>
+ <created_date>2026/09/16 08:00:00.000</created_date>
+ <modified_user>-</modified_user>
+ <modified_date>2026/09/16 08:00:00.000</modified_date>
+ <parameters>
+ </parameters>
+ <actions>
+ <action>
+ <name>START</name>
+ <description/>
+ <type>SPECIAL</type>
+ <attributes/>
+ <repeat>N</repeat>
+ <schedulerType>0</schedulerType>
+ <intervalSeconds>0</intervalSeconds>
+ <intervalMinutes>60</intervalMinutes>
+ <hour>12</hour>
+ <minutes>0</minutes>
+ <weekDay>1</weekDay>
+ <DayOfMonth>1</DayOfMonth>
+ <parallel>N</parallel>
+ <xloc>96</xloc>
+ <yloc>80</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>0113-plugin-catalog UNIT</name>
+ <description/>
+ <type>RunPipelineTests</type>
+ <attributes/>
+ <test_names>
+ <test_name>
+ <name>0113-plugin-catalog UNIT</name>
+ </test_name>
+ </test_names>
+ <parallel>N</parallel>
+ <xloc>256</xloc>
+ <yloc>80</yloc>
+ <attributes_hac/>
+ </action>
+ </actions>
+ <hops>
+ <hop>
+ <from>START</from>
+ <to>0113-plugin-catalog UNIT</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>Y</unconditional>
+ </hop>
+ </hops>
+ <notepads>
+ </notepads>
+ <attributes/>
+</workflow>
diff --git
a/integration-tests/transforms/metadata/dataset/golden-plugin-catalog.json
b/integration-tests/transforms/metadata/dataset/golden-plugin-catalog.json
new file mode 100644
index 0000000000..8bdc2c512c
--- /dev/null
+++ b/integration-tests/transforms/metadata/dataset/golden-plugin-catalog.json
@@ -0,0 +1,39 @@
+{
+ "base_filename": "golden-plugin-catalog.csv",
+ "name": "golden-plugin-catalog",
+ "description": "Plugin Catalog output for the Plugin Catalog transform
itself. Deliberately excludes name/description/category (locale dependent) and
any other plugin (Marketplace churn).",
+ "dataset_fields": [
+ {
+ "field_comment": "",
+ "field_length": -1,
+ "field_type": 2,
+ "field_precision": -1,
+ "field_name": "plugin_id",
+ "field_format": ""
+ },
+ {
+ "field_comment": "",
+ "field_length": -1,
+ "field_type": 2,
+ "field_precision": -1,
+ "field_name": "plugin_type",
+ "field_format": ""
+ },
+ {
+ "field_comment": "",
+ "field_length": -1,
+ "field_type": 2,
+ "field_precision": -1,
+ "field_name": "property_xml_key",
+ "field_format": ""
+ },
+ {
+ "field_comment": "",
+ "field_length": -1,
+ "field_type": 2,
+ "field_precision": -1,
+ "field_name": "property_java_type",
+ "field_format": ""
+ }
+ ]
+}
diff --git
a/integration-tests/transforms/metadata/unit-test/0113-plugin-catalog UNIT.json
b/integration-tests/transforms/metadata/unit-test/0113-plugin-catalog UNIT.json
new file mode 100644
index 0000000000..c663fe46d9
--- /dev/null
+++ b/integration-tests/transforms/metadata/unit-test/0113-plugin-catalog
UNIT.json
@@ -0,0 +1,43 @@
+{
+ "variableValues": [],
+ "database_replacements": [],
+ "autoOpening": false,
+ "basePath": "",
+ "golden_data_sets": [
+ {
+ "field_mappings": [
+ {
+ "transform_field": "plugin_id",
+ "data_set_field": "plugin_id"
+ },
+ {
+ "transform_field": "plugin_type",
+ "data_set_field": "plugin_type"
+ },
+ {
+ "transform_field": "property_xml_key",
+ "data_set_field": "property_xml_key"
+ },
+ {
+ "transform_field": "property_java_type",
+ "data_set_field": "property_java_type"
+ }
+ ],
+ "field_order": [
+ "plugin_id",
+ "plugin_type",
+ "property_xml_key",
+ "property_java_type"
+ ],
+ "transform_name": "OUTPUT",
+ "data_set_name": "golden-plugin-catalog"
+ }
+ ],
+ "input_data_sets": [],
+ "name": "0113-plugin-catalog UNIT",
+ "description": "Verifies Plugin Catalog reads the live plugin registry,
resolves the metadata class and reflects its @HopMetadataProperty fields, using
the transform's own registration as the fixture so other plugins cannot affect
the result",
+ "trans_test_tweaks": [],
+ "persist_filename": "",
+ "pipeline_filename": "./0113-plugin-catalog.hpl",
+ "test_type": "UNIT_TEST"
+}
diff --git a/plugins/transforms/plugincatalog/pom.xml
b/plugins/transforms/plugincatalog/pom.xml
new file mode 100644
index 0000000000..099b9b83dd
--- /dev/null
+++ b/plugins/transforms/plugincatalog/pom.xml
@@ -0,0 +1,32 @@
+<?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.
+ ~
+ -->
+<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+
+ <parent>
+ <groupId>org.apache.hop</groupId>
+ <artifactId>hop-plugins-transforms</artifactId>
+ <version>2.20.0-SNAPSHOT</version>
+ </parent>
+
+ <artifactId>hop-transform-plugincatalog</artifactId>
+ <packaging>jar</packaging>
+ <name>Hop Plugins Transforms Plugin Catalog</name>
+
+</project>
diff --git a/plugins/transforms/plugincatalog/src/assembly/assembly.xml
b/plugins/transforms/plugincatalog/src/assembly/assembly.xml
new file mode 100644
index 0000000000..7df283f65c
--- /dev/null
+++ b/plugins/transforms/plugincatalog/src/assembly/assembly.xml
@@ -0,0 +1,43 @@
+<?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.
+ ~
+ -->
+<assembly xmlns="http://maven.apache.org/ASSEMBLY/2.2.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/ASSEMBLY/2.2.0
http://maven.apache.org/xsd/assembly-2.2.0.xsd">
+ <id>hop-transform-plugincatalog</id>
+ <formats>
+ <format>zip</format>
+ </formats>
+ <baseDirectory>.</baseDirectory>
+ <files>
+ <file>
+ <source>${project.basedir}/src/main/resources/version.xml</source>
+ <outputDirectory>${hop.plugin.libdir}</outputDirectory>
+ <filtered>true</filtered>
+ </file>
+ </files>
+ <fileSets>
+ <fileSet>
+ <directory>${project.basedir}/src/main/samples</directory>
+ <outputDirectory>config/projects/samples/</outputDirectory>
+ </fileSet>
+ </fileSets>
+ <componentDescriptors>
+
<componentDescriptor>${maven.multiModuleProjectDirectory}/assemblies/shared/hop-plugin-libs.xml</componentDescriptor>
+ </componentDescriptors>
+</assembly>
diff --git
a/plugins/transforms/plugincatalog/src/main/java/org/apache/hop/pipeline/transforms/plugincatalog/DetailLevel.java
b/plugins/transforms/plugincatalog/src/main/java/org/apache/hop/pipeline/transforms/plugincatalog/DetailLevel.java
new file mode 100644
index 0000000000..20480eefc4
--- /dev/null
+++
b/plugins/transforms/plugincatalog/src/main/java/org/apache/hop/pipeline/transforms/plugincatalog/DetailLevel.java
@@ -0,0 +1,37 @@
+/*
+ * 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.pipeline.transforms.plugincatalog;
+
+/** Output granularity for the Plugin Catalog transform. */
+public enum DetailLevel {
+ /** One row per plugin; properties are emitted as a JSON array column. */
+ PER_PLUGIN,
+ /** One row per plugin property; plugin-level columns are repeated. */
+ PER_PROPERTY;
+
+ public static DetailLevel fromString(String value) {
+ if (value == null) {
+ return PER_PLUGIN;
+ }
+ for (DetailLevel level : values()) {
+ if (level.name().equalsIgnoreCase(value.trim())) {
+ return level;
+ }
+ }
+ return PER_PLUGIN;
+ }
+}
diff --git
a/plugins/transforms/plugincatalog/src/main/java/org/apache/hop/pipeline/transforms/plugincatalog/PluginCatalog.java
b/plugins/transforms/plugincatalog/src/main/java/org/apache/hop/pipeline/transforms/plugincatalog/PluginCatalog.java
new file mode 100644
index 0000000000..4d8a62d66c
--- /dev/null
+++
b/plugins/transforms/plugincatalog/src/main/java/org/apache/hop/pipeline/transforms/plugincatalog/PluginCatalog.java
@@ -0,0 +1,113 @@
+/*
+ * 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.pipeline.transforms.plugincatalog;
+
+import java.util.List;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.row.RowMeta;
+import org.apache.hop.pipeline.Pipeline;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.transform.BaseTransform;
+import org.apache.hop.pipeline.transform.TransformMeta;
+
+/**
+ * Plugin Catalog transform - a row-generating source that reflects the live
Hop plugin registry
+ * into structured rows describing every transform, action and metadata type.
+ */
+public class PluginCatalog extends BaseTransform<PluginCatalogMeta,
PluginCatalogData> {
+
+ public PluginCatalog(
+ TransformMeta transformMeta,
+ PluginCatalogMeta meta,
+ PluginCatalogData data,
+ int copyNr,
+ PipelineMeta pipelineMeta,
+ Pipeline pipeline) {
+ super(transformMeta, meta, data, copyNr, pipelineMeta, pipeline);
+ }
+
+ @Override
+ public boolean processRow() throws HopException {
+ data.outputRowMeta = new RowMeta();
+ meta.getFields(data.outputRowMeta, getTransformName(), null, null, this,
getMetadataProvider());
+
+ PluginCatalogReader reader = new PluginCatalogReader();
+ List<PluginRecord> plugins =
+ reader.readAll(
+ meta.isIncludeTransforms(),
+ meta.isIncludeActions(),
+ meta.isIncludeMetadataTypes(),
+ (message, error) ->
+ logDetailed(error == null ? message : message + ": " +
error.getMessage()));
+
+ long rowCount = 0;
+ for (PluginRecord plugin : plugins) {
+ if (meta.getDetailLevel() == DetailLevel.PER_PROPERTY) {
+ rowCount += emitPerProperty(plugin);
+ } else {
+ emitPerPlugin(plugin);
+ rowCount++;
+ }
+ }
+
+ logBasic("Plugin Catalog emitted " + rowCount + " row(s) for " +
plugins.size() + " plugin(s)");
+ setOutputDone();
+ return false;
+ }
+
+ private void emitPerPlugin(PluginRecord plugin) throws HopException {
+ Object[] row = baseRow(plugin);
+ row[PluginCatalogMeta.BASE_FIELD_COUNT] =
+ PluginCatalogReader.propertiesToJson(plugin.properties);
+ putRow(data.outputRowMeta, row);
+ }
+
+ private long emitPerProperty(PluginRecord plugin) throws HopException {
+ if (plugin.properties.isEmpty()) {
+ // Still surface the plugin itself, with empty property columns.
+ Object[] row = baseRow(plugin);
+ putRow(data.outputRowMeta, row);
+ return 1;
+ }
+ for (PropertyRecord property : plugin.properties) {
+ Object[] row = baseRow(plugin);
+ int i = PluginCatalogMeta.BASE_FIELD_COUNT;
+ row[i] = property.field();
+ row[i + 1] = property.xmlKey();
+ row[i + 2] = property.javaType();
+ row[i + 3] = property.password();
+ row[i + 4] = property.group();
+ row[i + 5] = property.groupKey();
+ putRow(data.outputRowMeta, row);
+ }
+ return plugin.properties.size();
+ }
+
+ private Object[] baseRow(PluginRecord plugin) {
+ Object[] row = new Object[data.outputRowMeta.size()];
+ row[0] = plugin.pluginId;
+ row[1] = plugin.pluginType;
+ row[2] = plugin.name;
+ row[3] = plugin.description;
+ row[4] = plugin.category;
+ row[5] = plugin.keywords;
+ row[6] = plugin.className;
+ row[7] = plugin.englishAliases;
+ row[8] = plugin.locale;
+ return row;
+ }
+}
diff --git
a/plugins/transforms/plugincatalog/src/main/java/org/apache/hop/pipeline/transforms/plugincatalog/PluginCatalogData.java
b/plugins/transforms/plugincatalog/src/main/java/org/apache/hop/pipeline/transforms/plugincatalog/PluginCatalogData.java
new file mode 100644
index 0000000000..4bc588f168
--- /dev/null
+++
b/plugins/transforms/plugincatalog/src/main/java/org/apache/hop/pipeline/transforms/plugincatalog/PluginCatalogData.java
@@ -0,0 +1,31 @@
+/*
+ * 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.pipeline.transforms.plugincatalog;
+
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.pipeline.transform.BaseTransformData;
+import org.apache.hop.pipeline.transform.ITransformData;
+
+/** Runtime data for the Plugin Catalog transform. */
+public class PluginCatalogData extends BaseTransformData implements
ITransformData {
+
+ public IRowMeta outputRowMeta;
+
+ public PluginCatalogData() {
+ super();
+ }
+}
diff --git
a/plugins/transforms/plugincatalog/src/main/java/org/apache/hop/pipeline/transforms/plugincatalog/PluginCatalogDialog.java
b/plugins/transforms/plugincatalog/src/main/java/org/apache/hop/pipeline/transforms/plugincatalog/PluginCatalogDialog.java
new file mode 100644
index 0000000000..8a9087af13
--- /dev/null
+++
b/plugins/transforms/plugincatalog/src/main/java/org/apache/hop/pipeline/transforms/plugincatalog/PluginCatalogDialog.java
@@ -0,0 +1,80 @@
+/*
+ * 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.pipeline.transforms.plugincatalog;
+
+import org.apache.hop.core.util.Utils;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.ui.core.dialog.BaseDialog;
+import org.apache.hop.ui.core.gui.GuiCompositeWidgets;
+import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
+import org.eclipse.swt.widgets.Shell;
+
+public class PluginCatalogDialog extends BaseTransformDialog {
+
+ private static final Class<?> PKG = PluginCatalogMeta.class;
+
+ private final PluginCatalogMeta input;
+ private GuiCompositeWidgets widgets;
+
+ public PluginCatalogDialog(
+ Shell parent,
+ IVariables variables,
+ PluginCatalogMeta transformMeta,
+ PipelineMeta pipelineMeta) {
+ super(parent, variables, transformMeta, pipelineMeta);
+ input = transformMeta;
+ }
+
+ @Override
+ public String open() {
+ createShell(BaseMessages.getString(PKG,
"PluginCatalogDialog.Shell.Title"));
+ buildButtonBar().ok(e -> ok()).cancel(e -> cancel()).build();
+
+ changed = input.hasChanged();
+
+ widgets =
+ GuiCompositeWidgets.addScrolledComposite(
+ shell,
+ variables,
+ wTransformName,
+ wOk,
+ PluginCatalogMeta.GUI_PLUGIN_ELEMENT_PARENT_ID,
+ input);
+
+ focusTransformName();
+ BaseDialog.defaultShellHandling(shell, c -> ok(), c -> cancel());
+ return transformName;
+ }
+
+ private void cancel() {
+ transformName = null;
+ input.setChanged(changed);
+ dispose();
+ }
+
+ private void ok() {
+ if (Utils.isEmpty(wTransformName.getText())) {
+ return;
+ }
+ widgets.getWidgetsContents(input,
PluginCatalogMeta.GUI_PLUGIN_ELEMENT_PARENT_ID);
+ transformName = wTransformName.getText();
+ input.setChanged();
+ dispose();
+ }
+}
diff --git
a/plugins/transforms/plugincatalog/src/main/java/org/apache/hop/pipeline/transforms/plugincatalog/PluginCatalogMeta.java
b/plugins/transforms/plugincatalog/src/main/java/org/apache/hop/pipeline/transforms/plugincatalog/PluginCatalogMeta.java
new file mode 100644
index 0000000000..a435727015
--- /dev/null
+++
b/plugins/transforms/plugincatalog/src/main/java/org/apache/hop/pipeline/transforms/plugincatalog/PluginCatalogMeta.java
@@ -0,0 +1,234 @@
+/*
+ * 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.pipeline.transforms.plugincatalog;
+
+import java.util.List;
+import lombok.Getter;
+import lombok.Setter;
+import org.apache.hop.core.CheckResult;
+import org.apache.hop.core.ICheckResult;
+import org.apache.hop.core.annotations.Transform;
+import org.apache.hop.core.exception.HopTransformException;
+import org.apache.hop.core.gui.plugin.GuiElementType;
+import org.apache.hop.core.gui.plugin.GuiPlugin;
+import org.apache.hop.core.gui.plugin.GuiWidgetElement;
+import org.apache.hop.core.gui.plugin.GuiWidgetGroupType;
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.core.row.value.ValueMetaBoolean;
+import org.apache.hop.core.row.value.ValueMetaString;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.metadata.api.HopMetadataProperty;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.transform.BaseTransformMeta;
+import org.apache.hop.pipeline.transform.ITransformIOMeta;
+import org.apache.hop.pipeline.transform.TransformIOMeta;
+import org.apache.hop.pipeline.transform.TransformMeta;
+
+/**
+ * Plugin Catalog transform metadata.
+ *
+ * <p>Emits one row per plugin (or per plugin property) by reflecting over the
live Hop {@link
+ * org.apache.hop.core.plugins.PluginRegistry}. Labels are resolved to
human-readable text; output
+ * is never stale against the running Hop version or any installed third-party
plugins.
+ */
+@Getter
+@Setter
+@Transform(
+ id = "PluginCatalog",
+ image = "plugincatalog.svg",
+ name = "i18n::PluginCatalog.Name",
+ description = "i18n::PluginCatalog.Description",
+ categoryDescription =
"i18n:org.apache.hop.pipeline.transform:BaseTransform.Category.Input",
+ documentationUrl = "/pipeline/transforms/plugincatalog.html",
+ keywords = "i18n::PluginCatalog.Keywords")
+@GuiPlugin
+public class PluginCatalogMeta extends BaseTransformMeta<PluginCatalog,
PluginCatalogData> {
+ private static final Class<?> PKG = PluginCatalogMeta.class;
+ public static final String GUI_PLUGIN_ELEMENT_PARENT_ID =
"PLUGIN_CATALOG_DIALOG_OPTIONS";
+ private static final String GROUP_OPTIONS = "Options";
+
+ // Output field names shared by both detail levels.
+ public static final String FIELD_PLUGIN_ID = "plugin_id";
+ public static final String FIELD_PLUGIN_TYPE = "plugin_type";
+ public static final String FIELD_NAME = "name";
+ public static final String FIELD_DESCRIPTION = "description";
+ public static final String FIELD_CATEGORY = "category";
+ public static final String FIELD_KEYWORDS = "keywords";
+ public static final String FIELD_CLASS_NAME = "class_name";
+ public static final String FIELD_ENGLISH_ALIASES = "english_aliases";
+ public static final String FIELD_LOCALE = "locale";
+
+ /** Number of plugin-level columns emitted before the detail-level columns.
*/
+ public static final int BASE_FIELD_COUNT = 9;
+
+ // PER_PLUGIN extra.
+ public static final String FIELD_METADATA_FIELDS = "metadata_fields";
+ // PER_PROPERTY extras.
+ public static final String FIELD_PROPERTY_FIELD = "property_field";
+ public static final String FIELD_PROPERTY_XML_KEY = "property_xml_key";
+ public static final String FIELD_PROPERTY_JAVA_TYPE = "property_java_type";
+ public static final String FIELD_PROPERTY_PASSWORD = "property_password";
+ public static final String FIELD_PROPERTY_GROUP = "property_group";
+ public static final String FIELD_PROPERTY_GROUP_KEY = "property_group_key";
+
+ @GuiWidgetElement(
+ order = "0100",
+ type = GuiElementType.CHECKBOX,
+ label = "i18n::PluginCatalog.includeTransforms.Label",
+ toolTip = "i18n::PluginCatalog.includeTransforms.Tooltip",
+ parentId = GUI_PLUGIN_ELEMENT_PARENT_ID,
+ groupType = GuiWidgetGroupType.BOXES,
+ group = GROUP_OPTIONS)
+ @HopMetadataProperty(key = "includeTransforms", injectionKey =
"INCLUDE_TRANSFORMS")
+ private boolean includeTransforms = true;
+
+ @GuiWidgetElement(
+ order = "0200",
+ type = GuiElementType.CHECKBOX,
+ label = "i18n::PluginCatalog.includeActions.Label",
+ toolTip = "i18n::PluginCatalog.includeActions.Tooltip",
+ parentId = GUI_PLUGIN_ELEMENT_PARENT_ID,
+ groupType = GuiWidgetGroupType.BOXES,
+ group = GROUP_OPTIONS)
+ @HopMetadataProperty(key = "includeActions", injectionKey =
"INCLUDE_ACTIONS")
+ private boolean includeActions = true;
+
+ @GuiWidgetElement(
+ order = "0300",
+ type = GuiElementType.CHECKBOX,
+ label = "i18n::PluginCatalog.includeMetadataTypes.Label",
+ toolTip = "i18n::PluginCatalog.includeMetadataTypes.Tooltip",
+ parentId = GUI_PLUGIN_ELEMENT_PARENT_ID,
+ groupType = GuiWidgetGroupType.BOXES,
+ group = GROUP_OPTIONS)
+ @HopMetadataProperty(key = "includeMetadataTypes", injectionKey =
"INCLUDE_METADATA_TYPES")
+ private boolean includeMetadataTypes = true;
+
+ @GuiWidgetElement(
+ order = "0400",
+ type = GuiElementType.COMBO,
+ label = "i18n::PluginCatalog.detailLevel.Label",
+ toolTip = "i18n::PluginCatalog.detailLevel.Tooltip",
+ parentId = GUI_PLUGIN_ELEMENT_PARENT_ID,
+ groupType = GuiWidgetGroupType.BOXES,
+ group = GROUP_OPTIONS)
+ @HopMetadataProperty(key = "detailLevel", injectionKey = "DETAIL_LEVEL")
+ private DetailLevel detailLevel = DetailLevel.PER_PLUGIN;
+
+ public PluginCatalogMeta() {
+ super();
+ }
+
+ @Override
+ public void setDefault() {
+ includeTransforms = true;
+ includeActions = true;
+ includeMetadataTypes = true;
+ detailLevel = DetailLevel.PER_PLUGIN;
+ }
+
+ @Override
+ public void getFields(
+ IRowMeta row,
+ String origin,
+ IRowMeta[] info,
+ TransformMeta nextTransform,
+ IVariables variables,
+ IHopMetadataProvider metadataProvider)
+ throws HopTransformException {
+ try {
+ row.clear();
+ addString(row, FIELD_PLUGIN_ID, origin);
+ addString(row, FIELD_PLUGIN_TYPE, origin);
+ addString(row, FIELD_NAME, origin);
+ addString(row, FIELD_DESCRIPTION, origin);
+ addString(row, FIELD_CATEGORY, origin);
+ addString(row, FIELD_KEYWORDS, origin);
+ addString(row, FIELD_CLASS_NAME, origin);
+ addString(row, FIELD_ENGLISH_ALIASES, origin);
+ addString(row, FIELD_LOCALE, origin);
+
+ if (detailLevel == DetailLevel.PER_PROPERTY) {
+ addString(row, FIELD_PROPERTY_FIELD, origin);
+ addString(row, FIELD_PROPERTY_XML_KEY, origin);
+ addString(row, FIELD_PROPERTY_JAVA_TYPE, origin);
+ addBoolean(row, FIELD_PROPERTY_PASSWORD, origin);
+ addString(row, FIELD_PROPERTY_GROUP, origin);
+ addString(row, FIELD_PROPERTY_GROUP_KEY, origin);
+ } else {
+ addString(row, FIELD_METADATA_FIELDS, origin);
+ }
+ } catch (Exception e) {
+ throw new HopTransformException("Error creating Plugin Catalog output
fields", e);
+ }
+ }
+
+ @Override
+ public void check(
+ List<ICheckResult> remarks,
+ PipelineMeta pipelineMeta,
+ TransformMeta transformMeta,
+ IRowMeta prev,
+ String[] input,
+ String[] output,
+ IRowMeta info,
+ IVariables variables,
+ IHopMetadataProvider metadataProvider) {
+ if (prev != null && !prev.isEmpty()) {
+ remarks.add(
+ new CheckResult(
+ ICheckResult.TYPE_RESULT_ERROR,
+ BaseMessages.getString(PKG,
"PluginCatalogMeta.CheckResult.NoInputStreamsError"),
+ transformMeta));
+ } else {
+ remarks.add(
+ new CheckResult(
+ ICheckResult.TYPE_RESULT_OK,
+ BaseMessages.getString(PKG,
"PluginCatalogMeta.CheckResult.NoInputStreamOk"),
+ transformMeta));
+ }
+ }
+
+ @Override
+ public ITransformIOMeta getTransformIOMeta() {
+ return new TransformIOMeta(false, true, false, false, false, false);
+ }
+
+ @Override
+ public boolean consumesMainInput() {
+ return false;
+ }
+
+ @Override
+ public boolean canStartWithoutInput() {
+ return true;
+ }
+
+ private static void addString(IRowMeta row, String name, String origin) {
+ ValueMetaString field = new ValueMetaString(name);
+ field.setOrigin(origin);
+ row.addValueMeta(field);
+ }
+
+ private static void addBoolean(IRowMeta row, String name, String origin) {
+ ValueMetaBoolean field = new ValueMetaBoolean(name);
+ field.setOrigin(origin);
+ row.addValueMeta(field);
+ }
+}
diff --git
a/plugins/transforms/plugincatalog/src/main/java/org/apache/hop/pipeline/transforms/plugincatalog/PluginCatalogReader.java
b/plugins/transforms/plugincatalog/src/main/java/org/apache/hop/pipeline/transforms/plugincatalog/PluginCatalogReader.java
new file mode 100644
index 0000000000..0e82049c2a
--- /dev/null
+++
b/plugins/transforms/plugincatalog/src/main/java/org/apache/hop/pipeline/transforms/plugincatalog/PluginCatalogReader.java
@@ -0,0 +1,309 @@
+/*
+ * 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.pipeline.transforms.plugincatalog;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.function.BiConsumer;
+import org.apache.hop.core.plugins.ActionPluginType;
+import org.apache.hop.core.plugins.IPlugin;
+import org.apache.hop.core.plugins.PluginRegistry;
+import org.apache.hop.core.plugins.TransformPluginType;
+import org.apache.hop.i18n.LanguageChoice;
+import org.apache.hop.metadata.api.HopMetadataProperty;
+import org.apache.hop.metadata.plugin.MetadataPluginType;
+
+/**
+ * Reads the live {@link PluginRegistry} and reflects over each plugin's
metadata class to expose a
+ * structured, never-stale catalog of transforms, actions and metadata types.
+ *
+ * <p>Field extraction is split out as a pure static method so it can be
unit-tested against fixture
+ * classes without bootstrapping a Hop plugin registry.
+ */
+public class PluginCatalogReader {
+
+ /** Maximum nesting depth when descending into complex {@code
@HopMetadataProperty} groups. */
+ private static final int MAX_NESTING_DEPTH = 1;
+
+ private final PluginRegistry registry;
+
+ public PluginCatalogReader() {
+ this(PluginRegistry.getInstance());
+ }
+
+ public PluginCatalogReader(PluginRegistry registry) {
+ this.registry = registry;
+ }
+
+ /**
+ * Enumerate the requested plugin families.
+ *
+ * @param warnings optional sink for non-fatal problems (e.g. a metadata
class that fails to
+ * load); may be {@code null}
+ */
+ public List<PluginRecord> readAll(
+ boolean includeTransforms,
+ boolean includeActions,
+ boolean includeMetadataTypes,
+ BiConsumer<String, Throwable> warnings) {
+ List<PluginRecord> out = new ArrayList<>();
+ if (includeTransforms) {
+ addFamily(registry.getPlugins(TransformPluginType.class), "transform",
out, warnings);
+ }
+ if (includeActions) {
+ addFamily(registry.getPlugins(ActionPluginType.class), "action", out,
warnings);
+ }
+ if (includeMetadataTypes) {
+ addFamily(registry.getPlugins(MetadataPluginType.class), "metadata",
out, warnings);
+ }
+ // Labels are locale-resolved, so record which locale produced them.
+ String localeTag = currentLocaleTag();
+ for (PluginRecord record : out) {
+ record.locale = localeTag;
+ }
+ return out;
+ }
+
+ private void addFamily(
+ List<IPlugin> plugins,
+ String pluginType,
+ List<PluginRecord> out,
+ BiConsumer<String, Throwable> warnings) {
+ if (plugins == null) {
+ return;
+ }
+ for (IPlugin plugin : plugins) {
+ String id = firstId(plugin);
+ if (id.isEmpty()) {
+ // Skip anonymous/unregistered plugins rather than emit a junk row
with no id.
+ if (warnings != null) {
+ warnings.accept("Skipping " + pluginType + " plugin with no id",
null);
+ }
+ continue;
+ }
+ PluginRecord record = new PluginRecord();
+ record.pluginId = id;
+ record.pluginType = pluginType;
+ record.name = clean(plugin.getName());
+ record.description = clean(plugin.getDescription());
+ record.category = clean(plugin.getCategory());
+ record.keywords = joinKeywords(plugin.getKeywords());
+ record.englishAliases = joinKeywords(plugin.getEnglishKeywords());
+ String className = resolveClassName(plugin);
+ record.className = className == null ? "" : className;
+ if (className != null) {
+ try {
+ ClassLoader classLoader = registry.getClassLoader(plugin);
+ Class<?> clazz = classLoader.loadClass(className);
+ record.properties = extractProperties(clazz);
+ } catch (Exception | LinkageError e) {
+ if (warnings != null) {
+ warnings.accept("Could not reflect properties for plugin '" +
record.pluginId + "'", e);
+ }
+ }
+ }
+ out.add(record);
+ }
+ }
+
+ /**
+ * Extract every {@code @HopMetadataProperty} field declared on {@code
clazz} (including inherited
+ * fields), descending one level into complex property groups.
+ */
+ public static List<PropertyRecord> extractProperties(Class<?> clazz) {
+ List<PropertyRecord> out = new ArrayList<>();
+ collect(clazz, "", out, 0);
+ return out;
+ }
+
+ private static void collect(Class<?> clazz, String group,
List<PropertyRecord> out, int depth) {
+ if (clazz == null) {
+ return;
+ }
+ for (Class<?> c = clazz; c != null && c != Object.class; c =
c.getSuperclass()) {
+ for (Field field : c.getDeclaredFields()) {
+ HopMetadataProperty annotation =
field.getAnnotation(HopMetadataProperty.class);
+ if (annotation == null) {
+ continue;
+ }
+ if (annotation.isExcludedFromSerialization()) {
+ continue;
+ }
+ String xmlKey = annotation.key().isEmpty() ? field.getName() :
annotation.key();
+ out.add(
+ new PropertyRecord(
+ field.getName(),
+ xmlKey,
+ field.getType().getSimpleName(),
+ annotation.password(),
+ group,
+ annotation.groupKey()));
+ if (depth < MAX_NESTING_DEPTH) {
+ Class<?> nested = complexHopType(field, annotation);
+ if (nested != null) {
+ collect(nested, xmlKey, out, depth + 1);
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * If {@code field} is a value class (or a collection of one) that itself
carries
+ * {@code @HopMetadataProperty} fields, return that class; otherwise {@code
null}.
+ *
+ * <p>Deliberately not restricted to {@code org.apache.hop}: a third-party
or Marketplace plugin
+ * declaring its own group class is exactly the case this catalog exists to
describe.
+ */
+ private static Class<?> complexHopType(Field field, HopMetadataProperty
annotation) {
+ Class<?> candidate;
+ if (Collection.class.isAssignableFrom(field.getType())) {
+ // An annotated raw List carries its element type on the annotation, as
XmlMetadataUtil reads
+ // it.
+ candidate =
+ annotation.listItemClass() != Object.class
+ ? annotation.listItemClass()
+ : collectionElementType(field);
+ } else {
+ candidate = field.getType();
+ }
+ if (candidate == null
+ || candidate.isEnum()
+ || candidate.isPrimitive()
+ || candidate.isArray()
+ || isPlatformClass(candidate)) {
+ return null;
+ }
+ for (Class<?> c = candidate; c != null && c != Object.class; c =
c.getSuperclass()) {
+ for (Field f : c.getDeclaredFields()) {
+ if (f.isAnnotationPresent(HopMetadataProperty.class)) {
+ return candidate;
+ }
+ }
+ }
+ return null;
+ }
+
+ /** JDK types can never carry Hop annotations, so skip the hierarchy walk
for them. */
+ private static boolean isPlatformClass(Class<?> clazz) {
+ String name = clazz.getName();
+ return name.startsWith("java.") || name.startsWith("javax.") ||
name.startsWith("jakarta.");
+ }
+
+ private static Class<?> collectionElementType(Field field) {
+ Type generic = field.getGenericType();
+ if (generic instanceof ParameterizedType parameterized) {
+ Type[] args = parameterized.getActualTypeArguments();
+ if (args.length == 1 && args[0] instanceof Class<?> elementType) {
+ return elementType;
+ }
+ }
+ return null;
+ }
+
+ private String resolveClassName(IPlugin plugin) {
+ Map<Class<?>, String> classMap = plugin.getClassMap();
+ if (classMap == null || classMap.isEmpty()) {
+ return null;
+ }
+ String className = plugin.getMainType() == null ? null :
classMap.get(plugin.getMainType());
+ if (className == null) {
+ className = classMap.values().iterator().next();
+ }
+ return className;
+ }
+
+ /** The Hop locale that plugin labels were resolved against, as a BCP-47
tag. */
+ static String currentLocaleTag() {
+ Locale locale;
+ try {
+ locale = LanguageChoice.getInstance().getDefaultLocale();
+ } catch (Exception | LinkageError e) {
+ locale = Locale.getDefault();
+ }
+ return locale == null ? "" : locale.toLanguageTag();
+ }
+
+ private static String firstId(IPlugin plugin) {
+ String[] ids = plugin.getIds();
+ return (ids != null && ids.length > 0) ? ids[0] : "";
+ }
+
+ private static String joinKeywords(String[] keywords) {
+ return keywords == null ? "" : String.join(",", keywords);
+ }
+
+ private static String clean(String value) {
+ return value == null ? "" : value;
+ }
+
+ /** Serialize a property list to a compact JSON array string
(dependency-free). */
+ public static String propertiesToJson(List<PropertyRecord> properties) {
+ StringBuilder sb = new StringBuilder("[");
+ for (int i = 0; i < properties.size(); i++) {
+ PropertyRecord p = properties.get(i);
+ if (i > 0) {
+ sb.append(',');
+ }
+ sb.append("{\"field\":")
+ .append(quote(p.field()))
+ .append(",\"xml_key\":")
+ .append(quote(p.xmlKey()))
+ .append(",\"java_type\":")
+ .append(quote(p.javaType()))
+ .append(",\"password\":")
+ .append(p.password())
+ .append(",\"group\":")
+ .append(quote(p.group()))
+ .append(",\"group_key\":")
+ .append(quote(p.groupKey()))
+ .append('}');
+ }
+ return sb.append(']').toString();
+ }
+
+ private static String quote(String value) {
+ if (value == null) {
+ return "null";
+ }
+ StringBuilder sb = new StringBuilder("\"");
+ for (int i = 0; i < value.length(); i++) {
+ char c = value.charAt(i);
+ switch (c) {
+ case '"' -> sb.append("\\\"");
+ case '\\' -> sb.append("\\\\");
+ case '\n' -> sb.append("\\n");
+ case '\r' -> sb.append("\\r");
+ case '\t' -> sb.append("\\t");
+ default -> {
+ if (c < 0x20) {
+ sb.append(String.format("\\u%04x", (int) c));
+ } else {
+ sb.append(c);
+ }
+ }
+ }
+ }
+ return sb.append('"').toString();
+ }
+}
diff --git
a/plugins/transforms/plugincatalog/src/main/java/org/apache/hop/pipeline/transforms/plugincatalog/PluginRecord.java
b/plugins/transforms/plugincatalog/src/main/java/org/apache/hop/pipeline/transforms/plugincatalog/PluginRecord.java
new file mode 100644
index 0000000000..52a94a773a
--- /dev/null
+++
b/plugins/transforms/plugincatalog/src/main/java/org/apache/hop/pipeline/transforms/plugincatalog/PluginRecord.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.pipeline.transforms.plugincatalog;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/** One catalogued plugin (transform, action or metadata type) with its
resolved labels. */
+public class PluginRecord {
+ public String pluginId = "";
+
+ /** {@code transform}, {@code action} or {@code metadata}. */
+ public String pluginType = "";
+
+ public String name = "";
+ public String description = "";
+ public String category = "";
+ public String keywords = "";
+ public String className = "";
+
+ /** Locale-independent search aliases (English name, category and keywords).
*/
+ public String englishAliases = "";
+
+ /** BCP-47 tag of the Hop locale the labels above were resolved against. */
+ public String locale = "";
+
+ public List<PropertyRecord> properties = new ArrayList<>();
+}
diff --git
a/plugins/transforms/plugincatalog/src/main/java/org/apache/hop/pipeline/transforms/plugincatalog/PropertyRecord.java
b/plugins/transforms/plugincatalog/src/main/java/org/apache/hop/pipeline/transforms/plugincatalog/PropertyRecord.java
new file mode 100644
index 0000000000..fce8a9c050
--- /dev/null
+++
b/plugins/transforms/plugincatalog/src/main/java/org/apache/hop/pipeline/transforms/plugincatalog/PropertyRecord.java
@@ -0,0 +1,36 @@
+/*
+ * 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.pipeline.transforms.plugincatalog;
+
+/**
+ * A single {@code @HopMetadataProperty} field discovered on a plugin's
metadata class.
+ *
+ * @param field the Java field name
+ * @param xmlKey the serialization key ({@code @HopMetadataProperty.key()}, or
the field name)
+ * @param javaType the simple name of the field's Java type
+ * @param password whether the property holds a sensitive value
+ * @param group the parent property key when this field belongs to a nested
group, otherwise empty
+ * @param groupKey the {@code @HopMetadataProperty.groupKey()} wrapper element
a list or map is
+ * serialized under, otherwise empty. Distinct from {@code group}, which
is nesting.
+ */
+public record PropertyRecord(
+ String field,
+ String xmlKey,
+ String javaType,
+ boolean password,
+ String group,
+ String groupKey) {}
diff --git
a/plugins/transforms/plugincatalog/src/main/resources/org/apache/hop/pipeline/transforms/plugincatalog/messages/messages_en_US.properties
b/plugins/transforms/plugincatalog/src/main/resources/org/apache/hop/pipeline/transforms/plugincatalog/messages/messages_en_US.properties
new file mode 100644
index 0000000000..d430749a6d
--- /dev/null
+++
b/plugins/transforms/plugincatalog/src/main/resources/org/apache/hop/pipeline/transforms/plugincatalog/messages/messages_en_US.properties
@@ -0,0 +1,36 @@
+#
+# 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.
+#
+PluginCatalogDialog.Shell.Title=Plugin Catalog
+
+PluginCatalog.Name=Plugin Catalog
+PluginCatalog.Description=Reflect the live Hop plugin registry into rows
(transforms, actions, metadata types) with resolved labels and property schemas
+PluginCatalog.Keywords=plugin,catalog,registry,transform,action,metadata,reflection,reference,RAG,documentation
+
+PluginCatalog.includeTransforms.Label=Include transforms
+PluginCatalog.includeTransforms.Tooltip=Emit one entry per registered
transform plugin
+
+PluginCatalog.includeActions.Label=Include actions
+PluginCatalog.includeActions.Tooltip=Emit one entry per registered workflow
action plugin
+
+PluginCatalog.includeMetadataTypes.Label=Include metadata types
+PluginCatalog.includeMetadataTypes.Tooltip=Emit one entry per registered Hop
metadata type (connections, run configurations, ...)
+
+PluginCatalog.detailLevel.Label=Detail level
+PluginCatalog.detailLevel.Tooltip=PER_PLUGIN: one row per plugin with
properties as a JSON column. PER_PROPERTY: one row per property with plugin
columns repeated.
+
+PluginCatalogMeta.CheckResult.NoInputStreamsError=This transform reads the
plugin registry and does not consume input rows. Remove the incoming hop.
+PluginCatalogMeta.CheckResult.NoInputStreamOk=No input streams, which is
correct for a transform that reads the plugin registry.
diff --git
a/plugins/transforms/plugincatalog/src/main/resources/plugincatalog.svg
b/plugins/transforms/plugincatalog/src/main/resources/plugincatalog.svg
new file mode 100644
index 0000000000..53ec8ed4b5
--- /dev/null
+++ b/plugins/transforms/plugincatalog/src/main/resources/plugincatalog.svg
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg">
+ <rect x="3" y="3" width="8" height="8" rx="1.5" stroke="#2A5D8F"
stroke-width="1.6"/>
+ <rect x="13" y="3" width="8" height="8" rx="1.5" stroke="#2A5D8F"
stroke-width="1.6"/>
+ <rect x="3" y="13" width="8" height="8" rx="1.5" stroke="#2A5D8F"
stroke-width="1.6"/>
+ <rect x="13" y="13" width="8" height="8" rx="1.5" stroke="#7BB661"
stroke-width="1.6"/>
+ <line x1="7" y1="11" x2="7" y2="13" stroke="#2A5D8F" stroke-width="1.6"/>
+ <line x1="17" y1="11" x2="17" y2="13" stroke="#2A5D8F" stroke-width="1.6"/>
+ <line x1="11" y1="7" x2="13" y2="7" stroke="#2A5D8F" stroke-width="1.6"/>
+ <line x1="11" y1="17" x2="13" y2="17" stroke="#2A5D8F" stroke-width="1.6"/>
+</svg>
diff --git a/plugins/transforms/plugincatalog/src/main/resources/version.xml
b/plugins/transforms/plugincatalog/src/main/resources/version.xml
new file mode 100644
index 0000000000..6be576acae
--- /dev/null
+++ b/plugins/transforms/plugincatalog/src/main/resources/version.xml
@@ -0,0 +1,20 @@
+<?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.
+ ~
+ -->
+
+<version>${project.version}</version>
\ No newline at end of file
diff --git
a/plugins/transforms/plugincatalog/src/main/samples/transforms/plugin-catalog-basic.hpl
b/plugins/transforms/plugincatalog/src/main/samples/transforms/plugin-catalog-basic.hpl
new file mode 100644
index 0000000000..7e6d9b3c06
--- /dev/null
+++
b/plugins/transforms/plugincatalog/src/main/samples/transforms/plugin-catalog-basic.hpl
@@ -0,0 +1,108 @@
+<?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.
+
+-->
+<pipeline>
+ <info>
+ <name>plugin-catalog-basic</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description>Dump the live Hop plugin registry (transforms, actions,
metadata types) into rows using the Plugin Catalog transform. Run and preview
the Dummy transform.</description>
+ <extended_description/>
+ <pipeline_version/>
+ <pipeline_type>Normal</pipeline_type>
+ <parameters>
+ </parameters>
+ <capture_transform_performance>N</capture_transform_performance>
+
<transform_performance_capturing_delay>1000</transform_performance_capturing_delay>
+
<transform_performance_capturing_size_limit>100</transform_performance_capturing_size_limit>
+ <created_user>-</created_user>
+ <created_date>2026/06/20 12:00:00.000</created_date>
+ <modified_user>-</modified_user>
+ <modified_date>2026/06/20 12:00:00.000</modified_date>
+ </info>
+ <notepads>
+ <notepad>
+ <note>Plugin Catalog sample: the Plugin Catalog transform needs no
input. It reflects the running Hop installation's plugin registry and emits one
row per plugin (PER_PLUGIN) with a metadata_fields JSON column, or one row per
property (PER_PROPERTY). Switch detailLevel in the transform dialog and
re-run.</note>
+ <xloc>32</xloc>
+ <yloc>32</yloc>
+ <width>520</width>
+ <height>80</height>
+ <fontname>Sans</fontname>
+ <fontsize>9</fontsize>
+ <fontbold>N</fontbold>
+ <fontitalic>N</fontitalic>
+ <fontcolorred>14</fontcolorred>
+ <fontcolorgreen>58</fontcolorgreen>
+ <fontcolorblue>90</fontcolorblue>
+ <backgroundcolorred>201</backgroundcolorred>
+ <backgroundcolorgreen>232</backgroundcolorgreen>
+ <backgroundcolorblue>251</backgroundcolorblue>
+ <bordercolorred>14</bordercolorred>
+ <bordercolorgreen>58</bordercolorgreen>
+ <bordercolorblue>90</bordercolorblue>
+ </notepad>
+ </notepads>
+ <order>
+ <hop>
+ <from>Plugin Catalog</from>
+ <to>Catalog</to>
+ <enabled>Y</enabled>
+ </hop>
+ </order>
+ <transform>
+ <name>Plugin Catalog</name>
+ <type>PluginCatalog</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <includeTransforms>Y</includeTransforms>
+ <includeActions>Y</includeActions>
+ <includeMetadataTypes>Y</includeMetadataTypes>
+ <detailLevel>PER_PLUGIN</detailLevel>
+ <attributes/>
+ <GUI>
+ <xloc>176</xloc>
+ <yloc>176</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>Catalog</name>
+ <type>Dummy</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <attributes/>
+ <GUI>
+ <xloc>368</xloc>
+ <yloc>176</yloc>
+ </GUI>
+ </transform>
+ <transform_error_handling>
+ </transform_error_handling>
+ <attributes/>
+</pipeline>
diff --git
a/plugins/transforms/plugincatalog/src/test/java/io/github/thirdparty/ThirdPartyMapping.java
b/plugins/transforms/plugincatalog/src/test/java/io/github/thirdparty/ThirdPartyMapping.java
new file mode 100644
index 0000000000..bc3ef8ab63
--- /dev/null
+++
b/plugins/transforms/plugincatalog/src/test/java/io/github/thirdparty/ThirdPartyMapping.java
@@ -0,0 +1,31 @@
+/*
+ * 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 io.github.thirdparty;
+
+import org.apache.hop.metadata.api.HopMetadataProperty;
+
+/**
+ * A property group declared outside {@code org.apache.hop}, standing in for a
third-party or
+ * Marketplace plugin. The catalog must descend into it like any other.
+ */
+public class ThirdPartyMapping {
+ @HopMetadataProperty(key = "source")
+ private String source;
+
+ @HopMetadataProperty(key = "target")
+ private String target;
+}
diff --git
a/plugins/transforms/plugincatalog/src/test/java/org/apache/hop/pipeline/transforms/plugincatalog/PluginCatalogReaderTest.java
b/plugins/transforms/plugincatalog/src/test/java/org/apache/hop/pipeline/transforms/plugincatalog/PluginCatalogReaderTest.java
new file mode 100644
index 0000000000..a1be89a516
--- /dev/null
+++
b/plugins/transforms/plugincatalog/src/test/java/org/apache/hop/pipeline/transforms/plugincatalog/PluginCatalogReaderTest.java
@@ -0,0 +1,202 @@
+/*
+ * 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.pipeline.transforms.plugincatalog;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import io.github.thirdparty.ThirdPartyMapping;
+import java.util.List;
+import java.util.Optional;
+import org.apache.hop.metadata.api.HopMetadataProperty;
+import org.junit.jupiter.api.Test;
+
+class PluginCatalogReaderTest {
+
+ /** Fixture nested group element. */
+ static class Mapping {
+ @HopMetadataProperty(key = "source")
+ private String source;
+
+ @HopMetadataProperty(key = "target")
+ private String target;
+ }
+
+ /** Fixture metadata class with a password, a default-keyed field, and a
nested list group. */
+ static class SampleMeta {
+ @HopMetadataProperty(key = "message")
+ private String messageField;
+
+ @HopMetadataProperty(key = "secret", password = true)
+ private String secret;
+
+ // No key() -> xml key falls back to the field name.
+ @HopMetadataProperty private int batchSize;
+
+ @HopMetadataProperty(key = "mappings")
+ private List<Mapping> mappings;
+ }
+
+ static class ChildMeta extends SampleMeta {
+ @HopMetadataProperty(key = "extra")
+ private String extra;
+ }
+
+ /** Stands in for a plugin whose group class lives outside org.apache.hop. */
+ static class ThirdPartyMeta {
+ @HopMetadataProperty(key = "mappings")
+ private List<ThirdPartyMapping> mappings;
+ }
+
+ /** A raw list: the element type is only discoverable through
listItemClass(). */
+ static class RawListMeta {
+ @HopMetadataProperty(key = "mappings", listItemClass =
ThirdPartyMapping.class)
+ private List mappings;
+ }
+
+ /** Carries a groupKey wrapper and a property that is never serialized. */
+ static class GroupKeyMeta {
+ @HopMetadataProperty(key = "field", groupKey = "fields")
+ private List<Mapping> fields;
+
+ @HopMetadataProperty(key = "runtimeOnly", isExcludedFromSerialization =
true)
+ private String runtimeOnly;
+ }
+
+ private static PropertyRecord byXmlKey(List<PropertyRecord> props, String
key) {
+ Optional<PropertyRecord> found = props.stream().filter(p ->
p.xmlKey().equals(key)).findFirst();
+ assertTrue(found.isPresent(), "expected property with xml_key=" + key);
+ return found.get();
+ }
+
+ @Test
+ void extractsAnnotatedFieldsWithKeyFallback() {
+ List<PropertyRecord> props =
PluginCatalogReader.extractProperties(SampleMeta.class);
+
+ PropertyRecord message = byXmlKey(props, "message");
+ assertEquals("messageField", message.field());
+ assertEquals("String", message.javaType());
+ assertEquals("", message.group());
+ assertEquals(false, message.password());
+
+ // Field without key() uses the Java field name as xml key.
+ PropertyRecord batch = byXmlKey(props, "batchSize");
+ assertEquals("int", batch.javaType());
+ }
+
+ @Test
+ void detectsPasswordProperties() {
+ List<PropertyRecord> props =
PluginCatalogReader.extractProperties(SampleMeta.class);
+ assertTrue(byXmlKey(props, "secret").password());
+ }
+
+ @Test
+ void descendsOneLevelIntoNestedGroups() {
+ List<PropertyRecord> props =
PluginCatalogReader.extractProperties(SampleMeta.class);
+
+ // The list field itself is present...
+ assertNotNull(byXmlKey(props, "mappings"));
+ // ...and its element's properties are tagged with the parent key as their
group.
+ PropertyRecord source = byXmlKey(props, "source");
+ assertEquals("mappings", source.group());
+ assertEquals("target", byXmlKey(props, "target").xmlKey());
+ }
+
+ @Test
+ void includesInheritedFields() {
+ List<PropertyRecord> props =
PluginCatalogReader.extractProperties(ChildMeta.class);
+ assertNotNull(byXmlKey(props, "extra"));
+ assertNotNull(byXmlKey(props, "message"));
+ }
+
+ @Test
+ void serializesPropertiesToJson() {
+ List<PropertyRecord> props =
+ List.of(new PropertyRecord("secret", "secret", "String", true, "auth",
"credentials"));
+ String json = PluginCatalogReader.propertiesToJson(props);
+ assertEquals(
+
"[{\"field\":\"secret\",\"xml_key\":\"secret\",\"java_type\":\"String\","
+ +
"\"password\":true,\"group\":\"auth\",\"group_key\":\"credentials\"}]",
+ json);
+ }
+
+ @Test
+ void jsonEscapesSpecialCharacters() {
+ List<PropertyRecord> props = List.of(new PropertyRecord("a\"b", "x",
"String", false, "", ""));
+ String json = PluginCatalogReader.propertiesToJson(props);
+ assertTrue(json.contains("a\\\"b"), "quotes should be escaped: " + json);
+ }
+
+ @Test
+ void emptyPropertyListSerializesToEmptyArray() {
+ assertEquals("[]", PluginCatalogReader.propertiesToJson(List.of()));
+ }
+
+ @Test
+ void jsonQuoterIsNullSafe() {
+ // null fields must not blow up serialization.
+ String json =
+ PluginCatalogReader.propertiesToJson(
+ List.of(new PropertyRecord(null, "k", null, false, null, null)));
+ assertTrue(json.contains("\"field\":null"), json);
+ assertTrue(json.contains("\"java_type\":null"), json);
+ }
+
+ @Test
+ void reportsAResolvableLocaleTag() {
+ String tag = PluginCatalogReader.currentLocaleTag();
+ // Deliberately not asserting a specific locale: this must hold on any
machine, and the point
+ // of the column is that consumers can tell which locale the labels came
from.
+ assertNotNull(tag);
+ assertTrue(!tag.isBlank(), "locale tag should not be blank");
+ assertEquals(tag, java.util.Locale.forLanguageTag(tag).toLanguageTag());
+ }
+
+ @Test
+ void descendsIntoGroupTypesOutsideTheHopPackage() {
+ List<PropertyRecord> props =
PluginCatalogReader.extractProperties(ThirdPartyMeta.class);
+ assertNotNull(byXmlKey(props, "mappings"));
+ assertEquals("mappings", byXmlKey(props, "source").group());
+ assertEquals("mappings", byXmlKey(props, "target").group());
+ }
+
+ @Test
+ void resolvesRawListElementTypeFromListItemClass() {
+ List<PropertyRecord> props =
PluginCatalogReader.extractProperties(RawListMeta.class);
+ assertNotNull(byXmlKey(props, "source"));
+ assertNotNull(byXmlKey(props, "target"));
+ }
+
+ @Test
+ void recordsTheGroupKeyWrapper() {
+ List<PropertyRecord> props =
PluginCatalogReader.extractProperties(GroupKeyMeta.class);
+ assertEquals("fields", byXmlKey(props, "field").groupKey());
+ // A plain property has no wrapper.
+ assertEquals(
+ "",
+ byXmlKey(PluginCatalogReader.extractProperties(SampleMeta.class),
"message").groupKey());
+ }
+
+ @Test
+ void skipsPropertiesExcludedFromSerialization() {
+ List<PropertyRecord> props =
PluginCatalogReader.extractProperties(GroupKeyMeta.class);
+ assertTrue(
+ props.stream().noneMatch(r -> "runtimeOnly".equals(r.xmlKey())),
+ "isExcludedFromSerialization properties must not be catalogued");
+ }
+}
diff --git a/plugins/transforms/pom.xml b/plugins/transforms/pom.xml
index 240b5e594d..82f4740e66 100644
--- a/plugins/transforms/pom.xml
+++ b/plugins/transforms/pom.xml
@@ -117,6 +117,7 @@
<module>odata</module>
<module>pgp</module>
<module>pipelineexecutor</module>
+ <module>plugincatalog</module>
<module>processfiles</module>
<module>propertyinput</module>
<module>propertyoutput</module>