From d9b13f10849dd45bbecad74651cd34660fc63cbf Mon Sep 17 00:00:00 2001 From: salatv-ai Date: Thu, 21 May 2026 18:02:06 +0200 Subject: [PATCH 1/2] utils schema pass throug --- .../SchemaPassThrough/SchemaPassThrough.js | 108 ++++++++++++++++++ .../controls/SchemaPassThrough/component.json | 54 +++++++++ src/appmixer/utils/controls/bundle.json | 5 +- 3 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 src/appmixer/utils/controls/SchemaPassThrough/SchemaPassThrough.js create mode 100644 src/appmixer/utils/controls/SchemaPassThrough/component.json diff --git a/src/appmixer/utils/controls/SchemaPassThrough/SchemaPassThrough.js b/src/appmixer/utils/controls/SchemaPassThrough/SchemaPassThrough.js new file mode 100644 index 0000000000..7ec3d53227 --- /dev/null +++ b/src/appmixer/utils/controls/SchemaPassThrough/SchemaPassThrough.js @@ -0,0 +1,108 @@ +'use strict'; + +const SCHEMA_TYPES = ['object', 'array', 'string', 'number', 'integer', 'boolean', 'null']; + +module.exports = { + + async receive(context) { + + const { schema: schemaString, data } = context.messages.in.content; + + if (context.properties.generateOutputPortOptions) { + return this.generateOutputPortSchema(context, schemaString); + } + + let output; + if (data !== undefined && data !== null && data !== '') { + output = typeof data === 'string' ? this.tryParseJson(data) : data; + } else { + // No data wired — fall back to the example JSON from the schema field if it + // wasn't a JSON Schema. Handy for testing the flow before upstream is wired. + const parsed = this.tryParseJson(schemaString); + output = this.isJsonSchema(parsed) ? {} : (parsed ?? {}); + } + + return context.sendJson(output, 'out'); + }, + + generateOutputPortSchema(context, schemaString) { + + let parsed; + try { + parsed = JSON.parse(schemaString); + } catch (err) { + return context.sendJson({}, 'out'); + } + + if (this.isJsonSchema(parsed)) { + return context.sendJson(parsed, 'out'); + } + + const schema = this.inferSchema(parsed); + if (parsed && typeof parsed === 'object') { + schema.example = parsed; + } + return context.sendJson(schema, 'out'); + }, + + isJsonSchema(value) { + + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + if (value.$schema) return true; + if (typeof value.type !== 'string' || !SCHEMA_TYPES.includes(value.type)) return false; + if (value.type === 'object' && value.properties) return true; + if (value.type === 'array' && value.items) return true; + return false; + }, + + inferSchema(value, key) { + + if (value === null) return { type: 'null', title: this.titleFromKey(key) }; + if (Array.isArray(value)) { + return { + type: 'array', + title: this.titleFromKey(key), + items: value.length > 0 ? this.inferSchema(value[0]) : {} + }; + } + if (typeof value === 'object') { + const properties = {}; + for (const k of Object.keys(value)) { + properties[k] = this.inferSchema(value[k], k); + } + const out = { type: 'object', properties }; + if (key) out.title = this.titleFromKey(key); + return out; + } + switch (typeof value) { + case 'string': return { type: 'string', title: this.titleFromKey(key) }; + case 'number': return { type: Number.isInteger(value) ? 'integer' : 'number', title: this.titleFromKey(key) }; + case 'boolean': return { type: 'boolean', title: this.titleFromKey(key) }; + default: return {}; + } + }, + + titleFromKey(key) { + + if (!key) return undefined; + // snake_case / kebab-case / camelCase → Title Case + return String(key) + .replace(/[_-]+/g, ' ') + .replace(/([a-z])([A-Z])/g, '$1 $2') + .replace(/\s+/g, ' ') + .trim() + .split(' ') + .map(w => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' '); + }, + + tryParseJson(str) { + + if (typeof str !== 'string') return str; + try { + return JSON.parse(str); + } catch (err) { + return undefined; + } + } +}; diff --git a/src/appmixer/utils/controls/SchemaPassThrough/component.json b/src/appmixer/utils/controls/SchemaPassThrough/component.json new file mode 100644 index 0000000000..b5f5319e9e --- /dev/null +++ b/src/appmixer/utils/controls/SchemaPassThrough/component.json @@ -0,0 +1,54 @@ +{ + "name": "appmixer.utils.controls.SchemaPassThrough", + "author": "Appmixer ", + "description": "Define an output shape from an example JSON or a JSON Schema, then pass JSON data through with a typed output port for easy variable mapping downstream.", + "icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBkPSJNOCAzSDdhMiAyIDAgMCAwLTIgMnY1YTIgMiAwIDAgMS0yIDIgMiAyIDAgMCAxIDIgMnY1YTIgMiAwIDAgMCAyIDJoMU0xNiAzaDFhMiAyIDAgMCAxIDIgMnY1YTIgMiAwIDAgMCAyIDIgMiAyIDAgMCAwLTIgMnY1YTIgMiAwIDAgMS0yIDJoLTFNOSA5aDZNOSAxM2g0TTkgMTdoNiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjwvc3ZnPgo=", + "private": false, + "inPorts": [ + { + "name": "in", + "maxConnections": 1, + "schema": { + "type": "object", + "properties": { + "schema": { "type": "string" }, + "data": {} + }, + "required": ["schema"] + }, + "inspector": { + "inputs": { + "schema": { + "type": "textarea", + "label": "Schema or Example JSON", + "index": 1, + "tooltip": "Paste either a JSON Schema or an example JSON object. The component auto-detects which one and uses it to derive the output port shape so downstream components can map properties easily. Example schema: {\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"},\"age\":{\"type\":\"number\"}}}. Example JSON: {\"name\":\"John\",\"age\":30}." + }, + "data": { + "type": "text", + "label": "Data", + "index": 2, + "tooltip": "JSON object to pass through. Wire it from an upstream component (e.g. {{{upstream.payload}}}) or paste a literal JSON value. If left empty and the Schema field contains an example JSON, that example is emitted (useful for testing)." + } + } + } + } + ], + "outPorts": [ + { + "name": "out", + "source": { + "url": "/component/appmixer/utils/controls/SchemaPassThrough?outPort=out", + "data": { + "properties": { + "generateOutputPortOptions": true + }, + "messages": { + "in/schema": "inputs/in/schema", + "in/data": "dummy" + } + } + } + } + ] +} diff --git a/src/appmixer/utils/controls/bundle.json b/src/appmixer/utils/controls/bundle.json index 6a35c8dc09..e3488898a6 100644 --- a/src/appmixer/utils/controls/bundle.json +++ b/src/appmixer/utils/controls/bundle.json @@ -1,7 +1,7 @@ { "name": "appmixer.utils.controls", "engine": ">=6.0", - "version": "1.12.3", + "version": "1.13.0", "changelog": { "1.0.0": [ "Initial version" @@ -107,6 +107,9 @@ ], "1.12.3": [ "Fix missing properties in the schema for `Testing` component." + ], + "1.13.0": [ + "Added component SchemaPassThrough: define an output shape from an example JSON or JSON Schema for easy variable mapping downstream." ] } } From 07c2e17fd84c635b031ddacf1617c8bcd87cd821 Mon Sep 17 00:00:00 2001 From: salatv-ai Date: Thu, 21 May 2026 18:38:58 +0200 Subject: [PATCH 2/2] update --- .../SchemaPassThrough/SchemaPassThrough.js | 102 ++++++++++++------ 1 file changed, 68 insertions(+), 34 deletions(-) diff --git a/src/appmixer/utils/controls/SchemaPassThrough/SchemaPassThrough.js b/src/appmixer/utils/controls/SchemaPassThrough/SchemaPassThrough.js index 7ec3d53227..459b51f70b 100644 --- a/src/appmixer/utils/controls/SchemaPassThrough/SchemaPassThrough.js +++ b/src/appmixer/utils/controls/SchemaPassThrough/SchemaPassThrough.js @@ -9,7 +9,7 @@ module.exports = { const { schema: schemaString, data } = context.messages.in.content; if (context.properties.generateOutputPortOptions) { - return this.generateOutputPortSchema(context, schemaString); + return this.generateOutputPortOptions(context, schemaString); } let output; @@ -25,24 +25,19 @@ module.exports = { return context.sendJson(output, 'out'); }, - generateOutputPortSchema(context, schemaString) { + generateOutputPortOptions(context, schemaString) { let parsed; try { parsed = JSON.parse(schemaString); } catch (err) { - return context.sendJson({}, 'out'); + return context.sendJson([], 'out'); } - if (this.isJsonSchema(parsed)) { - return context.sendJson(parsed, 'out'); - } - - const schema = this.inferSchema(parsed); - if (parsed && typeof parsed === 'object') { - schema.example = parsed; - } - return context.sendJson(schema, 'out'); + const schema = this.isJsonSchema(parsed) ? parsed : this.inferSchema(parsed); + const options = []; + this.schemaToOptions(schema, '', options); + return context.sendJson(options, 'out'); }, isJsonSchema(value) { @@ -55,45 +50,84 @@ module.exports = { return false; }, - inferSchema(value, key) { + inferSchema(value) { - if (value === null) return { type: 'null', title: this.titleFromKey(key) }; + if (value === null) return { type: 'null' }; if (Array.isArray(value)) { return { type: 'array', - title: this.titleFromKey(key), items: value.length > 0 ? this.inferSchema(value[0]) : {} }; } if (typeof value === 'object') { const properties = {}; for (const k of Object.keys(value)) { - properties[k] = this.inferSchema(value[k], k); + properties[k] = this.inferSchema(value[k]); } - const out = { type: 'object', properties }; - if (key) out.title = this.titleFromKey(key); - return out; + return { type: 'object', properties }; } switch (typeof value) { - case 'string': return { type: 'string', title: this.titleFromKey(key) }; - case 'number': return { type: Number.isInteger(value) ? 'integer' : 'number', title: this.titleFromKey(key) }; - case 'boolean': return { type: 'boolean', title: this.titleFromKey(key) }; + case 'string': return { type: 'string', example: value }; + case 'number': return { type: Number.isInteger(value) ? 'integer' : 'number', example: value }; + case 'boolean': return { type: 'boolean', example: value }; default: return {}; } }, - titleFromKey(key) { - - if (!key) return undefined; - // snake_case / kebab-case / camelCase → Title Case - return String(key) - .replace(/[_-]+/g, ' ') - .replace(/([a-z])([A-Z])/g, '$1 $2') - .replace(/\s+/g, ' ') - .trim() - .split(' ') - .map(w => w.charAt(0).toUpperCase() + w.slice(1)) - .join(' '); + // Mirror engine's createOutPortOptions: flat array with dotted paths, + // arrays are pushed as one option carrying the full schema, primitives + // and nested objects get individual entries. + schemaToOptions(schema, parentPath, options) { + + if (!schema) return; + + if (schema.type === 'array') { + options.push({ + label: this.titleFromPath(parentPath) || 'Items', + value: parentPath || 'value', + schema + }); + return; + } + + const properties = schema.properties; + if (!properties) { + if (parentPath === '') { + options.push({ + label: 'Value', + value: 'value', + schema + }); + } + return; + } + + Object.keys(properties).forEach(prop => { + const path = parentPath ? (parentPath + '.' + prop) : prop; + const propSchema = properties[prop]; + if (propSchema.type !== 'array') { + const option = { label: this.titleFromPath(path), value: path }; + if (propSchema.type) { + // For objects, only expose the type (no `properties`) — children are + // covered by separate flat dotted-path entries below; including + // `properties` would make the picker render duplicates. + option.schema = propSchema.type === 'object' + ? { type: 'object' } + : { type: propSchema.type, ...(propSchema.example !== undefined ? { example: propSchema.example } : {}) }; + } + options.push(option); + } + this.schemaToOptions(propSchema, path, options); + }); + }, + + titleFromPath(path) { + + if (!path) return ''; + return String(path) + .split('.') + .map(seg => seg.charAt(0).toUpperCase() + seg.slice(1)) + .join('.'); }, tryParseJson(str) {