Merge pull request #4 from lorennorman/delay-ui

Delay UI
This commit is contained in:
Loren Norman 2024-12-10 15:02:02 -05:00 committed by GitHub
commit 4157ad3026
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 1402 additions and 225 deletions

View file

@ -1 +1 @@
nodejs 18.17.0
nodejs 22.5.1

View file

@ -0,0 +1,21 @@
export default {
type: "delay_days",
visualization: {
colour: "0",
tooltip: "1 day is the maximum delay available"
},
connections: {
mode: 'value',
output: "delay_period",
},
lines: [ "1 day" ],
generators: {
json: () => {
}
}
}

View file

@ -0,0 +1,36 @@
import { makeOptions } from "#app/util/fields.js"
export default {
type: "delay_hours",
visualization: {
colour: "0",
tooltip: "Set a delay between 1 and 23 hours",
},
connections: {
mode: 'value',
output: "delay_period",
},
lines: [
[ "%SECONDS hours", {
field: 'SECONDS',
options: makeOptions({ from: 1, upTo: 24, valueFunc: sec => sec*60*60 })
// options: [
// ['1', '3600'],
// ['2', '7200'],
// ...
// ['58', '79200'],
// ['59', '82800'],
// ]
}]
],
generators: {
json: () => {
}
}
}

View file

@ -0,0 +1,36 @@
import { makeOptions } from "#app/util/fields.js"
export default {
type: "delay_minutes",
visualization: {
colour: "0",
tooltip: "Set a delay between 1 and 59 minutes",
},
connections: {
mode: 'value',
output: "delay_period",
},
lines: [
[ "%SECONDS minutes", {
field: 'SECONDS',
options: makeOptions({ from: 1, upTo: 60, valueFunc: sec => sec*60 })
// options: [
// ['1', '60'],
// ['2', '120'],
// ...
// ['58', '3480'],
// ['59', '3540'],
// ]
}]
],
generators: {
json: () => {
}
}
}

View file

@ -0,0 +1,21 @@
export default {
type: "delay_none",
visualization: {
colour: "0",
tooltip: "No delay: Actions run immediately when triggered."
},
connections: {
mode: 'value',
output: "delay_period",
},
lines: [ "No Delay" ],
generators: {
json: () => {
}
}
}

View file

@ -0,0 +1,35 @@
import { makeOptions } from "#app/util/fields.js"
export default {
type: "delay_seconds",
visualization: {
colour: "0",
tooltip: "Set a delay between 1 and 59 seconds (or 0 for no delay)",
},
connections: {
mode: 'value',
output: "delay_period",
},
lines: [
[ "%SECONDS seconds", {
field: 'SECONDS',
options: makeOptions({ from: 1, upTo: 60 })
// options: [
// ['1', '1'],
// ...
// ['58', '58'],
// ['59', '59'],
// ]
}]
],
generators: {
json: () => {
}
}
}

View file

