copy over all DSL files

This commit is contained in:
Loren Norman 2024-07-08 14:29:02 -04:00
parent a2afad6106
commit 2210518104
21 changed files with 1566 additions and 0 deletions

View file

@ -0,0 +1,88 @@
export default {
type: "action_email",
toolbox: {
category: 'Actions',
label: "Send yourself an email with custom subject and body templates."
},
visualization: {
colour: "0",
tooltip: [
"Sends an email with given SUBJECT and BODY",
"---------------",
"Parameters:",
"SUBJECT - a template for generating the email subject",
"BODY - a template for generating the email body",
].join('\n'),
},
connections: {
mode: "statement",
output: "expression",
next: 'expression'
},
lines: [
[ "📧 Email", 'CENTER'],
[ "Subject:", {
inputValue: "SUBJECT",
shadow: {
type: 'text_template',
inputs: { TEMPLATE: {
shadow: {
type: 'text',
fields: {
TEXT: '{{ vars.feed_name }} feed has a new value: {{ vars.feed_value }}'
}
}
}}
}
}],
[ "Body:", {
inputValue: "BODY",
// check: 'String',
shadow: {
type: 'text_template',
inputs: { TEMPLATE: {
shadow: {
type: 'text_multiline',
fields: {
TEXT: 'Hello!\nThe {{ vars.feed_name }} feed has a new value: {{ vars.value }}\nProcessed at: {{ vars.now }}'
}
}
}}
}
}]
],
generators: {
json: (block, generator) => {
const payload = {
emailAction: {
subjectTemplate: JSON.parse(generator.valueToCode(block, 'SUBJECT', 0)),
bodyTemplate: JSON.parse(generator.valueToCode(block, 'BODY', 0))
}
}
return JSON.stringify(payload)
}
},
regenerators: {
json: (blockObject, helpers) => {
const payload = blockObject.emailAction
return {
type: "action_email",
inputs: {
// TODO: regenerators need to support nested shadow blocks
SUBJECT: helpers.expressionToBlock(payload.subjectTemplate, { shadow: 'text_template' }),
BODY: helpers.expressionToBlock(payload.bodyTemplate, { shadow: 'text_template' })
}
}
}
}
}

61
app/blocks/action_log.js Normal file
View file

@ -0,0 +1,61 @@
export default {
type: "action_log",
toolbox: {
category: 'Actions',
label: "Plug in a block to see its resolved value in the action's log."
},
visualization: {
colour: "0",
tooltip: [
"Executes the block you plug in and reveals its final value or error message.",
"---------------",
"Parameters:",
"EXPRESSION - a block you'd like to see the resolved value of"
].join('\n'),
},
connections: {
mode: "statement",
output: "expression",
next: 'expression'
},
lines: [
["Log:", {
inputValue: 'EXPRESSION',
// check: ['expression', 'String'],
shadow: 'text'
}]
],
generators: {
json: (block, generator) => {
const payload = {
logAction: {
line: JSON.parse(generator.valueToCode(block, 'EXPRESSION', 0) || null)
}
}
return JSON.stringify(payload)
}
},
regenerators: {
json: (blockObject, helpers) => {
const payload = blockObject.logAction
if(!payload) {
throw new Error("No data for action_log regenerator")
}
return {
type: "action_log",
inputs: {
EXPRESSION: helpers.expressionToBlock(payload.line, { shadow: 'text' })
}
}
}
}
}

View file

@ -0,0 +1,68 @@
export default {
type: "action_publish",
toolbox: {
category: 'Actions',
label: "Publish a given value to a given feed."
},
visualization: {
colour: "0",
tooltip: [
"Sends a given VALUE to a given FEED.",
"---------------",
"Parameters:",
"VALUE - the value to write",
"FEED - the feed to write to",
].join('\n'),
},
connections: {
mode: "statement",
output: "expression",
next: 'expression'
},
lines: [
[ "📈 Publish", "CENTER" ],
[ "...value:", {
inputValue: "VALUE",
// check: [ "String", "Number" ],
shadow: 'text'
}],
[ "...to:", {
inputValue: "FEED",
// check: "feed",
shadow: 'feed_selector'
}]
],
generators: {
json: (block, generator) => {
const payload = {
publishAction: {
feed: JSON.parse(generator.valueToCode(block, 'FEED', 0)),
value: JSON.parse(generator.valueToCode(block, 'VALUE', 0))
}
}
return JSON.stringify(payload)
}
},
regenerators: {
json: (blockObject, helpers) => {
const payload = blockObject.publishAction
return {
type: "action_publish",
inputs: {
FEED: helpers.expressionToBlock(payload.feed, { shadow: 'feed_selector' }),
VALUE: helpers.expressionToBlock(payload.value, { shadow: 'text' }),
}
}
}
}
}

65
app/blocks/action_root.js Normal file
View file

