refactor: migrate custom fields to transactions

refactor: extract functions to api domain

refactor: strict custom field parsing

refactor: remove rundown cache utilities

refactor: directory restructure
This commit is contained in:
Carlos Valente
2025-06-06 21:08:30 +02:00
committed by Carlos Valente
parent b1d23467a2
commit 62c8319d70
75 changed files with 2060 additions and 2480 deletions
@@ -1,4 +1,6 @@
import { TriggerDTO, TimerLifeCycle, AutomationDTO, Automation } from 'ontime-types';
import { TriggerDTO, TimerLifeCycle, AutomationDTO, Automation, EntryId } from 'ontime-types';
import { makeRundown } from '../../rundown/__mocks__/rundown.mocks.js';
import {
addTrigger,
@@ -12,6 +14,7 @@ import {
getAutomationTriggers,
getAutomations,
} from '../automation.dao.js';
import { makeOSCAction, makeHTTPAction } from './testUtils.js';
beforeAll(() => {
@@ -186,11 +189,9 @@ describe('editAutomation()', async () => {
});
describe('deleteAutomation()', () => {
// saving the ID of the added automation
let firstAutomation: Automation;
beforeEach(async () => {
await deleteAll();
firstAutomation = await addAutomation({
await addAutomation({
title: 'test-osc',
filterRule: 'all',
filters: [],
@@ -198,35 +199,15 @@ describe('deleteAutomation()', () => {
});
});
it('should remove m automation from the list', async () => {
it('should remove an automation from the list', async () => {
const automations = getAutomations();
expect(Object.keys(automations).length).toEqual(1);
await deleteAutomation(Object.keys(automations)[0]);
const rundown = makeRundown({});
const timedEventOrder: EntryId[] = [];
await deleteAutomation(rundown, timedEventOrder, Object.keys(automations)[0]);
const removed = getAutomations();
expect(Object.keys(removed).length).toEqual(0);
});
it('should not remove an automation which is in use', async () => {
const automations = getAutomations();
await addTrigger({
title: 'test-automation',
trigger: TimerLifeCycle.onLoad,
automationId: firstAutomation.id,
});
const automationKeys = Object.keys(automations);
const automationId = automationKeys[0];
expect(automationId).toEqual(firstAutomation.id);
expect(automationKeys.length).toEqual(1);
expect(automations[automationId]).toMatchObject({
id: automationId,
title: 'test-osc',
filterRule: 'all',
filters: expect.any(Array),
outputs: expect.any(Array),
});
await expect(deleteAutomation(automationId)).rejects.toThrowError();
});
});
@@ -1,4 +1,6 @@
import { parseTemplateNested, stringToOSCArgs } from '../automation.utils.js';
import { TimerLifeCycle } from 'ontime-types';
import { makeOntimeEvent, makeRundown } from '../../rundown/__mocks__/rundown.mocks.js';
import { isAutomationUsed, parseTemplateNested, stringToOSCArgs } from '../automation.utils.js';
describe('parseTemplateNested()', () => {
it('parses string with a single-level variable name', () => {
@@ -245,3 +247,53 @@ describe('test stringToOSCArgs()', () => {
expect(stringToOSCArgs(test)).toStrictEqual(expected);
});
});
describe('isAutomationUsed()', () => {
it('returns the first event which uses an automation', () => {
const rundown = makeRundown({
entries: {
'1': makeOntimeEvent({
id: '1',
triggers: [
{
id: 'trigger-1',
title: 'Trigger 1',
trigger: TimerLifeCycle.onClock,
automationId: 'test-automation',
},
],
}),
},
});
const timedEventOrder = ['1'];
const automationId = 'test-automation';
const result = isAutomationUsed(rundown, timedEventOrder, automationId);
expect(result).toBe('1');
});
it('returns returns undefined if there are no matches', () => {
const rundown = makeRundown({
entries: {
'1': makeOntimeEvent({
id: '1',
triggers: [
{
id: 'trigger-1',
title: 'Trigger 1',
trigger: TimerLifeCycle.onClock,
automationId: 'test-automation',
},
],
}),
},
});
const timedEventOrder = ['1'];
const automationId = 'does-not-exist';
const result = isAutomationUsed(rundown, timedEventOrder, automationId);
expect(result).toBeUndefined();
});
});
@@ -5,6 +5,8 @@ import type { Request, Response } from 'express';
import { oscServer } from '../../adapters/OscAdapter.js';
import { getCurrentRundown, getRundownMetadata } from '../rundown/rundown.dao.js';
import * as automationDao from './automation.dao.js';
import * as automationService from './automation.service.js';
import { parseOutput } from './automation.validation.js';
@@ -106,7 +108,10 @@ export async function editAutomation(req: Request, res: Response<Automation | Er
export async function deleteAutomation(req: Request, res: Response<void | ErrorResponse>) {
try {
await automationDao.deleteAutomation(req.params.id);
const rundown = getCurrentRundown();
const { timedEventOrder } = getRundownMetadata();
await automationDao.deleteAutomation(rundown, timedEventOrder, req.params.id);
res.status(204).send();
} catch (error) {
const message = getErrorMessage(error);
@@ -2,14 +2,17 @@ import type {
Automation,
AutomationDTO,
AutomationSettings,
EntryId,
NormalisedAutomation,
Rundown,
Trigger,
TriggerDTO,
} from 'ontime-types';
import { deleteAtIndex, generateId } from 'ontime-utils';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import { getTimedEvents } from '../../services/rundown-service/rundownUtils.js';
import { isAutomationUsed } from './automation.utils.js';
/**
* Gets a copy of the stored automation settings
@@ -133,7 +136,7 @@ export async function editAutomation(id: string, newAutomation: AutomationDTO):
/**
* Deletes a automation given its ID
*/
export async function deleteAutomation(id: string): Promise<void> {
export async function deleteAutomation(rundown: Rundown, timedEventOrder: EntryId[], id: string): Promise<void> {
const automations = getAutomations();
// ignore request if automation does not exist
if (!Object.hasOwn(automations, id)) {
@@ -149,13 +152,9 @@ export async function deleteAutomation(id: string): Promise<void> {
}
// prevent deleting a automation that is in use in events
const events = getTimedEvents().filter(
(event) => event.triggers && event.triggers.some((trigger) => trigger.automationId === id),
);
if (events.length) {
throw new Error(
`Unable to delete automation used in event: ${events[0].id}${events.length > 1 ? ` and ${events.length - 1} more` : ''}`,
);
const isInUse = isAutomationUsed(rundown, timedEventOrder, id);
if (isInUse) {
throw new Error(`Unable to delete automation used in event with ID ${isInUse}`);
}
delete automations[id];
@@ -1,7 +1,7 @@
import { DatabaseModel, AutomationSettings, NormalisedAutomation, Trigger } from 'ontime-types';
import { dbModel } from '../../models/dataModel.js';
import type { ErrorEmitter } from '../../utils/parser.js';
import type { ErrorEmitter } from '../../utils/parserUtils.js';
interface LegacyData extends Partial<DatabaseModel> {
http?: unknown;
@@ -1,4 +1,4 @@
import { FilterRule, MaybeNumber, OntimeAction } from 'ontime-types';
import { EntryId, FilterRule, isOntimeEvent, MaybeNumber, OntimeAction, Rundown } from 'ontime-types';
import { millisToString, removeLeadingZero, splitWhitespace, getPropertyFromPath } from 'ontime-utils';
import type { OscArgOrArrayInput, OscArgInput } from 'osc-min';
@@ -195,3 +195,25 @@ export function isBooleanEquals(a: boolean, b: string): boolean {
}
return false;
}
/**
* Checks is an automation is used in a rundown
* TODO(v4): this currently only checks the current rundown, we will need to check all rundowns in the future
*/
export function isAutomationUsed(
rundown: Rundown,
timedEventOrder: EntryId[],
automationId: string,
): EntryId | undefined {
for (let i = 0; i < timedEventOrder.length; i++) {
const eventId = timedEventOrder[i];
const event = rundown.entries[eventId];
if (isOntimeEvent(event) && event.triggers) {
for (const trigger of event.triggers) {
if (trigger.automationId === automationId) {
return eventId;
}
}
}
}
}
@@ -0,0 +1,128 @@
import { CustomFields } from 'ontime-types';
import { parseCustomFields, sanitiseCustomFields } from '../customFields.parser.js';
describe('parseCustomFields()', () => {
it('returns an a base model if nothing is given', () => {
const errorEmitter = vi.fn();
const result = parseCustomFields({}, errorEmitter);
expect(result).toBeTypeOf('object');
expect(errorEmitter).toHaveBeenCalledOnce();
});
it('parses data, skipping invalid results', () => {
const errorEmitter = vi.fn();
// @ts-expect-error -- data is external, we check bad types
const customFields = {
1: { label: 'test', type: 'string', colour: 'red' }, // ok
2: { label: 'test', type: 'string' }, // duplicate label
3: { label: '', type: 'string' }, // missing colour
4: { type: 'string', colour: '' }, // missing label
} as CustomFields;
const result = parseCustomFields({ customFields }, errorEmitter);
expect(result).toMatchObject({
test: {
label: 'test',
type: 'string',
colour: 'red',
},
});
expect(errorEmitter).toHaveBeenCalled();
});
});
describe('sanitiseCustomFields()', () => {
it('returns an empty object the type is incorrect', () => {
expect(sanitiseCustomFields({})).toEqual({});
});
it('returns an object of valid entries', () => {
const customFields: CustomFields = {
test: { label: 'test', type: 'string', colour: 'red' },
test2: { label: 'test2', type: 'string', colour: 'green' },
Test3: { label: 'Test3', type: 'string', colour: '' },
};
const sanitationResult = sanitiseCustomFields(customFields);
expect(sanitationResult).toStrictEqual(customFields);
});
it('type should be one of (image | string)', () => {
const testTypes = sanitiseCustomFields({
test1: { label: 'test1', type: 'another', colour: 'red' },
test2: { label: 'test2', type: 'image', colour: 'red' },
test3: { label: 'test3', type: 'string', colour: 'red' },
});
expect(testTypes).toMatchObject({
test2: { label: 'test2', type: 'image', colour: 'red' },
test3: { label: 'test3', type: 'string', colour: 'red' },
});
});
it('colour must be a string', () => {
const customFields: CustomFields = {
// @ts-expect-error intentional bad data
test: { label: 'test', type: 'string', colour: 5 },
};
const sanitationResult = sanitiseCustomFields(customFields);
expect(sanitationResult).toStrictEqual({});
});
it('label can not be empty', () => {
const customFields: CustomFields = {
'': { label: '', type: 'string', colour: 'red' },
};
const sanitationResult = sanitiseCustomFields(customFields);
expect(sanitationResult).toStrictEqual({});
});
it('remove extra stuff', () => {
const customFields: CustomFields = {
// @ts-expect-error intentional bad data
test: { label: 'test', type: 'string', colour: 'red', extra: 'should be removed' },
};
const expectedCustomFields: CustomFields = {
test: { label: 'test', type: 'string', colour: 'red' },
};
const sanitationResult = sanitiseCustomFields(customFields);
expect(sanitationResult).toStrictEqual(expectedCustomFields);
});
it('enforce name cohesion', () => {
const customFields: CustomFields = {
test: { label: 'NewName', type: 'string', colour: 'red' },
};
const expectedCustomFields: CustomFields = {
NewName: { label: 'NewName', type: 'string', colour: 'red' },
};
const sanitationResult = sanitiseCustomFields(customFields);
expect(sanitationResult).toStrictEqual(expectedCustomFields);
});
it('labels with space', () => {
const customFields: CustomFields = {
Test_with_Space: { label: 'Test with Space', type: 'string', colour: 'red' },
};
const expectedCustomFields: CustomFields = {
Test_with_Space: { label: 'Test with Space', type: 'string', colour: 'red' },
};
const sanitationResult = sanitiseCustomFields(customFields);
expect(sanitationResult).toStrictEqual(expectedCustomFields);
});
it('filters invalid entries', () => {
const customFields: CustomFields = {
test: { label: 'test', type: 'string', colour: 'red' },
test2: { label: 'test2', type: 'string', colour: 'green' },
bad: { label: '', type: 'string', colour: '' },
Test3: { label: 'Test3', type: 'string', colour: '' },
};
const expectedCustomFields: CustomFields = {
test: { label: 'test', type: 'string', colour: 'red' },
test2: { label: 'test2', type: 'string', colour: 'green' },
Test3: { label: 'Test3', type: 'string', colour: '' },
};
const sanitationResult = sanitiseCustomFields(customFields);
expect(sanitationResult).toStrictEqual(expectedCustomFields);
});
});
@@ -1,49 +0,0 @@
import { CustomField, CustomFields, ErrorResponse } from 'ontime-types';
import type { Request, Response } from 'express';
import { getErrorMessage } from 'ontime-utils';
import { createCustomField, editCustomField, removeCustomField } from '../../services/rundown-service/rundownCache.js';
import { getProjectCustomFields } from '../rundown/rundown.dao.js';
export async function getCustomFields(_req: Request, res: Response<CustomFields>) {
const customFields = getProjectCustomFields();
res.json(customFields);
}
export async function postCustomField(req: Request, res: Response<CustomFields | ErrorResponse>) {
try {
const newField = req.body as CustomField;
const allFields = await createCustomField(newField);
res.status(201).send(allFields);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
export async function putCustomField(req: Request, res: Response<CustomFields | ErrorResponse>) {
try {
const oldLabel = req.params.label;
const { colour, type, label } = req.body;
const newFields = await editCustomField(oldLabel, { label, colour, type });
res.status(200).send(newFields);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
// Expects { label: <label> }
export async function deleteCustomField(req: Request, res: Response<CustomFields | ErrorResponse>) {
try {
const fieldToDelete = req.params.label;
await removeCustomField(fieldToDelete);
res.sendStatus(204);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
@@ -0,0 +1,64 @@
import { DatabaseModel, CustomFields, CustomField } from 'ontime-types';
import { isAlphanumericWithSpace, customFieldLabelToKey } from 'ontime-utils';
import type { ErrorEmitter } from '../../utils/parserUtils.js';
/**
* Parse customFields entry
*/
export function parseCustomFields(data: Partial<DatabaseModel>, emitError?: ErrorEmitter): CustomFields {
if (typeof data.customFields !== 'object') {
emitError?.('No data found to import');
return {};
}
console.log('Found Custom Fields, importing...');
const customFields = sanitiseCustomFields(data.customFields);
if (Object.keys(customFields).length !== Object.keys(data.customFields).length) {
emitError?.('Skipped invalid custom fields');
}
return customFields;
}
export function sanitiseCustomFields(data: object): CustomFields {
const newCustomFields: CustomFields = {};
for (const [_originalKey, field] of Object.entries(data)) {
if (!isValidField(field)) {
continue;
}
if (!isAlphanumericWithSpace(field.label)) {
continue;
}
// the key is always made from the label
const key = customFieldLabelToKey(field.label);
if (key in newCustomFields) {
continue;
}
newCustomFields[key] = {
type: field.type,
colour: field.colour,
label: field.label,
};
}
function isValidField(data: unknown): data is CustomField {
return (
typeof data === 'object' &&
data !== null &&
'label' in data &&
data.label !== '' &&
'colour' in data &&
typeof data.colour === 'string' &&
'type' in data &&
(data.type === 'string' || data.type === 'image')
);
}
return newCustomFields;
}
@@ -1,14 +1,49 @@
import express from 'express';
import { CustomField, CustomFields, ErrorResponse } from 'ontime-types';
import { getErrorMessage } from 'ontime-utils';
import express from 'express';
import type { Request, Response } from 'express';
import { getProjectCustomFields } from '../rundown/rundown.dao.js';
import { createCustomField, editCustomField, deleteCustomField } from '../rundown/rundown.service.js';
import { deleteCustomField, getCustomFields, postCustomField, putCustomField } from './customFields.controller.js';
import { validateCustomField, validateDeleteCustomField, validateEditCustomField } from './customFields.validation.js';
export const router = express.Router();
router.get('/', getCustomFields);
router.get('/', async (_req: Request, res: Response<CustomFields>) => {
const customFields = getProjectCustomFields();
res.json(customFields);
});
router.post('/', validateCustomField, postCustomField);
router.post('/', validateCustomField, async (req: Request, res: Response<CustomFields | ErrorResponse>) => {
try {
const newFields = await createCustomField(req.body as CustomField);
res.status(201).send(newFields);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
});
router.put('/:label', validateEditCustomField, putCustomField);
router.put('/:key', validateEditCustomField, async (req: Request, res: Response<CustomFields | ErrorResponse>) => {
try {
const currentKey = req.params.key;
const { colour, type, label } = req.body;
const newFields = await editCustomField(currentKey, { label, colour, type });
res.status(200).send(newFields);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
});
router.delete('/:label', validateDeleteCustomField, deleteCustomField);
router.delete('/:key', validateDeleteCustomField, async (req: Request, res: Response<CustomFields | ErrorResponse>) => {
try {
const customFields = await deleteCustomField(req.params.key);
res.status(200).send(customFields);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
});
@@ -5,14 +5,14 @@ import { body, param, validationResult } from 'express-validator';
export const validateCustomField = [
body('label')
.exists()
.isString()
.trim()
.notEmpty()
.custom((value) => {
return isAlphanumericWithSpace(value);
}),
body('type').exists().isIn(['string', 'image']),
body('colour').exists().isString().trim(),
body('type').isIn(['string', 'image']),
body('colour').isString().trim(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
@@ -22,16 +22,16 @@ export const validateCustomField = [
];
export const validateEditCustomField = [
param('label').exists().isString().trim(),
param('key').isString().trim().notEmpty(),
body('label')
.exists()
.isString()
.trim()
.notEmpty()
.custom((value) => {
return isAlphanumericWithSpace(value);
}),
body('type').exists().isIn(['string', 'image']),
body('colour').exists().isString().trim(),
body('type').isIn(['string', 'image']),
body('colour').isString().trim(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
@@ -41,7 +41,7 @@ export const validateEditCustomField = [
];
export const validateDeleteCustomField = [
param('label').exists().isString(),
param('key').isString().notEmpty(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
@@ -0,0 +1,56 @@
/* eslint-disable no-console -- we are mocking the console */
import { demoDb } from '../../../models/demoProject.js';
import { parseDatabaseModel } from '../db.parser.js';
// mock data provider
beforeAll(() => {
vi.mock('../../classes/data-provider/DataProvider.js', () => {
return {
getDataProvider: vi.fn().mockImplementation(() => {
return {
setRundown: vi.fn().mockImplementation((newData) => newData),
setCustomFields: vi.fn().mockImplementation((newData) => newData),
};
}),
};
});
});
describe('test parseDatabaseModel() with demo project (valid)', () => {
const filteredDemoProject = structuredClone(demoDb);
const { data } = parseDatabaseModel(filteredDemoProject);
it('has 17 events with 12 top level events', () => {
expect(data.rundowns.default.order.length).toBe(12);
expect(Object.keys(data.rundowns.default.entries).length).toBe(17);
});
it('is the same as the demo project since all data is valid', () => {
// @ts-expect-error -- its ok
delete filteredDemoProject.settings.version;
// @ts-expect-error -- its ok
delete data.settings.version;
expect(data).toMatchObject(filteredDemoProject);
});
});
describe('test parseDatabaseModel() edge cases', () => {
it('skips unknown app and version settings', () => {
console.log = vi.fn();
const testData = {
settings: {
osc_port: 8888,
},
};
// @ts-expect-error -- we know this is wrong, testing imports outside domain
expect(() => parseDatabaseModel(testData)).toThrow();
});
it('fails with invalid JSON', () => {
// @ts-expect-error -- we know this is wrong, testing imports outside domain
expect(() => parseDatabaseModel('some random dataset')).toThrow();
});
});
+1 -2
View File
@@ -1,11 +1,10 @@
import type { Request } from 'express';
import multer, { type FileFilterCallback } from 'multer';
import { JSON_MIME } from '../../utils/parser.js';
import { storage } from '../../utils/upload.js';
const filterProjectFile = (_req: Request, file: Express.Multer.File, cb: FileFilterCallback) => {
if (file.mimetype.includes(JSON_MIME)) {
if (file.mimetype.includes('application/json')) {
cb(null, true);
} else {
cb(null, false);
+49
View File
@@ -0,0 +1,49 @@
import { DatabaseModel, LogOrigin } from 'ontime-types';
import { logger } from '../../classes/Logger.js';
import { parseAutomationSettings } from '../automation/automation.parser.js';
import { parseProjectData } from '../project-data/projectData.parser.js';
import { parseRundowns } from '../rundown/rundown.parser.js';
import { parseSettings } from '../settings/settings.parser.js';
import { parseUrlPresets } from '../url-presets/urlPresets.parser.js';
import { parseViewSettings } from '../view-settings/viewSettings.parser.js';
import { parseCustomFields } from '../custom-fields/customFields.parser.js';
type ParsingError = {
context: string;
message: string;
};
/**
* @description handles parsing of ontime project file
* @param {object} jsonData - project file to be parsed
* @returns {object} - parsed object
*/
export function parseDatabaseModel(jsonData: Partial<DatabaseModel>): { data: DatabaseModel; errors: ParsingError[] } {
// we need to parse settings first to make sure the data is ours
// this may throw
const settings = parseSettings(jsonData);
const errors: ParsingError[] = [];
const makeEmitError = (context: string) => (message: string) => {
logger.error(LogOrigin.Server, `Error parsing ${context}: ${message}`);
errors.push({ context, message });
};
// we need to parse the custom fields first so they can be used in validating events
const customFields = parseCustomFields(jsonData, makeEmitError('Custom Fields'));
const rundowns = parseRundowns(jsonData, customFields, makeEmitError('Rundowns'));
const data: DatabaseModel = {
rundowns,
project: parseProjectData(jsonData, makeEmitError('Project')),
settings,
viewSettings: parseViewSettings(jsonData, makeEmitError('View Settings')),
urlPresets: parseUrlPresets(jsonData, makeEmitError('URL Presets')),
customFields,
automation: parseAutomationSettings(jsonData),
};
return { data, errors };
}
@@ -0,0 +1,582 @@
import { CustomFields, OntimeEvent, SupportedEntry, TimerType } from 'ontime-types';
import { defaultImportMap, ImportMap, MILLIS_PER_MINUTE } from 'ontime-utils';
import { getCustomFieldData, parseExcel } from '../excel.parser.js';
import { dataFromExcelTemplate } from './mockData.js';
describe('parseExcel()', () => {
it('parses the example file', () => {
// partial import map with only custom fields
const importMap = {
custom: {
user0: 't0',
user1: 'Test1',
user2: 'test2',
user3: 'test3',
},
};
const existingCustomFields: CustomFields = {
user0: { type: 'string', colour: 'red', label: 'user0' },
user1: { type: 'string', colour: 'green', label: 'user1' },
user2: { type: 'string', colour: 'blue', label: 'user2' },
};
const parsedData = parseExcel(dataFromExcelTemplate, existingCustomFields, 'testSheet', importMap);
expect(parsedData.customFields).toStrictEqual({
user0: {
type: 'string',
colour: 'red',
label: 'user0',
},
user1: {
type: 'string',
colour: 'green',
label: 'user1',
},
user2: {
type: 'string',
colour: 'blue',
label: 'user2',
},
user3: {
type: 'string',
colour: '',
label: 'user3',
},
});
expect(parsedData.rundown.order.length).toBe(2);
// TODO: why dont we parse the date in UTC?
expect(parsedData.rundown.entries).toMatchObject({
'event-a': {
id: 'event-a',
//timeStart: 28800000,
//timeEnd: 32410000,
title: 'Guest Welcome',
timerType: 'count-down',
endAction: 'none',
isPublic: true,
skip: false,
note: 'Ballyhoo',
custom: {
user0: 'a0',
user1: 'a1',
user2: 'a2',
user3: 'a3',
},
colour: 'red',
type: 'event',
cue: '101',
},
'event-b': {
id: 'event-b',
//timeStart: 32400000,
//timeEnd: 34200000,
title: 'A song from the hearth',
timerType: 'clock',
endAction: 'load-next',
isPublic: false,
skip: true,
note: 'Rainbow chase',
custom: {},
colour: '#F00',
type: 'event',
cue: '102',
},
});
});
it('parses a file without custom fields', () => {
// partial import map with only custom fields
const importMap = {
custom: {
niu1: 'niu1',
niu2: 'niu2',
},
};
const parsedData = parseExcel(dataFromExcelTemplate, {}, 'testSheet', importMap);
expect(parsedData.customFields).toStrictEqual({
niu1: {
type: 'string',
colour: '',
label: 'niu1',
},
niu2: {
type: 'string',
colour: '',
label: 'niu2',
},
});
expect(parsedData.rundown.order).toMatchObject(['event-a', 'event-b']);
expect(parsedData.rundown.entries['event-a']).toMatchObject({
//timeStart: 28800000,
//timeEnd: 32410000,
id: 'event-a',
title: 'Guest Welcome',
timerType: 'count-down',
endAction: 'none',
isPublic: true,
skip: false,
note: 'Ballyhoo',
custom: {},
colour: 'red',
type: 'event',
cue: '101',
});
expect(parsedData.rundown.entries['event-b']).toMatchObject({
//timeStart: 32400000,
//timeEnd: 34200000,
id: 'event-b',
title: 'A song from the hearth',
timerType: 'clock',
endAction: 'load-next',
isPublic: false,
skip: true,
note: 'Rainbow chase',
custom: {},
colour: '#F00',
type: 'event',
cue: '102',
});
});
it('ignores unknown event types', () => {
const testdata = [
['Title', 'Timer type'],
['Guest Welcome', 'x'],
['A song from the hearth', 'clock'],
];
const importMap = {
title: 'title',
timerType: 'timer type',
};
const result = parseExcel(testdata, {}, 'testSheet', importMap);
const firstEvent = result.rundown.entries[result.rundown.order[0]];
expect(result.rundown.order.length).toBe(1);
expect((firstEvent as OntimeEvent).title).toBe('A song from the hearth');
});
it('imports blocks', () => {
const testdata = [
['Title', 'Timer type'],
['a block', 'block'],
['an event', 'clock'],
];
const importMap = {
title: 'title',
timerType: 'timer type',
};
const result = parseExcel(testdata, {}, 'testSheet', importMap);
const firstEvent = result.rundown.entries[result.rundown.order[0]];
expect(result.rundown.order.length).toBe(2);
expect((firstEvent as OntimeEvent).type).toBe(SupportedEntry.Block);
});
it('imports as events if there is no timer type column', () => {
const testdata = [['Title'], ['no timer type'], ['also no timer type']];
const importMap = {
title: 'title',
};
const result = parseExcel(testdata, {}, 'testSheet', importMap);
const firstEvent = result.rundown.entries[result.rundown.order[0]];
const secondEvent = result.rundown.entries[result.rundown.order[1]];
expect(result.rundown.order.length).toBe(2);
expect(firstEvent).toMatchObject({
type: SupportedEntry.Event,
timerType: TimerType.CountDown,
});
expect(secondEvent).toMatchObject({
type: SupportedEntry.Event,
timerType: TimerType.CountDown,
});
});
it('imports as events if timer type is empty or has whitespace', () => {
const testdata = [
['Title', 'Timer type'],
['first', ' '],
['second', undefined],
['third', ' count-up '],
];
const importMap = {
title: 'title',
timerType: 'timer type',
};
const result = parseExcel(testdata, {}, 'testSheet', importMap);
const firstEvent = result.rundown.entries[result.rundown.order[0]];
const secondEvent = result.rundown.entries[result.rundown.order[1]];
const thirdEvent = result.rundown.entries[result.rundown.order[2]];
expect(result.rundown.order.length).toBe(3);
expect(firstEvent).toMatchObject({ title: 'first', type: SupportedEntry.Event, timerType: TimerType.CountDown });
expect(secondEvent).toMatchObject({ title: 'second', type: SupportedEntry.Event, timerType: TimerType.CountDown });
expect(thirdEvent).toMatchObject({ title: 'third', type: SupportedEntry.Event, timerType: TimerType.CountUp });
});
it('am/pm conversion to 24h', () => {
const testData = [
['Time Start', 'Time End', 'ID'],
['4:30:00', '4:36:00', 'event-1'],
['9:45:00', '10:56:00', 'event-2'],
['16:30:00', '16:36:00', 'event-3'],
['21:45:00', '22:56:00', 'event-4'],
['4:30:00AM', '4:36:00AM', 'event-5'],
['9:45:00AM', '10:56:00AM', 'event-6'],
['4:30:00PM', '4:36:00PM', 'event-7'],
['9:45:00PM', '10:56:00PM', 'event-8'],
];
const importMap = {
timeStart: 'time start',
timeEnd: 'time end',
id: 'id',
};
const result = parseExcel(testData, {}, 'testSheet', importMap);
expect(result.rundown.order.length).toBe(8);
expect(result.rundown.entries['event-1']).toMatchObject({
timeStart: 16200000,
timeEnd: 16560000,
});
expect(result.rundown.entries['event-2']).toMatchObject({
timeStart: 35100000,
timeEnd: 39360000,
});
expect(result.rundown.entries['event-3']).toMatchObject({
timeStart: 59400000,
timeEnd: 59760000,
});
expect(result.rundown.entries['event-4']).toMatchObject({
timeStart: 78300000,
timeEnd: 82560000,
});
expect(result.rundown.entries['event-5']).toMatchObject({
timeStart: 16200000,
timeEnd: 16560000,
});
expect(result.rundown.entries['event-6']).toMatchObject({
timeStart: 35100000,
timeEnd: 39360000,
});
expect(result.rundown.entries['event-7']).toMatchObject({
timeStart: 59400000,
timeEnd: 59760000,
});
expect(result.rundown.entries['event-8']).toMatchObject({
timeStart: 78300000,
timeEnd: 82560000,
});
});
it('handle leading and trailing whitespace', () => {
const testData = [
[' ID', ' title ', 'Colour '], // <--- leading and trailing white space
['event-a', 'title', '#F00'],
];
const importMap = {
id: 'id',
title: ' title', // <--- leading white space
colour: 'colour ', // <--- trailing white space
};
const result = parseExcel(testData, {}, 'testSheet', importMap);
expect(result.rundown.order.length).toBe(1);
expect(result.rundown.entries['event-a']).toMatchObject({
colour: '#F00',
id: 'event-a',
title: 'title',
});
});
it('parses link start', () => {
const testData = [
['Time Start', 'Time End', 'ID', 'Link Start', 'Timer type'],
['4:30:00', '9:45:00', 'A', '', 'count-down'],
['9:45:00', '10:56:00', 'B', 'x', 'count-down'],
['10:00:00', '16:36:00', 'C', 'x', 'count-down'],
['21:45:00', '22:56:00', 'D', '', 'count-down'],
['', '', 'BLOCK', 'x', 'block'], // <-- block with link
['00:0:00', '23:56:00', 'E', 'x', 'count-down'], // <-- link past blocks
];
const importMap = {
timeStart: 'time start',
timeEnd: 'time end',
linkStart: 'link start',
id: 'id',
timerType: 'timer type',
};
const result = parseExcel(testData, {}, 'testSheet', importMap);
expect(result.rundown.order.length).toBe(6);
expect(result.rundown.order).toMatchObject(['A', 'B', 'C', 'D', 'BLOCK', 'E']);
expect(result.rundown.entries).toMatchObject({
A: {
linkStart: false,
},
B: {
linkStart: true,
},
C: {
linkStart: true,
},
D: {
linkStart: false,
},
BLOCK: {
type: SupportedEntry.Block,
},
E: {
linkStart: true,
},
});
});
it('#971 BUG: parses time fields and booleans', () => {
const testData = [
[
'ID',
'Time Start',
'Time End',
'Duration',
'Link Start',
'Timer Type',
'End Action',
'Warning time',
'Danger time',
],
[
'SETUP',
'1899-12-30T07:15:00.000Z',
'1899-12-30T08:30:00.000Z',
'',
'false',
'count-down',
'none',
'15',
'00:05:00',
],
[
'MEET1',
'1899-12-30T08:30:00.000Z',
'1899-12-30T10:00:00.000Z',
'',
'false',
'count-down',
'none',
15,
'00:05:00',
],
['MEET2', '1899-12-30T10:00:00.000Z', '', '60', 'false', 'count-down', 'none', '13', '5'],
['lunch', '', '1899-12-30T11:30:00.000Z', '', 'true', 'count-down', 'none', 13, 5],
['MEET3', '1899-12-30T11:30:00.000Z', '', 90, false, 'count-up', 'none', '11', 5],
['MEET4', '', '', 30, true, 'count-up', 'none', 11, '00:05:00'],
];
const parsedData = parseExcel(testData, {}, 'bug-report');
// '15' as a string is parsed by smart time entry as minutes
expect(parsedData.rundown.entries['SETUP']).toMatchObject({
timeWarning: 15 * MILLIS_PER_MINUTE,
});
// elements in bug report
// 15 is a number, in which case we parse it as a minutes value
expect(parsedData.rundown.entries['MEET1']).toMatchObject({
timeWarning: 15 * MILLIS_PER_MINUTE,
});
// in the case where a string is passed, we need to check whether it is an ISO 8601 date
expect(parsedData.rundown.entries['MEET2']).toMatchObject({
duration: 60 * MILLIS_PER_MINUTE,
timeDanger: 5 * MILLIS_PER_MINUTE,
});
expect(parsedData.rundown.entries['lunch']).toMatchObject({
timeWarning: 13 * MILLIS_PER_MINUTE,
timeDanger: 5 * MILLIS_PER_MINUTE,
});
expect(parsedData.rundown.entries['MEET3']).toMatchObject({
duration: 90 * MILLIS_PER_MINUTE,
linkStart: false,
timeWarning: 11 * MILLIS_PER_MINUTE,
timeDanger: 5 * MILLIS_PER_MINUTE,
});
expect(parsedData.rundown.entries['MEET4']).toMatchObject({
duration: 30 * MILLIS_PER_MINUTE,
timeWarning: 11 * MILLIS_PER_MINUTE,
linkStart: true,
});
});
});
describe('getCustomFieldData()', () => {
it('generates a list of keys from the given import map', () => {
const importMap = {
worksheet: 'event schedule',
timeStart: 'time start',
linkStart: 'link start',
timeEnd: 'time end',
duration: 'duration',
cue: 'cue',
title: 'title',
countToEnd: 'count to end',
isPublic: 'public',
skip: 'skip',
note: 'notes',
colour: 'colour',
endAction: 'end action',
timerType: 'timer type',
timeWarning: 'warning time',
timeDanger: 'danger time',
custom: {
lighting: 'lx',
sound: 'sound',
video: 'av',
},
entryId: 'id',
} as ImportMap;
const result = getCustomFieldData(importMap, {});
expect(result.mergedCustomFields).toStrictEqual({
lighting: {
type: 'string',
colour: '',
label: 'lighting',
},
sound: {
type: 'string',
colour: '',
label: 'sound',
},
video: {
type: 'string',
colour: '',
label: 'video',
},
});
// it is an inverted record of <importKey, ontimeKey>
expect(result.customFieldImportKeys).toStrictEqual({
lx: 'lighting',
sound: 'sound',
av: 'video',
});
});
it('keeps colour information from existing fields', () => {
const importMap = {
worksheet: 'event schedule',
timeStart: 'time start',
linkStart: 'link start',
timeEnd: 'time end',
duration: 'duration',
cue: 'cue',
title: 'title',
countToEnd: 'count to end',
isPublic: 'public',
skip: 'skip',
note: 'notes',
colour: 'colour',
endAction: 'end action',
timerType: 'timer type',
timeWarning: 'warning time',
timeDanger: 'danger time',
custom: {
lighting: 'lx',
sound: 'sound',
video: 'av',
'ontime key': 'excel label',
},
entryId: 'id',
} as ImportMap;
const existingCustomFields: CustomFields = {
lighting: { label: 'lighting', type: 'string', colour: 'red' },
sound: { label: 'sound', type: 'string', colour: 'green' },
ontime_key: { label: 'ontime key', type: 'string', colour: 'blue' },
};
const result = getCustomFieldData(importMap, existingCustomFields);
expect(result.mergedCustomFields).toStrictEqual({
lighting: {
type: 'string',
colour: 'red',
label: 'lighting',
},
sound: {
type: 'string',
colour: 'green',
label: 'sound',
},
video: {
type: 'string',
colour: '',
label: 'video',
},
ontime_key: {
type: 'string',
colour: 'blue',
label: 'ontime key',
},
});
// it is an inverted record of <importKey, ontimeKey>
expect(result.customFieldImportKeys).toStrictEqual({
lx: 'lighting',
sound: 'sound',
av: 'video',
'excel label': 'ontime_key',
});
});
it('lowercases the keys in the import map', () => {
const importMap: ImportMap = {
...defaultImportMap,
custom: {
Lighting: 'Lx',
Sound: 'sound',
video: 'av',
},
};
const result = getCustomFieldData(importMap, {});
expect(result.mergedCustomFields).toStrictEqual({
Lighting: {
type: 'string',
colour: '',
label: 'Lighting',
},
Sound: {
type: 'string',
colour: '',
label: 'Sound',
},
video: {
type: 'string',
colour: '',
label: 'video',
},
});
// notice that the keys excel keys are lowercased
expect(result.customFieldImportKeys).toStrictEqual({
lx: 'Lighting',
sound: 'Sound',
av: 'video',
});
});
});
@@ -0,0 +1,59 @@
export const dataFromExcelTemplate = [
['Ontime ┬À Schedule Template'],
[],
[
'id',
'Time Start',
'Time End',
'Title',
'End Action',
'Timer type',
'Count to end',
'Public',
'Skip',
'Notes',
't0',
'Test1',
'test2',
'test3',
'Colour',
'cue',
],
[
'event-a', // <-- eventId
'07:00:00', // <-- timeStart
'08:00:10', // <-- timeEnd
'Guest Welcome', // <-- title
'', // <-- endAction
'', // <-- timerType
'x', // <-- count to end
'x', // <-- public
'', // <-- skip
'Ballyhoo', // <-- notes
'a0', // <-- t0
'a1', // <-- test1
'a2', // <-- test2
'a3', // <-- test3
'red', // <-- colour
101, // <-- cue
],
[
'event-b', // <-- eventId
'08:00:00', // <-- timeStart
'08:30:00', // <-- timeEnd
'A song from the hearth', // <-- title
'load-next', // <-- endAction
'clock', // timerType
'x', // <-- count to end
'', // <-- public
'x', // <-- skip
'Rainbow chase', // <-- notes
'b0', // <-- t0
'', // <-- test1
'', // <-- test2
'', // <-- test3
'#F00', // <-- colour
102, // <-- cue
],
[],
];
@@ -1,9 +1,10 @@
import type { Request } from 'express';
import multer, { type FileFilterCallback } from 'multer';
import { EXCEL_MIME } from '../../utils/parser.js';
import { storage } from '../../utils/upload.js';
export const EXCEL_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
const filterExcel = (_req: Request, file: Express.Multer.File, cb: FileFilterCallback) => {
if (file.mimetype.includes(EXCEL_MIME)) {
cb(null, true);
@@ -0,0 +1,348 @@
import {
CustomFields,
Rundown,
OntimeEvent,
OntimeBlock,
EntryCustomFields,
SupportedEntry,
isOntimeBlock,
TimerType,
CustomFieldKey,
} from 'ontime-types';
import {
ImportMap,
defaultImportMap,
generateId,
isKnownTimerType,
validateTimerType,
validateEndAction,
customFieldLabelToKey,
isAlphanumericWithSpace,
} from 'ontime-utils';
import { Merge } from 'ts-essentials';
import { is } from '../../utils/is.js';
import { makeString } from '../../utils/parserUtils.js';
import { parseExcelDate } from '../../utils/time.js';
/**
* @description Excel array parser
* @param {array} excelData - array with excel sheet
* @param {ImportOptions} options - an object that contains the import map
* @returns {object} - parsed object
*/
export const parseExcel = (
excelData: unknown[][],
existingCustomFields: CustomFields,
sheetName: string = 'Rundown from excel',
options?: Partial<ImportMap>,
): {
rundown: Rundown;
customFields: CustomFields;
rundownMetadata: Record<string, { row: number; col: number }>;
} => {
const rundownMetadata: Record<string, { row: number; col: number }> = {};
const importMap: ImportMap = { ...defaultImportMap, ...options };
for (const [key, value] of Object.entries(importMap)) {
if (is.string(value)) {
// @ts-expect-error -- we are sure that the key exists
importMap[key] = value.toLowerCase().trim();
}
}
const { mergedCustomFields, customFieldImportKeys } = getCustomFieldData(importMap, existingCustomFields);
const rundown: Rundown = {
id: generateId(),
title: sheetName,
order: [],
flatOrder: [],
entries: {},
revision: 0,
};
// title stuff: strings
let titleIndex: number | null = null;
let cueIndex: number | null = null;
let notesIndex: number | null = null;
let colourIndex: number | null = null;
// options: booleans
let isPublicIndex: number | null = null;
let skipIndex: number | null = null;
let countToEndIndex: number | null = null;
let linkStartIndex: number | null = null;
// times: numbers
let timeStartIndex: number | null = null;
let timeEndIndex: number | null = null;
let durationIndex: number | null = null;
let timeWarningIndex: number | null = null;
let timeDangerIndex: number | null = null;
// options: enum properties
let endActionIndex: number | null = null;
let timerTypeIndex: number | null = null;
//ID
let entryIdIndex: number | null = null;
// record of column index and the name of the field
const customFieldIndexes: Record<number, string> = {};
excelData.forEach((row, rowIndex) => {
if (row.length === 0) {
return;
}
// TODO: extract generating handlers from importMap
const handlers = {
[importMap.timeStart]: (row: number, col: number) => {
timeStartIndex = col;
rundownMetadata['timeStart'] = { row, col };
},
[importMap.linkStart]: (row: number, col: number) => {
linkStartIndex = col;
rundownMetadata['linkStart'] = { row, col };
},
[importMap.timeEnd]: (row: number, col: number) => {
timeEndIndex = col;
rundownMetadata['timeEnd'] = { row, col };
},
[importMap.duration]: (row: number, col: number) => {
durationIndex = col;
rundownMetadata['duration'] = { row, col };
},
[importMap.cue]: (row: number, col: number) => {
cueIndex = col;
rundownMetadata['cue'] = { row, col };
},
[importMap.title]: (row: number, col: number) => {
titleIndex = col;
rundownMetadata['title'] = { row, col };
},
[importMap.countToEnd]: (row: number, col: number) => {
countToEndIndex = col;
rundownMetadata['countToEnd'] = { row, col };
},
[importMap.isPublic]: (row: number, col: number) => {
isPublicIndex = col;
rundownMetadata['isPublic'] = { row, col };
},
[importMap.skip]: (row: number, col: number) => {
skipIndex = col;
rundownMetadata['skip'] = { row, col };
},
[importMap.note]: (row: number, col: number) => {
notesIndex = col;
rundownMetadata['note'] = { row, col };
},
[importMap.colour]: (row: number, col: number) => {
colourIndex = col;
rundownMetadata['colour'] = { row, col };
},
[importMap.endAction]: (row: number, col: number) => {
endActionIndex = col;
rundownMetadata['endAction'] = { row, col };
},
[importMap.timerType]: (row: number, col: number) => {
timerTypeIndex = col;
rundownMetadata['timerType'] = { row, col };
},
[importMap.timeWarning]: (row: number, col: number) => {
timeWarningIndex = col;
rundownMetadata['timeWarning'] = { row, col };
},
[importMap.timeDanger]: (row: number, col: number) => {
timeDangerIndex = col;
rundownMetadata['timeDanger'] = { row, col };
},
[importMap.entryId]: (row: number, col: number) => {
entryIdIndex = col;
rundownMetadata['id'] = { row, col };
},
custom: (row: number, col: number, columnText: string, ontimeKey: string) => {
customFieldIndexes[col] = columnText;
rundownMetadata[`custom:${ontimeKey}`] = { row, col };
},
} as const;
const entry: Partial<Merge<OntimeEvent, OntimeBlock>> = {};
const entryCustomFields: EntryCustomFields = {};
for (let j = 0; j < row.length; j++) {
const column = row[j];
// 1. we check if we have set a flag for a known field
if (j === timerTypeIndex) {
const maybeTimeType = makeString(column, '');
if (maybeTimeType === 'block') {
// we leave this as a clue for the object filtering later on
entry.type = SupportedEntry.Block;
} else if (maybeTimeType === '' || maybeTimeType === 'event' || isKnownTimerType(maybeTimeType)) {
// @ts-expect-error -- we leave this as a clue for the object filtering later on
entry.type = SupportedEntry.Event;
entry.timerType = validateTimerType(maybeTimeType);
} else {
// if it is not a block or a known type, we dont import it
return;
}
} else if (j === titleIndex) {
entry.title = makeString(column, '');
} else if (j === timeStartIndex) {
entry.timeStart = parseExcelDate(column);
} else if (j === linkStartIndex) {
entry.linkStart = parseBooleanString(column);
} else if (j === timeEndIndex) {
entry.timeEnd = parseExcelDate(column);
} else if (j === durationIndex) {
entry.duration = parseExcelDate(column);
} else if (j === cueIndex) {
entry.cue = makeString(column, '');
} else if (j === countToEndIndex) {
entry.countToEnd = parseBooleanString(column);
} else if (j === isPublicIndex) {
entry.isPublic = parseBooleanString(column);
} else if (j === skipIndex) {
entry.skip = parseBooleanString(column);
} else if (j === notesIndex) {
entry.note = makeString(column, '');
} else if (j === endActionIndex) {
entry.endAction = validateEndAction(column);
} else if (j === timeWarningIndex) {
entry.timeWarning = parseExcelDate(column);
} else if (j === timeDangerIndex) {
entry.timeDanger = parseExcelDate(column);
} else if (j === colourIndex) {
entry.colour = makeString(column, '');
} else if (j === entryIdIndex) {
entry.id = encodeURIComponent(makeString(column, undefined));
} else if (j in customFieldIndexes) {
const importKey = customFieldIndexes[j];
const ontimeKey = customFieldImportKeys[importKey];
entryCustomFields[ontimeKey] = makeString(column, '');
} else {
// 2. if there is no flag, lets see if we know the field type
if (typeof column === 'string') {
// we cant deal with empty content
if (column.length === 0) {
continue;
}
const columnText = column.toLowerCase().trim();
// check if it is an ontime column
if (handlers[columnText]) {
// @ts-expect-error -- its ok
handlers[columnText](rowIndex, j, undefined, undefined);
}
// check if it is a custom field
if (columnText in customFieldImportKeys) {
const ontimeKey = customFieldImportKeys[columnText];
handlers.custom(rowIndex, j, columnText, ontimeKey);
}
// else. we don't know how to handle this column
// just ignore it
}
}
}
// if we didnt find any keys (empty row, or some other data), skip making an event
const keysFound = Object.keys(entry).length + Object.keys(entryCustomFields).length;
if (keysFound === 0) {
return;
}
const id = entry.id || generateId();
// from excel, we can only get blocks and events
if (isOntimeBlock(entry)) {
const block: OntimeBlock = { ...entry, custom: { ...entryCustomFields } };
rundown.order.push(id);
rundown.entries[id] = block;
return;
}
const event = {
...entry,
custom: { ...entryCustomFields },
type: SupportedEntry.Event,
} as OntimeEvent;
if (timerTypeIndex === null) {
event.timerType = TimerType.CountDown;
}
rundown.order.push(id);
rundown.flatOrder.push(id);
rundown.entries[id] = event;
});
return {
rundown,
customFields: mergedCustomFields,
rundownMetadata,
};
};
/**
* Utility function infers a boolean from a string value
*/
function parseBooleanString(value: unknown): boolean {
if (typeof value === 'boolean') {
return value;
}
// falsy values would be nullish or empty string
if (!value || typeof value !== 'string') {
return false;
}
return value.toLowerCase() !== 'false';
}
/**
* Receives an import map which contains custom field labels and a custom fields object
* the result importkeys is an inverted record of <importKey, ontimeKey>
* We need this function since, when importing from sheets, the user gives us custom field labels, not keys
* @returns the new custom fields, and a map of excel column names to ontime keys
* @private exported for testing
*/
export function getCustomFieldData(
importMap: ImportMap,
existingCustomFields: CustomFields,
): {
mergedCustomFields: CustomFields;
customFieldImportKeys: Record<keyof CustomFields, string>;
} {
const mergedCustomFields: CustomFields = {};
/**
* A map of import keys to ontime keys
* Map<excel column name, ontime key>
*/
const customFieldImportKeys: Record<string, CustomFieldKey> = {};
for (const ontimeLabel in importMap.custom) {
// if the label is not valid, we skip the import
if (!isAlphanumericWithSpace(ontimeLabel)) {
continue;
}
// generate a key for the custom field
const keyInCustomFields = customFieldLabelToKey(ontimeLabel);
// we lower case the excel key to make it easier to match
const columnNameInExcel = importMap.custom[ontimeLabel].toLowerCase();
const maybeExistingColour = existingCustomFields[keyInCustomFields]?.colour ?? '';
// 1. add the custom field to the merged custom fields
mergedCustomFields[keyInCustomFields] = {
type: 'string', // we currently only support string custom fields
colour: maybeExistingColour,
label: ontimeLabel,
};
// 2. add the column to the import keys
customFieldImportKeys[columnNameInExcel] = keyInCustomFields;
}
return { mergedCustomFields, customFieldImportKeys };
}
@@ -11,12 +11,13 @@ import { existsSync } from 'fs';
import xlsx from 'xlsx';
import type { WorkBook } from 'xlsx';
import { parseExcel } from '../../utils/parser.js';
import { parseCustomFields } from '../../utils/parserFunctions.js';
import { deleteFile } from '../../utils/parserUtils.js';
import { deleteFile } from '../../utils/fileManagement.js';
import { parseRundown } from '../rundown/rundown.parser.js';
import { getProjectCustomFields } from '../rundown/rundown.dao.js';
import { parseCustomFields } from '../custom-fields/customFields.parser.js';
import { parseExcel } from './excel.parser.js';
let excelData: WorkBook = xlsx.utils.book_new();
+1 -1
View File
@@ -4,7 +4,7 @@ import { router as automationsRouter } from './automation/automation.router.js';
import { router as urlPresetsRouter } from './url-presets/urlPresets.router.js';
import { router as customFieldsRouter } from './custom-fields/customFields.router.js';
import { router as dbRouter } from './db/db.router.js';
import { router as projectRouter } from './project/project.router.js';
import { router as projectRouter } from './project-data/projectData.router.js';
import { router as rundownRouter } from './rundown/rundown.router.js';
import { router as settingsRouter } from './settings/settings.router.js';
import { router as sheetsRouter } from './sheets/sheets.router.js';
@@ -0,0 +1,10 @@
import { parseProjectData } from '../projectData.parser.js';
describe('parseProjectData()', () => {
it('returns an a base model if nothing is given', () => {
const errorEmitter = vi.fn();
const result = parseProjectData({}, errorEmitter);
expect(result).toBeTypeOf('object');
expect(errorEmitter).toHaveBeenCalledOnce();
});
});
@@ -6,7 +6,7 @@ import type { Request, Response } from 'express';
import { removeUndefined } from '../../utils/parserUtils.js';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import { editCurrentProjectData } from '../../services/project-service/ProjectService.js';
import * as projectDao from './project.dao.js';
import * as projectDao from './projectData.dao.js';
export function getProjectData(_req: Request, res: Response<ProjectData>) {
res.json(projectDao.getProjectData());
@@ -1,4 +1,5 @@
import { ProjectData } from 'ontime-types';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
/**
@@ -0,0 +1,27 @@
import { DatabaseModel, ProjectData } from 'ontime-types';
import { dbModel } from '../../models/dataModel.js';
import { ErrorEmitter } from '../../utils/parserUtils.js';
/**
* Parse event portion of an entry
*/
export function parseProjectData(data: Partial<DatabaseModel>, emitError?: ErrorEmitter): ProjectData {
if (!data.project) {
emitError?.('No data found to import');
return { ...dbModel.project };
}
console.log('Found project data, importing...');
return {
title: data.project.title ?? dbModel.project.title,
description: data.project.description ?? dbModel.project.description,
publicUrl: data.project.publicUrl ?? dbModel.project.publicUrl,
publicInfo: data.project.publicInfo ?? dbModel.project.publicInfo,
backstageUrl: data.project.backstageUrl ?? dbModel.project.backstageUrl,
backstageInfo: data.project.backstageInfo ?? dbModel.project.backstageInfo,
projectLogo: data.project.projectLogo ?? dbModel.project.projectLogo,
custom: data.project.custom ?? dbModel.project.custom,
};
}
@@ -1,7 +1,7 @@
import express from 'express';
import { getProjectData, postProjectData } from './project.controller.js';
import { projectSanitiser } from './project.validation.js';
import { getProjectData, postProjectData } from './projectData.controller.js';
import { projectSanitiser } from './projectData.validation.js';
import { uploadImageFile } from '../db/db.middleware.js';
import { postProjectLogo } from '../db/db.controller.js';
@@ -1,4 +1,5 @@
import { SupportedEntry, OntimeEvent, OntimeDelay, OntimeBlock, Rundown } from 'ontime-types';
import { SupportedEntry, OntimeEvent, OntimeDelay, OntimeBlock, Rundown, CustomField } from 'ontime-types';
import { defaultRundown } from '../../../models/dataModel.js';
const baseEvent = {
@@ -46,6 +47,15 @@ export function makeRundown(patch: Partial<Rundown>): Rundown {
};
}
export function makeCustomField(patch: Partial<CustomField>): CustomField {
return {
type: 'string',
colour: '#000000',
label: 'Custom Field',
...patch,
};
}
/**
* Utility to generate a rundown of OntimeEvents form partial objects
*/
@@ -1,18 +1,35 @@
import { CustomFields, OntimeBlock, OntimeDelay, OntimeEvent, SupportedEntry, TimeStrategy } from 'ontime-types';
import { dayInMs, MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils';
import { makeOntimeEvent, makeRundown, makeOntimeBlock, makeOntimeDelay } from '../__mocks__/rundown.mocks.js';
import {
makeOntimeEvent,
makeRundown,
makeOntimeBlock,
makeOntimeDelay,
makeCustomField,
} from '../__mocks__/rundown.mocks.js';
import { createTransaction, processRundown, rundownCache, rundownMutation } from '../rundown.dao.js';
import {
createTransaction,
customFieldMutation,
processRundown,
rundownCache,
rundownMutation,
} from '../rundown.dao.js';
import { demoDb } from '../../../models/demoProject.js';
import { ProcessedRundownMetadata } from '../../../services/rundown-service/rundownCache.utils.js';
import type { AssignedMap } from '../rundown.types.js';
import { type ProcessedRundownMetadata } from '../rundown.parser.js';
const setRundownMock = vi.fn();
const setCustomFieldsMock = vi.fn();
beforeAll(() => {
vi.mock('../../../classes/data-provider/DataProvider.js', () => {
return {
getDataProvider: vi.fn().mockImplementation(() => {
return {
setRundown: vi.fn().mockImplementation(() => undefined),
setRundown: setRundownMock,
setCustomFields: setCustomFieldsMock,
};
}),
};
@@ -24,17 +41,39 @@ afterAll(() => {
});
describe('createTransaction', () => {
it('should return a snapshot of the cached rundown and an commit function', () => {
const { rundown, commit } = createTransaction();
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
});
it('should return a snapshot of the cached data and an commit function', () => {
const { rundown, customFields, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: true });
expect(rundown).toBeDefined();
expect(customFields).toBeDefined();
expect(typeof commit).toBe('function');
});
it('should return the updated rundown after commit is called and update the db', () => {
const { rundown, commit } = createTransaction();
it('should return the updated data after commit is called and writes are scheduled', () => {
const { rundown, customFields, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: true });
rundown.title = 'Another Title';
customFields['newField'] = {
label: 'New Field',
type: 'string',
colour: 'blue',
};
const updated = commit();
vi.runAllTimers();
expect(updated.rundown.title).toBe('Another Title');
expect(updated.customFields).toHaveProperty('newField');
expect(setRundownMock).toHaveBeenCalledOnce();
expect(setCustomFieldsMock).toHaveBeenCalledOnce();
});
});
@@ -1568,3 +1607,119 @@ describe('rundownMutation.ungroup()', () => {
});
});
});
describe('customFieldMutation.add()', () => {
it('adds a custom field given object', () => {
const customFields = {
one: makeCustomField({ label: 'one' }),
};
customFieldMutation.add(customFields, 'two', makeCustomField({ label: 'two' }));
expect(customFields).toMatchObject({
one: { label: 'one' },
two: { label: 'two' },
});
});
});
describe('customFieldMutation.edit()', () => {
it('changes properties of an existing custom field', () => {
const customFields = {
one: makeCustomField({ label: 'one', colour: 'blue' }),
};
customFieldMutation.edit(customFields, 'one', customFields.one, { colour: 'red' });
expect(customFields).toMatchObject({
one: { label: 'one', colour: 'red' },
});
});
it('changing the label makes a new key', () => {
const customFields = {
one: makeCustomField({ label: 'one', colour: 'blue' }),
};
const { oldKey, newKey } = customFieldMutation.edit(customFields, 'one', customFields.one, {
label: 'two',
colour: 'red',
});
expect(oldKey).toBe('one');
expect(newKey).not.toEqual(oldKey);
expect(customFields).toMatchObject({
[oldKey]: { label: 'one', colour: 'blue' },
[newKey]: { label: 'two', colour: 'red' },
});
});
});
describe('customFieldMutation.remove()', () => {
it('deletes a custom field from the object', () => {
const customFields = {
one: makeCustomField({ label: 'one', colour: 'blue' }),
};
customFieldMutation.remove(customFields, 'one');
expect(customFields).not.toHaveProperty('one');
});
});
describe('customFieldMutation.renameUsages()', () => {
it('renames all custom field entries in a given rundown', () => {
const rundown = makeRundown({
order: ['1', '2', '3'],
entries: {
'1': makeOntimeEvent({ id: '1', custom: { one: 'value1' } }),
'2': makeOntimeEvent({ id: '2', custom: { one: 'value2' } }),
'3': makeOntimeEvent({ id: '3', custom: { two: 'value3' } }),
},
});
const assigned: AssignedMap = {
one: ['1', '2'],
two: ['3'],
};
customFieldMutation.renameUsages(rundown, assigned, 'one', 'new-one');
expect(rundown.entries).toMatchObject({
'1': { id: '1', custom: { 'new-one': 'value1' } },
'2': { id: '2', custom: { 'new-one': 'value2' } },
'3': { id: '3', custom: { two: 'value3' } },
});
expect(assigned).toStrictEqual({
'new-one': ['1', '2'],
two: ['3'],
});
});
});
describe('customFieldMutation.removeUsages()', () => {
it('deletes all custom field entries in a given rundown', () => {
const rundown = makeRundown({
order: ['1', '2', '3'],
entries: {
'1': makeOntimeEvent({ id: '1', custom: { one: 'value1' } }),
'2': makeOntimeEvent({ id: '2', custom: { one: 'value2' } }),
'3': makeOntimeEvent({ id: '3', custom: { two: 'value3' } }),
},
});
const assigned: AssignedMap = {
one: ['1', '2'],
two: ['3'],
};
customFieldMutation.removeUsages(rundown, assigned, 'one');
expect((rundown.entries['1'] as OntimeEvent).custom).not.toHaveProperty('one');
expect((rundown.entries['2'] as OntimeEvent).custom).not.toHaveProperty('one');
expect(assigned).toStrictEqual({
two: ['3'],
});
});
});
@@ -1,19 +1,18 @@
import { SupportedEntry, OntimeEvent, OntimeBlock, Rundown } from 'ontime-types';
import { SupportedEntry, OntimeEvent, OntimeBlock, Rundown, CustomFields } from 'ontime-types';
import { defaultRundown } from '../../../models/dataModel.js';
import { makeOntimeBlock, makeOntimeEvent } from '../__mocks__/rundown.mocks.js';
import { parseRundowns, parseRundown } from '../rundown.parser.js';
import { parseRundowns, parseRundown, handleCustomField, addToCustomAssignment } from '../rundown.parser.js';
describe('parseRundowns()', () => {
it('returns a default project rundown if nothing is given', () => {
const errorEmitter = vi.fn();
const result = parseRundowns({}, errorEmitter);
expect(result.customFields).toEqual({});
expect(result.rundowns).toStrictEqual({ default: defaultRundown });
const result = parseRundowns({}, {}, errorEmitter);
expect(result).toStrictEqual({ default: defaultRundown });
// one for not having custom fields
// one for not having a rundown
expect(errorEmitter).toHaveBeenCalledTimes(2);
expect(errorEmitter).toHaveBeenCalledTimes(1);
});
it('ensures the rundown IDs are consistent', () => {
@@ -27,14 +26,14 @@ describe('parseRundowns()', () => {
'3': r2,
},
},
{},
errorEmitter,
);
expect(result.rundowns).toMatchObject({
expect(result).toMatchObject({
'1': r1,
'2': r2,
});
// one for not having a rundown
expect(errorEmitter).toHaveBeenCalledTimes(1);
expect(errorEmitter).toHaveBeenCalledTimes(0);
});
});
@@ -183,6 +182,37 @@ describe('parseRundown()', () => {
expect(Object.keys(parsedRundown.entries).length).toEqual(2);
});
it('parses customFields', () => {
const rundown = {
id: 'test',
title: '',
order: ['1', '2'],
flatOrder: ['1', '2'],
entries: {
'1': makeOntimeEvent({ id: '1', custom: { lighting: 'on' } }),
'2': makeOntimeEvent({ id: '2', custom: { sound: 'loud' } }),
},
revision: 1,
} as Rundown;
const customFields: CustomFields = {
lighting: {
type: 'string',
colour: 'red',
label: 'lighting',
},
sound: {
type: 'string',
colour: 'red',
label: 'sound',
},
};
const parsedRundown = parseRundown(rundown, customFields);
expect((parsedRundown.entries['1'] as OntimeEvent).custom).toStrictEqual({ lighting: 'on' });
expect((parsedRundown.entries['2'] as OntimeEvent).custom).toStrictEqual({ sound: 'loud' });
});
it('parses events nested in blocks', () => {
const rundown = {
id: 'test',
@@ -203,3 +233,50 @@ describe('parseRundown()', () => {
expect(Object.keys(parsedRundown.entries).length).toEqual(3);
});
});
describe('addToCustomAssignment()', () => {
it('adds given entry to assignedCustomFields', () => {
const assignedCustomFields = {};
addToCustomAssignment('label1', 'eventId 1', assignedCustomFields);
expect(assignedCustomFields).toStrictEqual({ label1: ['eventId 1'] });
addToCustomAssignment('label1', 'eventId 2', assignedCustomFields);
expect(assignedCustomFields).toStrictEqual({ label1: ['eventId 1', 'eventId 2'] });
});
});
describe('handleCustomField()', () => {
it('creates a map of where custom fields are used', () => {
const customFields = {
lighting: {
type: 'string',
colour: 'red',
label: 'lighting',
},
sound: {
type: 'string',
colour: 'red',
label: 'sound',
},
} as CustomFields;
const event = makeOntimeEvent({
type: SupportedEntry.Event,
id: '2',
timeStart: 0,
linkStart: true,
custom: {
lighting: 'on',
},
});
const assignedCustomFields = {};
const result = handleCustomField(customFields, event, assignedCustomFields);
expect(result).toBeUndefined();
expect(assignedCustomFields).toStrictEqual({ lighting: ['2'] });
expect(event.custom).toStrictEqual({
lighting: 'on',
});
});
});
@@ -1,8 +1,10 @@
import { TimeStrategy, EndAction, TimerType, OntimeEvent } from 'ontime-types';
import { MILLIS_PER_HOUR } from 'ontime-utils';
import { assertType } from 'vitest';
import { createEvent, deleteById, doesInvalidateMetadata, hasChanges } from '../rundown.utils.js';
import { calculateDayOffset, createEvent, deleteById, doesInvalidateMetadata, getInsertAfterId, hasChanges } from '../rundown.utils.js';
import { makeRundown } from '../__mocks__/rundown.mocks.js';
describe('test event validator', () => {
it('validates a good object', () => {
@@ -130,7 +132,7 @@ describe('hasChanges()', () => {
});
});
describe('deleteById', () => {
describe('deleteById()', () => {
it('should delete the first instance of the specified ID from the array', () => {
const array = ['id1', 'id2', 'id3', 'id4'];
const result = deleteById(array, 'id2');
@@ -156,3 +158,83 @@ describe('deleteById', () => {
expect(result).toStrictEqual(['id1', 'id2', 'id3']);
});
});
describe('calculateDayOffset()', () => {
it('returns 0 if there is no previous event', () => {
expect(calculateDayOffset({ timeStart: 0 }, null)).toBe(0);
});
it('returns 0 if the previous event duration is 0', () => {
expect(calculateDayOffset({ timeStart: 0 }, { timeStart: 0, duration: 0 })).toBe(0);
});
it('returns 0 if event starts after previous', () => {
expect(calculateDayOffset({ timeStart: 11 }, { timeStart: 10, duration: 2 })).toBe(0);
});
it('returns 1 if event starts before previous', () => {
expect(calculateDayOffset({ timeStart: 9 }, { timeStart: 10, duration: 2 })).toBe(1);
});
it('returns 1 if event starts at the same time as one before', () => {
expect(calculateDayOffset({ timeStart: 10 }, { timeStart: 10, duration: 2 })).toBe(1);
});
it('should account for an event that crossed midnight and there is a overlap', () => {
expect(
calculateDayOffset(
{ timeStart: MILLIS_PER_HOUR }, // starts at 01:00:00
{ timeStart: 20 * MILLIS_PER_HOUR, duration: 6 * MILLIS_PER_HOUR }, // ends at 02:00:00
),
).toBe(1);
});
it('should account for an event that crossed midnight and there is a gap', () => {
expect(
calculateDayOffset(
{ timeStart: 2 * MILLIS_PER_HOUR }, // starts at 02:00:00
{ timeStart: 23 * MILLIS_PER_HOUR, duration: 2 * MILLIS_PER_HOUR }, // ends at 01:00:00
),
).toBe(1);
});
it('should account for an event that crossed midnight with no overlaps or gaps', () => {
expect(
calculateDayOffset(
{ timeStart: 2 * MILLIS_PER_HOUR }, // starts at 02:00:00
{ timeStart: 20 * MILLIS_PER_HOUR, duration: 6 * MILLIS_PER_HOUR }, // ends at 02:00:00
),
).toBe(1);
});
it('should account for an event that finishes exactly at midnight', () => {
expect(
calculateDayOffset(
{ timeStart: 2 * MILLIS_PER_HOUR }, // starts at 02:00:00
{ timeStart: 23 * MILLIS_PER_HOUR, duration: 6 * MILLIS_PER_HOUR }, // ends at 24:00:00
),
).toBe(1);
});
});
describe('getInsertAfterId()', () => {
const rundown = makeRundown({
flatOrder: ['a', 'b', 'c', 'd'],
});
it('returns afterId if provided', () => {
expect(getInsertAfterId(rundown, 'b')).toBe('b');
});
it('returns the previous id before beforeId if provided', () => {
expect(getInsertAfterId(rundown, undefined, 'c')).toBe('b');
});
it('returns undefined if neither afterId nor beforeId is provided', () => {
expect(getInsertAfterId(rundown)).toBeNull();
});
it('returns undefined if beforeId is not found', () => {
expect(getInsertAfterId(rundown, undefined, 'z')).toBeNull();
});
});
+195 -35
View File
@@ -11,6 +11,8 @@
*/
import {
CustomField,
CustomFieldKey,
CustomFields,
EntryId,
isOntimeBlock,
@@ -23,13 +25,11 @@ import {
PatchWithId,
Rundown,
} from 'ontime-types';
import { insertAtIndex } from 'ontime-utils';
import { customFieldLabelToKey, insertAtIndex } from 'ontime-utils';
import { makeRundownMetadata, ProcessedRundownMetadata } from '../../services/rundown-service/rundownCache.utils.js';
import { customFieldChangelog } from '../../services/rundown-service/rundownCache.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import type { RundownMetadata } from './rundown.types.js';
import type { AssignedMap, CustomFieldsMetadata, RundownMetadata } from './rundown.types.js';
import {
applyPatchToEntry,
cloneBlock,
@@ -39,6 +39,7 @@ import {
doesInvalidateMetadata,
getUniqueId,
} from './rundown.utils.js';
import { makeRundownMetadata, ProcessedRundownMetadata } from './rundown.parser.js';
/**
* The currently loaded rundown in cache
@@ -62,8 +63,15 @@ let rundownMetadata: RundownMetadata = {
playableEventOrder: [],
timedEventOrder: [],
flatEntryOrder: [],
};
assignedCustomFields: {},
const customFieldsMetadata: CustomFieldsMetadata = {
/**
* Keep track of which custom fields are used.
* This will be handy for when we delete custom fields
* since we can clear the custom fields from every event where they are used
*/
assigned: {},
};
/**
@@ -73,47 +81,103 @@ let rundownMetadata: RundownMetadata = {
let projectCustomFields: CustomFields = {};
export const getCurrentRundown = (): Readonly<Rundown> => cachedRundown;
export const getRundownMetadata = (): Readonly<RundownMetadata> => rundownMetadata;
export const getProjectCustomFields = (): Readonly<CustomFields> => projectCustomFields;
export const getEntryWithId = (entryId: EntryId): OntimeEntry | undefined => cachedRundown.entries[entryId];
export function createTransaction() {
const rundown = structuredClone(cachedRundown);
const customFields = projectCustomFields;
type Transaction = {
customFields: CustomFields;
customFieldsMetadata: Readonly<CustomFieldsMetadata>;
rundown: Rundown;
rundownMetadata: Readonly<RundownMetadata>;
commit: (shouldProcess?: boolean) => {
rundown: Readonly<Rundown>;
rundownMetadata: Readonly<RundownMetadata>;
customFields: Readonly<CustomFields>;
revision: Readonly<number>;
};
};
type TransactionOptions = {
mutableRundown?: boolean;
mutableCustomFields?: boolean;
};
export function createTransaction(options: TransactionOptions): Transaction {
const rundown = options.mutableRundown ? structuredClone(cachedRundown) : cachedRundown;
const customFields = options.mutableCustomFields ? structuredClone(projectCustomFields) : projectCustomFields;
/**
* Applies a mutated rundown to the cache
* @param shouldProcess - whether the rundown should be processed after the commit
* Some edit mutations, and custom field changes do not require processing
*/
function commit(shouldProcess: boolean = true) {
// schedule a database update
setImmediate(async () => {
await getDataProvider().setRundown(cachedRundown.id, cachedRundown);
});
// if the rundown is mutable we persist the changes
if (options.mutableRundown) {
// schedule a database update
setImmediate(async () => {
await getDataProvider().setRundown(cachedRundown.id, cachedRundown);
});
const revision = rundown.revision + 1;
cachedRundown.revision = revision;
// increment the revision number
cachedRundown.revision = cachedRundown.revision + 1;
/**
* Some mutations do not require processing the rundown
* We simply increment the revision and return the rundown
*/
if (!shouldProcess) {
cachedRundown.entries = rundown.entries;
cachedRundown.order = rundown.order;
cachedRundown.flatOrder = rundown.flatOrder;
return { rundown, rundownMetadata, customFields: projectCustomFields, revision: cachedRundown.revision };
/**
* Some mutations do not require processing the rundown
* We simply increment the revision and return the rundown
*/
if (!shouldProcess) {
cachedRundown.title = rundown.title;
cachedRundown.entries = rundown.entries;
cachedRundown.order = rundown.order;
cachedRundown.flatOrder = rundown.flatOrder;
return {
rundown: cachedRundown,
rundownMetadata, // metadata doesnt change as long as we dont process the rundown
customFields: projectCustomFields,
revision: cachedRundown.revision,
};
}
const processedData = processRundown(rundown, projectCustomFields);
// update the cache values
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- we are not interested in the iteration data
const { previousEvent, latestEvent, previousEntry, entries, order, assignedCustomFields, ...metadata } =
processedData;
cachedRundown.title = rundown.title;
cachedRundown.entries = entries;
cachedRundown.order = order;
cachedRundown.flatOrder = metadata.flatEntryOrder; // TODO: remove in favour of the metadata flatEntryOrder
customFieldsMetadata.assigned = assignedCustomFields;
rundownMetadata = metadata;
}
const processedData = processRundown(rundown, projectCustomFields);
// update the cache values
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- we are not interested in the iteration data
const { previousEvent, latestEvent, previousEntry, entries, order, ...metadata } = processedData;
cachedRundown.entries = entries;
cachedRundown.order = order;
cachedRundown.flatOrder = metadata.flatEntryOrder; // TODO: remove in favour of the metadata flatEntryOrder
rundownMetadata = metadata;
// if the customFields are mutable we persist the changes
if (options.mutableCustomFields) {
// schedule a database update
setImmediate(async () => {
await getDataProvider().setCustomFields(projectCustomFields);
});
return { rundown, rundownMetadata, customFields: projectCustomFields, revision: cachedRundown.revision };
projectCustomFields = customFields;
}
return {
rundown: cachedRundown,
rundownMetadata,
customFields: projectCustomFields,
revision: cachedRundown.revision,
};
}
return {
customFields,
customFieldsMetadata,
rundown,
rundownMetadata,
commit,
};
}
@@ -329,7 +393,7 @@ function applyDelay(rundown: Rundown, delay: OntimeDelay) {
/**
* Swaps the data between two events
* The schedule and metadata are preserved
* TODO: this logic is for now duplcate of Ontime-Utils.swapEventData
* TODO: this logic is for now duplicate of Ontime-Utils.swapEventData
*/
function swap(rundown: Rundown, eventFrom: OntimeEvent, eventTo: OntimeEvent) {
rundown.entries[eventFrom.id] = {
@@ -484,6 +548,100 @@ export const rundownMutation = {
ungroup,
};
/**
* Adds a new custom field to the object and returns it
*/
function customFieldAdd(customFields: CustomFields, key: CustomFieldKey, newCustomField: CustomField): CustomFields {
customFields[key] = {
label: newCustomField.label,
type: newCustomField.type,
colour: newCustomField.colour,
};
return { [key]: newCustomField };
}
/**
* Edits an existing custom field
*/
function customFieldEdit(
customFields: CustomFields,
key: CustomFieldKey,
existingField: CustomField,
newField: Partial<CustomField>,
): { oldKey: CustomFieldKey; newKey: CustomFieldKey } {
// calculate the key in case it has changed
const newKey = newField?.label ? customFieldLabelToKey(newField.label ?? key) : key;
// patch the new field and replace the reference in the object
customFields[newKey] = { ...existingField, ...newField };
return { oldKey: key, newKey };
}
/**
* Removes a custom field from the object
*/
function customFieldRemove(customFields: CustomFields, key: CustomFieldKey) {
delete customFields[key];
}
/**
* Renames a custom field key in all the rundown entries that use it
*/
function customFieldRenameUsages(
rundown: Rundown,
assigned: AssignedMap,
oldKey: CustomFieldKey,
newKey: CustomFieldKey,
) {
const usages = assigned[oldKey];
// iterate through all the entries that use the custom field
for (let i = 0; i < usages.length; i++) {
const entryId = usages[i];
const entry = rundown.entries[entryId] as OntimeEvent;
// copy the data a new key and delete the old key
entry.custom[newKey] = entry.custom[oldKey];
delete entry.custom[oldKey];
}
// update assignment
assigned[newKey] = [...assigned[oldKey]];
delete assigned[oldKey];
}
/**
* Deletes data for a custom field from all the entries that use it
*/
function customFieldRemoveUsages(rundown: Rundown, assigned: AssignedMap, key: CustomFieldKey) {
const usages = assigned[key];
if (!usages) {
return;
}
// iterate through all the entries that use the custom field
for (let i = 0; i < usages.length; i++) {
const entryId = usages[i];
const entry = rundown.entries[entryId] as OntimeEvent;
// delete the custom field entry
delete entry.custom[key];
}
// update assignment
delete assigned[key];
}
export const customFieldMutation = {
add: customFieldAdd,
edit: customFieldEdit,
remove: customFieldRemove,
renameUsages: customFieldRenameUsages,
removeUsages: customFieldRemoveUsages,
};
/**
* Expose function to add an initial rundown to the system
*/
@@ -498,11 +656,13 @@ export function init(initialRundown: Readonly<Rundown>, initialCustomFields: Rea
projectCustomFields = customFields;
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- we are not interested in the iteration data
const { previousEvent, latestEvent, previousEntry, entries, order, ...metadata } = processedData;
const { previousEvent, latestEvent, previousEntry, entries, order, assignedCustomFields, ...metadata } =
processedData;
cachedRundown.entries = entries;
cachedRundown.order = order;
cachedRundown.flatOrder = metadata.flatEntryOrder; // TODO: remove in favour of the metadata flatEntryOrder
cachedRundown.revision = rundown.revision;
customFieldsMetadata.assigned = assignedCustomFields;
rundownMetadata = metadata;
// defer writing to the database
@@ -533,7 +693,7 @@ export function processRundown(
initialRundown: Readonly<Rundown>,
customFields: Readonly<CustomFields>,
): ProcessedRundownMetadata {
const { process, getMetadata } = makeRundownMetadata(customFields, customFieldChangelog);
const { process, getMetadata } = makeRundownMetadata(customFields);
for (let i = 0; i < initialRundown.order.length; i++) {
// we assign a reference to the current entry, this will be mutated in place
@@ -9,15 +9,21 @@ import {
isOntimeEvent,
isOntimeDelay,
isOntimeBlock,
CustomFieldKey,
EntryId,
OntimeEntry,
PlayableEvent,
RundownEntries,
isPlayableEvent,
} from 'ontime-types';
import { isObjectEmpty, generateId } from 'ontime-utils';
import { isObjectEmpty, generateId, getLinkedTimes, getTimeFrom, isNewLatest } from 'ontime-utils';
import { defaultRundown } from '../../models/dataModel.js';
import { delay as delayDef, block as blockDef } from '../../models/eventsDefinition.js';
import { ErrorEmitter } from '../../utils/parser.js';
import { parseCustomFields } from '../../utils/parserFunctions.js';
import type { ErrorEmitter } from '../../utils/parserUtils.js';
import { createEvent } from './rundown.utils.js';
import { calculateDayOffset, createEvent } from './rundown.utils.js';
import { RundownMetadata } from './rundown.types.js';
/**
* Parse a rundowns object along with the project custom fields
@@ -25,21 +31,16 @@ import { createEvent } from './rundown.utils.js';
*/
export function parseRundowns(
data: Partial<DatabaseModel>,
parsedCustomFields: Readonly<CustomFields>,
emitError?: ErrorEmitter,
): { customFields: CustomFields; rundowns: ProjectRundowns } {
// check custom fields first
const parsedCustomFields = parseCustomFields(data, emitError);
): ProjectRundowns {
// ensure there is always a rundown to import
// this is important since the rest of the app assumes this exist
if (!data.rundowns || isObjectEmpty(data.rundowns)) {
emitError?.('No data found to import');
return {
customFields: parsedCustomFields,
rundowns: {
default: {
...defaultRundown,
},
[defaultRundown.id]: {
...defaultRundown,
},
};
}
@@ -55,7 +56,7 @@ export function parseRundowns(
parsedRundowns[parsedRundown.id] = parsedRundown;
}
return { customFields: parsedCustomFields, rundowns: parsedRundowns };
return parsedRundowns;
}
/**
@@ -81,7 +82,7 @@ export function parseRundown(
const entryId = rundown.order[i];
const event = rundown.entries[entryId];
if (event === undefined) {
if (!event) {
emitError?.('Could not find referenced event, skipping');
continue;
}
@@ -170,3 +171,195 @@ export function parseRundown(
console.log(`Imported rundown ${parsedRundown.title} with ${parsedRundown.order.length} entries`);
return parsedRundown;
}
/**
* Utility function to add an entry, mutates given assignedCustomFields in place
* @param label
* @param eventId
*/
export function addToCustomAssignment(
key: CustomFieldKey,
eventId: EntryId,
assignedCustomFields: Record<string, string[]>,
) {
if (!Array.isArray(assignedCustomFields[key])) {
assignedCustomFields[key] = [];
}
assignedCustomFields[key].push(eventId);
}
/**
* Keeps track of which custom fields are assigned to which events
* Mutates the given assignedCustomFields in place
* If a field is referenced but is not in the customFields map, it is deleted
*/
export function handleCustomField(
customFields: CustomFields,
event: OntimeEvent,
assignedCustomFields: Record<CustomFieldKey, EntryId[]>,
) {
for (const field in event.custom) {
if (field in customFields) {
// add field to assignment map
addToCustomAssignment(field, event.id, assignedCustomFields);
} else {
// delete data if it is not declared in project level custom fields
delete event.custom[field];
}
}
}
export type ProcessedRundownMetadata = RundownMetadata & {
entries: RundownEntries;
order: EntryId[];
previousEvent: PlayableEvent | null; // The playableEvent from the previous iteration
latestEvent: PlayableEvent | null; // The playableEvent most forwards in time processed so far
previousEntry: OntimeEntry | null; // The entry processed in the previous iteration
assignedCustomFields: Record<CustomFieldKey, string[]>; // Custom fields assigned to events
};
/**
* Factory function to create a rundown metadata processor
* @returns {process, getMetadata} process() - processes entries in order | getMetadata() -> returns the current metadata
*/
export function makeRundownMetadata(customFields: CustomFields) {
let rundownMeta: ProcessedRundownMetadata = {
totalDelay: 0,
totalDuration: 0,
totalDays: 0,
firstStart: null,
lastEnd: null,
assignedCustomFields: {},
playableEventOrder: [],
timedEventOrder: [],
flatEntryOrder: [],
entries: {},
order: [],
previousEvent: null,
latestEvent: null,
previousEntry: null,
};
function process<T extends OntimeEntry>(
entry: T,
childOfBlock: EntryId | null,
): { processedData: ProcessedRundownMetadata; processedEntry: T } {
const data = processEntry(rundownMeta, customFields, entry, childOfBlock);
rundownMeta = data.processedData;
return data;
}
function getMetadata(): ProcessedRundownMetadata {
return rundownMeta;
}
return { process, getMetadata };
}
/**
* Processes a single entry and updates the rundown metadata
*/
function processEntry<T extends OntimeEntry>(
rundownMetadata: ProcessedRundownMetadata,
customFields: CustomFields,
entry: T,
childOfBlock: EntryId | null,
): { processedData: ProcessedRundownMetadata; processedEntry: T } {
const processedData = { ...rundownMetadata };
const currentEntry = structuredClone(entry);
processedData.flatEntryOrder.push(currentEntry.id);
if (isOntimeEvent(currentEntry)) {
processedData.timedEventOrder.push(currentEntry.id);
/**
* 1.Checks that link can be established (ie, events exist and are valid)
* and populates the time data from link
* The linked event is always the previous playable event
* If no previous event exists, the link is removed
*/
if (currentEntry.linkStart) {
if (processedData.previousEvent) {
const timePatch = getLinkedTimes(currentEntry, processedData.previousEvent);
currentEntry.timeStart = timePatch.timeStart;
currentEntry.timeEnd = timePatch.timeEnd;
currentEntry.duration = timePatch.duration;
} else {
currentEntry.linkStart = false;
}
}
// 2. handle custom fields - mutates currentEntry
handleCustomField(customFields, currentEntry, processedData.assignedCustomFields);
processedData.totalDays += calculateDayOffset(currentEntry, processedData.previousEvent);
currentEntry.dayOffset = processedData.totalDays;
currentEntry.delay = 0; // this means we dont calculate delays or gaps for skipped events
currentEntry.gap = 0; // this means we dont calculate delays or gaps for skipped events
currentEntry.parent = childOfBlock;
// update rundown metadata, it only concerns playable events
if (isPlayableEvent(currentEntry)) {
processedData.playableEventOrder.push(currentEntry.id);
// first start is always the first event
if (processedData.firstStart === null) {
processedData.firstStart = currentEntry.timeStart;
}
currentEntry.gap = getTimeFrom(currentEntry, processedData.latestEvent);
if (currentEntry.gap === 0) {
// event starts on previous finish, we add its duration
processedData.totalDuration += currentEntry.duration;
} else if (currentEntry.gap > 0) {
// event has a gap, we add the gap and the duration
processedData.totalDuration += currentEntry.gap + currentEntry.duration;
} else if (currentEntry.gap < 0) {
// there is an overlap, we remove the overlap from the duration
// ensuring that the sum is not negative (ie: fully overlapped events)
// NOTE: we add the gap since it is a negative number
processedData.totalDuration += Math.max(currentEntry.duration + currentEntry.gap, 0);
}
// remove eventual gaps from the accumulated delay
// we only affect positive delays (time forwards)
if (processedData.totalDelay > 0 && currentEntry.gap > 0) {
let correctedDelay = 0;
// we need to separate the delay that is accumulated from one that may exist after the gap
if (isOntimeDelay(processedData.previousEntry)) {
correctedDelay = processedData.previousEntry.duration;
processedData.totalDelay -= correctedDelay;
}
processedData.totalDelay = Math.max(processedData.totalDelay - currentEntry.gap, 0);
processedData.totalDelay += correctedDelay;
}
// current event delay is the current accumulated delay
currentEntry.delay = processedData.totalDelay;
// assign data for next iteration
processedData.previousEvent = currentEntry;
// lastEntry is the event with the latest end time
if (isNewLatest(currentEntry, processedData.latestEvent)) {
processedData.latestEvent = currentEntry;
processedData.lastEnd = currentEntry.timeEnd;
}
}
} else if (isOntimeDelay(currentEntry)) {
// !!! this must happen after handling the links
processedData.totalDelay += currentEntry.duration;
currentEntry.parent = childOfBlock;
}
if (!childOfBlock) {
processedData.order.push(currentEntry.id);
}
processedData.entries[currentEntry.id] = currentEntry;
processedData.previousEntry = currentEntry;
return { processedData, processedEntry: currentEntry };
}
@@ -1,4 +1,6 @@
import {
CustomField,
CustomFieldKey,
CustomFields,
EntryId,
EventPostPayload,
@@ -9,21 +11,21 @@ import {
PatchWithId,
Rundown,
} from 'ontime-types';
import { customFieldLabelToKey } from 'ontime-utils';
import { getPreviousId } from '../../services/rundown-service/rundownUtils.js';
import { updateRundownData } from '../../stores/runtimeState.js';
import { sendRefetch } from '../../adapters/websocketAux.js';
import { runtimeService } from '../../services/runtime-service/RuntimeService.js';
import { createTransaction, rundownCache, rundownMutation } from './rundown.dao.js';
import { RundownMetadata } from './rundown.types.js';
import { generateEvent, hasChanges } from './rundown.utils.js';
import { createTransaction, customFieldMutation, rundownCache, rundownMutation } from './rundown.dao.js';
import type { RundownMetadata } from './rundown.types.js';
import { generateEvent, getInsertAfterId, hasChanges } from './rundown.utils.js';
/**
* creates a new entry with given data
*/
export async function addEntry(eventData: EventPostPayload): Promise<OntimeEntry> {
const { rundown, commit } = createTransaction();
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
// we allow the user to provide an ID, but make sure it is unique
if (eventData?.id && Object.hasOwn(rundown.entries, eventData.id)) {
@@ -41,7 +43,7 @@ export async function addEntry(eventData: EventPostPayload): Promise<OntimeEntry
}
// normalise the position of the event in the rundown order
const afterId = getPreviousId(rundown, eventData?.after, eventData?.before);
const afterId = getInsertAfterId(rundown, eventData?.after, eventData?.before);
// generate a fully formed entry from the patch
const newEntry = generateEvent(rundown, eventData, afterId);
@@ -66,7 +68,7 @@ export async function addEntry(eventData: EventPostPayload): Promise<OntimeEntry
* Applies a patch to an entry in the rundown
*/
export async function editEntry(patch: PatchWithId): Promise<OntimeEntry> {
const { rundown, commit } = createTransaction();
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
const currentEntry = rundown.entries[patch.id];
/**
@@ -115,7 +117,7 @@ export async function editEntry(patch: PatchWithId): Promise<OntimeEntry> {
* Applies a patch to several entries in the rundown
*/
export async function batchEditEntries(ids: EntryId[], patch: Partial<OntimeEntry>): Promise<Rundown> {
const { rundown, commit } = createTransaction();
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
/**
* We can do some validation globally, but mostly we will validate each entry individually
@@ -179,7 +181,7 @@ export async function batchEditEntries(ids: EntryId[], patch: Partial<OntimeEntr
* Deletes a known entry from the current rundown
*/
export async function deleteEntries(entryIds: EntryId[]): Promise<Rundown> {
const { rundown, commit } = createTransaction();
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
for (let i = 0; i < entryIds.length; i++) {
const entry = rundown.entries[entryIds[i]];
@@ -207,7 +209,7 @@ export async function deleteEntries(entryIds: EntryId[]): Promise<Rundown> {
* Deletes all entries from the current rundown
*/
export async function deleteAllEntries(): Promise<Rundown> {
const { rundown, commit } = createTransaction();
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
rundownMutation.removeAll(rundown);
@@ -231,7 +233,7 @@ export async function deleteAllEntries(): Promise<Rundown> {
* @throws if entryId or destinationId not found
*/
export function reorderEntry(entryId: EntryId, destinationId: EntryId, order: 'before' | 'after' | 'insert') {
const { rundown, commit } = createTransaction();
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
// check that both entries exist
const eventFrom = rundown.entries[entryId];
@@ -262,7 +264,7 @@ export function reorderEntry(entryId: EntryId, destinationId: EntryId, order: 'b
* The applied delay is deleted
*/
export async function applyDelay(delayId: EntryId): Promise<Rundown> {
const { rundown, commit } = createTransaction();
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
// check that delay exists
const delay = rundown.entries[delayId];
@@ -292,7 +294,7 @@ export async function applyDelay(delayId: EntryId): Promise<Rundown> {
* Swaps the data between two events in the rundown
*/
export async function swapEvents(fromId: EntryId, toId: EntryId): Promise<Rundown> {
const { rundown, commit } = createTransaction();
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
const eventFrom = rundown.entries[fromId];
const eventTo = rundown.entries[toId];
@@ -326,7 +328,7 @@ export async function swapEvents(fromId: EntryId, toId: EntryId): Promise<Rundow
* @throws if the entry to clone does not exist
*/
export async function cloneEntry(entryId: EntryId): Promise<Rundown> {
const { rundown, commit } = createTransaction();
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
const originalEntry = rundown.entries[entryId];
if (!originalEntry) {
@@ -359,7 +361,7 @@ export async function cloneEntry(entryId: EntryId): Promise<Rundown> {
* Groups a list of entries into a new block
*/
export async function groupEntries(entryIds: EntryId[]): Promise<Rundown> {
const { rundown, commit } = createTransaction();
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
rundownMutation.group(rundown, entryIds);
const { rundown: rundownResult, rundownMetadata, revision } = commit();
@@ -380,7 +382,7 @@ export async function groupEntries(entryIds: EntryId[]): Promise<Rundown> {
* Deletes a block and moves all its children to the top level
*/
export async function ungroupEntries(blockId: EntryId): Promise<Rundown> {
const { rundown, commit } = createTransaction();
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
const block = rundown.entries[blockId];
if (!block || !isOntimeBlock(block)) {
@@ -402,6 +404,106 @@ export async function ungroupEntries(blockId: EntryId): Promise<Rundown> {
return rundownResult;
}
/**
* Adds a new custom field to the project
* @throws if the label is missing or invalid
*/
export async function createCustomField(customField: CustomField): Promise<CustomFields> {
const key = customFieldLabelToKey(customField.label);
if (!key) {
throw new Error('Unable to convert label to a valid key');
}
const { customFields, commit } = createTransaction({ mutableRundown: false, mutableCustomFields: true });
// check if label already exists
if (Object.hasOwn(customFields, key)) {
throw new Error('Label already exists');
}
customFieldMutation.add(customFields, key, customField);
// Adding a custom field has no immediate implications on the rundown
const { customFields: resultCustomFields } = commit(false);
// TODO: notify clients to refetch the custom fields
return resultCustomFields;
}
/**
* Edits an existing custom field
* In practice users can only change the label and the colour of the field
* @throws if the field does not exist
* @throws if the field type is changed
* @throws if the label is missing or invalid
* @throws if the new label already exists
*/
export async function editCustomField(key: CustomFieldKey, newField: Partial<CustomField>): Promise<CustomFields> {
const { customFields, customFieldsMetadata, rundown, commit } = createTransaction({
mutableRundown: true,
mutableCustomFields: true,
});
if (!(key in customFields)) {
throw new Error('Could not find label');
}
const existingField = customFields[key];
// if user provides a type, it must be the same from before
if (newField.type && existingField.type !== newField.type) {
throw new Error('Change of field type is not allowed');
}
const { oldKey, newKey } = customFieldMutation.edit(customFields, key, existingField, newField);
// if key has changed we remove the old reference
if (oldKey !== newKey && oldKey in customFieldsMetadata.assigned) {
customFieldMutation.renameUsages(rundown, customFieldsMetadata.assigned, oldKey, newKey);
}
// the custom fields have been removed and there is no processing to be done
const { rundownMetadata, revision, customFields: resultCustomFields } = commit(false);
// schedule the side effects
setImmediate(() => {
// TODO: notify clients to refetch the custom fields
notifyChanges(rundownMetadata, revision, { timer: true, external: true });
});
return resultCustomFields;
}
/**
* Deletes an existing custom field
*/
export async function deleteCustomField(key: CustomFieldKey): Promise<CustomFields> {
const { customFields, customFieldsMetadata, rundown, commit } = createTransaction({
mutableRundown: true,
mutableCustomFields: true,
});
if (!(key in customFields)) {
return customFields;
}
customFieldMutation.remove(customFields, key);
if (key in customFieldsMetadata.assigned) {
customFieldMutation.removeUsages(rundown, customFieldsMetadata.assigned, key);
}
// the custom fields have been removed and there is no processing to be done
const { rundownMetadata, revision, customFields: resultCustomFields } = commit(false);
// schedule the side effects
setImmediate(() => {
// TODO: notify clients to refetch the custom fields
notifyChanges(rundownMetadata, revision, { timer: true, external: true });
});
return resultCustomFields;
}
/**
* Forces update in the store
* Called when we make changes to the rundown object
@@ -1,4 +1,4 @@
import { CustomFieldLabel, EntryId, MaybeNumber } from 'ontime-types';
import { CustomFieldKey, EntryId, MaybeNumber } from 'ontime-types';
export type RundownMetadata = {
totalDelay: number;
@@ -10,11 +10,9 @@ export type RundownMetadata = {
playableEventOrder: EntryId[]; // flat order of playable events
timedEventOrder: EntryId[]; // flat order of timed events
flatEntryOrder: EntryId[]; // flat order of entries
/**
* Keep track of which custom fields are used.
* This will be handy for when we delete custom fields
* since we can clear the custom fields from every event where they are used
*/
assignedCustomFields: Record<CustomFieldLabel, string[]>;
};
export type AssignedMap = Record<CustomFieldKey, EntryId[]>;
export type CustomFieldsMetadata = {
assigned: AssignedMap;
};
@@ -12,7 +12,14 @@ import {
SupportedEntry,
TimeStrategy,
} from 'ontime-types';
import { generateId, getCueCandidate, validateEndAction, validateTimerType, validateTimes } from 'ontime-utils';
import {
dayInMs,
generateId,
getCueCandidate,
validateEndAction,
validateTimerType,
validateTimes,
} from 'ontime-utils';
import { event as eventDef, block as blockDef, delay as delayDef } from '../../models/eventsDefinition.js';
import { makeString } from '../../utils/parserUtils.js';
@@ -300,3 +307,54 @@ export function cloneEntry<T extends OntimeEntry>(entry: T, newId: EntryId): T {
}
throw new Error(`Unsupported entry type for cloning: ${entry}`);
}
/**
* Utility for calculating if the current events should have a day offset
* @param current the current event under test
* @param previous the previous event
* @returns 0 or 1 for easy accumulation with the total days
*/
export function calculateDayOffset(
current: Pick<OntimeEvent, 'timeStart'>,
previous: Pick<OntimeEvent, 'timeStart' | 'duration'> | null,
) {
// if there is no previous there can't be a day offset
if (!previous) {
return 0;
}
// if the previous events duration is zero it will push the current event to next day
if (previous.duration === 0) {
return 0;
}
// if the previous event crossed midnight then the current event is in the next day
if (previous.timeStart + previous.duration >= dayInMs) {
return 1;
}
// if the current events starts at the same time or before the previous event then it is the next day
if (current.timeStart <= previous.timeStart) {
return 1;
}
return 0;
}
/**
* Receives an insertion order and returns the reference to an event ID
* after which we will insert the new event
*/
export function getInsertAfterId(rundown: Rundown, afterId?: EntryId, beforeId?: EntryId): EntryId | null {
if (afterId) {
return afterId;
}
if (beforeId) {
const atIndex = rundown.flatOrder.findIndex((id) => id === beforeId);
if (atIndex < 1) return null;
return rundown.flatOrder[atIndex - 1];
}
return null;
}
@@ -0,0 +1,22 @@
import { Settings } from 'ontime-types';
import { parseSettings } from '../settings.parser.js';
describe('parseSettings()', () => {
it('throws if settings object does not exist', () => {
expect(() => parseSettings({})).toThrow();
});
it('returns an a base model as long as we have the app version', () => {
const result = parseSettings({ settings: { version: '1' } as Settings });
expect(result).toBeTypeOf('object');
expect(result).toMatchObject({
version: expect.any(String),
serverPort: 4001,
editorKey: null,
operatorKey: null,
timeFormat: '24',
language: 'en',
});
});
});
@@ -0,0 +1,25 @@
import { DatabaseModel, Settings } from 'ontime-types';
import { dbModel } from '../../models/dataModel.js';
/**
* Parse settings portion of a project file
*/
export function parseSettings(data: Partial<DatabaseModel>): Settings {
// skip if file definition is missing
// TODO: skip parsing if the version is not correct
if (!data.settings || data.settings?.version == null) {
throw new Error('ERROR: unable to parse settings, missing or incorrect version');
}
console.log('Found settings, importing...');
return {
version: dbModel.settings.version,
serverPort: data.settings.serverPort ?? dbModel.settings.serverPort,
editorKey: data.settings.editorKey ?? null,
operatorKey: data.settings.operatorKey ?? null,
timeFormat: data.settings.timeFormat ?? '24',
language: data.settings.language ?? 'en',
};
}
@@ -3,12 +3,12 @@
* Google Sheets
*/
import type { AuthenticationStatus, CustomFields, ErrorResponse, Rundown } from 'ontime-types';
import { getErrorMessage } from 'ontime-utils';
import { Request, Response } from 'express';
import { readFileSync } from 'fs';
import type { AuthenticationStatus, CustomFields, ErrorResponse, Rundown } from 'ontime-types';
import { deleteFile } from '../../utils/parserUtils.js';
import {
revoke,
handleClientSecret,
@@ -18,7 +18,7 @@ import {
upload,
getWorksheetOptions,
} from '../../services/sheet-service/SheetService.js';
import { getErrorMessage } from 'ontime-utils';
import { deleteFile } from '../../utils/fileManagement.js';
export async function requestConnection(
req: Request,
@@ -40,11 +40,7 @@ export async function requestConnection(
}
// delete uploaded file after parsing
try {
await deleteFile(filePath);
} catch (_error) {
/** we dont handle failure here */
}
await deleteFile(filePath);
}
export async function verifyAuthentication(
@@ -1,11 +1,10 @@
import { Request } from 'express';
import multer, { FileFilterCallback } from 'multer';
import { JSON_MIME } from '../../utils/parser.js';
import { storage } from '../../utils/upload.js';
const filterClientSecret = (_req: Request, file: Express.Multer.File, cb: FileFilterCallback) => {
if (file.mimetype.includes(JSON_MIME)) {
if (file.mimetype.includes('application/json')) {
cb(null, true);
} else {
cb(null, false);
@@ -0,0 +1,47 @@
import { DatabaseModel, URLPreset } from 'ontime-types';
import { parseUrlPresets } from '../urlPresets.parser.js';
describe('parseUrlPresets()', () => {
it('returns an a base model if nothing is given', () => {
const errorEmitter = vi.fn();
const result = parseUrlPresets({}, errorEmitter);
expect(result).toBeTypeOf('object');
expect(errorEmitter).toHaveBeenCalledOnce();
});
it('parses data, skipping invalid results', () => {
const errorEmitter = vi.fn();
const urlPresets = [{ enabled: true, alias: 'alias', pathAndParams: 'ss' }] as URLPreset[];
const result = parseUrlPresets({ urlPresets }, errorEmitter);
expect(result.length).toEqual(1);
expect(result.at(0)).toMatchObject({
enabled: true,
alias: 'alias',
pathAndParams: 'ss',
});
expect(errorEmitter).not.toHaveBeenCalled();
});
it('imports a well defined urlPreset', () => {
const testData = {
rundown: [],
settings: {
version: '2.0.0',
},
urlPresets: [
{
enabled: false,
alias: 'testalias',
pathAndParams: 'testpathAndParams',
},
],
} as unknown as DatabaseModel;
const parsed = parseUrlPresets(testData);
expect(parsed.length).toBe(1);
// generates missing id
expect(parsed[0].alias).toBeDefined();
});
});
@@ -0,0 +1,30 @@
import { DatabaseModel, URLPreset } from 'ontime-types';
import { ErrorEmitter } from '../../utils/parserUtils.js';
/**
* Parse URL preset portion of a project file
*/
export function parseUrlPresets(data: Partial<DatabaseModel>, emitError?: ErrorEmitter): URLPreset[] {
if (!data.urlPresets) {
emitError?.('No data found to import');
return [];
}
console.log('Found URL presets, importing...');
const newPresets: URLPreset[] = [];
for (const preset of data.urlPresets) {
const newPreset = {
enabled: preset.enabled ?? false,
alias: preset.alias ?? '',
pathAndParams: preset.pathAndParams ?? '',
};
newPresets.push(newPreset);
}
console.log(`Uploaded ${newPresets.length} preset(s)`);
return newPresets;
}
@@ -0,0 +1,10 @@
import { parseViewSettings } from '../viewSettings.parser.js';
describe('parseViewSettings()', () => {
it('returns an a base model if nothing is given', () => {
const errorEmitter = vi.fn();
const result = parseViewSettings({}, errorEmitter);
expect(result).toBeTypeOf('object');
expect(errorEmitter).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,25 @@
import { DatabaseModel, ViewSettings } from 'ontime-types';
import { dbModel } from '../../models/dataModel.js';
import { ErrorEmitter } from '../../utils/parserUtils.js';
/**
* Parse viewSettings portion of a project file
*/
export function parseViewSettings(data: Partial<DatabaseModel>, emitError?: ErrorEmitter): ViewSettings {
if (!data.viewSettings) {
emitError?.('No data found to import');
return { ...dbModel.viewSettings };
}
console.log('Found view settings, importing...');
return {
dangerColor: data.viewSettings.dangerColor ?? dbModel.viewSettings.dangerColor,
endMessage: data.viewSettings.endMessage ?? dbModel.viewSettings.endMessage,
freezeEnd: data.viewSettings.freezeEnd ?? dbModel.viewSettings.freezeEnd,
normalColor: data.viewSettings.normalColor ?? dbModel.viewSettings.normalColor,
overrideStyles: data.viewSettings.overrideStyles ?? dbModel.viewSettings.overrideStyles,
warningColor: data.viewSettings.warningColor ?? dbModel.viewSettings.warningColor,
};
}