@ -0,0 +1,51 @@
export default {
type: "delay_settings",
// same as Blockly JSON
visualization: {
colour: '0',
tooltip: [
"Causes a delay between this Action's trigger and its execution",
"---------------",
"Parameters:",
"Delay - how long to delay, from 1 second to 1 day",
"Mode - how to proceed if another action is already on delay.",
"...'keep' will keep the existing delay and ignore new triggers",
"...'reset' will delete the existing delay and start a new one",
].join('\n'),
},
connections: { },
lines: [
["Delay Settings", "CENTER"],
["Delay:", {
// for a single block input
inputValue: 'DELAY_PERIOD',
check: 'delay_period',
shadow: 'delay_none',
}],
["and", {
field: 'DELAY_MODE',
options: [
['reset', 'extend'],
['keep', 'static'],
],
}],
"existing delays"
],
// 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,66 @@
/** Action settings on the root block */
export default {
delaySeconds: 0,
delayMode: 'extend',
saveExtraState: function() {
return {
delaySeconds: this.delaySeconds,
delayMode: this.delayMode,
}
},
loadExtraState: function({ delaySeconds, delayMode }) {
this.delaySeconds = delaySeconds || 0
this.delayMode = delayMode || "extend"
},
flyoutBlockTypes: [ 'delay_none', 'delay_seconds', 'delay_minutes', 'delay_hours', 'delay_days' ],
decompose: function(workspace) {
// initialize the top-level block for the sub-diagram
const delaySettingsBlock = workspace.newBlock('delay_settings')
delaySettingsBlock.initSvg()
// set the appropriate delay block type by the seconds
const delayBlockType = (this.delaySeconds <= 0) ? "delay_none"
: (this.delaySeconds < 60) ? "delay_seconds"
: (this.delaySeconds < 3600) ? "delay_minutes"
: (this.delaySeconds < 86400) ? "delay_hours"
: (this.delaySeconds >= 86400) ? "delay_days"
: "delay_none"
const delayPeriodBlock = workspace.newBlock(delayBlockType)
delayPeriodBlock.initSvg()
if(delayBlockType !== "delay_days" && delayBlockType !== "delay_none") {
// set its seconds field (day doesn't have one)
delayPeriodBlock.setFieldValue(this.delaySeconds.toString(), "SECONDS")
}
// connect it to the delay settings block
const
{ connection } = delaySettingsBlock.getInput("DELAY_PERIOD"),
{ outputConnection } = delayPeriodBlock
connection.connect(outputConnection)
connection.setShadowState({ type: 'delay_none' })
// set the mode field
delaySettingsBlock.setFieldValue(this.delayMode, 'DELAY_MODE')
return delaySettingsBlock
},
compose: function(delayBlock) {
const
periodInput = delayBlock.getInputTargetBlock("DELAY_PERIOD"),
value = (periodInput?.type == "delay_days") // hard-code for day
? "86400"
: periodInput?.getFieldValue("SECONDS") || 0
this.delaySeconds = parseInt(value, 10)
this.delayMode = delayBlock.getFieldValue("DELAY_MODE")
}
}

View file

@ -1,48 +1,32 @@
const
random = Math.random()*100000000, // busts the NodeJS file cache
mutator = (await import(`./action_settings/mutator.js?key=${random}`)).default
export default {
type: "action_root",
toolbox: {},
connections: {},
visualization: {
colour: "0",
extensions: [ ],
tooltip: "Add Triggers to determine when this Action runs.\nAdd Actions to determine what this Action does."
},
mutator,
lines: [
[ "", "LEFT" ],
[ "Triggers:", "LEFT" ],
[ "", {
inputStatement: "TRIGGERS",
check: 'trigger'
}],
[ "...delay this Action by %DELAY", {
align: "LEFT",
field: 'DELAY',
options: [
["no delay", "0"],
["5s", "5"],
["5m", "300"],
["5h", "1800"],
]
}],
[ "...repeat Actions %DELAY_MODE", {
field: 'DELAY_MODE',
options: [
["reset existing delays", "extend"],
["are ignored", "static"],
]
}],
[ "", "LEFT" ],
[ "Actions:", "LEFT" ],
[ "", {
inputStatement: "EXPRESSIONS",
// check: "expression"
check: "expression"
}],
[ "", "LEFT" ],
@ -71,8 +55,8 @@ export default {
}
const
seconds = parseInt(block.getFieldValue('DELAY'), 10),
mode = block.getFieldValue('DELAY_MODE'),
seconds = block.delaySeconds,
mode = block.delayMode,
delay = (seconds > 0)
? { seconds, mode }
: undefined
@ -96,9 +80,9 @@ export default {
deletable: false,
x: 50,
y: 50,
fields: {
DELAY: (settings.delay?.seconds || 0).toString(),
DELAY_MODE: settings.delay?.mode || 'extend'
extraState: {
delaySeconds: settings.delay?.seconds || 0,
delayMode: settings.delay?.mode || 'extend'
},
inputs: {
"TRIGGERS": helpers.arrayToStatements(triggers),

View file

@ -1,84 +0,0 @@
import { map, range } from 'lodash-es'
export default {
type: "every_day",
toolbox: {
category: 'Triggers',
label: "Runs the Action every day on the specified hour."
},
visualization: {
colour: 30,
tooltip: "Run this action daily on the specified hour."
},
connections: {
mode: "statement",
output: "trigger",
next: "trigger"
},
lines: [
[ "...every day at %HOUR", {
align: 'LEFT',
fields: {
HOUR: {
options: [
["12 midnight", "0"],
["1am", "1"],
["2am", "2"],
["3am", "3"],
["4am", "4"],
["5am", "5"],
["6am", "6"],
["7am", "7"],
["8am", "8"],
["9am", "9"],
["10am", "10"],
["11am", "11"],
["12 noon", "12"],
["1pm", "13"],
["2pm", "14"],
["3pm", "15"],
["4pm", "16"],
["5pm", "17"],
["6pm", "18"],
["7pm", "19"],
["8pm", "20"],
["9pm", "21"],
["10pm", "22"],
["11pm", "23"],
]
}
}
}],
],
generators: {
json: block => {
const hour = block.getFieldValue('HOUR')
return JSON.stringify({
everyDay: {
schedule: `0 ${hour} * * *`
}
})
}
},
regenerators: {
json: blockObject => {
const
{ schedule } = blockObject.everyDay,
hour = schedule.split(' ')[1]
return {
type: "every_day",
fields: {
HOUR: hour
}
}
}
}
}

View file

@ -1,76 +0,0 @@
import { map, range } from 'lodash-es'
export default {
type: "every_hour",
toolbox: {
category: 'Triggers',
label: "Runs the Action every hour at the specified minute."
},
visualization: {
inputsInline: true,
colour: 30,
tooltip: "Run this action hourly at the minute specified."
},
connections: {
mode: "statement",
output: "trigger",
next: "trigger"
},
lines: [
[ "...%FREQUENCY an hour, at minute %MINUTE", {
align: 'LEFT',
fields: {
FREQUENCY: {
options: [
["once", "once"],
["twice", "twice"],
]
},
MINUTE: {
options: map(map(range(60), String), idx => ([ idx, idx ]))
}
}
}],
],
generators: {
json: block => {
const
frequency = block.getFieldValue('FREQUENCY'),
minute = block.getFieldValue('MINUTE'),
cronMinutes = frequency === 'once'
? minute
: `${minute%30}/30`
return JSON.stringify({
everyHour: {
schedule: `${cronMinutes} * * * *`
}
})
}
},
regenerators: {
json: blockObject => {
const
{ schedule } = blockObject.everyHour,
cronMinutes = schedule.split(" ")[0],
minute = cronMinutes.split('/')[0],
frequency = cronMinutes.includes('/')
? 'twice'
: 'once'
return {
type: "every_hour",
fields: {
MINUTE: minute,
FREQUENCY: frequency,
}
}
}
}
}

View file

@ -0,0 +1,169 @@
export default {
type: "on_schedule",
toolbox: {
label: "Run this Action on a set schedule."
},
visualization: {
colour: 30,
tooltip: "A schedule to run the action, from every minute to once a year."
},
connections: {
mode: "statement",
output: "trigger",
next: "trigger"
},
lines: [
[ "Schedule", "CENTER" ],
[ "Months:", {
inputValue: "MONTH",
check: "cron_month",
block: "all_months",
}],
[ "Days:", {
inputValue: "DAY",
check: "cron_day",
block: "all_days"
}],
[ "Hours:", {
inputValue: "HOUR",
check: "cron_hour",
block: "all_hours"
}],
[ "Minutes:", {
inputValue: "MINUTE",
check: "cron_minute",
block: {
type: "one_minute",
fields: {
MINUTE: '15'
}
}
}],
],
generators: {
json: (block, generator) => {
// each schedule block generates a crontab for its unit
const
minute = generator.valueToCode(block, 'MINUTE', 0) || "*/1",
hour = generator.valueToCode(block, 'HOUR', 0) || "*/1",
daysOfMonth = generator.valueToCode(block, 'DAY', 0) || "*/1",
daysOfWeek = "*",
month = generator.valueToCode(block, 'MONTH', 0) || "*"
return JSON.stringify({
onSchedule: {
schedule: `${minute} ${hour} ${daysOfMonth} ${month} ${daysOfWeek}`
}
})
}
},
regenerators: {
json: (blockObject, helpers) => {
const EVERY_REGEX = /^(\d{1,2})(-(\d{1,2}))?\/(\d{1,2})$/m
const
monthCronToBlock = monthCron => {
if(monthCron === '*') {
return { block: { type: 'all_months' } }
} else if(/^\d*$/gm.test(monthCron)) {
return { block: { type: 'one_month', fields: { MONTH: monthCron } } }
} else if(/^\w{3}(,\w{3})*$/gm.test(monthCron)) {
const
fieldNames = monthCron.toUpperCase().split(','),
fields = fieldNames.reduce((acc, month) => {
acc[month] = true
return acc
}, {})
return { block: { type: 'some_months', fields }}
} else {
console.warn(`Bad crontab for months: ${monthCron}`)
}
},
dayCronToBlock = dayCron => {
if(dayCron === '*') {
return { block: { type: 'all_days' } }
} else if(/^\d*$/gm.test(dayCron)) {
return { block: { type: 'one_day', fields: { DAY: dayCron } } }
} else {
throw new Error(`Bad cron string for days: ${dayCron}`)
}
},
hourCronToBlock = hourCron => {
if(hourCron === '*') {
return { block: { type: 'all_hours' } }
} else if(/^\d*$/gm.test(hourCron)) {
return { block: { type: 'one_hour', fields: { HOUR: hourCron } } }
} else {
throw new Error(`Bad cron string for hours: ${hourCron}`)
}
},
minuteCronToBlock = minuteCron => {
if(minuteCron === '*') {
return { block: { type: 'all_minutes' } }
} else if(/^\d*$/gm.test(minuteCron)) {
return { block: { type: 'one_minute', fields: { MINUTE: minuteCron } } }
} else if(EVERY_REGEX.test(minuteCron)) {
const [ skip1, START, skip2, END, FREQUENCY ] = minuteCron.match(EVERY_REGEX)
return { block: {
type: 'every_minutes_between',
fields: { START, END, FREQUENCY }
}}
} else {
throw new Error(`Bad cron string for minutes: ${minuteCron}`)
}
}
const
// { schedule } = blockObject.onSchedule,
{ schedule } = Object.values(blockObject)[0],
[ minute, hour, daysOfMonth, month, daysOfWeek ] = schedule.split(' ')
return {
type: "on_schedule",
inputs: {
MONTH: {
...monthCronToBlock(month),
shadow: { type: "all_months" }
},
DAY: {
...dayCronToBlock(daysOfMonth),
shadow: { type: "all_days" }
},
HOUR: {
...hourCronToBlock(hour),
shadow: { type: "all_hours" }
},
MINUTE: {
...minuteCronToBlock(minute),
shadow: { type: "one_minute", fields: { 'MINUTE': '15' }}
},
}
}
}
}
}

View file

@ -0,0 +1,27 @@
const
random = Math.random()*100000000, // busts the NodeJS file cache
mutator = (await import(`./day_mutator.js?key=${random}`)).default
export default {
type: "all_days",
visualization: {
colour: 30,
tooltip: "Runs during every day of the month."
},
connections: {
mode: 'value',
output: 'cron_day'
},
mutator,
lines: [ "Every day" ],
generators: {
json: block => {
return [ '*', 0 ]
}
}
}

View file

@ -0,0 +1,9 @@
const
random = Math.random()*100000000, // busts the NodeJS file cache
makeScheduleMutator = (await import(`../schedule_mutator.js?key=${random}`)).default
export default makeScheduleMutator(
[ 'all_days', 'one_day' ],
'day_settings',
'all_days'
)

View file

@ -0,0 +1,22 @@
export default {
type: "day_settings",
visualization: {
colour: 30,
tooltip: "How would you like to specify days of the month for your schedule?"
},
connections: {},
lines: [
[ "Day:", {
inputValue: 'DAY_BLOCK',
check: 'cron_day',
shadow: 'all_days'
}],
],
generators: {
json: block => { }
}
}

View file

@ -0,0 +1,40 @@
import { makeOptions } from "#app/util/fields.js"
const
random = Math.random()*100000000, // busts the NodeJS file cache
mutator = (await import(`./day_mutator.js?key=${random}`)).default
export default {
type: "one_day",
visualization: {
colour: 30,
tooltip: [
"Runs during a particular day of the month.",
"Remember: not all months have days after 28!"
].join("\n")
},
connections: {
mode: 'value',
output: 'cron_day'
},
mutator,
lines: [
["On date:", {
field: 'DAY',
options: makeOptions({ from: 1, upTo: 31 })
}]
],
generators: {
json: block => {
const day = block.getFieldValue('DAY')
return [ day, 0 ]
}
}
}

View file

@ -0,0 +1,28 @@
const
random = Math.random()*100000000, // busts the NodeJS file cache
mutator = (await import(`./hour_mutator.js?key=${random}`)).default
export default {
type: "all_hours",
visualization: {
colour: 30,
tooltip: "Runs during all hours of the day."
},
connections: {
mode: 'value',
output: 'cron_hour'
},
mutator,
lines: [ "Every hour" ],
generators: {
json: block => {
return [ '*', 0 ]
}
}
}

View file

@ -0,0 +1,9 @@
const
random = Math.random()*100000000, // busts the NodeJS file cache
makeScheduleMutator = (await import(`../schedule_mutator.js?key=${random}`)).default
export default makeScheduleMutator(
[ 'all_hours', 'one_hour' ],
'hour_settings',
'all_hours'
)

View file

@ -0,0 +1,22 @@
export default {
type: "hour_settings",
visualization: {
colour: 30,
tooltip: "How would you like to specify hours of the day for your schedule?"
},
connections: {},
lines: [
[ "Hour:", {
inputValue: 'HOUR_BLOCK',
check: 'cron_hour',
shadow: 'all_hours'
}],
],
generators: {
json: block => { }
}
}

View file

@ -0,0 +1,37 @@
import { makeOptions } from "#app/util/fields.js"
const
random = Math.random()*100000000, // busts the NodeJS file cache
mutator = (await import(`./hour_mutator.js?key=${random}`)).default
export default {
type: "one_hour",
visualization: {
colour: 30,
tooltip: "Runs during a particular hour of the day."
},
connections: {
mode: 'value',
output: 'cron_hour'
},
mutator,
lines: [
["During hour:", {
field: 'HOUR',
options: makeOptions({ upTo: 24 })
}]
],
generators: {
json: block => {
const hour = block.getFieldValue('HOUR')
return [ hour, 0 ]
}
}
}

View file

@ -0,0 +1,28 @@
const
random = Math.random()*100000000, // busts the NodeJS file cache
mutator = (await import(`./minute_mutator.js?key=${random}`)).default
export default {
type: "all_minutes",
visualization: {
colour: 30,
tooltip: "Runs every minute of the hour."
},
connections: {
mode: 'value',
output: 'cron_minute'
},
mutator,
lines: [ "Every minute" ],
generators: {
json: block => {
return [ '*', 0 ]
}
}
}

View file

@ -0,0 +1,45 @@
import { makeOptions } from "#app/util/fields.js"
const
random = Math.random()*100000000, // busts the NodeJS file cache
mutator = (await import(`./minute_mutator.js?key=${random}`)).default
export default {
type: "every_minutes_between",
visualization: {
colour: 30,
tooltip: "Runs every X minutes, between minutes Y and Z."
},
connections: {
mode: 'value',
output: 'cron_minute'
},
mutator,
lines: [
["Every %FREQUENCY minutes between %START and %END", {
fields: {
FREQUENCY: { options: makeOptions({ factorsOf: 60 }) },
START: { options: makeOptions({ upTo: 60 }) },
END: { options: makeOptions({ upTo: 60, reverse: true }) }
}
}]
],
generators: {
json: block => {
const
frequency = block.getFieldValue('FREQUENCY'),
start = block.getFieldValue('START'),
end = block.getFieldValue('END'),
cronString = `${start}-${end}/${frequency}`
return [ cronString, 0 ]
}
}
}

View file

@ -0,0 +1,9 @@
const
random = Math.random()*100000000, // busts the NodeJS file cache
makeScheduleMutator = (await import(`../schedule_mutator.js?key=${random}`)).default
export default makeScheduleMutator(
[ 'all_minutes', 'one_minute', 'every_minutes_between' ],
'minute_settings',
'all_minutes'
)

View file

@ -0,0 +1,22 @@
export default {
type: "minute_settings",
visualization: {
colour: 30,
tooltip: "How would you like to specify minutes of the hour for your schedule?"
},
connections: {},
lines: [
[ "Minute:", {
inputValue: 'MINUTE_BLOCK',
check: 'cron_minute',
shadow: 'all_minutes'
}],
],
generators: {
json: block => { }
}
}

View file

@ -0,0 +1,36 @@
import { makeOptions } from "#app/util/fields.js"
const
random = Math.random()*100000000, // busts the NodeJS file cache
mutator = (await import(`./minute_mutator.js?key=${random}`)).default
export default {
type: "one_minute",
visualization: {
colour: 30,
tooltip: "Runs at a particular minute of the hour."
},
connections: {
mode: 'value',
output: 'cron_minute'
},
mutator,
lines: [
["At minute:", {
field: 'MINUTE',
options: makeOptions({ upTo: 60 })
}]
],
generators: {
json: block => {
const minute = block.getFieldValue('MINUTE')
return [ minute, 0 ]
}
}
}

View file

@ -0,0 +1,27 @@
const
random = Math.random()*100000000, // busts the NodeJS file cache
mutator = (await import(`./month_mutator.js?key=${random}`)).default
export default {
type: "all_months",
visualization: {
colour: 30,
tooltip: "Runs during all months."
},
connections: {
mode: 'value',
output: 'cron_month'
},
mutator,
lines: [ "Every month" ],
generators: {
json: block => {
return [ '*', 0 ]
}
}
}

View file

@ -0,0 +1,9 @@
const
random = Math.random()*100000000, // busts the NodeJS file cache
makeScheduleMutator = (await import(`../schedule_mutator.js?key=${random}`)).default
export default makeScheduleMutator(
[ 'all_months', 'one_month', 'some_months', ],
'month_settings',
'all_months'
)

View file

@ -0,0 +1,22 @@
export default {
type: "month_settings",
visualization: {
colour: 30,
tooltip: "How would you like to specify the months portion of your schedule?"
},
connections: {},
lines: [
[ "Month:", {
inputValue: 'MONTH_BLOCK',
check: 'cron_month',
shadow: 'all_months'
}],
],
generators: {
json: block => { }
}
}

View file

@ -0,0 +1,47 @@
const
random = Math.random()*100000000, // busts the NodeJS file cache
mutator = (await import(`./month_mutator.js?key=${random}`)).default
export default {
type: "one_month",
visualization: {
colour: 30,
tooltip: "Runs during a particular month."
},
connections: {
mode: 'value',
output: 'cron_month'
},
mutator,
lines: [
["", {
field: 'MONTH',
options: [
[ "January", '1' ],
[ "February", '2' ],
[ "March", '3' ],
[ "April", '4' ],
[ "May", '5' ],
[ "June", '6' ],
[ "July", '7' ],
[ "August", '8' ],
[ "September", '9' ],
[ "October", '10' ],
[ "November", '11' ],
[ "December", '12' ],
]
}]
],
generators: {
json: block => {
const month = block.getFieldValue('MONTH')
return [ month, 0 ]
}
}
}

View file

@ -0,0 +1,66 @@
const
random = Math.random()*100000000, // busts the NodeJS file cache
mutator = (await import(`./month_mutator.js?key=${random}`)).default
export default {
type: 'some_months',
visualization: {
colour: 30,
tooltip: "Run during particular months."
},
connections: {
mode: 'value',
output: 'cron_month'
},
mutator,
lines: [
[ 'Jan: %JAN Feb: %FEB Mar: %MAR Apr: %APR', {
align: 'CENTER',
fields: {
JAN: { checked: false },
FEB: { checked: false },
MAR: { checked: false },
APR: { checked: false }
},
}],
[ 'May: %MAY Jun: %JUN Jul: %JUL Aug: %AUG', {
align: 'CENTER',
fields: {
MAY: { checked: false },
JUN: { checked: false },
JUL: { checked: false },
AUG: { checked: false },
}
}],
[ 'Sep: %SEP Oct: %OCT Nov: %NOV Dec: %DEC', {
align: 'CENTER',
fields: {
SEP: { checked: false },
OCT: { checked: false },
NOV: { checked: false },
DEC: { checked: false },
}
}]
],
generators: {
json: block => {
const
MONTHS = [ 'JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC' ],
selectedMonths = MONTHS.reduce((months, month) => (
block.getFieldValue(month) === "TRUE"
? months.concat(month)
: months
), []),
months = selectedMonths.join(',').toLowerCase()
return [ months, 0 ]
}
}
}

View file

@ -0,0 +1,139 @@
const MUTATOR_BASE = {
loadExtraState: function () { },
saveExtraState: function () { },
flyoutBlockTypes: [],
decompose: function (workspace) {
// prepare the mutator root block as usual in decompose
const settingsBlock = workspace.newBlock(this.rootBlock) // here
settingsBlock.initSvg()
this.innerInputName = settingsBlock.inputList[0]?.name
if(!this.innerInputName) {
throw new Error("No inner input detected for self-mutating block mutator.")
}
const
defaultType = this.defaultType, // here
outerUnitType = this.type || defaultType,
// create inner versions
innerUnitInput = settingsBlock.getInput(this.innerInputName), // here
innerUnitConnection = innerUnitInput.connection,
innerUnitBlock = workspace.newBlock(outerUnitType),
{ outputConnection } = innerUnitBlock
// copy fields from outer block to inner block
this.copyFields(this, innerUnitBlock)
// initialize the block for interaction
innerUnitBlock.initSvg()
// attach to schedule settings
innerUnitConnection.connect(outputConnection)
// remove mutators from all the inner blocks
const innerBlocks = workspace.getFlyout().getWorkspace().getTopBlocks() || []
innerBlocks.forEach(block => block.setMutator(null))
// self-removing listener callback, applied to the outer workspace,
// that calls attachOuterBlock when the mutator is closed
const bubbleCloseCallback = e => {
if(e.type !== Blockly.Events.BUBBLE_OPEN) { return }
// mutator is closing
if(!e.isOpen) {
// update outer block if inner present
if(this.newBlockType) { this.attachOuterBlock(settingsBlock) }
// remove ourself
this.workspace.removeChangeListener(bubbleCloseCallback)
}
}
this.workspace.addChangeListener(bubbleCloseCallback)
// when a new block is created in this mutator, remove mutators from those blocks too
workspace.addChangeListener(e => {
if(e.type !== Blockly.Events.BLOCK_CREATE) { return }
if(e.blockId === settingsBlock.id) { return }
const block = workspace.getBlockById(e.blockId)
block?.setMutator(null)
})
return settingsBlock
},
// compose gets called frequently, despite what the docs say
// as a result it needs to store the changes it wants to make
// for later processing, instead of just doing them
compose: function (settingsBlock) {
const newBlock = settingsBlock.getInputTargetBlock(this.innerInputName) // here
if(newBlock) {
this.newBlockType = newBlock.type
this.storeFieldData(newBlock)
} else {
this.newBlockType = null
this.fieldData = {}
}
},
// replace ourself with the new block and its data
attachOuterBlock: function(settingsBlock) {
const
outerUnitConnection = this.outputConnection.targetConnection,
outerUnitBlock = this.workspace.newBlock(this.newBlockType)
// populate new block with data stored from the inner block
this.restoreFieldData(outerUnitBlock)
// disconnect ourself
outerUnitConnection.disconnect()
// connect our new block and intialize its graphics
outerUnitConnection.connect(outerUnitBlock.outputConnection)
outerUnitBlock.initSvg()
outerUnitBlock.render()
// be done with ourself
this.dispose()
},
// field data helpers
getFieldNames: function(block) {
return block.inputList.reduce((names, input) =>
names.concat(input.fieldRow.map(field => field.name).filter(name => name))
, [])
},
copyFields: function(fromBlock, toBlock) {
const fieldNames = this.getFieldNames(fromBlock)
// get from source, set on destination
fieldNames.forEach(fieldName => {
toBlock.setFieldValue(fromBlock.getFieldValue(fieldName), fieldName)
})
},
storeFieldData: function(fromBlock) {
const fieldNames = this.getFieldNames(fromBlock)
this.fieldData = fieldNames.reduce((data, name) => {
data[name] = fromBlock.getFieldValue(name)
return data
}, {})
},
restoreFieldData: function(toBlock) {
for (const [fieldName, fieldValue] of Object.entries(this.fieldData)) {
toBlock.setFieldValue(fieldValue, fieldName)
}
}
}
export default (flyoutBlockTypes, rootBlock, defaultType) => {
return {
...MUTATOR_BASE,
flyoutBlockTypes, rootBlock, defaultType
}
}

View file

@ -0,0 +1,6 @@
// makes disabled blocks
export const autoDisable = ({ block }) => {
block.disabled = true
}
export default autoDisable

View file

@ -7,10 +7,9 @@ export default {
"Actions cannot fire more than once in a 5 minute period."
],
contents: [
'on_schedule',
'when_data',
'when_data_matching',
'when_data_matching_state',
'every_hour',
'every_day',
]
}

56
app/util/fields.js Normal file
View file

@ -0,0 +1,56 @@
import { filter, identity, map, range, reverse } from 'lodash-es'
const makeFactorsOf = target => {
return filter(range(target), idx => (target % idx) == 0)
}
const makeUpTo = (from, target, step) => {
return range(from, target, step)
}
const stringifyOptions = (options) => {
}
/**
*
* @param {Object} options
* @param {number} [options.factorsOf]
* @param {number} [options.upTo]
* @param {number} [options.from]
* @param {number} [options.step]
* @param {boolean} [options.reverse]
* @param {Function} [options.valueFunc]
* @returns {Array}
*/
export const makeOptions = (options = {}) => {
let optionValues
if(options.factorsOf) {
optionValues = makeFactorsOf(options.factorsOf)
} else if(options.upTo) {
const
from = options.from || 0,
step = options.step || 1
optionValues = makeUpTo(from, options.upTo, step)
}
if(!optionValues) {
throw new Error(`No valid options sent to makeOptions: ${options}`)
}
if(options.reverse) {
optionValues = reverse(optionValues)
}
const
valueFunc = options.valueFunc || identity,
mappedValues = map(optionValues, labelValue =>
map([ labelValue, valueFunc(labelValue) ], String)
)
return mappedValues
}

View file

@ -11,9 +11,14 @@
<div id="page-container">
<div id="menu-pane">
<button id="button-clear">Clear</button>
<button id="button-reload-bytecode">Reload via Bytecode</button>
<button id="button-reload-serialized">Reload via LocalStorage</button>
<div id="stats-row">
<span>Top Blocks: <span id="top-blocks">0</span></span>
<span>Blocks: <span id="total-blocks">0</span></span>
<span title="Regular:Flyout:Mutator">Workspaces: <span id="total-workspaces">0:0:0</span></span>
</div>
<pre id="bytecode-json-container"><code id="bytecode-json"></code></pre>
<pre id="blockly-json-container"><code id="blockly-json"></code></pre>
</div>

12
jsconfig.json Normal file
View file

@ -0,0 +1,12 @@
{
"compilerOptions": {
"checkJs": true,
"module": "nodenext",
"target": "es2022",
"paths": {
"#app/*.js": ["./app/*.js"],
"#src/*.js": ["./src/*.js"],
"#test/*.js": ["./test/*.js"]
}
}
}

73
package-lock.json generated
View file

@ -14,6 +14,7 @@
},
"devDependencies": {
"@eslint/js": "^9.2.0",
"chai": "^5.1.2",
"eslint": "^8.57.0",
"esm-reload": "^1.0.1",
"glob": "^10.4.2",
@ -761,6 +762,15 @@
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"engines": {
"node": ">=12"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@ -807,6 +817,22 @@
"node": ">=6"
}
},
"node_modules/chai": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-5.1.2.tgz",
"integrity": "sha512-aGtmf24DW6MLHHG5gCx4zaI3uBq3KRtxeVs0DjFH6Z0rDNbsvTxFASFvdj79pxjxZ8/5u3PIiN3IwEIQkiiuPw==",
"dev": true,
"dependencies": {
"assertion-error": "^2.0.1",
"check-error": "^2.1.1",
"deep-eql": "^5.0.1",
"loupe": "^3.1.0",
"pathval": "^2.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@ -823,6 +849,15 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/check-error": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz",
"integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==",
"dev": true,
"engines": {
"node": ">= 16"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -897,11 +932,11 @@
}
},
"node_modules/debug": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"dependencies": {
"ms": "2.1.2"
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
@ -917,6 +952,15 @@
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz",
"integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA=="
},
"node_modules/deep-eql": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
"dev": true,
"engines": {
"node": ">=6"
}
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@ -1693,6 +1737,12 @@
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
"dev": true
},
"node_modules/loupe": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.2.tgz",
"integrity": "sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==",
"dev": true
},
"node_modules/lru-cache": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.3.0.tgz",
@ -1743,9 +1793,9 @@
}
},
"node_modules/ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/nanoid": {
"version": "3.3.6",
@ -1904,6 +1954,15 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/pathval": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz",
"integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==",
"dev": true,
"engines": {
"node": ">= 14.16"
}
},
"node_modules/picocolors": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",