@ -0,0 +1,65 @@
export default {
type: "action_root",
toolbox: {},
connections: {},
visualization: {
colour: "0",
extensions: [ ],
tooltip: "Drag some statement blocks into the \"Do\" list to build a custom Action"
},
lines: [
[ "Action Commands", "CENTER" ],
[ "Do:", {
inputStatement: "EXPRESSIONS",
// check: "expression"
}],
],
generators: {
json: (block, generator) => {
let expressionsJson, expressions = []
try {
expressionsJson = generator.statementToCode(block, `EXPRESSIONS`)
try {
expressions = JSON.parse(`[${expressionsJson}]`)
} catch(e) {
console.error("Error parsing JSON:", expressionsJson)
console.error(e)
}
} catch(e) {
console.error("Error calling statementToCode on expression statements:")
console.error(e)
}
return JSON.stringify({
version: "1.0.0-beta.1",
settings: {},
expressions,
}, null, 2)
}
},
regenerators: {
json: (blockObject, helpers) => {
const { expressions } = blockObject
return {
"type": "action_root",
"movable": false,
"deletable": false,
"x": 50,
"y": 50,
"inputs": {
"EXPRESSIONS": helpers.arrayToStatements(expressions)
}
}
}
}
}

74
app/blocks/action_sms.js Normal file
View file

@ -0,0 +1,74 @@
export default {
type: "action_sms",
toolbox: {
category: 'Actions',
label: "Send yourself a templated SMS, or text message."
},
visualization: {
colour: "0",
tooltip: [
"(IO+ only)",
"Sends a text message with a given BODY template.",
"---------------",
"Parameters:",
"BODY - a template for generating the SMS body",
].join('\n')
},
connections: {
mode: "statement",
output: "expression",
next: 'expression'
},
lines: [
[ "📲 SMS", "CENTER" ],
[ "Message:", {
inputValue: "BODY",
// check: "String",
shadow: {
type: 'text_template',
inputs: { TEMPLATE: {
shadow: {
type: 'text_multiline',
fields: {
TEXT: [
'The {{ vars.feed_name }} feed has a new',
'value: {{ vars.feed_value }} at {{ vars.now }}'
].join('\n')
}
}
}}
}
}],
],
generators: {
json: (block, generator) => {
const payload = {
smsAction: {
bodyTemplate: JSON.parse(generator.valueToCode(block, 'BODY', 0))
}
}
return JSON.stringify(payload)
}
},
regenerators: {
json: (blockObject, helpers) => {
const payload = blockObject.smsAction
return {
type: "action_sms",
inputs: {
// TODO: regenerators need to support nested shadow blocks
BODY: helpers.expressionToBlock(payload.bodyTemplate, { shadow: 'text_template' })
}
}
}
}
}

View file

@ -0,0 +1,97 @@
export default {
type: "action_webhook",
toolbox: {
category: 'Actions',
label: "POST a given body template to a given web URL."
},
visualization: {
colour: "0",
tooltip: [
"Sends an HTTP POST request to a given URL, with a BODY template using FEED data.",
"---------------",
"Parameters:",
"URL - a valid web location to send a request to",
"BODY - a JSON template to render and POST",
"FORM_ENCODE - optionally encode as form input",
].join('\n')
},
connections: {
mode: "statement",
output: "expression",
next: 'expression'
},
lines: [
[ "🔗 Webhook", "CENTER" ],
[ "URL:", {
inputValue: "URL",
// check: "String",
shadow: {
type: 'text',
fields: { TEXT: 'https://...' }
}
}],
[ "Form Encode?", {
field: "FORM_ENCODE",
checked: false
}],
[ "POST Body:", {
inputValue: "BODY",
// check: "String",
shadow: {
type: 'text_template',
inputs: {
TEMPLATE: {
shadow: {
type: 'text_multiline',
fields: {
TEXT:
`{
"id": "{{ vars.feed_id }}",
"value": "{{ vars.feed_value }}"
}`
}
}
}
}
}
}],
],
generators: {
json: (block, generator) => {
const payload = {
webhookAction: {
url: JSON.parse(generator.valueToCode(block, 'URL', 0)),
bodyTemplate: JSON.parse(generator.valueToCode(block, 'BODY', 0)),
formEncoded: block.getFieldValue('FORM_ENCODE') === 'TRUE'
}
}
return JSON.stringify(payload)
}
},
regenerators: {
json: (blockObject, helpers) => {
const payload = blockObject.webhookAction
return {
type: "action_webhook",
inputs: {
URL: helpers.expressionToBlock(payload.url, { shadow: 'text' }),
BODY: helpers.expressionToBlock(payload.bodyTemplate, { shadow: 'text_multiline' }),
},
fields: {
FORM_ENCODE: payload.formEncoded ? 'TRUE' : 'FALSE'
}
}
}
}
}

