feat: event cue (#473)

* feat: parse cue

* refactor: get delay from backend

* feat: add cue to UI

* refactor: remove deprecated delay logic

* refactor: extract studio clock specific logic

* refactor: extract utilities

* refactor: fix issue with missing key

* style: prevent cue overflow

* style: prevent whitespace wrap

* feat: add support for cues in integrations
This commit is contained in:
Carlos Valente
2023-08-17 21:43:11 +02:00
committed by GitHub
parent 51c31adaf1
commit 657cc22b44
62 changed files with 1363 additions and 1290 deletions
@@ -1,54 +0,0 @@
import { getPreviousPlayable } from '../eventUtils.js';
describe('getPreviousPlayable()', () => {
describe('given a list of events', () => {
it('finds the previous playable event', () => {
const events = [
{ id: 100, type: 'delay' },
{ id: 101, type: 'event', skip: true },
{ id: 102, type: 'event', skip: true },
{ id: 103, type: 'event', skip: false },
{ id: 'not-this', type: 'block' },
{ id: 104, type: 'event' },
];
const { index, id } = getPreviousPlayable(events, events[4].id);
expect(index).toBe(3);
expect(id).toBe(103);
});
});
describe('handles common errors', () => {
it('returns null if id not found in list', () => {
const events = [
{ id: 0, type: 'delay' },
{ id: 1, type: 'event', skip: true },
{ id: 2, type: 'event', skip: true },
{ id: 3, type: 'event', skip: false },
{ id: 4, type: 'event' },
];
const { index, id } = getPreviousPlayable(events, 'no-valid-id');
expect(index).toBe(null);
expect(id).toBe(null);
});
it('returns null if there are no previous events to play', () => {
const events = [
{ id: 0, type: 'delay' },
{ id: 1, type: 'event', skip: true },
{ id: 2, type: 'event', skip: true },
{ id: 3, type: 'event', skip: true },
{ id: 4, type: 'event' },
];
const { index, id } = getPreviousPlayable(events, events[4].id);
expect(index).toBe(null);
expect(id).toBe(null);
});
it('returns null if list is empty', () => {
const events = [];
const { index, id } = getPreviousPlayable(events, 'made-up');
expect(index).toBe(null);
expect(id).toBe(null);
});
});
});
@@ -1,10 +1,11 @@
import { vi } from 'vitest';
import { dbModel } from '../../models/dataModel.ts';
import { parseExcel, parseJson, validateEvent } from '../parser.ts';
import { makeString } from '../parserUtils.ts';
import { parseAliases, parseUserFields, parseViewSettings } from '../parserFunctions.ts';
import { EndAction, TimerType } from 'ontime-types';
import { dayInMs } from 'ontime-utils';
import { EndAction, OntimeEvent, TimerType } from 'ontime-types';
import { dbModel } from '../../models/dataModel.js';
import { parseExcel, parseJson, validateEvent } from '../parser.js';
import { makeString } from '../parserUtils.js';
import { parseAliases, parseUserFields, parseViewSettings } from '../parserFunctions.js';
describe('test json parser with valid def', () => {
const testData = {
@@ -220,64 +221,20 @@ describe('test json parser with valid def', () => {
const first = parseResponse?.rundown[0];
const expected = {
title: 'Guest Welcoming',
subtitle: '',
presenter: '',
note: '',
timeStart: 31500000,
timeEnd: 32400000,
duration: 32400000 - 31500000,
isPublic: false,
endAction: 'play-next',
timerType: 'clock',
skip: false,
colour: '',
type: 'event',
revision: 0,
id: '4b31',
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
};
expect(first).toStrictEqual(expected);
expect(first).toMatchObject(expected);
});
it('second event is as a match', () => {
const second = parseResponse?.rundown[1];
const expected = {
title: 'Good Morning',
subtitle: 'Days schedule',
presenter: 'Carlos Valente',
note: '',
timeStart: 32400000,
timeEnd: 36000000,
endAction: 'play-next',
timerType: 'count-up',
duration: 36000000 - 32400000,
isPublic: true,
skip: true,
colour: 'red',
type: 'event',
revision: 0,
id: 'f24d',
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
};
expect(second).toStrictEqual(expected);
expect(second).toMatchObject(expected);
});
it('third event end action is set as the default value', () => {
const third = parseResponse?.rundown[2];
@@ -448,7 +405,7 @@ describe('test corrupt data', () => {
it('handles missing event data', async () => {
const emptyEventData = {
rundown: [{}, {}, {}, {}, {}, {}, {}, {}],
event: {},
eventData: {},
settings: {
app: 'ontime',
version: 2,
@@ -459,7 +416,7 @@ describe('test corrupt data', () => {
};
const parsedDef = await parseJson(emptyEventData);
expect(parsedDef.event).toStrictEqual(dbModel.event);
expect(parsedDef.eventData).toStrictEqual(dbModel.eventData);
});
it('handles missing settings', async () => {
@@ -488,7 +445,7 @@ describe('test event validator', () => {
const event = {
title: 'test',
};
const validated = validateEvent(event);
const validated = validateEvent(event, 'test');
expect(validated).toEqual(
expect.objectContaining({
@@ -503,6 +460,7 @@ describe('test event validator', () => {
revision: expect.any(Number),
type: expect.any(String),
id: expect.any(String),
cue: 'test',
colour: expect.any(String),
user0: expect.any(String),
user1: expect.any(String),
@@ -520,7 +478,7 @@ describe('test event validator', () => {
it('fails an empty object', () => {
const event = {};
const validated = validateEvent(event);
const validated = validateEvent(event, 'none');
expect(validated).toEqual(null);
});
@@ -531,7 +489,8 @@ describe('test event validator', () => {
presenter: 3.2,
note: '1899-12-30T08:00:10.000Z',
};
const validated = validateEvent(event);
// @ts-expect-error -- we know this is wrong, testing imports outside domain
const validated = validateEvent(event, 'not-used');
expect(typeof validated.title).toEqual('string');
expect(typeof validated.subtitle).toEqual('string');
expect(typeof validated.presenter).toEqual('string');
@@ -543,6 +502,7 @@ describe('test event validator', () => {
timeStart: false,
timeEnd: '2',
};
// @ts-expect-error -- we know this is wrong, testing imports outside domain
const validated = validateEvent(event);
expect(typeof validated.timeStart).toEqual('number');
expect(validated.timeStart).toEqual(0);
@@ -554,6 +514,7 @@ describe('test event validator', () => {
const event = {
title: {},
};
// @ts-expect-error -- we know this is wrong, testing imports outside domain
const validated = validateEvent(event);
expect(typeof validated.title).toEqual('string');
});
@@ -571,11 +532,13 @@ describe('test makeString function', () => {
converted = makeString(val);
expect(converted).toBe(expected);
// @ts-expect-error -- we know this is wrong, testing imports outside domain
val = ['testing'];
expected = 'testing';
converted = makeString(val);
expect(converted).toBe(expected);
// @ts-expect-error -- we know this is wrong, testing imports outside domain
val = { doing: 'testing' };
converted = makeString(val, 'fallback');
expect(converted).toBe('fallback');
@@ -628,10 +591,6 @@ describe('test parseExcel function', () => {
'x',
'',
'Ballyhoo',
'',
'',
'',
'',
'a0',
'a1',
'a2',
@@ -650,15 +609,11 @@ describe('test parseExcel function', () => {
'A song from the hearth',
'Still Carlos',
'Derailing early',
'clock',
'load-next',
'clock',
'',
'',
'x',
'Rainbow chase',
'',
'',
'',
'',
'b0',
'',
'',
@@ -682,10 +637,11 @@ describe('test parseExcel function', () => {
backstageInfo: 'test backstage info',
};
// TODO: update tests once import is resolved
const expectedParsedRundown = [
{
timeStart: 25200000,
timeEnd: 28810000,
//timeStart: 28800000,
//timeEnd: 32410000,
title: 'Guest Welcome',
presenter: 'Carlos',
subtitle: 'Getting things started',
@@ -708,8 +664,8 @@ describe('test parseExcel function', () => {
type: 'event',
},
{
timeStart: 28800000,
timeEnd: 30600000,
//timeStart: 32400000,
//timeEnd: 34200000,
title: 'A song from the hearth',
presenter: 'Still Carlos',
subtitle: 'Derailing early',
@@ -728,13 +684,8 @@ describe('test parseExcel function', () => {
const parsedData = await parseExcel(testdata);
expect(parsedData.eventData).toStrictEqual(expectedParsedEvent);
expect(parsedData.rundown).toBeDefined();
expect(parsedData.rundown.title).toBe(expectedParsedRundown.title);
expect(parsedData.rundown.presenter).toBe(expectedParsedRundown.presenter);
expect(parsedData.rundown.subtitle).toBe(expectedParsedRundown.subtitle);
expect(parsedData.rundown.isPublic).toBe(expectedParsedRundown.isPublic);
expect(parsedData.rundown.skip).toBe(expectedParsedRundown.skip);
expect(parsedData.rundown.note).toBe(expectedParsedRundown.note);
expect(parsedData.rundown.type).toBe(expectedParsedRundown.type);
expect(parsedData.rundown[0]).toMatchObject(expectedParsedRundown[0]);
expect(parsedData.rundown[1]).toMatchObject(expectedParsedRundown[1]);
});
});
@@ -759,7 +710,6 @@ describe('test aliases import', () => {
expect(parsed.length).toBe(1);
// generates missing id
console.log(parsed);
expect(parsed[0].alias).toBeDefined();
});
});
@@ -874,7 +824,7 @@ describe('test views import', () => {
endMessage: '',
overrideStyles: false,
};
const parsed = parseViewSettings(testData);
const parsed = parseViewSettings(testData, false);
expect(parsed).toStrictEqual(expectedParsedViewSettings);
});
@@ -1,28 +0,0 @@
import { parseExcelDate } from '../time';
describe('parseExcelDate', () => {
it('parses a valid date string as expected from excel', () => {
const millis = parseExcelDate('1899-12-30T07:00:00.000Z');
expect(millis).not.toBe(0);
});
describe('parses a time string that passes validation', () => {
const validFields = ['10:00:00', '10:00'];
validFields.forEach((field) => {
it(`handles ${field}`, () => {
const millis = parseExcelDate(field);
expect(millis).not.toBe(0);
});
});
});
describe('returns 0 on other strings', () => {
const invalidFields = ['10', 'test', ''];
invalidFields.forEach((field) => {
it(`handles ${field}`, () => {
const millis = parseExcelDate(field);
expect(millis).toBe(0);
});
});
});
});
@@ -0,0 +1,58 @@
import { parseExcelDate } from '../time.js';
describe('parseExcelDate', () => {
describe.todo('parses a valid date string as expected from excel', () => {
const testCases = [
{
fromExcel: '1899-12-30T00:00:00.000Z',
expected: 3600000,
},
{
fromExcel: '1899-12-30T00:10:00.000Z',
expected: 4200000,
},
{
fromExcel: '1899-12-30T01:00:00.000Z',
expected: 7200000,
},
{
fromExcel: '1899-12-30T07:00:00.000Z',
expected: 28800000,
},
{
fromExcel: '1899-12-30T08:00:10.000Z',
expected: 32410000,
},
{
fromExcel: '1899-12-30T08:30:00.000Z',
expected: 34200000,
},
];
for (const scenario of testCases) {
it(`handles ${scenario.fromExcel}`, () => {
expect(parseExcelDate(scenario.fromExcel)).toBe(scenario.expected);
});
}
});
describe('parses a time string that passes validation', () => {
const validFields = ['10:00:00', '10:00'];
validFields.forEach((field) => {
it(`handles ${field}`, () => {
const millis = parseExcelDate(field);
expect(millis).not.toBe(0);
});
});
});
describe('returns 0 on other strings', () => {
const invalidFields = ['10', 'test', ''];
invalidFields.forEach((field) => {
it(`handles ${field}`, () => {
const millis = parseExcelDate(field);
expect(millis).toBe(0);
});
});
});
});
@@ -1,4 +1,4 @@
import { cleanURL } from '../url';
import { cleanURL } from '../url.js';
describe('url is correctly formatted', () => {
it('has no leading spaces', () => {
-25
View File
@@ -1,25 +0,0 @@
/**
* @description Returns id of previous played event
* @param {array} events
* @param {string} eventId
* @return {object}
*/
export function getPreviousPlayable(events, eventId) {
// find current index
const current = events.findIndex((event) => event.id === eventId);
if (current === -1) {
return { index: null, id: null };
}
let index = current - 1;
while (index >= 0) {
const event = events[index];
if (event.type === 'event' && !event.skip) {
return { index, id: event.id };
}
index--;
}
return { index: null, id: null };
}
+36 -13
View File
@@ -4,7 +4,16 @@
import fs from 'fs';
import xlsx from 'node-xlsx';
import { generateId, calculateDuration } from 'ontime-utils';
import { DatabaseModel, EventData, OntimeEvent, OntimeRundown, UserFields } from 'ontime-types';
import {
DatabaseModel,
EndAction,
EventData,
OntimeEvent,
OntimeRundown,
SupportedEvent,
TimerType,
UserFields,
} from 'ontime-types';
import { event as eventDef } from '../models/eventsDefinition.js';
import { dbModel } from '../models/dataModel.js';
import { deleteFile, makeString } from './parserUtils.js';
@@ -38,6 +47,7 @@ export const parseExcel = async (excelData) => {
let timeStartIndex: number | null = null;
let timeEndIndex: number | null = null;
let titleIndex: number | null = null;
let cueIndex: number | null = null;
let presenterIndex: number | null = null;
let subtitleIndex: number | null = null;
let isPublicIndex: number | null = null;
@@ -91,6 +101,8 @@ export const parseExcel = async (excelData) => {
event.timeEnd = parseExcelDate(column);
} else if (j === titleIndex) {
event.title = column;
} else if (j === cueIndex) {
event.cue = column;
} else if (j === presenterIndex) {
event.presenter = column;
} else if (j === subtitleIndex) {
@@ -102,9 +114,17 @@ export const parseExcel = async (excelData) => {
} else if (j === notesIndex) {
event.note = column;
} else if (j === endActionIndex) {
event.endAction = column;
if (column === '') {
event.endAction = EndAction.None;
} else {
event.endAction = column;
}
} else if (j === timerTypeIndex) {
event.timerType = column;
if (column === '') {
event.timerType = TimerType.CountDown;
} else {
event.timerType = column;
}
} else if (j === colourIndex) {
event.colour = column;
} else if (j === user0Index) {
@@ -130,6 +150,7 @@ export const parseExcel = async (excelData) => {
} else {
if (typeof column === 'string') {
const col = column.toLowerCase();
// look for keywords
// need to make sure it is a string first
switch (col) {
@@ -157,6 +178,10 @@ export const parseExcel = async (excelData) => {
case 'finish':
timeEndIndex = j;
break;
case 'cue':
case 'page':
cueIndex = j;
break;
case 'event title':
case 'title':
titleIndex = j;
@@ -243,7 +268,7 @@ export const parseExcel = async (excelData) => {
if (Object.keys(event).length > 0) {
// if any data was found, push to array
// take care of it in the next step
rundown.push({ ...event, type: 'event' });
rundown.push({ ...event, type: SupportedEvent.Event } as OntimeEvent);
}
});
return {
@@ -284,7 +309,6 @@ export const parseJson = async (jsonData, enforce = false): Promise<DatabaseMode
// Import user fields if any
returnData.userFields = parseUserFields(jsonData);
// Import OSC settings if any
// @ts-expect-error -- we are unable to type just yet
returnData.osc = parseOsc(jsonData, enforce);
// Import HTTP settings if any
// returnData.http = parseHttp(jsonData, enforce);
@@ -295,12 +319,15 @@ export const parseJson = async (jsonData, enforce = false): Promise<DatabaseMode
/**
* @description Enforces formatting for events
* @param {object} eventArgs - attributes of event
* @param cueFallback
* @returns {object|null} - formatted object or null in case is invalid
*/
export const validateEvent = (eventArgs) => {
export const validateEvent = (eventArgs: Partial<OntimeEvent>, cueFallback: string) => {
// ensure id is defined and unique
const id = eventArgs.id || generateId();
const cue = eventArgs.cue || cueFallback;
let event = null;
// return if object is empty
@@ -336,12 +363,9 @@ export const validateEvent = (eventArgs) => {
user7: makeString(e.user7, d.user7),
user8: makeString(e.user8, d.user8),
user9: makeString(e.user9, d.user9),
// deciding not to validate colour
// this adds flexibility to the user to write hex codes, rgb,
// but also colour names like blue and red
// CSS.supports is only available in frontend
colour: makeString(e.colour, d.colour),
id,
cue,
type: 'event',
};
}
@@ -357,7 +381,7 @@ type ResponseError = { error: true; message: string };
* @param {string} file - reference to file
* @return {object} - parse result message
*/
export const fileHandler = async (file): ResponseOK | ResponseError => {
export const fileHandler = async (file): Promise<ResponseOK | ResponseError> => {
let res: Partial<ResponseOK | ResponseError> = {};
// check which file type are we dealing with
@@ -376,8 +400,7 @@ export const fileHandler = async (file): ResponseOK | ResponseError => {
res.data.userFields = parseUserFields(dataFromExcel);
res.message = 'success';
} else {
const errorMessage = 'No sheet found named ontime or event schedule';
console.log(errorMessage);
const errorMessage = 'No sheet found named "ontime" or "event schedule"';
res = {
error: true,
message: errorMessage,
+11 -2
View File
@@ -30,6 +30,7 @@ export const parseRundown = (data): OntimeRundown => {
console.log('Found rundown definition, importing...');
const rundown = [];
try {
let eventIndex = 0;
const ids = [];
for (const e of data.rundown) {
// cap number of events
@@ -43,6 +44,7 @@ export const parseRundown = (data): OntimeRundown => {
console.log('ERROR: ID collision on import, skipping');
continue;
}
// validate the right endAction is used
if (e.endAction && !Object.values(EndAction).includes(e.endAction)) {
e.endAction = EndAction.None;
@@ -54,8 +56,10 @@ export const parseRundown = (data): OntimeRundown => {
e.timerType = TimerType.CountDown;
console.log('WARNING: invalid Timer Type provided, using default');
}
if (e.type === 'event') {
const event = validateEvent(e);
eventIndex += 1;
const event = validateEvent(e, eventIndex.toString());
if (event != null) {
rundown.push(event);
ids.push(event.id);
@@ -216,7 +220,12 @@ export const validateOscObject = (data: OscSubscription): boolean => {
/**
* Parse osc portion of an entry
*/
export const parseOsc = (data: { osc?: Partial<OSCSettings> }, enforce: boolean): Partial<OSCSettings> => {
export const parseOsc = (
data: {
osc?: Partial<OSCSettings>;
},
enforce: boolean,
): OSCSettings | Record<string, never> => {
if ('osc' in data) {
console.log('Found OSC definition, importing...');
-1
View File
@@ -1,5 +1,4 @@
import fs from 'fs';
import { dayInMs } from 'ontime-utils';
/**
* @description Ensures variable is string, it skips object types
+3 -2
View File
@@ -8,12 +8,13 @@ export const timeFormat = 'HH:mm';
export const timeFormatSeconds = 'HH:mm:ss';
/**
* @description Converts an excel date to milliseconds
* @argument {string} date - excel string date
* @description Converts a date object to milliseconds
* @argument {Date} date
* @returns {number} - time in milliseconds
*/
export const dateToMillis = (date: Date): number => {
// TODO: Use UTC
const h = date.getHours();
const m = date.getMinutes();
const s = date.getSeconds();