View file

@ -3,12 +3,18 @@
"version": "1.0.0",
"description": "A Blockly tooling project",
"type": "module",
"imports": {
"#app/*.js": "./app/*.js",
"#src/*.js": "./src/*.js",
"#test/*.js": "./test/*.js"
},
"main": "index.js",
"scripts": {
"start": "vite --force",
"test": "node --test",
"test-watch": "node --test --watch",
"lint": "eslint src/",
"lint-export": "eslint export/",
"test": "echo \"Error: no test specified\" && exit 1",
"build": "npm run export && vite build",
"build-all-branches": "node build_all_branches.js",
"preview": "npm run build && vite preview",
@ -21,6 +27,7 @@
"license": "",
"devDependencies": {
"@eslint/js": "^9.2.0",
"chai": "^5.1.2",
"eslint": "^8.57.0",
"esm-reload": "^1.0.1",
"glob": "^10.4.2",

View file

@ -48,7 +48,7 @@ const
if(!definition) {
throw new Error(`No Block definition found at path: ${BLOCK_LOCATION}${path}`)
}
// console.log("Processing Block:", path)//, definition)
// ensure only supported keys are present
const extraKeys = without(keys(definition), ...BLOCK_KEYS)
@ -58,7 +58,13 @@ const
if(definition.disabled) { return }
// TODO: locate block defaults
// TODO: mechanism for Definition JSON defaults
if(definition.connections?.mode === 'value') {
// default input values with no output to 'expression'
definition.connections.output = definition.connections.output || 'expression'
}
// TODO: mechanism for Blockly JSON defaults
const blockDefaults = {
inputsInline: false
}

View file

@ -42,7 +42,7 @@ const processLine = (line) => {
check: lineData.check
})
// append inputStatement
// append inputValue
} else if(lineData.inputValue) {
args.push({
type: "input_value",
@ -58,7 +58,7 @@ const processLine = (line) => {
check: lineData.check
})
// append a field to args
// append a field to args
} else if(lineData.field) {
args.push({
name: lineData.field,
@ -149,7 +149,7 @@ const parseArrayLine = line => {
}
if(isObject(second)) {
const extraKeys = without(keys(second), "align", "inputDummy", "inputValue", "inputStatement", "check", "field", "fields", "type", "text", "multiline_text", "options", "shadow", "checked")
const extraKeys = without(keys(second), "align", "inputDummy", "inputValue", "inputStatement", "check", "block", "field", "fields", "type", "text", "multiline_text", "options", "shadow", "checked")
if(extraKeys.length) {
throw new Error(`Unrecognized keys (${extraKeys.join(', ')}) for block line with text: "${text}"`)
}

View file

@ -1,15 +1,30 @@
import { isString, isFunction, map, mapValues, pickBy } from 'lodash-es'
import { forOwn, isString, isFunction, isArray, isNumber, isNull, isObject, map, mapValues, pickBy, isRegExp } from 'lodash-es'
import { importBlockDefinitions } from './block_importer.js'
import renderTemplate from './template_renderer.js'
const renderValue = value => {
if(isString(value)) {
if (isString(value)) {
return `"${value}"`
} else if(isFunction) {
return value.toString().replaceAll("\n", "\n ")
} else {
} else if (isRegExp(value) || isNull(value) || isNumber(value) || value === false) {
return value
} else if (isFunction(value)) {
return value.toString().replaceAll("\n", "\n ")
} else if (isArray(value)) {
return `[ ${value.map(renderValue).join(", ")} ]`
} else if (isObject(value)) {
const lines = []
forOwn(value, (val, key) => {
lines.push(`"${key}": ${renderValue(val)}`)
})
return `{ ${lines.join(",\n")} }`
} else {
throw new Error(`Unexpected value type: ${value}`)
}
}

View file

@ -3,9 +3,5 @@ const allBlockMutators = {}
/* <<-LOCAL */
for (const [blockName, mutatorObject] of Object.entries(allBlockMutators)) {
if(mutatorObject.helperFunction) {
Blockly.Extensions.registerMutator(blockName, mutatorObject, mutatorObject.helperFunction)
} else {
Blockly.Extensions.registerMutator(blockName, mutatorObject)
}
Blockly.Extensions.registerMutator(blockName, mutatorObject, mutatorObject.helperFunction, mutatorObject.flyoutBlockTypes)
}

View file

@ -3,8 +3,11 @@ const blockRegenerators = {}
/* <<-LOCAL */
const BYTECODE_BLOCK_TYPE_MAP = {
everyHour: 'every_hour',
everyDay: 'every_day',
onSchedule: 'on_schedule',
everyMonth: 'on_schedule',
everyDay: 'on_schedule',
everyHour: 'on_schedule',
everyMinute: 'on_schedule',
whenData: 'when_data',
whenDataMatching: 'when_data_matching',
whenDataMatchStateChanged: 'when_data_matching_state',

View file

@ -164,7 +164,7 @@ const
map(lines, '[1]'),
"inputValue"),
"inputValue"),
shadowPropertyToInput)
definitionPropsToInputs)
return isEmpty(inputs) ? undefined : inputs
},
@ -197,10 +197,34 @@ const
return isEmpty(fields) ? undefined : fields
},
shadowPropertyToInput = ({ shadow }) =>
isString(shadow) // is shorthand?
? { shadow: { type: shadow }} // expand to full object
: { shadow } // set as shadow value
definitionPropsToInputs = ({ inputValue, block, shadow }) => {
if(!block && !shadow) {
console.warn("Warning: no block or shadow specified for", inputValue)
return
}
if(block) {
const
blockJson = blockToInput(block),
shadowJson = shadowToInput(shadow || block)
return {
...blockJson,
...shadowJson
}
} else if(shadow) {
return shadowToInput(shadow)
}
},
blockToInput = block => isString(block) // is shorthand?
? { block: { type: block }} // expand to full object
: { block }, // set as shadow value
shadowToInput = shadow => isString(shadow) // is shorthand?
? { shadow: { type: shadow }} // expand to full object
: { shadow } // set as shadow value
export default importToolbox

View file

@ -34,7 +34,9 @@ export default function ImportUserAppPlugin() {
},
handleHotUpdate(ctx) {
if (!ctx.file.includes('/app/') && !ctx.file.includes('/blockly_api.js')) {
if(ctx.file.includes("/test/")) { return }
if(!ctx.file.includes('/app/') && !ctx.file.includes('/blockly_api.js')) {
return
}

View file

@ -1,4 +1,5 @@
import Blockly from 'blockly'
import { filter } from 'lodash-es'
import initialWorkspace from '../export/workspace.json'
import { inject, jsonToWorkspace, workspaceToJson } from '../export/blockly.js'
@ -9,10 +10,16 @@ import './index.css'
// wire up the internal json to the dom
const
topBlocksDiv = document.getElementById('top-blocks'),
totalBlocksDiv = document.getElementById('total-blocks'),
totalWorkspacesDiv = document.getElementById('total-workspaces'),
blocklyJsonOutputDiv = document.getElementById('blockly-json'),
bytecodeJsonOutputDiv = document.getElementById('bytecode-json'),
onJsonUpdated = bytecodeJson => {
topBlocksDiv.innerText = workspace.getTopBlocks().length
totalBlocksDiv.innerText = workspace.getAllBlocks().length
blocklyJsonOutputDiv.innerText = ``
bytecodeJsonOutputDiv.innerText = `Bytecode is valid JSON ✅\n\n${bytecodeJson}`
@ -20,11 +27,14 @@ const
const workspaceJson = JSON.stringify(jsonToWorkspace(bytecodeJson), null, 2)
blocklyJsonOutputDiv.innerText = `Workspace JSON\n\n${workspaceJson}`
} catch(error) {
console.error("Workspace JSON Error:\n", error)
console.error("JSON:\n", bytecodeJson)
blocklyJsonOutputDiv.innerText = `Workspace JSON generation failed ❌\nYou may need the "Clear" button above.`
}
},
onJsonError = error => {
console.error("Bytecode JSON Error:\n", error)
bytecodeJsonOutputDiv.innerText = `Bytecode generation failed ❌\nYou may need the "Clear" button above.`
blocklyJsonOutputDiv.innerText = `Workspace JSON not generated.`
}
@ -48,6 +58,17 @@ const workspace = inject('blocklyDiv', {
// register listeners
setInterval(() => {
const
workspaces = Blockly.Workspace.getAll(),
total = workspaces.length,
mutators = filter(workspaces, 'isMutator').length,
flyouts = filter(workspaces, 'isFlyout').length,
rest = total - mutators - flyouts
totalWorkspacesDiv.innerText = `${rest}:${flyouts}:${mutators}`
}, 1000)
// auto-save on non-UI changes
workspace.addChangeListener((e) => e.isUiEvent || save(workspace))
@ -64,8 +85,15 @@ const reloadBytecodeButton = document.getElementById('button-reload-bytecode')
reloadBytecodeButton.addEventListener('click', () => {
// export bytecode
const bytecodeJson = workspaceToJson(workspace)
// convert bytecode to workspace json
const workspaceJson = jsonToWorkspace(bytecodeJson)
let workspaceJson
try{
// convert bytecode to workspace json
workspaceJson = jsonToWorkspace(bytecodeJson)
} catch(e) {
console.error("Failed to parse bytecode:", bytecodeJson)
}
// disable events while we're working
Blockly.Events.disable()

View file

@ -0,0 +1,56 @@
import { describe, it } from 'node:test'
import { assert } from 'chai'
import { makeOptions } from "#app/util/fields.js"
describe("Fields utils", () => {
describe("makeOptions", () => {
it("blows up with no valid options", () => {
assert.throws(() => makeOptions())
})
})
describe("makeOptions.upTo", () => {
it("uses from: 0 and step: 1 if options not given", () => {
assert.deepEqual(makeOptions({ upTo: 2 }), [["0", "0"], ["1", "1"]])
})
it("uses 'from' option if given", () => {
assert.deepEqual(makeOptions({ from: 1, upTo: 2 }), [["1", "1"]])
})
it("uses 'step' option if given", () => {
assert.deepEqual(makeOptions({ upTo: 21, step: 10 }), [
["0", "0"], ["10", "10"], ["20", "20"]
])
})
it("uses 'valueFunc' option if given", () => {
assert.deepEqual(makeOptions({ upTo: 3, valueFunc: (v) => v*5 }),
[["0", "0"], ["1", "5"], ["2", "10"]]
)
})
})
describe("makeOptions.factorsOf", () => {
it("makes options with factors", () => {
assert.deepEqual(makeOptions({ factorsOf: 2 }), [["1", "1"]])
assert.deepEqual(makeOptions({ factorsOf: 24 }),
[
[ '1', '1' ], [ '2', '2' ], [ '3', '3' ],
[ '4', '4' ], [ '6', '6' ], [ '8', '8' ],
[ '12', '12' ]
]
)
assert.deepEqual(makeOptions({ factorsOf: 60 }),
[
[ '1', '1' ], [ '2', '2' ], [ '3', '3' ],
[ '4', '4' ], [ '5', '5' ], [ '6', '6' ],
[ '10', '10' ], [ '12', '12' ], [ '15', '15' ],
[ '20', '20' ], [ '30', '30' ]
]
)
})
})
})