View file

@ -0,0 +1,109 @@
export default {
// required, unique
// identifier for this block, used all over Blockly
type: "block_type",
// determines where the block shows up in the toolbox
toolbox: {
// category must match a category in the toolbox config
category: 'Category Name',
// shorthand that adds an extra toolbox label
label: 'Helpful text that appears alongside the block in the toolbox'
},
// same as Blockly JSON
visualization: {
inputsInline: false,
colour: 100,
tooltip: "",
helpUrl: ""
},
// list of extension names registered from extensions directory, or...
extensions: [ 'populateFeedDropdown' ],
// object naming and defining extensions inline here
extensions: {
populateFeedDropdown: ({ block, data, Blockly }) => {
/* do extension stuff */
}
},
// TODO: consider something simpler, maybe leftOutput, topOutput, and bottomCheck?
// Block connections
connections: {
// mode options:
// - falsy or "none": no connections
// - "value": has a side input, "output" maps to "output"
// - statements
// - "statement": top and bottom connections, "output" maps to "previousStatement", "next" maps to "nextStatement"
// - "statement:first": bottom connection, "output" invalid, "next" maps to "nextStatement"
// - "statement:last": top connection, "output" maps to "previousStatement", "next" invalid
mode: "value",
output: "trigger",
},
// describes each line of the block, from top to bottom
lines: [
// STRING LINES
"line contents", // simple text line, default alignment,
// ARRAY LINES
// [ string, string ]: [ text, alignment ]
[ "line contents", "alignment" ],
// [ string, object ]: [ text, { inputValue, inputStatement, field, fields } ]
[ "line $FIELD_NAME contents", { // template string: field gets embedded where its name is referenced
// for a single block input
inputValue: 'INPUT_VALUE_NAME',
// check: 'input_block_output',
shadow: 'block_type_to_shadow', // -> { shadow: { type: 'block_type_to_shadow' }}
shadow: {
type: 'block_type_to_shadow',
inputs: {}, // fill in the inputs on the shadowed block
fields: {}, // fill in the fields on the shadowed block
},
// for multiple block inputs
inputStatement: 'INPUT_STATEMENT_NAME',
// for a single field input
field: 'FIELD_NAME',
text: 'whatever', // makes a text field
spellcheck: true, // text field option
checked: true, // makes a checkbox field
options: [ // makes a dropdown field
['user text', 'computer id'],
// ...
],
// for multiple field inputs
// hint: use template strings (see above comment) to choose where inline fields go
fields: {
// each field name is a key
FIELD_NAME: {
// same options as above for singular field
text: 'whatever', // makes a text field
spellcheck: true, // text field option
checked: true, // makes a checkbox field
options: [ // makes a dropdown field
['user text', 'computer id'],
// ...
],
}
}
}],
],
// generators for this block type
// these get aggregated and registered together
generators: {
json: (block, generator) => {
// fetch connected block: block.getInputTargetBlock('INPUT_VALUE_NAME')?.type,
// generate connected block: generator.valueToCode(block, 'INPUT_VALUE_NAME', 0)
// field value: block.getFieldValue('FIELD_NAME')
return [ {}, 0 ]
}
}
}

View file

@ -0,0 +1,61 @@
export default {
type: "feed_selector",
toolbox: {
category: 'Feeds'
},
visualization: {
colour: 300,
tooltip: "The last value of this feed or component, always a String"
},
extensions: {
populateFeedDropdown: ({ block, data, Blockly }) => {
const { feedOptions } = data
if(!feedOptions) {
console.error(`No feedOptions in extension data to populate dropdowns!`)
return
}
const input = block.getInput('')
input.removeField("FEED_KEY")
input.appendField(new Blockly.FieldDropdown(feedOptions), "FEED_KEY")
}
},
lines: [
[ "Feed:", {
field: "FEED_KEY",
options: [
[ "Loading Feeds...", "" ],
]
}],
],
generators: {
json: block => {
const
key = block.getFieldValue('FEED_KEY'),
payload = JSON.stringify({
feed: { key }
})
return [ payload, 0 ]
}
},
regenerators: {
json: blockObject => {
const payload = blockObject.feed
return {
type: "feed_selector",
fields: {
FEED_KEY: payload.key
}
}
}
}
}

View file

