feat(automation): define validated automation recipes

Provide tested recipe definitions, lifecycle labels, and input validation for common automation setups.
This commit is contained in:
Carlos Valente
2026-09-12 16:48:32 +02:00
parent 0f27334f97
commit 67b874fe04
4 changed files with 640 additions and 23 deletions
@@ -0,0 +1,193 @@
import { isHTTPOutput, isOSCOutput, isOntimeAction, timerLifecycleValues } from 'ontime-types';
import {
automationRecipes,
defaultValues,
getAvailableRecipes,
recipeCategoryOrder,
validateRecipeValues,
} from '../automationRecipes';
import { operators } from '../automationUtils';
/**
* Recipes are shipped as constants but created through the same endpoint as a hand written
* automation. These assertions stand in for the server side validation, so a recipe cannot
* silently rot into something that 400s when the user presses create.
*/
describe('automationRecipes', () => {
const built = automationRecipes.map((recipe) => ({ recipe, automation: recipe.build(defaultValues(recipe)) }));
it('has unique ids', () => {
const ids = automationRecipes.map(({ id }) => id);
expect(new Set(ids).size).toBe(ids.length);
});
it('only uses categories the picker knows how to render', () => {
for (const { recipe } of built) {
expect(recipeCategoryOrder).toContain(recipe.category);
}
});
it('binds every recipe to at least one valid lifecycle', () => {
for (const { recipe } of built) {
expect(recipe.triggers.length).toBeGreaterThan(0);
for (const cycle of recipe.triggers) {
expect(timerLifecycleValues).toContain(cycle);
}
}
});
it('builds a titled automation with something to send, from its own defaults', () => {
for (const { automation } of built) {
expect(automation.title).not.toBe('');
expect(automation.outputs.length).toBeGreaterThan(0);
for (const output of automation.outputs) {
expect(isOSCOutput(output) || isHTTPOutput(output) || isOntimeAction(output)).toBe(true);
}
}
});
it('gives every choice parameter options, and a default that is one of them', () => {
const choices = automationRecipes.flatMap(({ params }) => params.filter(({ type }) => type === 'choice'));
expect(choices.filter(({ options }) => !options?.length)).toEqual([]);
expect(choices.filter(({ options, defaultValue }) => !options?.some((o) => o.value === defaultValue))).toEqual([]);
});
it('reads every parameter it declares', () => {
// a param the builder ignores is a field the user fills in for nothing, and a typo in
// either half would put the literal 'undefined' inside a URL
for (const { recipe } of built) {
for (const param of recipe.params) {
// a choice can only take one of its own options, so probe with the last one
if (param.type === 'choice') {
const last = param.options?.at(-1)?.value ?? '';
expect(JSON.stringify(recipe.build({ ...defaultValues(recipe), [param.name]: last }))).toContain(last);
continue;
}
const marker =
param.type === 'number' ? '4242' : param.validation?.kind === 'url' ? 'http://ontime-probe' : 'ontime-probe';
const probed = { ...defaultValues(recipe), [param.name]: marker };
expect(JSON.stringify(recipe.build(probed))).toContain(marker);
}
}
});
it('only uses filter operators the server accepts', () => {
const allowed = operators.map(({ value }) => value);
for (const { automation } of built) {
for (const filter of automation.filters) {
expect(allowed).toContain(filter.operator);
}
}
});
it('defaults every external target to this machine', () => {
const outputs = built.flatMap(({ automation }) => automation.outputs);
const osc = outputs.filter(isOSCOutput);
const http = outputs.filter(isHTTPOutput);
// filtering rather than asserting in a branch, so a failure names the offending recipe
expect(osc.filter(({ targetIP }) => targetIP !== '127.0.0.1')).toEqual([]);
expect(osc.filter(({ targetPort }) => !Number.isFinite(targetPort))).toEqual([]);
expect(http.filter(({ url }) => !url.startsWith('http://127.0.0.1'))).toEqual([]);
});
it('hides local-network recipes in Ontime Cloud', () => {
expect(getAvailableRecipes(true).map(({ id }) => id)).toEqual([
'ontime-aux-timer',
'ontime-aux-stop',
'ontime-warn-stage',
'ontime-clear-message',
'ontime-secondary-message',
'webhook-event-title',
]);
});
it('validates recipe addresses and numeric bounds', () => {
const qlab = automationRecipes.find(({ id }) => id === 'qlab-go');
const companion = automationRecipes.find(({ id }) => id === 'companion-press');
const vmix = automationRecipes.find(({ id }) => id === 'vmix-overlay-warning');
const webhook = automationRecipes.find(({ id }) => id === 'webhook-event-title');
expect(qlab && validateRecipeValues(qlab, { ip: 'not a host', port: '70000' })).toEqual({
ip: 'Enter an IP address or hostname',
port: 'Enter a whole number from 1024 to 65535',
});
expect(
companion && validateRecipeValues(companion, { host: 'localhost:8888', page: '1.5', row: '-1', column: '0' }),
).toEqual({
host: 'Enter a URL starting with http:// or https://',
page: 'Enter a whole number of 1 or more',
row: 'Enter a whole number of 0 or more',
});
expect(vmix && validateRecipeValues(vmix, { host: 'http://127.0.0.1:8088', overlay: '5' })).toEqual({
overlay: 'Enter a whole number from 1 to 4',
});
expect(webhook && validateRecipeValues(webhook, { url: 'ftp://example.com' })).toEqual({
url: 'Enter a URL starting with http:// or https://',
});
});
it('accepts every recipe default', () => {
for (const recipe of automationRecipes) {
expect(validateRecipeValues(recipe, defaultValues(recipe))).toEqual({});
}
});
/** the outputs a recipe builds from the given answers, as plain JSON to assert against */
function buildWith(id: string, values: Record<string, string>) {
const recipe = automationRecipes.find((candidate) => candidate.id === id);
return JSON.stringify(recipe?.build(values).outputs);
}
it('tolerates a URL that already carries a query', () => {
expect(buildWith('webhook-event-title', { url: 'http://127.0.0.1:3000/now?source=ontime' })).toContain(
'/now?source=ontime&title=',
);
});
it('marks the event title for URL-safe substitution', () => {
expect(buildWith('webhook-event-title', { url: 'http://127.0.0.1:3000/now' })).toContain(
'title={{url:eventNow.title}}',
);
});
it('adds webhook query parameters before a URL fragment', () => {
expect(buildWith('webhook-event-title', { url: 'http://127.0.0.1:3000/now#details' })).toContain(
'/now?title={{url:eventNow.title}}#details',
);
});
it('rejects invalid aux timer durations', () => {
const auxTimer = automationRecipes.find(({ id }) => id === 'ontime-aux-timer');
expect(auxTimer && validateRecipeValues(auxTimer, { aux: '1', duration: 'abc' })).toEqual({
duration: 'Enter a valid duration',
});
});
it('rejects unsupported OSC targets', () => {
const qlab = automationRecipes.find(({ id }) => id === 'qlab-go');
expect(qlab && validateRecipeValues(qlab, { ip: '::1', port: '53000' })).toEqual({
ip: 'Enter an IP address or hostname',
});
});
it('keeps recipe previews available while an address is invalid', () => {
expect(() => buildWith('companion-press', { host: 'http:', page: '1', row: '0', column: '0' })).not.toThrow();
});
it('tolerates an address pasted with a trailing slash', () => {
expect(
buildWith('companion-press', { host: 'http://127.0.0.1:8888/', page: '1', row: '0', column: '0' }),
).toContain('http://127.0.0.1:8888/api/location/1/0/0/press');
});
it('drops a pasted query before adding a Companion path', () => {
expect(
buildWith('companion-press', { host: 'http://127.0.0.1:8888?token=value', page: '1', row: '0', column: '0' }),
).toContain('http://127.0.0.1:8888/api/location/1/0/0/press');
});
});
@@ -1,6 +1,6 @@
import { TimerLifeCycle, Trigger } from 'ontime-types'; import { TimerLifeCycle, Trigger } from 'ontime-types';
import { checkDuplicates } from '../automationUtils'; import { checkDuplicates, cycles, groupTriggersByAutomation, operators } from '../automationUtils';
describe('checkDuplicates', () => { describe('checkDuplicates', () => {
it('should return undefined if there are no duplicates', () => { it('should return undefined if there are no duplicates', () => {
@@ -22,3 +22,43 @@ describe('checkDuplicates', () => {
expect(checkDuplicates(triggers)).toStrictEqual([2]); expect(checkDuplicates(triggers)).toStrictEqual([2]);
}); });
}); });
describe('groupTriggersByAutomation', () => {
it('returns an empty object when there are no triggers', () => {
expect(groupTriggersByAutomation([])).toEqual({});
});
it('collects the lifecycles each automation is bound to', () => {
const triggers: Trigger[] = [
{ id: '1', title: 'First', trigger: TimerLifeCycle.onStart, automationId: 'a' },
{ id: '2', title: 'Second', trigger: TimerLifeCycle.onFinish, automationId: 'a' },
{ id: '3', title: 'Third', trigger: TimerLifeCycle.onLoad, automationId: 'b' },
];
expect(groupTriggersByAutomation(triggers)).toEqual({
a: [TimerLifeCycle.onStart, TimerLifeCycle.onFinish],
b: [TimerLifeCycle.onLoad],
});
});
it('collapses duplicates, the runtime only fires an automation once per lifecycle', () => {
const triggers: Trigger[] = [
{ id: '1', title: 'First', trigger: TimerLifeCycle.onStart, automationId: 'a' },
{ id: '2', title: 'Second', trigger: TimerLifeCycle.onStart, automationId: 'a' },
];
expect(groupTriggersByAutomation(triggers)).toEqual({ a: [TimerLifeCycle.onStart] });
});
});
describe('operators', () => {
it('does not offer not_contains, which the server validation rejects', () => {
expect(operators.map(({ value }) => value)).not.toContain('not_contains');
});
});
describe('cycles', () => {
it('uses the shared user facing labels', () => {
expect(cycles.find(({ value }) => value === 'onStart')?.label).toBe('On Start');
});
});
@@ -0,0 +1,337 @@
import type { AutomationDTO, AutomationOutput, TimerLifeCycle } from 'ontime-types';
import { TimerLifeCycle as Cycle } from 'ontime-types';
import { parseUserTime } from 'ontime-utils';
export type RecipeCategory = 'ontime' | 'playback' | 'video' | 'messaging';
export const recipeCategoryLabels: Record<RecipeCategory, string> = {
ontime: 'Ontime automations',
playback: 'Playback and cue systems',
video: 'Video and streaming',
messaging: 'Webhooks and messaging',
};
export const recipeCategoryOrder: RecipeCategory[] = ['ontime', 'playback', 'video', 'messaging'];
export type RecipeParam = {
name: string;
label: string;
hint?: string;
type?: 'text' | 'number' | 'choice';
options?: { value: string; label: string }[];
wide?: boolean;
defaultValue: string;
validation?:
| { kind: 'duration' }
| { kind: 'host' }
| { kind: 'url' }
| { kind: 'integer'; min: number; max?: number };
};
export type RecipeValues = Record<string, string>;
export type AutomationRecipe = {
id: string;
title: string;
description: string;
category: RecipeCategory;
localOnly?: boolean;
keywords?: string[];
params: RecipeParam[];
triggers: TimerLifeCycle[];
build: (values: RecipeValues) => AutomationDTO;
};
const auxTimers = [
{ value: '1', label: 'Aux timer 1' },
{ value: '2', label: 'Aux timer 2' },
{ value: '3', label: 'Aux timer 3' },
];
type AuxNumber = '1' | '2' | '3';
function toAux(value: string): AuxNumber {
return value === '2' || value === '3' ? value : '1';
}
const auxSet = { 1: 'aux1-set', 2: 'aux2-set', 3: 'aux3-set' } as const;
const auxStart = { 1: 'aux1-start', 2: 'aux2-start', 3: 'aux3-start' } as const;
const auxStop = { 1: 'aux1-stop', 2: 'aux2-stop', 3: 'aux3-stop' } as const;
const auxSource = { 1: 'aux1', 2: 'aux2', 3: 'aux3' } as const;
function origin(value: string): string {
try {
return new URL(value.trim()).origin;
} catch {
return value.trim();
}
}
function withQuery(url: string, query: string): string {
const trimmed = url.trim();
const fragmentIndex = trimmed.indexOf('#');
const base = fragmentIndex === -1 ? trimmed : trimmed.slice(0, fragmentIndex);
const fragment = fragmentIndex === -1 ? '' : trimmed.slice(fragmentIndex);
return `${base}${base.includes('?') ? '&' : '?'}${query}${fragment}`;
}
function buildUnfilteredAutomation(title: string, outputs: AutomationOutput[]): AutomationDTO {
return { title, filterRule: 'all', filters: [], outputs };
}
export const automationRecipes: AutomationRecipe[] = [
{
id: 'ontime-aux-timer',
title: 'Run an aux timer with the event',
description: 'Sets an aux timer and starts it whenever an event starts.',
category: 'ontime',
keywords: ['countdown', 'stage timer', 'speaker'],
params: [
{ name: 'aux', label: 'Which timer', type: 'choice', options: auxTimers, defaultValue: '1' },
{
name: 'duration',
label: 'Duration',
hint: 'hh:mm:ss',
defaultValue: '00:05:00',
validation: { kind: 'duration' },
},
],
triggers: [Cycle.onStart],
build: ({ aux, duration }) =>
buildUnfilteredAutomation(`Run Aux Timer ${toAux(aux)} with the event`, [
{ type: 'ontime', action: auxSet[toAux(aux)], time: duration.trim() },
{ type: 'ontime', action: auxStart[toAux(aux)] },
]),
},
{
id: 'ontime-aux-stop',
title: 'Stop the aux timer when the event ends',
description: 'Stops an aux timer on finish, so it does not keep running into the next event.',
category: 'ontime',
keywords: ['countdown', 'stage timer', 'reset'],
params: [{ name: 'aux', label: 'Which timer', type: 'choice', options: auxTimers, defaultValue: '1' }],
triggers: [Cycle.onFinish],
build: ({ aux }) =>
buildUnfilteredAutomation(`Stop Aux Timer ${toAux(aux)} on finish`, [
{ type: 'ontime', action: auxStop[toAux(aux)] },
]),
},
{
id: 'ontime-warn-stage',
title: 'Warn the stage when time runs low',
description: 'Shows a message on the stage timer as the running event enters its danger window.',
category: 'ontime',
keywords: ['message', 'danger', 'wrap up', 'presenter'],
params: [{ name: 'message', label: 'Message', wide: true, defaultValue: 'Please wrap up' }],
triggers: [Cycle.onDanger],
build: ({ message }) =>
buildUnfilteredAutomation('Warn the stage at danger', [
{ type: 'ontime', action: 'message-set', text: message, visible: true },
]),
},
{
id: 'ontime-clear-message',
title: 'Hide the stage message on finish',
description: 'Clears the stage message once the event finishes. Pairs with the warning above.',
category: 'ontime',
keywords: ['message', 'clear', 'presenter'],
params: [],
triggers: [Cycle.onFinish],
build: () =>
buildUnfilteredAutomation('Hide the stage message on finish', [
{ type: 'ontime', action: 'message-set', text: '', visible: false },
]),
},
{
id: 'ontime-secondary-message',
title: 'Show an aux timer beside the stage message',
description: 'Points the secondary field on the stage timer at an aux timer when an event loads.',
category: 'ontime',
keywords: ['message', 'secondary', 'stage', 'countdown'],
params: [{ name: 'aux', label: 'Which timer', type: 'choice', options: auxTimers, defaultValue: '1' }],
triggers: [Cycle.onLoad],
build: ({ aux }) =>
buildUnfilteredAutomation(`Show Aux Timer ${toAux(aux)} as the secondary message`, [
{ type: 'ontime', action: 'message-secondary', secondarySource: auxSource[toAux(aux)] },
]),
},
{
id: 'qlab-go',
title: 'QLab — fire the matching cue',
description: "Starts the QLab cue whose number matches the Ontime event's cue.",
category: 'playback',
localOnly: true,
keywords: ['osc', 'sound', 'audio', 'mac', 'figure 53'],
params: [
{
name: 'ip',
label: 'QLab computer',
hint: 'IP address of the machine running QLab',
wide: true,
defaultValue: '127.0.0.1',
validation: { kind: 'host' },
},
{
name: 'port',
label: 'OSC port',
type: 'number',
hint: "QLab's default is 53000",
defaultValue: '53000',
validation: { kind: 'integer', min: 1024, max: 65535 },
},
],
triggers: [Cycle.onStart],
build: ({ ip, port }) =>
buildUnfilteredAutomation('QLab GO on event start', [
{
type: 'osc',
targetIP: ip.trim(),
targetPort: Number(port),
address: '/cue/{{eventNow.cue}}/start',
args: '',
},
]),
},
{
id: 'companion-press',
title: 'Companion — press a button',
description: 'Presses a Stream Deck button through the Companion HTTP API when an event starts.',
category: 'playback',
localOnly: true,
keywords: ['stream deck', 'bitfocus', 'obs', 'http', 'elgato'],
params: [
{
name: 'host',
label: 'Companion address',
hint: 'Where the Companion HTTP API is listening',
wide: true,
defaultValue: 'http://127.0.0.1:8888',
validation: { kind: 'url' },
},
{ name: 'page', label: 'Page', type: 'number', defaultValue: '1', validation: { kind: 'integer', min: 1 } },
{ name: 'row', label: 'Row', type: 'number', defaultValue: '0', validation: { kind: 'integer', min: 0 } },
{ name: 'column', label: 'Column', type: 'number', defaultValue: '0', validation: { kind: 'integer', min: 0 } },
],
triggers: [Cycle.onStart],
build: ({ host, page, row, column }) =>
buildUnfilteredAutomation('Companion button press', [
{ type: 'http', url: `${origin(host)}/api/location/${page}/${row}/${column}/press` },
]),
},
{
id: 'vmix-overlay-warning',
title: 'vMix — show an overlay on warning',
description: 'Triggers a vMix overlay through the web controller when the timer enters its warning window.',
category: 'video',
localOnly: true,
keywords: ['streaming', 'http', 'lower third', 'graphics'],
params: [
{
name: 'host',
label: 'vMix address',
hint: 'The vMix web controller',
wide: true,
defaultValue: 'http://127.0.0.1:8088',
validation: { kind: 'url' },
},
{
name: 'overlay',
label: 'Overlay number',
type: 'number',
defaultValue: '1',
validation: { kind: 'integer', min: 1, max: 4 },
},
],
triggers: [Cycle.onWarning],
build: ({ host, overlay }) =>
buildUnfilteredAutomation('vMix overlay on warning', [
{ type: 'http', url: `${origin(host)}/api/?Function=OverlayInput${overlay}In` },
]),
},
{
id: 'webhook-event-title',
title: 'Webhook — send the running event title',
description: 'Calls any URL with the running event title, as a template string you can edit afterwards.',
category: 'messaging',
keywords: ['http', 'rest', 'api', 'integration', 'slack'],
params: [
{
name: 'url',
label: 'URL',
hint: 'The event title is added as a title parameter',
wide: true,
defaultValue: 'http://127.0.0.1:3000/now',
validation: { kind: 'url' },
},
],
triggers: [Cycle.onStart],
build: ({ url }) =>
buildUnfilteredAutomation('Webhook with the current event', [
{ type: 'http', url: withQuery(url, 'title={{url:eventNow.title}}') },
]),
},
];
export function defaultValues(recipe: AutomationRecipe): RecipeValues {
return Object.fromEntries(recipe.params.map(({ name, defaultValue }) => [name, defaultValue]));
}
export function getAvailableRecipes(isCloud: boolean): AutomationRecipe[] {
return isCloud ? automationRecipes.filter((recipe) => !recipe.localOnly) : automationRecipes;
}
export function validateRecipeValues(recipe: AutomationRecipe, values: RecipeValues): Record<string, string> {
const errors: Record<string, string> = {};
for (const param of recipe.params) {
const value = values[param.name]?.trim() ?? '';
if (!value) {
errors[param.name] = 'Required field';
continue;
}
if (param.validation?.kind === 'url' && !isHttpUrl(value)) {
errors[param.name] = 'Enter a URL starting with http:// or https://';
} else if (param.validation?.kind === 'duration' && !isDuration(value)) {
errors[param.name] = 'Enter a valid duration';
} else if (param.validation?.kind === 'host' && !isHost(value)) {
errors[param.name] = 'Enter an IP address or hostname';
} else if (param.validation?.kind === 'integer') {
const number = Number(value);
const { min, max } = param.validation;
if (!Number.isInteger(number) || number < min || (max !== undefined && number > max)) {
errors[param.name] =
max === undefined ? `Enter a whole number of ${min} or more` : `Enter a whole number from ${min} to ${max}`;
}
}
}
return errors;
}
function isDuration(value: string): boolean {
return /^\d+(?::\d{1,2}){0,2}$/.test(value) && (parseUserTime(value) > 0 || /^0+(?::0+){0,2}$/.test(value));
}
function isHttpUrl(value: string): boolean {
try {
const url = new URL(value);
return (url.protocol === 'http:' || url.protocol === 'https:') && Boolean(url.hostname);
} catch {
return false;
}
}
function isHost(value: string): boolean {
if (value === 'localhost') {
return true;
}
const ipv4 = /^(?:(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)$/;
const hostname = /^(?=.{1,253}$)[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?(?:\.[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?)*$/i;
if (ipv4.test(value) || hostname.test(value)) {
return true;
}
return false;
}
@@ -1,21 +1,50 @@
import { Automation, AutomationDTO, CustomFields, TimerLifeCycle, Trigger } from 'ontime-types'; import { Automation, AutomationDTO, AutomationFilter, CustomFields, TimerLifeCycle, Trigger } from 'ontime-types';
import { getLifecycleLabel, lifecycleLabels } from '../../../../common/constants/timerLifecycle';
/**
* Names a trigger created from an automation's lifecycle picker.
* Shared so a trigger made by the form and one made by a recipe read the same in the list.
*/
export function makeTriggerTitle(automationTitle: string, cycle: TimerLifeCycle): string {
return `${automationTitle}${getLifecycleLabel(cycle)}`;
}
/**
* Outputs are a union, so react-hook-form cannot resolve a field's error by name.
* Every output card knows which fields it registered, this just makes them reachable.
*/
export type OutputErrors = Partial<Record<string, { message?: string }>>;
type CycleLabel = { type CycleLabel = {
id: number;
label: string; label: string;
value: keyof typeof TimerLifeCycle; value: TimerLifeCycle;
}; };
export const cycles: CycleLabel[] = [ export const cycles: CycleLabel[] = [
{ id: 1, label: 'On Load', value: 'onLoad' }, { label: lifecycleLabels.onLoad, value: TimerLifeCycle.onLoad },
{ id: 2, label: 'On Start', value: 'onStart' }, { label: lifecycleLabels.onStart, value: TimerLifeCycle.onStart },
{ id: 3, label: 'On Pause', value: 'onPause' }, { label: lifecycleLabels.onPause, value: TimerLifeCycle.onPause },
{ id: 4, label: 'On Stop', value: 'onStop' }, { label: lifecycleLabels.onStop, value: TimerLifeCycle.onStop },
{ id: 5, label: 'Every second', value: 'onClock' }, { label: lifecycleLabels.onClock, value: TimerLifeCycle.onClock },
{ id: 6, label: 'On Timer Update', value: 'onUpdate' }, { label: lifecycleLabels.onUpdate, value: TimerLifeCycle.onUpdate },
{ id: 7, label: 'On Finish', value: 'onFinish' }, { label: lifecycleLabels.onFinish, value: TimerLifeCycle.onFinish },
{ id: 8, label: 'On Warning', value: 'onWarning' }, { label: lifecycleLabels.onWarning, value: TimerLifeCycle.onWarning },
{ id: 9, label: 'On Danger', value: 'onDanger' }, { label: lifecycleLabels.onDanger, value: TimerLifeCycle.onDanger },
];
/**
* Filter operators offered in the automation form
* NOTE: not_contains is supported by the type and by the runtime, but the server
* validation list omits it, so an automation using it cannot be saved.
* It stays out of the UI until the server accepts it.
*/
export const operators: Array<{ value: AutomationFilter['operator']; label: string }> = [
{ value: 'equals', label: 'equals' },
{ value: 'not_equals', label: 'does not equal' },
{ value: 'contains', label: 'contains' },
{ value: 'greater_than', label: 'is greater than' },
{ value: 'less_than', label: 'is less than' },
]; ];
/** /**
@@ -66,20 +95,38 @@ export function makeFieldList(customFields: CustomFields): SelectableField[] {
* We warn the user if they have created multiple links between the same automation and a trigger * We warn the user if they have created multiple links between the same automation and a trigger
*/ */
export function checkDuplicates(triggers: Trigger[]) { export function checkDuplicates(triggers: Trigger[]) {
const triggersMap: Record<string, string[]> = {}; const seen = new Set<string>();
const duplicates = []; const duplicates: number[] = [];
for (let i = 0; i < triggers.length; i++) { for (const [index, trigger] of triggers.entries()) {
const trigger = triggers[i]; const key = `${trigger.trigger}:${trigger.automationId}`;
if (!Object.hasOwn(triggersMap, trigger.trigger)) {
triggersMap[trigger.trigger] = [];
}
if (triggersMap[trigger.trigger].includes(trigger.automationId)) { if (seen.has(key)) {
duplicates.push(i); duplicates.push(index);
} else { } else {
triggersMap[trigger.trigger].push(trigger.automationId); seen.add(key);
} }
} }
return duplicates.length > 0 ? duplicates : undefined; return duplicates.length > 0 ? duplicates : undefined;
} }
/**
* Groups the lifecycles each automation is bound to
* Used to show when an automation runs, and to highlight the ones that never will
*/
export function groupTriggersByAutomation(triggers: Trigger[]): Record<string, TimerLifeCycle[]> {
const grouped: Record<string, TimerLifeCycle[]> = {};
for (const trigger of triggers) {
if (!Object.hasOwn(grouped, trigger.automationId)) {
grouped[trigger.automationId] = [];
}
// the runtime fires an automation once per lifecycle, duplicates would be noise here
if (!grouped[trigger.automationId].includes(trigger.trigger)) {
grouped[trigger.automationId].push(trigger.trigger);
}
}
return grouped;
}