@ -0,0 +1,93 @@
import mutator from './io_controls_if/mutator.js'
export default {
type: 'io_controls_if',
toolbox: {
category: 'Logic'
},
visualization: {
colour: 60,
},
connections: {
mode: "statement",
output: "expression",
next: 'expression'
},
mutator,
lines: [
[ "if", { inputValue: 'IF0', shadow: 'logic_boolean' }],
[ "do", { inputStatement: 'THEN0' }],
[ "else if", { inputDummy: 'ELSE_IF_LABEL' }],
[ "else", { inputDummy: 'ELSE_LABEL' }]
],
generators: {
json: (block, generator) => {
const payload = {
conditional: {
ifThens: []
}
}
let index = 0
while(block.getInput(`IF${index}`)) {
const
ifClause = generator.valueToCode(block, `IF${index}`, 0) || 'null',
thenClause = generator.statementToCode(block, `THEN${index}`) || ''
payload.conditional.ifThens.push({
if: JSON.parse(ifClause),
then: JSON.parse(`[ ${thenClause} ]`),
})
index += 1
}
if(block.getInput('ELSE')) {
const elseClause = generator.statementToCode(block, 'ELSE') || ''
payload.conditional.else = JSON.parse(`[${ elseClause }]`)
}
return JSON.stringify(payload)
}
},
regenerators: {
json: (bytecode, helpers) => {
const payload = bytecode.conditional
if(!payload) {
throw new Error("No data for io_controls_if regenerator")
}
const
{ ifThens } = payload,
inputs = {}
ifThens.forEach((item, index) => {
if(item.if !== null) { inputs[`IF${index}`] = helpers.expressionToBlock(item.if, { shadow: 'logic_boolean' }) }
if(item.then) { inputs[`THEN${index}`] = helpers.arrayToStatements(item.then) }
})
if(payload.else) {
inputs.ELSE = helpers.arrayToStatements(payload.else)
}
return {
type: "io_controls_if",
inputs,
extraState: {
elseIfCount: ifThens.length-1,
elsePresent: !!payload.else
}
}
}
}
}

View file

@ -0,0 +1,163 @@
import Blockly from 'blockly'
/** Dynamic else-if and else support */
export default {
elseIfCount: 0,
elsePresent: false,
// helper to touch the above state and automatically fire events on change
setProperty: function(propertyName, propertyValue, element, name) {
const old = this[propertyName]
if(old == propertyValue) { return }
this[propertyName] = propertyValue
Blockly.Events.fire(new Blockly.Events.BlockChange(this, element, name,
{ [propertyName]: old }, { [propertyName]: this[propertyName] }
))
},
saveExtraState: function() {
return { elseIfCount: this.elseIfCount, elsePresent: this.elsePresent }
},
loadExtraState: function(state) {
this.initializeElseIfs(state.elseIfCount)
this.initializeElse(state.elsePresent)
},
// dynamic else-if-then support
initializeElseIfs: function(elseIfCount) {
this.addElseIf(elseIfCount)
this.insertPlusElseIfButton()
},
insertPlusElseIfButton: function() {
const elseIfLabel = this.getInput('ELSE_IF_LABEL')
elseIfLabel.removeField('PLUS_ELSE_IF', true)
elseIfLabel.insertFieldAt(0, this.plusField(() => this.addElseIf()), 'PLUS_ELSE_IF')
},
addElseIf: function(howMany=1) {
for(let i = 1; i <= howMany; i++) {
this.addElseIfAt(this.elseIfCount + i)
}
this.setProperty('elseIfCount', this.elseIfCount+howMany)
},
addElseIfAt: function(index) {
this
.appendValueInput(`IF${index}`)
.appendField(this.minusField(() => this.removeElseIfAt(index)))
.appendField(new Blockly.FieldLabel("else if"))
this
.appendStatementInput(`THEN${index}`)
.appendField(new Blockly.FieldLabel("do"))
// move them up above the last items
this.moveInputBefore(`IF${index}`, 'ELSE_IF_LABEL')
this.moveInputBefore(`THEN${index}`, 'ELSE_IF_LABEL')
},
removeElseIfAt: function(targetIndex) {
// detect my parent IF input and its THEN
const
ifName = `IF${targetIndex}`,
thenName = `THEN${targetIndex}`,
ifInput = this.getInput(ifName),
thenInput = this.getInput(thenName)
// sever the connections
const ifConnection = ifInput.connection
if(ifConnection.isConnected()) { ifConnection.disconnect() }
const thenConnection = thenInput.connection
if(thenConnection.isConnected()) { thenConnection.disconnect() }
// shuffle remaining if/then connections down
const
inputs = this.inputList,
nextIndex = inputs.indexOf(ifInput) + 2
let i = nextIndex
for (let input; (input = inputs[i]); i++) {
if (input.name == 'ELSE_LABEL') { break }
const { targetConnection } = input.connection || {}
if (targetConnection) {
inputs[i - 2].connection.connect(targetConnection)
}
}
// remove the last/highest index ifThen
const indexToRemove = Math.floor((i-2)/2)
this.removeInput(`IF${indexToRemove}`)
this.removeInput(`THEN${indexToRemove}`)
this.bumpNeighbours()
this.setProperty('elseIfCount', this.elseIfCount-1)
},
// dynamic else support
initializeElse: function(elsePresent) {
elsePresent ? this.addElse() : this.removeElse()
},
addElse: function() {
const elseLabel = this.getInput('ELSE_LABEL')
// swap the plus to minus
elseLabel.removeField('PLUS_ELSE', true)
elseLabel.removeField('MINUS_ELSE', true)
elseLabel.insertFieldAt(0, this.minusField(() => this.removeElse()), 'MINUS_ELSE')
// add the input, write its label
if(!this.getInput('ELSE')) {
this
.appendStatementInput('ELSE')
.appendField(new Blockly.FieldLabel("do"))
}
this.setProperty('elsePresent', true, 'input', 'ELSE')
},
removeElse: function() {
const elseLabel = this.getInput('ELSE_LABEL')
// swap the minus to plus
elseLabel.removeField('MINUS_ELSE', true)
elseLabel.removeField('PLUS_ELSE', true)
elseLabel.insertFieldAt(0, this.plusField(() => this.addElse()), 'PLUS_ELSE')
const elseInput = this.getInput('ELSE')
// sever the connection
if(elseInput) {
const { connection } = elseInput
if(connection.isConnected()) { connection.disconnect() }
// remove the input
this.removeInput('ELSE')
}
this.setProperty('elsePresent', false)
},
plusImage:
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC' +
'9zdmciIHZlcnNpb249IjEuMSIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0Ij48cGF0aCBkPSJNMT' +
'ggMTBoLTR2LTRjMC0xLjEwNC0uODk2LTItMi0ycy0yIC44OTYtMiAybC4wNzEgNGgtNC4wNz' +
'FjLTEuMTA0IDAtMiAuODk2LTIgMnMuODk2IDIgMiAybDQuMDcxLS4wNzEtLjA3MSA0LjA3MW' +
'MwIDEuMTA0Ljg5NiAyIDIgMnMyLS44OTYgMi0ydi00LjA3MWw0IC4wNzFjMS4xMDQgMCAyLS' +
'44OTYgMi0ycy0uODk2LTItMi0yeiIgZmlsbD0id2hpdGUiIC8+PC9zdmc+Cg==',
plusField: function(onClick) {
return new Blockly.FieldImage(this.plusImage, 15, 15, undefined, onClick)
},
minusImage:
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAw' +
'MC9zdmciIHZlcnNpb249IjEuMSIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0Ij48cGF0aCBkPS' +
'JNMTggMTFoLTEyYy0xLjEwNCAwLTIgLjg5Ni0yIDJzLjg5NiAyIDIgMmgxMmMxLjEwNCAw' +
'IDItLjg5NiAyLTJzLS44OTYtMi0yLTJ6IiBmaWxsPSJ3aGl0ZSIgLz48L3N2Zz4K',
minusField: function(onClick) {
return new Blockly.FieldImage(this.minusImage, 15, 15, undefined, onClick)
},
}

View file

@ -0,0 +1,76 @@
export default {
type: 'io_logic_compare',
toolbox: {
category: 'Math',
},
visualization: {
inputsInline: true,
colour: 224,
tooltip: [
"Compare two numeric values in various ways.",
"-",
"Inputs:",
"---------------",
"Comparator - check equality, inequality, greater than, greater than or equal to, less than, less than or equal to?",
"Number A - the first number",
"Number B - the second number",
"-",
"Casting:",
"---------------",
"both inputs are coerced to floating point numbers",
].join('\n'),
},
lines: [
["", { inputValue: 'A', shadow: 'math_number' }],
[ "", {
field: "OP",
options: [
['=', 'EQ'],
['\u2260', 'NEQ'],
['\u200F<', 'LT'],
['\u200F\u2264', 'LTE'],
['\u200F>', 'GT'],
['\u200F\u2265', 'GTE'],
]
}],
["", { inputValue: 'B', shadow: 'math_number' }]
],
generators: {
json: (block, generator) => {
const
comparator = block.getFieldValue('OP'),
leftExp = generator.valueToCode(block, 'A', 0) || 'null',
rightExp = generator.valueToCode(block, 'B', 0) || 'null',
blockPayload = JSON.stringify({
compare: {
left: JSON.parse(leftExp),
comparator: comparator?.toLowerCase() || null,
right: JSON.parse(rightExp),
},
})
return [ blockPayload, 0 ]
}
},
regenerators: {
json: (blockObject, helpers) => {
const
{ comparator, left, right } = blockObject.compare,
fields = {
OP: comparator?.toUpperCase()
},
inputs = {
A: helpers.expressionToBlock(left, { shadow: 'math_number' }),
B: helpers.expressionToBlock(right, { shadow: 'math_number' }),
}
return { type: 'io_logic_compare', fields, inputs }
}
}
}

View file

@ -0,0 +1,42 @@
export default {
type: 'io_logic_negate',
toolbox: {
category: 'Logic',
},
visualization: {
colour: 60,
},
lines: [
["not", { inputValue: 'EXPRESSION', shadow: 'logic_boolean' }]
],
generators: {
json: (block, generator) => {
const
operand = generator.valueToCode(block, 'EXPRESSION', 0) || null,
payload = {
negate: {
target: JSON.parse(operand)
}
}
return [ JSON.stringify(payload), 0 ]
}
},
regenerators: {
json: (blockObject, helpers) => {
const payload = blockObject.negate
return {
type: 'io_logic_negate',
inputs: {
EXPRESSION: helpers.expressionToBlock(payload.target, { shadow: 'logic_boolean' })
}
}
}
}
}

View file

@ -0,0 +1,59 @@
export default {
type: 'io_logic_operation',
toolbox: {
category: 'Logic',
},
visualization: {
inputsInline: true,
colour: 60,
},
lines: [
["", { inputValue: 'A', shadow: 'logic_boolean' }],
["", {
field: 'OP',
options: [
['and', 'AND'],
['or', 'OR'],
]
}],
["", { inputValue: 'B', shadow: 'logic_boolean' }],
],
generators: {
json: (block, generator) => {
const
operator = block.getFieldValue('OP'),
leftExp = generator.valueToCode(block, 'A', 0) || null,
rightExp = generator.valueToCode(block, 'B', 0) || null,
blockPayload = JSON.stringify({
logic: {
left: JSON.parse(leftExp),
comparator: operator?.toLowerCase() || null,
right: JSON.parse(rightExp),
},
})
return [ blockPayload, 0 ]
}
},
regenerators: {
json: (blockObject, helpers) => {
const
{ comparator, left, right } = blockObject.logic,
fields = {
OP: comparator?.toUpperCase()
},
inputs = {
A: helpers.expressionToBlock(left, { shadow: 'logic_boolean' }),
B: helpers.expressionToBlock(right, { shadow: 'logic_boolean' }),
}
return { type: 'io_logic_operation', fields, inputs }
}
}
}

View file

@ -0,0 +1,37 @@
export default {
type: 'io_logic_ternary',
toolbox: {
category: 'Logic',
},
visualization: {
colour: 60,
},
lines: [
["if", { inputValue: 'IF', shadow: 'logic_boolean' }],
["then", { inputValue: 'THEN', shadow: 'logic_boolean' }],
["else", { inputValue: 'ELSE', shadow: 'logic_boolean' }],
],
generators: {
json: (block, generator) => {
const
ifLogic = generator.valueToCode(block, 'IF', 0),
thenLogic = generator.valueToCode(block, 'THEN', 0),
elseLogic = generator.valueToCode(block, 'ELSE', 0),
blockPayload = JSON.stringify({
conditional: {
ifThens: [
{ if: ifLogic ? JSON.parse(ifLogic) : null,
then: thenLogic ? JSON.parse(thenLogic) : null }
],
else: elseLogic ? JSON.parse(elseLogic) : null
}
})
return [blockPayload, 0]
}
}
}

View file

@ -0,0 +1,78 @@
export default {
type: 'io_math_arithmetic',
toolbox: {
category: 'Math',
},
visualization: {
inputsInline: true,
colour: 230,
},
lines: [
[ "", { inputValue: 'A', shadow: 'math_number'}],
[ "", {
field: 'OP',
options: [
['+', 'ADD'],
['-', 'MINUS'],
['x', 'MULTIPLY'],
['/', 'DIVIDE'],
['^', 'POWER'],
]
}],
[ "", { inputValue: 'B', shadow: 'math_number'}],
],
generators: {
json: (block, generator) => {
const
operatorMap = {
ADD: '+',
MINUS: '-',
MULTIPLY: '*',
DIVIDE: '/',
POWER: '^'
},
operator = block.getFieldValue('OP'),
leftExp = generator.valueToCode(block, 'A', 0) || 'null',
rightExp = generator.valueToCode(block, 'B', 0) || 'null',
blockPayload = JSON.stringify({
arithmetic: {
left: JSON.parse(leftExp),
operator: operator
? operatorMap[operator]
: null,
right: JSON.parse(rightExp)
}
})
return [ blockPayload, 0 ]
}
},
regenerators: {
json: (blockObject, helpers) => {
const
payload = blockObject.arithmetic,
operatorMap = {
'+': 'ADD',
'-': 'MINUS',
'*': 'MULTIPLY',
'/': 'DIVIDE',
'^': 'POWER',
},
fields = {
OP: operatorMap[payload.operator]
},
inputs = {
A: helpers.expressionToBlock(payload.left, { shadow: 'math_number' }),
B: helpers.expressionToBlock(payload.right, { shadow: 'math_number' }),
}
return { type: 'io_math_arithmetic', fields, inputs }
}
}
}

View file

@ -0,0 +1,78 @@
export default {
type: 'text_compare',
toolbox: {
category: 'Text',
},
visualization: {
inputsInline: true,
colour: 180,
tooltip: [
"Compare two chunks of text for equality or inequality.",
"-",
"Inputs:",
"---------------",
"Comparator - check for equality or inequality?",
"Text A - the first string of text",
"Text B - the second string of text",
"-",
"Casting:",
"---------------",
"both inputs are coerced to strings",
"-",
"Options: (not implemented)",
"---------------",
"Trim? - trim whitespace from the front and back of the input strings",
"Trim front? - trim whitespace from the front of the input strings",
"Trim back? - trim whitespace from the back of the input strings",
].join('\n'),
},
lines: [
["", { inputValue: 'A', shadow: 'text' }],
[ "", {
field: "OP",
options: [
['=', 'EQ'],
['\u2260', 'NEQ'],
]
}],
["", { inputValue: 'B', shadow: 'text' }]
],
generators: {
json: (block, generator) => {
const
comparator = block.getFieldValue('OP'),
leftExp = generator.valueToCode(block, 'A', 0) || null,
rightExp = generator.valueToCode(block, 'B', 0) || null,
blockPayload = JSON.stringify({
textCompare: {
left: JSON.parse(leftExp),
comparator: comparator?.toLowerCase() || null,
right: JSON.parse(rightExp),
},
})
return [ blockPayload, 0 ]
}
},
regenerators: {
json: (blockObject, helpers) => {
const
{ comparator, left, right } = blockObject.textCompare,
fields = {
OP: comparator?.toUpperCase()
},
inputs = {
A: helpers.expressionToBlock(left, { shadow: 'text' }),
B: helpers.expressionToBlock(right, { shadow: 'text' }),
}
return { type: 'text_compare', fields, inputs }
}
}
}

60
app/blocks/text_join.js Normal file
View file

@ -0,0 +1,60 @@
export default {
type: 'io_text_join',
toolbox: {
category: 'Text',
},
visualization: {
inputsInline: true,
colour: 180,
tooltip: [
"Join two chunks of text into one.",
"-",
"Inputs:",
"---------------",
"Text A - the first string of text",
"Text B - the second string of text",
"-",
"Casting:",
"---------------",
"both inputs are coerced to strings",
].join('\n'),
},
lines: [
["", { inputValue: 'A', shadow: 'text' }],
["+", ""],
["", { inputValue: 'B', shadow: 'text' }]
],
generators: {
json: (block, generator) => {
const
leftExp = generator.valueToCode(block, 'A', 0) || null,
rightExp = generator.valueToCode(block, 'B', 0) || null,
blockPayload = JSON.stringify({
textJoin: {
left: JSON.parse(leftExp),
right: JSON.parse(rightExp),
},
})
return [ blockPayload, 0 ]
}
},
regenerators: {
json: (blockObject, helpers) => {
const
{ left, right } = blockObject.textJoin,
inputs = {
A: helpers.expressionToBlock(left, { shadow: 'text' }),
B: helpers.expressionToBlock(right, { shadow: 'text' }),
}
return { type: 'io_text_join', inputs }
}
}
}

60
app/blocks/text_regex.js Normal file
View file

@ -0,0 +1,60 @@
export default {
type: 'text_regex',
toolbox: {
category: 'Text',
},
visualization: {
colour: 180,
tooltip: [
"Check for a regex match",
"-",
"Inputs:",
"---------------",
// "Text A - the first string of text",
// "Text B - the second string of text",
"-",
"Casting:",
"---------------",
"-",
"Options:",
"---------------",
].join('\n'),
},
lines: [
["Regex:", { inputValue: 'REGEX', shadow: 'text' }],
["Matches?", { inputValue: 'TARGET', shadow: 'text' }]
],
generators: {
json: (block, generator) => {
const
regexExp = generator.valueToCode(block, 'REGEX', 0) || null,
targetExp = generator.valueToCode(block, 'TARGET', 0) || null,
blockPayload = JSON.stringify({
textRegex: {
regex: JSON.parse(regexExp),
target: JSON.parse(targetExp),
},
})
return [ blockPayload, 0 ]
}
},
regenerators: {
json: (blockObject, helpers) => {
const
{ regex, target } = blockObject.textRegex,
inputs = {
REGEX: helpers.expressionToBlock(regex, { shadow: 'text' }),
TARGET: helpers.expressionToBlock(target, { shadow: 'text' }),
}
return { type: 'text_regex', inputs }
}
}
}

View file

@ -0,0 +1,68 @@
export default {
type: 'text_template',
toolbox: {
category: 'Text',
},
visualization: {
inputsInline: true,
helpUrl: "https://shopify.github.io/liquid/basics/introduction/",
colour: 180,
tooltip: [
"Render a text template. Special forms surrounded by {{ curly_braces }}",
"will be replaced with their current value during the action run. (See",
"'Template Variables' below for a list of available forms.)",
"-",
"Inputs:",
"---------------",
"Text - the text to render with the template engine",
"-",
"Template Variables:",
"---------------",
"{{ variables.var_name }} - get the value of a variable you have defined",
" with name 'var_name'",
"{{ vars.var_name }} - shorthand for same as above",
"{{ variables['var name'] }} - get the value of a variable you have",
" defined with name 'var name' (allows spaces in variable names",
"{{ vars.['var name'] }} - shorthand for same as above",
"{{ user.name }} - your user's name",
"{{ user.username }} - your user's username",
"{{ feeds['group_key.feed_key'].name }} - access a feed with key",
" 'group_key.feed_key' and get its name",
"{{ feeds[...].key }} - ...get its key",
"{{ feeds[...].value }} - ...get its last value",
].join('\n'),
},
lines: [
["{{ %1", { inputValue: 'TEMPLATE', shadow: 'text_multiline' }],
],
generators: {
json: (block, generator) => {
const
template = generator.valueToCode(block, 'TEMPLATE', 0) || null,
blockPayload = JSON.stringify({
textTemplate: {
template: JSON.parse(template)
},
})
return [ blockPayload, 0 ]
}
},
regenerators: {
json: (blockObject, helpers) => {
const
{ template } = blockObject.textTemplate,
inputs = {
TEMPLATE: helpers.expressionToBlock(template, { shadow: 'text' })
}
return { type: 'text_template', inputs }
}
}
}

115
app/toolbox/index.js Normal file
View file

@ -0,0 +1,115 @@
// build toolbox from a config and blocks that reference it
import { compact, forEach, includes, isEmpty, isString, keyBy, map, mapValues, filter, flatMap, reduce } from 'lodash-es'
import { allBlockDefinitions } from '../blocks/index.js'
const
SEP = '---',
TOOLBOX_CONFIG = [
{ name: 'Logic', colour: 60 },
{ name: 'Math', colour: 120 },
{ name: 'Text', colour: 180 },
// TODO: build our own variables category & blocks
{ name: 'Variables', colour: 240, extras: { custom: "VARIABLE" }},
{ name: 'Feeds', colour: 300 },
{ name: 'Actions', colour: 360 },
],
generateToolboxContents = () => map(TOOLBOX_CONFIG, category =>
// inject other kinds of toolbox objects here
category === SEP
? { kind: 'sep' }
: {
kind: 'category',
name: category.name,
colour: (category.colour === 0) ? "0" : category.colour,
...category.extras,
contents: generateCategoryContents(category)
}
),
generateCategoryContents = ({ name }) =>
flatMap(selectBlocksByCategoryName(name), blockToLabelAndBlock),
generateAllBlocks = () =>
flatMap(allBlockDefinitions, blockToLabelAndBlock),
selectBlocksByCategoryName = name =>
filter(allBlockDefinitions, def =>
def.toolbox?.category === name || includes(def.toolbox?.categories, name)
),
blockToLabelAndBlock = block => compact([
{
kind: 'block',
type: block.type,
inputs: blockToInputs(block),
fields: blockToFields(block)
}, block.toolbox.label
? { kind: 'label',
text: block.toolbox.label
}
: null
]),
blockToInputs = ({ lines }) => {
if(!lines) { return }
const inputs =
mapValues(
keyBy(
filter(
map(lines, '[1]'),
"inputValue"),
"inputValue"),
shadowPropertyToInput)
return isEmpty(inputs) ? undefined : inputs
},
blockToFields = ({ lines }) => {
if(!lines) { return }
// get every field that contains a "value" property
const fields =
reduce(
map(
filter(
map(lines, '[1]'),
"fields"),
"fields"),
(acc, fields) => {
forEach(fields, (field, fieldKey) => {
if(field.value){
acc[fieldKey] = field.value
}
})
return acc
}, {})
// produces:
// {
// FIELD_NAME: field_value,
// ...
// }
return isEmpty(fields) ? undefined : fields
},
shadowPropertyToInput = ({ shadow }) =>
isString(shadow) // is shorthand?
? { shadow: { type: shadow }} // expand to full object
: { shadow } // set as shadow value
export const toolbox = {
// Use given categories, fill them with blocks that declare those categories
kind: 'categoryToolbox',
contents: generateToolboxContents()
// No categories, just a gutter full of blocks
// kind: 'flyoutToolbox',
// contents: generateAllBlocks()
}
export default toolbox

View file

@ -0,0 +1,14 @@
{
"blocks": {
"languageVersion": 0,
"blocks": [
{
"type": "action_root",
"movable": false,
"deletable": false,
"x": 50,
"y": 50
}
]
}
}