Revert "Session version" (#1649)

* Revert "refactor: add version to session endpoint"

This reverts commit cd40a6e55e.

* Revert "Remove public event feature (#1645)"

This reverts commit 08d9e24871.

* Revert "Fix: rearrange playing event (#1640)"

This reverts commit 0649678dca.

* Revert "refactor: style tweaks to edit css modal"

This reverts commit a00ec2d02a.

* Revert "refactor: remove usages of framer-motion"

This reverts commit 54a74ccc2a.

* Revert "Upgrade expressjs (#1633)"

This reverts commit 6f3ab274bd.

* Revert "Refactor: require trigger in all events objects (#1636)"

This reverts commit 90870ecfb6.

* Revert "Fix: Correct boundary condition in applyDelay"

This reverts commit b640e0e181.

* Revert "let vite be the proxy to the dev server (#1630)"

This reverts commit c41fe824cf.

* Revert "refactor: migrate custom fields to transactions"

This reverts commit 62c8319d70.

* Revert "refactor: simplify validations"

This reverts commit b1d23467a2.

* Revert "refactor: create transaction system and apply to adding entry (#1620)"

This reverts commit 9a62daf047.

* Revert "Refactor: better rounding (#1594)"

This reverts commit b9ab1c6fd7.

* Revert "refactor: improve reorder logic"

This reverts commit c1054711b0.

* Revert "chore: simplify URLs"

This reverts commit a4d4f29a37.

* Revert "refactor: order is single source of truth"

This reverts commit 2793aadea0.

* Revert "refactor: refetch targets is enum"

This reverts commit e7cfb7d9d9.

* Revert "refactor: small ux improvements"

This reverts commit 256a851c9b.

* Revert "refactor: remove trivially inferred numEvents"

This reverts commit 4ab9c81cb8.

* Revert "feat: duplicate groups"

This reverts commit 2f13d6c89e.

* Revert "feat: create group from entry selection"

This reverts commit f1f7bad25e.

* Revert "feat: create block from rundown empty"

This reverts commit a8b52a48f7.

* Revert "fix: collapsed blocks dont render children"

This reverts commit cd0999b2ab.

* Revert "refactor: type cleanup and test improvements"

This reverts commit b4c60f3f04.

* Revert "feat: allow dissolving a block"

This reverts commit 5cefad3666.

* Revert "fix: uncontrolled prop on controlled component"

This reverts commit 7a6ecd8c34.

* Revert "refactor: improve return of reorder"

This reverts commit ba96ecfd91.

* Revert "refactor: mutations on batch elements must have IDs"

This reverts commit 7bed3757f2.

* Revert "chore: upgrade dependencies"

This reverts commit e2e755b1d2.

* Revert "refactor: extract utility to merge two arrays"

This reverts commit a77d23109d.

* Revert "refactor: change network mode defaults"

This reverts commit 0eb3b8d382.

* Revert "fix: delete nested events"

This reverts commit 0021185288.

* Revert "fix: add event at end of block"

This reverts commit 94c72ff4f6.

* Revert "refactor: make finder available in exported rundown"

This reverts commit e4c08dc9b2.

* Revert "assert non null and update test (#1604)"

This reverts commit ec74af0d62.

* Revert "Fix project renumber (#1597)"

This reverts commit b6d72dd082.

* Revert "Refactor: WebSocket from flush queue to one patch (#1595)"

This reverts commit 31c311daf0.

* Revert "Refactor: ms for api calls (#1593)"

This reverts commit e9b3cc6090.

* Revert "fix test (#1601)"

This reverts commit 543b04a097.

* Revert "fix: rebase master"

This reverts commit d39b85b6e6.

* Revert "chore: correct test path"

This reverts commit bbe107bb2b.

* Revert "refactor: extract rundown parsing"

This reverts commit c616240db1.

* Revert "chore: improve convention entry <> event"

This reverts commit 166be66ce3.

* Revert "refactor: maintain flat orders"

This reverts commit 4180d0a337.

* Revert "refactor: implement operations on nested events"

This reverts commit 78108e316c.

* Revert "refactor: process events in rundown"

This reverts commit 3bb8b70915.

* Revert "chore: improve convention entry <> event"

This reverts commit 1c4f13a0ed.

* Revert "chore: rename currentBlock > parent"

This reverts commit 3ca0abad53.

* Revert "refactor: fix delay positioning in gaps"

This reverts commit 030c8f897f.

* Revert "refactor(e2e): skip flaky test"

This reverts commit 68175cfa3b.

* Revert "refactor: improve project loading"

This reverts commit fd8f757851.

* Revert "refactor: gather group metadata"

This reverts commit 876d111c61.

* Revert "refactor: swap maintains schedule"

This reverts commit 730cb95c04.

* Revert "chore: rename files"

This reverts commit 3c388d4fb5.

* Revert "refactor: restructure model to contain an object of rundowns"

This reverts commit 2e23718d73.

* Revert "refactor: clearer relationship on rundown elements"

This reverts commit 89ea8c470b.

* Revert "refactor: use strict typing"

This reverts commit 178640bfc4.

* Revert "refactor: remove stop as a possible end action"

This reverts commit 4ed38340e0.

* Revert "refactor: restructure model to contain an object of rundowns"

This reverts commit 69eb9a5eff.

* Revert "chore: remove IDE files"

This reverts commit 351425127a.

* Revert "refactor: remove unused and legacy code"

This reverts commit 95f2ba37cc.
This commit is contained in:
Alex Christoffer Rasmussen
2025-06-20 12:28:20 +02:00
committed by GitHub
parent ad6804019b
commit 722e045b20
366 changed files with 13067 additions and 13052 deletions
+7 -20
View File
@@ -29,7 +29,7 @@ import { authenticateSocket } from '../middleware/authenticate.js';
let instance: SocketServer | null = null;
class SocketServer implements IAdapter {
export class SocketServer implements IAdapter {
private readonly MAX_PAYLOAD = 1024 * 256; // 256Kb
private wss: WebSocketServer | null;
@@ -102,7 +102,7 @@ class SocketServer implements IAdapter {
ws.on('message', (data) => {
try {
// @ts-expect-error -- this works fine
// @ts-expect-error -- ??
const message = JSON.parse(data);
const { type, payload } = message;
@@ -120,7 +120,7 @@ class SocketServer implements IAdapter {
ws.send(
JSON.stringify({
type: 'client-name',
payload: this.getOrCreateClient(clientId),
payload: this.clients.get(clientId).name,
}),
);
return;
@@ -136,7 +136,7 @@ class SocketServer implements IAdapter {
if (type === 'set-client-type') {
if (payload && typeof payload == 'string') {
const previousData = this.getOrCreateClient(clientId);
const previousData = this.clients.get(clientId);
this.clients.set(clientId, { ...previousData, type: payload });
}
this.sendClientList();
@@ -145,7 +145,7 @@ class SocketServer implements IAdapter {
if (type === 'set-client-path') {
if (payload && typeof payload == 'string') {
const previousData = this.getOrCreateClient(clientId);
const previousData = this.clients.get(clientId);
previousData.path = payload;
this.clients.set(clientId, previousData);
@@ -166,13 +166,13 @@ class SocketServer implements IAdapter {
if (type === 'set-client-name') {
if (payload) {
const previousData = this.getOrCreateClient(clientId);
const previousData = this.clients.get(clientId);
logger.info(LogOrigin.Client, `Client ${previousData.name} renamed to ${payload}`);
this.clients.set(clientId, { ...previousData, name: payload });
ws.send(
JSON.stringify({
type: 'client-name',
payload: this.getOrCreateClient(clientId).name,
payload: this.clients.get(clientId).name,
}),
);
}
@@ -215,19 +215,6 @@ class SocketServer implements IAdapter {
};
}
private getOrCreateClient(clientId: string): Client {
if (!this.clients.has(clientId)) {
this.clients.set(clientId, {
type: 'unknown',
identify: false,
name: getRandomName(),
origin: '',
path: '',
});
}
return this.clients.get(clientId) as Client;
}
private sendClientList(): void {
const payload = Object.fromEntries(this.clients.entries());
this.sendAsJson({ type: 'client-list', payload });
+2 -2
View File
@@ -4,7 +4,7 @@
* @param {string} value - value to assign
* @returns {object | string | null} nested object or null if no object was created
*/
export function integrationPayloadFromPath(path: string[], value?: unknown): object | string | null {
export const integrationPayloadFromPath = (path: string[], value?: unknown): object | string | null => {
if (path.length === 1) {
const key = path[0];
return value === undefined ? key : { [key]: value };
@@ -16,4 +16,4 @@ export function integrationPayloadFromPath(path: string[], value?: unknown): obj
const obj = shortenedPath.reduceRight((result, key) => ({ [key]: result }), parsedValue);
return typeof obj === 'object' ? obj : null;
}
};
-5
View File
@@ -1,10 +1,5 @@
import { socket } from './WebsocketAdapter.js';
export enum RefetchTargets {
Rundown = 'rundown',
Report = 'report',
}
/**
* Utility function to notify clients that the REST data is stale
* @param payload -- possible patch payload
@@ -0,0 +1,41 @@
import { defaultCss } from '../../user/styles/bundledCss.js';
import type { Request, Response } from 'express';
import { readCssFile, writeCssFile } from './assets.service.js';
/**
* Exposes the contents of the cssOverride.css file
*/
export async function getCssOverride(_req: Request, res: Response) {
try {
const data = await readCssFile();
res.status(200).send(data);
} catch (error) {
res.status(500).send({ message: error });
}
}
/**
* Allows modifying the cssOverride.css file
*/
export async function postCssOverride(req: Request, res: Response) {
const { css } = req.body;
try {
await writeCssFile(css);
res.status(204).send();
} catch (error) {
res.status(500).send({ message: error });
}
}
/**
* Restores the default cssOverride.css file
*/
export async function restoreCss(_req: Request, res: Response) {
try {
await writeCssFile(defaultCss);
res.status(200).send(defaultCss);
} catch (error) {
res.status(500).send({ message: error });
}
}
@@ -1,40 +1,10 @@
import express from 'express';
import type { Request, Response } from 'express';
import type { ErrorResponse } from 'ontime-types';
import { getCssOverride, postCssOverride, restoreCss } from './assets.controller.js';
import { validatePostCss } from './assets.validation.js';
import { readCssFile, writeCssFile } from './assets.service.js';
import { getErrorMessage } from 'ontime-utils';
import { defaultCss } from '../../user/styles/bundledCss.js';
export const router = express.Router();
router.get('/css', async (_req: Request, res: Response<string | ErrorResponse>) => {
try {
const data = await readCssFile();
res.status(200).send(data);
} catch (error) {
const message = getErrorMessage(error);
res.status(500).send({ message });
}
});
router.post('/css', validatePostCss, async (req: Request, res: Response<never | ErrorResponse>) => {
const { css } = req.body;
try {
await writeCssFile(css);
res.status(204).send();
} catch (error) {
const message = getErrorMessage(error);
res.status(500).send({ message });
}
});
router.post('/css/restore', async (_req: Request, res: Response<string | ErrorResponse>) => {
try {
await writeCssFile(defaultCss);
res.status(200).send(defaultCss);
} catch (error) {
const message = getErrorMessage(error);
res.status(500).send({ message });
}
});
router.get('/css', getCssOverride);
router.post('/css', validatePostCss, postCssOverride);
router.post('/css/restore', restoreCss);
@@ -1,4 +1,12 @@
import { body } from 'express-validator';
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
import { Request, Response, NextFunction } from 'express';
import { body, validationResult } from 'express-validator';
export const validatePostCss = [body('css').isString().trim(), requestValidationFunction];
export const validatePostCss = [
body('css').exists().isString().trim(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
@@ -1,6 +1,4 @@
import { TriggerDTO, TimerLifeCycle, AutomationDTO, Automation, EntryId } from 'ontime-types';
import { makeRundown } from '../../rundown/__mocks__/rundown.mocks.js';
import { TriggerDTO, TimerLifeCycle, AutomationDTO, Automation } from 'ontime-types';
import {
addTrigger,
@@ -14,7 +12,6 @@ import {
getAutomationTriggers,
getAutomations,
} from '../automation.dao.js';
import { makeOSCAction, makeHTTPAction } from './testUtils.js';
beforeAll(() => {
@@ -189,9 +186,11 @@ describe('editAutomation()', async () => {
});
describe('deleteAutomation()', () => {
// saving the ID of the added automation
let firstAutomation: Automation;
beforeEach(async () => {
await deleteAll();
await addAutomation({
firstAutomation = await addAutomation({
title: 'test-osc',
filterRule: 'all',
filters: [],
@@ -199,15 +198,35 @@ describe('deleteAutomation()', () => {
});
});
it('should remove an automation from the list', async () => {
it('should remove m automation from the list', async () => {
const automations = getAutomations();
expect(Object.keys(automations).length).toEqual(1);
const rundown = makeRundown({});
const timedEventOrder: EntryId[] = [];
await deleteAutomation(rundown, timedEventOrder, Object.keys(automations)[0]);
await deleteAutomation(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,7 +1,7 @@
import { PlayableEvent, TimerLifeCycle } from 'ontime-types';
import { makeRuntimeStateData } from '../../../stores/__mocks__/runtimeState.mocks.js';
import { makeOntimeEvent } from '../../rundown/__mocks__/rundown.mocks.js';
import { makeOntimeEvent } from '../../../services/rundown-service/__mocks__/rundown.mocks.js';
import { deleteAllTriggers, addTrigger, addAutomation } from '../automation.dao.js';
import { testConditions, triggerAutomations } from '../automation.service.js';
@@ -1,6 +1,4 @@
import { TimerLifeCycle } from 'ontime-types';
import { makeOntimeEvent, makeRundown } from '../../rundown/__mocks__/rundown.mocks.js';
import { isAutomationUsed, parseTemplateNested, stringToOSCArgs } from '../automation.utils.js';
import { parseTemplateNested, stringToOSCArgs } from '../automation.utils.js';
describe('parseTemplateNested()', () => {
it('parses string with a single-level variable name', () => {
@@ -247,53 +245,3 @@ 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,14 +5,12 @@ 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';
export function getAutomationSettings(_req: Request, res: Response<AutomationSettings>) {
res.status(200).json(automationDao.getAutomationSettings());
res.json(automationDao.getAutomationSettings());
}
export async function postAutomationSettings(req: Request, res: Response<AutomationSettings | ErrorResponse>) {
@@ -108,10 +106,7 @@ export async function editAutomation(req: Request, res: Response<Automation | Er
export async function deleteAutomation(req: Request, res: Response<void | ErrorResponse>) {
try {
const rundown = getCurrentRundown();
const { timedEventOrder } = getRundownMetadata();
await automationDao.deleteAutomation(rundown, timedEventOrder, req.params.id);
await automationDao.deleteAutomation(req.params.id);
res.status(204).send();
} catch (error) {
const message = getErrorMessage(error);
@@ -2,17 +2,14 @@ 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 { isAutomationUsed } from './automation.utils.js';
import { getTimedEvents } from '../../services/rundown-service/rundownUtils.js';
/**
* Gets a copy of the stored automation settings
@@ -136,7 +133,7 @@ export async function editAutomation(id: string, newAutomation: AutomationDTO):
/**
* Deletes a automation given its ID
*/
export async function deleteAutomation(rundown: Rundown, timedEventOrder: EntryId[], id: string): Promise<void> {
export async function deleteAutomation(id: string): Promise<void> {
const automations = getAutomations();
// ignore request if automation does not exist
if (!Object.hasOwn(automations, id)) {
@@ -152,9 +149,13 @@ export async function deleteAutomation(rundown: Rundown, timedEventOrder: EntryI
}
// prevent deleting a automation that is in use in events
const isInUse = isAutomationUsed(rundown, timedEventOrder, id);
if (isInUse) {
throw new Error(`Unable to delete automation used in event with ID ${isInUse}`);
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` : ''}`,
);
}
delete automations[id];
@@ -168,12 +169,7 @@ async function saveChanges(patch: Partial<AutomationSettings>) {
const automation = getDataProvider().getAutomation();
// remove undefined keys from object, we probably want a better solution
Object.keys(patch).forEach((key) => {
const typedKey = key as keyof AutomationSettings;
if (patch[typedKey] === undefined) {
delete patch[typedKey];
}
});
Object.keys(patch).forEach((key) => (patch[key] === undefined ? delete patch[key] : {}));
await getDataProvider().setAutomation({ ...automation, ...patch });
}
@@ -1,7 +1,7 @@
import { DatabaseModel, AutomationSettings, NormalisedAutomation, Trigger } from 'ontime-types';
import { dbModel } from '../../models/dataModel.js';
import type { ErrorEmitter } from '../../utils/parserUtils.js';
import type { ErrorEmitter } from '../../utils/parser.js';
interface LegacyData extends Partial<DatabaseModel> {
http?: unknown;
@@ -12,6 +12,7 @@ import {
testOutput,
} from './automation.controller.js';
import {
paramContainsId,
validateAutomationSettings,
validateAutomation,
validateAutomationPatch,
@@ -19,7 +20,6 @@ import {
validateTrigger,
validateTriggerPatch,
} from './automation.validation.js';
import { paramsWithId } from '../validation-utils/validationFunction.js';
export const router = express.Router();
@@ -28,10 +28,10 @@ router.post('/', validateAutomationSettings, postAutomationSettings);
router.post('/trigger', validateTrigger, postTrigger);
router.put('/trigger/:id', validateTriggerPatch, putTrigger);
router.delete('/trigger/:id', paramsWithId, deleteTrigger);
router.delete('/trigger/:id', paramContainsId, deleteTrigger);
router.post('/automation', validateAutomation, postAutomation);
router.put('/automation/:id', validateAutomationPatch, editAutomation);
router.delete('/automation/:id', paramsWithId, deleteAutomation);
router.delete('/automation/:id', paramContainsId, deleteAutomation);
router.post('/test', validateTestPayload, testOutput);
@@ -1,4 +1,4 @@
import { EntryId, FilterRule, isOntimeEvent, MaybeNumber, OntimeAction, Rundown } from 'ontime-types';
import { FilterRule, MaybeNumber, OntimeAction } from 'ontime-types';
import { millisToString, removeLeadingZero, splitWhitespace, getPropertyFromPath } from 'ontime-utils';
import type { OscArgOrArrayInput, OscArgInput } from 'osc-min';
@@ -195,25 +195,3 @@ 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;
}
}
}
}
}
@@ -10,50 +10,84 @@ import {
} from 'ontime-types';
import { parseUserTime } from 'ontime-utils';
import { body, oneOf, param } from 'express-validator';
import type { Request, Response, NextFunction } from 'express';
import { body, oneOf, param, validationResult } from 'express-validator';
import * as assert from '../../utils/assert.js';
import { isFilterOperator, isFilterRule, isOntimeActionAction } from './automation.utils.js';
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
export const paramContainsId = [
param('id').exists(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
export const validateAutomationSettings = [
body('enabledAutomations').isBoolean(),
body('enabledOscIn').isBoolean(),
body('oscPortIn').isPort(),
body('enabledAutomations').exists().isBoolean(),
body('enabledOscIn').exists().isBoolean(),
body('oscPortIn').exists().isPort(),
body('triggers').optional().isArray(),
body('triggers.*.title').optional().isString().trim(),
body('triggers.*.trigger').optional().isIn(timerLifecycleValues),
body('triggers.*.automationId').optional().isString().trim(),
body('automations').optional().custom(parseAutomation),
requestValidationFunction,
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
export const validateTrigger = [
body('title').isString().trim().notEmpty(),
body('trigger').isIn(timerLifecycleValues),
body('automationId').isString().trim().notEmpty(),
body('title').exists().isString().trim(),
body('trigger').exists().isIn(timerLifecycleValues),
body('automationId').exists().isString().trim(),
requestValidationFunction,
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
export const validateTriggerPatch = [
param('id').isString().notEmpty(),
body('title').optional().isString().trim().notEmpty(),
param('id').exists(),
body('title').optional().isString().trim(),
body('trigger').optional().isIn(timerLifecycleValues),
body('automationId').optional().isString().trim().notEmpty(),
body('automationId').optional().isString().trim(),
requestValidationFunction,
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
export const validateAutomation = [body().custom(parseAutomation), requestValidationFunction];
export const validateAutomationPatch = [
param('id').isString().notEmpty(),
export const validateAutomation = [
body().custom(parseAutomation),
requestValidationFunction,
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
export const validateAutomationPatch = [
param('id').exists(),
body().custom(parseAutomation),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
/**
@@ -106,7 +140,7 @@ function validateOutput(output: Array<unknown>): output is AutomationOutput[] {
}
export const validateTestPayload = [
body('type').isIn(['osc', 'http', 'ontime']),
body('type').exists().isIn(['osc', 'http', 'ontime']),
// validation for OSC message
oneOf([
@@ -128,7 +162,11 @@ export const validateTestPayload = [
body('visible').if(body('type').equals('ontime')).optional().isString().trim(),
body('secondarySource').if(body('type').equals('ontime')).optional().isString().trim(),
requestValidationFunction,
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
/**
@@ -1,128 +0,0 @@
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);
});
});
@@ -0,0 +1,51 @@
import { CustomField, CustomFields, ErrorResponse } from 'ontime-types';
import type { Request, Response } from 'express';
import { getErrorMessage } from 'ontime-utils';
import {
createCustomField,
editCustomField,
getCustomFields as getCustomFieldsFromCache,
removeCustomField,
} from '../../services/rundown-service/rundownCache.js';
export async function getCustomFields(_req: Request, res: Response<CustomFields>) {
const customFields = getCustomFieldsFromCache();
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 });
}
}
@@ -1,64 +0,0 @@
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,49 +1,14 @@
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('/', async (_req: Request, res: Response<CustomFields>) => {
const customFields = getProjectCustomFields();
res.status(200).json(customFields);
});
router.get('/', getCustomFields);
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.post('/', validateCustomField, postCustomField);
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.put('/:label', validateEditCustomField, putCustomField);
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 });
}
});
router.delete('/:label', validateDeleteCustomField, deleteCustomField);
@@ -1,35 +1,51 @@
import { isAlphanumericWithSpace } from 'ontime-utils';
import { body, param } from 'express-validator';
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
import type { Request, Response, NextFunction } from 'express';
import { body, param, validationResult } from 'express-validator';
export const validateCustomField = [
body('label')
.exists()
.isString()
.trim()
.notEmpty()
.custom((value) => {
return isAlphanumericWithSpace(value);
}),
body('type').isIn(['string', 'image']),
body('colour').isString().trim(),
body('type').exists().isIn(['string', 'image']),
body('colour').exists().isString().trim(),
requestValidationFunction,
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
export const validateEditCustomField = [
param('key').isString().trim().notEmpty(),
param('label').exists().isString().trim(),
body('label')
.exists()
.isString()
.trim()
.notEmpty()
.custom((value) => {
return isAlphanumericWithSpace(value);
}),
body('type').isIn(['string', 'image']),
body('colour').isString().trim(),
body('type').exists().isIn(['string', 'image']),
body('colour').exists().isString().trim(),
requestValidationFunction,
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
export const validateDeleteCustomField = [param('key').isString().notEmpty(), requestValidationFunction];
export const validateDeleteCustomField = [
param('label').exists().isString(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
@@ -1,56 +0,0 @@
/* 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();
});
});
+13 -19
View File
@@ -18,9 +18,9 @@ import * as projectService from '../../services/project-service/ProjectService.j
export async function patchPartialProjectFile(req: Request, res: Response<DatabaseModel | ErrorResponse>) {
try {
const { rundowns, project, settings, viewSettings, urlPresets, customFields, automation } = req.body;
const { rundown, project, settings, viewSettings, urlPresets, customFields, automation } = req.body;
const patchDb: DatabaseModel = {
rundowns,
rundown,
project,
settings,
viewSettings,
@@ -53,6 +53,8 @@ export async function createProjectFile(req: Request, res: Response<{ filename:
project: {
title: req.body?.title ?? '',
description: req.body?.description ?? '',
publicUrl: req.body?.publicUrl ?? '',
publicInfo: req.body?.publicInfo ?? '',
backstageUrl: req.body?.backstageUrl ?? '',
backstageInfo: req.body?.backstageInfo ?? '',
projectLogo: req.body?.projectLogo ?? null,
@@ -89,7 +91,7 @@ export async function quickProjectFile(req: Request, res: Response<{ filename: s
*/
export async function currentProjectDownload(_req: Request, res: Response) {
const { filename, pathToFile } = await projectService.getCurrentProject();
res.download(pathToFile, filename, (error: Error | null) => {
res.download(pathToFile, filename, (error) => {
if (error) {
const message = getErrorMessage(error);
res.status(500).send({ message });
@@ -104,8 +106,7 @@ export async function projectDownload(req: Request, res: Response) {
const { filename } = req.body;
const pathToFile = doesProjectExist(filename);
if (!pathToFile) {
res.status(404).send({ message: `Project ${filename} not found.` });
return;
return res.status(404).send({ message: `Project ${filename} not found.` });
}
res.download(pathToFile, filename, (error) => {
@@ -137,8 +138,7 @@ export async function postProjectFile(req: Request, res: Response<MessageRespons
} catch (error) {
const message = getErrorMessage(error);
if (message.startsWith('Project file')) {
res.status(403).send({ message });
return;
return res.status(403).send({ message });
}
res.status(400).send({ message });
}
@@ -195,8 +195,7 @@ export async function loadProject(req: Request, res: Response<MessageResponse |
} catch (error) {
const message = getErrorMessage(error);
if (message.startsWith('Project file')) {
res.status(403).send({ message });
return;
return res.status(403).send({ message });
}
res.status(500).send({ message });
}
@@ -215,8 +214,7 @@ export async function loadDemo(_req: Request, res: Response<MessageResponse | Er
} catch (error) {
const message = getErrorMessage(error);
if (message.startsWith('Project file')) {
res.status(403).send({ message });
return;
return res.status(403).send({ message });
}
res.status(500).send({ message });
}
@@ -247,8 +245,7 @@ export async function duplicateProjectFile(req: Request, res: Response<MessageRe
} catch (error) {
const message = getErrorMessage(error);
if (message.startsWith('Project file')) {
res.status(403).send({ message });
return;
return res.status(403).send({ message });
}
res.status(500).send({ message });
@@ -278,8 +275,7 @@ export async function renameProjectFile(req: Request, res: Response<MessageRespo
} catch (error) {
const message = getErrorMessage(error);
if (message.startsWith('Project file')) {
res.status(403).send({ message });
return;
return res.status(403).send({ message });
}
res.status(500).send({ message });
@@ -307,12 +303,10 @@ export async function deleteProjectFile(req: Request, res: Response<MessageRespo
} catch (error) {
const message = getErrorMessage(error);
if (message === 'Cannot delete currently loaded project') {
res.status(403).send({ message });
return;
return res.status(403).send({ message });
}
if (message === 'Project file not found') {
res.status(404).send({ message });
return;
return res.status(404).send({ message });
}
res.status(500).send({ message });
+3 -2
View File
@@ -1,10 +1,11 @@
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('application/json')) {
if (file.mimetype.includes(JSON_MIME)) {
cb(null, true);
} else {
cb(null, false);
@@ -17,7 +18,7 @@ const filterImageFile = (_req: Request, file: Express.Multer.File, cb: FileFilte
} else {
cb(null, false);
}
};
}
// Build multer uploader for a single file
export const uploadProjectFile = multer({
-49
View File
@@ -1,49 +0,0 @@
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 };
}
+55 -11
View File
@@ -1,23 +1,28 @@
import { body, param } from 'express-validator';
import type { Request, Response, NextFunction } from 'express';
import { body, param, validationResult } from 'express-validator';
import sanitize from 'sanitize-filename';
import { ensureJsonExtension } from '../../utils/fileManagement.js';
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
/**
* @description Validates request for a new project.
*/
export const validateNewProject = [
body().notEmpty().withMessage('No object found in request'),
body('filename').optional().isString().trim(),
body('title').optional().isString().trim(),
body('description').optional().isString().trim(),
body('publicUrl').optional().isString().trim(),
body('publicInfo').optional().isString().trim(),
body('backstageUrl').optional().isString().trim(),
body('backstageInfo').optional().isString().trim(),
body('projectLogo').optional().isString().trim(),
body('endMessage').optional().isString().trim(),
body('custom').optional().isArray(),
requestValidationFunction,
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
/**
@@ -35,15 +40,26 @@ export const validateQuickProject = [
body('viewSettings.freezeEnd').optional().isBoolean(),
body('viewSettings.endMessage').optional().isString().trim(),
requestValidationFunction,
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
/**
* @description Validates request for pathing data in the project.
*/
export const validatePatchProject = [
body().notEmpty().withMessage('No object found in request'),
body('rundowns').isObject().optional({ nullable: false }),
// Custom validator to ensure the body is not empty
(req: Request, res: Response, next: NextFunction) => {
if (Object.keys(req.body).length === 0) {
return res.status(422).json({ errors: [{ msg: 'Request body cannot be empty' }] });
}
next();
},
body('rundown').isArray().optional({ nullable: false }),
body('project').isObject().optional({ nullable: false }),
body('settings').isObject().optional({ nullable: false }),
body('viewSettings').isObject().optional({ nullable: false }),
@@ -52,7 +68,11 @@ export const validatePatchProject = [
body('osc').isObject().optional({ nullable: false }),
body('http').isObject().optional({ nullable: false }),
requestValidationFunction,
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
/**
@@ -60,6 +80,7 @@ export const validatePatchProject = [
*/
export const validateNewFilenameBody = [
body('newFilename')
.exists()
.isString()
.trim()
.customSanitizer((input: string) => sanitize(input))
@@ -68,7 +89,14 @@ export const validateNewFilenameBody = [
.withMessage('Filename was empty or contained only invalid characters')
.customSanitizer((input: string) => ensureJsonExtension(input)),
requestValidationFunction,
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(422).json({ errors: errors.array() });
}
next();
},
];
/**
@@ -76,6 +104,7 @@ export const validateNewFilenameBody = [
*/
export const validateFilenameBody = [
body('filename')
.exists()
.isString()
.trim()
.customSanitizer((input: string) => sanitize(input))
@@ -84,7 +113,14 @@ export const validateFilenameBody = [
.withMessage('Filename was empty or contained only invalid characters')
.customSanitizer((input: string) => ensureJsonExtension(input)),
requestValidationFunction,
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(422).json({ errors: errors.array() });
}
next();
},
];
/**
@@ -92,6 +128,7 @@ export const validateFilenameBody = [
*/
export const validateFilenameParam = [
param('filename')
.exists()
.isString()
.trim()
.customSanitizer((input: string) => sanitize(input))
@@ -100,5 +137,12 @@ export const validateFilenameParam = [
.withMessage('Filename was empty or contained only invalid characters')
.customSanitizer((input: string) => ensureJsonExtension(input)),
requestValidationFunction,
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(422).json({ errors: errors.array() });
}
next();
},
];
@@ -1,576 +0,0 @@
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',
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',
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',
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',
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',
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',
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',
});
});
});
@@ -1,56 +0,0 @@
export const dataFromExcelTemplate = [
['Ontime ┬À Schedule Template'],
[],
[
'id',
'Time Start',
'Time End',
'Title',
'End Action',
'Timer type',
'Count to end',
'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
'', // <-- 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
'x', // <-- skip
'Rainbow chase', // <-- notes
'b0', // <-- t0
'', // <-- test1
'', // <-- test2
'', // <-- test3
'#F00', // <-- colour
102, // <-- cue
],
[],
];
@@ -0,0 +1,40 @@
/**
* This module encapsulates logic related to
* Google Sheets
*/
import type { Request, Response } from 'express';
import { generateRundownPreview, listWorksheets, saveExcelFile } from './excel.service.js';
export async function postExcel(req: Request, res: Response) {
try {
const filePath = req.file.path;
await saveExcelFile(filePath);
res.status(200).send();
} catch (error) {
res.status(500).send({ message: String(error) });
}
}
export async function getWorksheets(req: Request, res: Response) {
try {
const names = listWorksheets();
res.status(200).send(names);
} catch (error) {
res.status(500).send({ message: String(error) });
}
}
/**
* parses an Excel spreadsheet
* @returns parsed result
*/
export async function previewExcel(req: Request, res: Response) {
try {
const { options } = req.body;
const data = generateRundownPreview(options);
res.status(200).send(data);
} catch (error) {
res.status(500).send({ message: String(error) });
}
}
@@ -1,10 +1,9 @@
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);
@@ -1,341 +0,0 @@
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 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.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 === 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 };
}
+9 -35
View File
@@ -1,42 +1,16 @@
/**
* This is a feature specific router for integration with Excel
*/
import express from 'express';
import type { Request, Response } from 'express';
import { uploadExcel } from './excel.middleware.js';
import { getWorksheets, postExcel, previewExcel } from './excel.controller.js';
import { validateFileExists, validateImportMapOptions } from './excel.validation.js';
import { CustomFields, ErrorResponse, Rundown } from 'ontime-types';
import { generateRundownPreview, listWorksheets, saveExcelFile } from './excel.service.js';
export const router = express.Router();
router.post('/upload', uploadExcel, validateFileExists, async (req: Request, res: Response<never | ErrorResponse>) => {
try {
// file has been validated by middleware
const filePath = (req.file as Express.Multer.File).path;
await saveExcelFile(filePath);
res.status(201).send();
} catch (error) {
res.status(500).send({ message: String(error) });
}
});
router.post('/upload', uploadExcel, validateFileExists, postExcel);
router.get('/worksheets', getWorksheets);
router.post('/preview', validateImportMapOptions, previewExcel);
router.get('/worksheets', (_req: Request, res: Response<string[] | ErrorResponse>) => {
try {
const names = listWorksheets();
res.status(200).send(names);
} catch (error) {
res.status(500).send({ message: String(error) });
}
});
router.post(
'/preview',
validateImportMapOptions,
(req: Request, res: Response<{ rundown: Rundown; customFields: CustomFields } | ErrorResponse>) => {
try {
const { options } = req.body;
const data = generateRundownPreview(options);
res.status(200).send(data);
} catch (error) {
res.status(500).send({ message: String(error) });
}
},
);
// TODO: validate import map
+11 -16
View File
@@ -3,21 +3,18 @@
* Google Sheets
*/
import { CustomFields, Rundown } from 'ontime-types';
import { type ImportMap } from 'ontime-utils';
import { CustomFields, OntimeRundown } from 'ontime-types';
import type { ImportMap } from 'ontime-utils';
import { extname } from 'path';
import { existsSync } from 'fs';
import xlsx from 'xlsx';
import type { WorkBook } from 'xlsx';
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';
import { parseExcel } from '../../utils/parser.js';
import { parseRundown } from '../../utils/parserFunctions.js';
import { deleteFile } from '../../utils/parserUtils.js';
import { getCustomFields } from '../../services/rundown-service/rundownCache.js';
let excelData: WorkBook = xlsx.utils.book_new();
@@ -37,7 +34,7 @@ export function listWorksheets(): string[] {
return excelData.SheetNames;
}
export function generateRundownPreview(options: ImportMap): { rundown: Rundown; customFields: CustomFields } {
export function generateRundownPreview(options: ImportMap): { rundown: OntimeRundown; customFields: CustomFields } {
const data = excelData.Sheets[options.worksheet];
if (!data) {
@@ -46,17 +43,15 @@ export function generateRundownPreview(options: ImportMap): { rundown: Rundown;
const arrayOfData: unknown[][] = xlsx.utils.sheet_to_json(data, { header: 1, blankrows: false, raw: false });
const dataFromExcel = parseExcel(arrayOfData, getProjectCustomFields(), options.worksheet, options);
const parsedCustomFields = parseCustomFields(dataFromExcel);
const dataFromExcel = parseExcel(arrayOfData, getCustomFields(), options);
// we run the parsed data through an extra step to ensure the objects shape
const Rundown = parseRundown(dataFromExcel.rundown, parsedCustomFields);
if (Rundown.order.length === 0) {
const { rundown, customFields } = parseRundown(dataFromExcel);
if (rundown.length === 0) {
throw new Error(`Could not find data to import in the worksheet: ${options.worksheet}`);
}
// clear the data
excelData = xlsx.utils.book_new();
return { rundown: Rundown, customFields: parsedCustomFields };
return { rundown, customFields };
}
@@ -1,19 +1,28 @@
import { isImportMap } from 'ontime-utils';
import { body } from 'express-validator';
import {
requestValidationFunction,
requestValidationFunctionWithFile,
} from '../validation-utils/validationFunction.js';
import { body, validationResult } from 'express-validator';
import type { NextFunction, Request, Response } from 'express';
export const validateFileExists = [requestValidationFunctionWithFile];
export const validateFileExists = [
(req: Request, res: Response, next: NextFunction) => {
if (!req.file) {
return res.status(422).json({ errors: 'File not found' });
}
next();
},
];
export const validateImportMapOptions = [
body('options')
.exists()
.isObject()
.custom((content) => {
return isImportMap(content);
}),
requestValidationFunction,
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
+3 -3
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-data/projectData.router.js';
import { router as projectRouter } from './project/project.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';
@@ -31,6 +31,6 @@ appRouter.use('/report', reportRouter);
appRouter.use('/assets', assetsRouter);
//we don't want to redirect to react index when using api routes
appRouter.all('/*splat', (_req, res) => {
res.status(404).send('data path not found');
appRouter.all('/*', (_req, res) => {
res.status(404).send();
});
@@ -1,10 +0,0 @@
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();
});
});
@@ -1,25 +0,0 @@
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,
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,26 +1,28 @@
import express from 'express';
import type { Request, Response } from 'express';
import type { ErrorResponse, ProjectData } from 'ontime-types';
import { ErrorResponse, ProjectData } from 'ontime-types';
import { getErrorMessage } from 'ontime-utils';
import { projectSanitiser } from './projectData.validation.js';
import { uploadImageFile } from '../db/db.middleware.js';
import { postProjectLogo } from '../db/db.controller.js';
import * as projectDao from './projectData.dao.js';
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';
export const router = express.Router();
export function getProjectData(_req: Request, res: Response<ProjectData>) {
res.json(projectDao.getProjectData());
}
router.get('/', (_req: Request, res: Response<ProjectData>) => {
res.status(200).json(projectDao.getProjectData());
});
export async function postProjectData(req: Request, res: Response<ProjectData | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) {
return;
}
router.post('/', projectSanitiser, async (req: Request, res: Response<ProjectData | ErrorResponse>) => {
try {
const newData: Partial<ProjectData> = removeUndefined({
title: req.body?.title,
description: req.body?.description,
publicUrl: req.body?.publicUrl,
publicInfo: req.body?.publicInfo,
backstageUrl: req.body?.backstageUrl,
backstageInfo: req.body?.backstageInfo,
endMessage: req.body?.endMessage,
@@ -35,6 +37,4 @@ router.post('/', projectSanitiser, async (req: Request, res: Response<ProjectDat
const message = getErrorMessage(error);
res.status(400).send({ message });
}
});
router.post('/upload', uploadImageFile, postProjectLogo);
}
@@ -1,5 +1,4 @@
import { ProjectData } from 'ontime-types';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
/**
@@ -0,0 +1,12 @@
import express from 'express';
import { getProjectData, postProjectData } from './project.controller.js';
import { projectSanitiser } from './project.validation.js';
import { uploadImageFile } from '../db/db.middleware.js';
import { postProjectLogo } from '../db/db.controller.js';
export const router = express.Router();
router.get('/', getProjectData);
router.post('/', projectSanitiser, postProjectData);
router.post('/upload', uploadImageFile, postProjectLogo);
@@ -1,17 +1,22 @@
import { body } from 'express-validator';
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
import { Request, Response, NextFunction } from 'express';
import { body, validationResult } from 'express-validator';
export const projectSanitiser = [
body().notEmpty().withMessage('No object found in request'),
body('title').optional().isString().trim(),
body('description').optional().isString().trim(),
body('publicUrl').optional().isString().trim(),
body('publicInfo').optional().isString().trim(),
body('backstageUrl').optional().isString().trim(),
body('backstageInfo').optional().isString().trim(),
body('endMessage').optional().isString().trim(),
body('projectLogo').optional({ nullable: true }).isString().trim().isBase64(),
body('projectLogo').optional({ nullable: true }).isString().trim(),
body('custom').optional().isArray(),
body('custom.*.title').optional().isString().trim().notEmpty(),
body('custom.*.value').optional().isString().trim().notEmpty(),
requestValidationFunction,
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
@@ -0,0 +1,18 @@
import type { Request, Response } from 'express';
import type { OntimeReport } from 'ontime-types';
import * as report from './report.service.js';
export function getAll(_req: Request, res: Response<OntimeReport>) {
res.json(report.generate());
}
export function deleteAll(_req: Request, res: Response<OntimeReport>) {
report.clear();
res.status(200).send();
}
export function deleteWithId(req: Request, res: Response<OntimeReport>) {
const { eventId } = req.params;
report.clear(eventId);
res.status(200).send();
}
@@ -1,21 +1,10 @@
import express from 'express';
import type { Request, Response } from 'express';
import { paramsWithId } from '../validation-utils/validationFunction.js';
import * as report from './report.service.js';
import { getAll, deleteWithId, deleteAll } from './report.controller.js';
import { paramsMustHaveEventId } from '../rundown/rundown.validation.js';
export const router = express.Router();
router.get('/', (_req: Request, res: Response) => {
res.status(200).json(report.generate());
});
router.get('/', getAll);
router.delete('/all', (_req: Request, res: Response) => {
report.clear();
res.status(204).send();
});
router.delete('/:id', paramsWithId, (req: Request, res: Response) => {
const { id } = req.params;
report.clear(id);
res.status(204).send();
});
router.delete('/all', deleteAll);
router.delete('/:eventId', paramsMustHaveEventId, deleteWithId);
@@ -1,6 +1,6 @@
import { OntimeReport, OntimeEventReport, TimerLifeCycle } from 'ontime-types';
import { RuntimeState } from '../../stores/runtimeState.js';
import { RefetchTargets, sendRefetch } from '../../adapters/websocketAux.js';
import { sendRefetch } from '../../adapters/websocketAux.js';
import { DeepReadonly } from 'ts-essentials';
const report = new Map<string, OntimeEventReport>();
@@ -58,7 +58,8 @@ export function triggerReportEntry(
report.set(eventId, { startedAt, endedAt: state.clock });
formattedReport = null;
sendRefetch({
target: RefetchTargets.Report,
target: 'REPORT',
});
return;
}
}
@@ -1,64 +0,0 @@
import { SupportedEntry, OntimeEvent, OntimeDelay, OntimeBlock, Rundown, CustomField } from 'ontime-types';
import { defaultRundown } from '../../../models/dataModel.js';
const baseEvent = {
type: SupportedEntry.Event,
skip: false,
revision: 1,
};
const baseBlock = {
type: SupportedEntry.Block,
events: [],
};
/**
* Utility to create a Ontime event
*/
export function makeOntimeEvent(patch: Partial<OntimeEvent>): OntimeEvent {
return {
...baseEvent,
...patch,
} as OntimeEvent;
}
/**
* Utility to create a delay event
*/
export function makeOntimeDelay(patch: Partial<OntimeDelay>): OntimeDelay {
return { id: 'delay', type: SupportedEntry.Delay, duration: 0, ...patch } as OntimeDelay;
}
/**
* Utility to create a block event
*/
export function makeOntimeBlock(patch: Partial<OntimeBlock>): OntimeBlock {
return { id: 'block', ...baseBlock, ...patch } as OntimeBlock;
}
/**
* Utility to create a rundown object
*/
export function makeRundown(patch: Partial<Rundown>): Rundown {
return {
...defaultRundown,
...patch,
};
}
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
*/
export function prepareTimedEvents(events: Partial<OntimeEvent>[]): OntimeEvent[] {
return events.map(makeOntimeEvent);
}
File diff suppressed because it is too large Load Diff
@@ -1,282 +0,0 @@
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, 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).toStrictEqual({ default: defaultRundown });
// one for not having custom fields
// one for not having a rundown
expect(errorEmitter).toHaveBeenCalledTimes(1);
});
it('ensures the rundown IDs are consistent', () => {
const errorEmitter = vi.fn();
const r1 = { ...defaultRundown, id: '1' };
const r2 = { ...defaultRundown, id: '2' };
const result = parseRundowns(
{
rundowns: {
'1': r1,
'3': r2,
},
},
{},
errorEmitter,
);
expect(result).toMatchObject({
'1': r1,
'2': r2,
});
expect(errorEmitter).toHaveBeenCalledTimes(0);
});
});
describe('parseRundown()', () => {
it('parses data, skipping invalid results', () => {
const errorEmitter = vi.fn();
const rundown = {
id: '',
title: '',
order: ['1', '2', '3', '4'],
flatOrder: ['1', '2', '3', '4'],
entries: {
'1': { id: '1', type: SupportedEntry.Event, title: 'test', skip: false } as OntimeEvent, // OK
'2': { id: '1', type: SupportedEntry.Block, title: 'test 2', skip: false } as OntimeBlock, // duplicate ID
'3': {} as OntimeEvent, // no data
'4': { id: '4', title: 'test 2', skip: false } as OntimeEvent, // no type
},
revision: 1,
} as Rundown;
const parsedRundown = parseRundown(rundown, {}, errorEmitter);
expect(parsedRundown.id).not.toBe('');
expect(parsedRundown.id).toBeTypeOf('string');
expect(parsedRundown.order.length).toEqual(1);
expect(parsedRundown.order).toEqual(['1']);
expect(parsedRundown.entries).toMatchObject({
'1': {
id: '1',
type: SupportedEntry.Event,
title: 'test',
skip: false,
},
});
expect(errorEmitter).toHaveBeenCalled();
});
it('stringifies necessary values', () => {
const rundown = {
id: '',
title: '',
order: ['1', '2'],
flatOrder: ['1', '2'],
entries: {
// @ts-expect-error -- testing external data which could be incorrect
'1': { id: '1', type: SupportedEntry.Event, cue: 101 } as OntimeEvent,
// @ts-expect-error -- testing external data which could be incorrect
'2': { id: '2', type: SupportedEntry.Event, cue: 101.1 } as OntimeEvent,
},
revision: 1,
} as Rundown;
expect(parseRundown(rundown, {})).toMatchObject({
entries: {
'1': {
cue: '101',
},
'2': {
cue: '101.1',
},
},
});
});
it('detects duplicate Ids', () => {
const rundown = {
id: '',
title: '',
order: ['1', '1'],
flatOrder: ['1', '1'],
entries: {
'1': { id: '1', type: SupportedEntry.Event } as OntimeEvent,
'2': { id: '2', type: SupportedEntry.Event } as OntimeEvent,
},
revision: 1,
} as Rundown;
const parsedRundown = parseRundown(rundown, {});
expect(parsedRundown.order.length).toEqual(1);
expect(Object.keys(parsedRundown.entries).length).toEqual(1);
});
it('completes partial datasets', () => {
const rundown = {
id: 'test',
title: '',
order: ['1', '2'],
flatOrder: ['1', '2'],
entries: {
'1': { id: '1', type: SupportedEntry.Event } as OntimeEvent,
'2': { id: '2', type: SupportedEntry.Event } as OntimeEvent,
},
revision: 1,
} as Rundown;
const parsedRundown = parseRundown(rundown, {});
expect(parsedRundown.order.length).toEqual(2);
expect(parsedRundown.entries).toMatchObject({
'1': {
title: '',
cue: '1',
custom: {},
},
'2': {
title: '',
cue: '2',
custom: {},
},
});
});
it('handles empty events', () => {
const rundown = {
id: 'test',
title: '',
order: ['1', '2', '3', '4'],
flatOrder: ['1', '2', '3', '4'],
entries: {
'1': { id: '1', type: SupportedEntry.Event } as OntimeEvent,
'2': { id: '2', type: SupportedEntry.Event } as OntimeEvent,
'not-mentioned': {} as OntimeEvent,
},
revision: 1,
} as Rundown;
const parsedRundown = parseRundown(rundown, {});
expect(parsedRundown.order.length).toEqual(2);
expect(Object.keys(parsedRundown.entries).length).toEqual(2);
});
it('handles empty events', () => {
const rundown = {
id: 'test',
title: '',
order: ['1', '2', '3', '4'],
flatOrder: ['1', '2', '3', '4'],
entries: {
'1': { id: '1', type: SupportedEntry.Event } as OntimeEvent,
'2': { id: '2', type: SupportedEntry.Event } as OntimeEvent,
'not-mentioned': {} as OntimeEvent,
},
revision: 1,
} as Rundown;
const parsedRundown = parseRundown(rundown, {});
expect(parsedRundown.order.length).toEqual(2);
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',
title: '',
order: ['block'],
flatOrder: ['block'],
entries: {
block: makeOntimeBlock({ id: 'block', events: ['1', '2'] }),
'1': makeOntimeEvent({ id: '1' }),
'2': makeOntimeEvent({ id: '2' }),
},
revision: 1,
} as Rundown;
const parsedRundown = parseRundown(rundown, {});
expect(parsedRundown.order.length).toEqual(1);
expect(parsedRundown.entries.block).toMatchObject({ events: ['1', '2'] });
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,238 +0,0 @@
import { TimeStrategy, EndAction, TimerType, OntimeEvent } from 'ontime-types';
import { MILLIS_PER_HOUR } from 'ontime-utils';
import { assertType } from 'vitest';
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', () => {
const event = {
title: 'test',
};
const validated = createEvent(event, 1);
expect(validated).toEqual(
expect.objectContaining({
title: expect.any(String),
note: expect.any(String),
timeStart: expect.any(Number),
timeEnd: expect.any(Number),
countToEnd: expect.any(Boolean),
skip: expect.any(Boolean),
revision: expect.any(Number),
type: expect.any(String),
id: expect.any(String),
cue: '2',
colour: expect.any(String),
custom: expect.any(Object),
}),
);
});
it('fails an empty object', () => {
const event = {};
const validated = createEvent(event, 1);
expect(validated).toEqual(null);
});
it('makes objects strings', () => {
const event = {
title: 2,
note: '1899-12-30T08:00:10.000Z',
};
// @ts-expect-error -- we know this is wrong, testing imports outside domain
const validated = createEvent(event, 1);
if (validated === null) {
throw new Error('unexpected value');
}
expect(typeof validated.title).toEqual('string');
expect(typeof validated.note).toEqual('string');
});
it('enforces numbers on times', () => {
const event = {
timeStart: false,
timeEnd: '2',
};
// @ts-expect-error -- we know this is wrong, testing imports outside domain
const validated = createEvent(event);
if (validated === null) {
throw new Error('unexpected value');
}
assertType<number>(validated.timeStart);
assertType<number>(validated.timeEnd);
assertType<number>(validated.duration);
expect(validated.timeStart).toEqual(0);
expect(validated.timeEnd).toEqual(2);
expect(validated.duration).toEqual(2);
});
it('handles bad objects', () => {
const event = {
title: {},
};
// @ts-expect-error -- we know this is wrong, testing imports outside domain
const validated = createEvent(event);
if (validated === null) {
throw new Error('unexpected value');
}
expect(typeof validated.title).toEqual('string');
});
});
describe('doesInvalidateMetadata()', () => {
it('is stale if data contains timers', () => {
const needsRecompute = [
{ timeStart: 10 },
{ timeEnd: 10 },
{ duration: 10 },
{ linkStart: true },
{ timerStrategy: TimeStrategy.LockDuration },
];
for (const testCase of needsRecompute) {
expect(doesInvalidateMetadata(testCase)).toBe(true);
}
expect.assertions(needsRecompute.length);
});
it('is not stale if data contains auxiliary dataset', () => {
expect(
doesInvalidateMetadata({
cue: 'cue',
title: 'title',
note: 'note',
endAction: EndAction.LoadNext,
timerType: TimerType.Clock,
colour: 'colour',
timeWarning: 1,
timeDanger: 2,
custom: {
lighting: '3',
},
}),
).toBe(false);
});
});
describe('hasChanges()', () => {
it('identifies objects with new values', () => {
const newEvent = { id: '1', title: 'new-title' } as OntimeEvent;
const existing = { id: '1', cue: 'cue', title: 'title' } as OntimeEvent;
expect(hasChanges(existing, newEvent)).toBe(true);
});
it('identifies objects with all same values', () => {
const newEvent = { id: '1', title: 'title' } as OntimeEvent;
const existing = { id: '1', cue: 'cue', title: 'title' } as OntimeEvent;
expect(hasChanges(existing, newEvent)).toBe(false);
});
});
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');
expect(result).toStrictEqual(['id1', 'id3', 'id4']);
expect(result).not.toBe(array); // Ensure a new array is returned
});
it('should not modify the array if the specified ID does not exist', () => {
const array = ['id1', 'id2', 'id3', 'id4'];
const result = deleteById(array, 'id5');
expect(result).toStrictEqual(['id1', 'id2', 'id3', 'id4']);
});
it('should return the same array if it is empty', () => {
const array: string[] = [];
const result = deleteById(array, 'id1');
expect(result).toStrictEqual([]);
});
it('should handle scenarios where the delete id is not found', () => {
const array = ['id1', 'id2', 'id3'];
const result = deleteById(array, 'id4');
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();
});
});
@@ -0,0 +1,187 @@
import {
ErrorResponse,
MessageResponse,
OntimeRundown,
OntimeRundownEntry,
RundownCached,
RundownPaginated,
} from 'ontime-types';
import { getErrorMessage } from 'ontime-utils';
import type { Request, Response } from 'express';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import {
addEvent,
applyDelay,
batchEditEvents,
deleteAllEvents,
deleteEvent,
editEvent,
reorderEvent,
swapEvents,
} from '../../services/rundown-service/RundownService.js';
import {
getEventWithId,
getNormalisedRundown,
getPaginated,
getRundown,
} from '../../services/rundown-service/rundownUtils.js';
export async function rundownGetAll(_req: Request, res: Response<OntimeRundown>) {
const rundown = getRundown();
res.json(rundown);
}
export async function rundownGetNormalised(_req: Request, res: Response<RundownCached>) {
const cachedRundown = getNormalisedRundown();
res.json(cachedRundown);
}
export async function rundownGetById(req: Request, res: Response<OntimeRundownEntry | ErrorResponse>) {
const { eventId } = req.params;
try {
const event = getEventWithId(eventId);
if (!event) {
res.status(404).send({ message: 'Event not found' });
return;
}
res.status(200).json(event);
} catch (error) {
const message = getErrorMessage(error);
res.status(500).json({ message });
}
}
export async function rundownGetPaginated(req: Request, res: Response<RundownPaginated | ErrorResponse>) {
const { limit, offset } = req.query;
if (limit == null && offset == null) {
return res.json({
rundown: getRundown(),
total: getRundown().length,
});
}
try {
let parsedOffset = Number(offset);
if (Number.isNaN(parsedOffset)) {
parsedOffset = 0;
}
let parsedLimit = Number(limit);
if (Number.isNaN(parsedLimit)) {
parsedLimit = Infinity;
}
const paginatedRundown = getPaginated(parsedOffset, parsedLimit);
res.status(200).json(paginatedRundown);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).json({ message });
}
}
export async function rundownPost(req: Request, res: Response<OntimeRundownEntry | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const newEvent = await addEvent(req.body);
res.status(201).send(newEvent);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
export async function rundownPut(req: Request, res: Response<OntimeRundownEntry | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const event = await editEvent(req.body);
res.status(200).send(event);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
export async function rundownBatchPut(req: Request, res: Response<MessageResponse | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) {
return res.status(404);
}
try {
const { data, ids } = req.body;
await batchEditEvents(ids, data);
res.status(200).send({ message: 'Batch edit successful' });
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
export async function rundownReorder(req: Request, res: Response<OntimeRundownEntry | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const { eventId, from, to } = req.body;
const event = await reorderEvent(eventId, from, to);
res.status(200).send(event.newEvent);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
export async function rundownSwap(req: Request, res: Response<MessageResponse | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const { from, to } = req.body;
await swapEvents(from, to);
res.status(200).send({ message: 'Swap successful' });
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
export async function rundownApplyDelay(req: Request, res: Response<MessageResponse | ErrorResponse>) {
try {
await applyDelay(req.params.eventId);
res.status(200).send({ message: 'Delay applied' });
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
export async function rundownDelete(_req: Request, res: Response<MessageResponse | ErrorResponse>) {
try {
await deleteAllEvents();
res.status(204).send({ message: 'All events deleted' });
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
export async function deletesEventById(req: Request, res: Response<MessageResponse | ErrorResponse>) {
try {
await deleteEvent(req.body.ids);
res.status(204).send({ message: 'Events deleted' });
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
@@ -1,758 +0,0 @@
/**
* This module handles interfacing with the stored rundown
* Additionally it provides a transaction-like interface on a caching layer
*
* The mutation functions mutate the rundown in place
* This is to simplify the logic and avoid multiple copies of the objects
*
* The mutations assume that the data has been validated
* - in shape
* - in domain
*/
import {
CustomField,
CustomFieldKey,
CustomFields,
EntryId,
isOntimeBlock,
isOntimeEvent,
isPlayableEvent,
OntimeBlock,
OntimeDelay,
OntimeEntry,
OntimeEvent,
PatchWithId,
Rundown,
} from 'ontime-types';
import { customFieldLabelToKey, insertAtIndex } from 'ontime-utils';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import type { AssignedMap, CustomFieldsMetadata, RundownMetadata } from './rundown.types.js';
import {
applyPatchToEntry,
cloneBlock,
cloneEntry,
createBlock,
deleteById,
doesInvalidateMetadata,
getUniqueId,
} from './rundown.utils.js';
import { makeRundownMetadata, ProcessedRundownMetadata } from './rundown.parser.js';
/**
* The currently loaded rundown in cache
*/
const cachedRundown: Rundown = {
id: '',
title: '',
order: [],
flatOrder: [], // TODO: remove in favour of the metadata flatEntryOrder
entries: {},
revision: 0,
};
let rundownMetadata: RundownMetadata = {
totalDelay: 0,
totalDuration: 0,
totalDays: 0,
firstStart: null,
lastEnd: null,
playableEventOrder: [],
timedEventOrder: [],
flatEntryOrder: [],
};
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: {},
};
/**
* The custom fields that are used in the project
* Not unique to the loaded rundown
*/
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];
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) {
// if the rundown is mutable we persist the changes
if (options.mutableRundown) {
// schedule a database update
setImmediate(async () => {
await getDataProvider().setRundown(cachedRundown.id, cachedRundown);
});
// 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.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;
}
// if the customFields are mutable we persist the changes
if (options.mutableCustomFields) {
// schedule a database update
setImmediate(async () => {
await getDataProvider().setCustomFields(projectCustomFields);
});
projectCustomFields = customFields;
}
return {
rundown: cachedRundown,
rundownMetadata,
customFields: projectCustomFields,
revision: cachedRundown.revision,
};
}
return {
customFields,
customFieldsMetadata,
rundown,
rundownMetadata,
commit,
};
}
/**
* Add entry to rundown, handles the following cases:
* - 1a. add entry in block, after a given entry
* - 1b. add entry in block, at the beginning
* - 2a. add entry to the rundown, after a given entry
* - 2b. add entry to the rundown, at the beginning
*/
function add(rundown: Rundown, entry: OntimeEntry, afterId: EntryId | null, parentId: EntryId | null): OntimeEntry {
if (parentId) {
// 1. inserting an entry inside a block
const parentBlock = rundown.entries[parentId] as OntimeBlock;
if (afterId) {
const atEventsIndex = parentBlock.events.indexOf(afterId) + 1;
const atFlatIndex = rundown.flatOrder.indexOf(afterId) + 1;
parentBlock.events = insertAtIndex(atEventsIndex, entry.id, parentBlock.events);
rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder);
} else {
parentBlock.events = insertAtIndex(0, entry.id, parentBlock.events);
const atFlatIndex = rundown.flatOrder.indexOf(parentId) + 1;
rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder);
}
} else {
// 2. inserting an entry at top level
if (afterId) {
const atOrderIndex = rundown.order.indexOf(afterId) + 1;
const atFlatIndex = rundown.flatOrder.indexOf(afterId) + 1;
rundown.order = insertAtIndex(atOrderIndex, entry.id, rundown.order);
rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder);
} else {
rundown.order = insertAtIndex(0, entry.id, rundown.order);
rundown.flatOrder = insertAtIndex(0, entry.id, rundown.flatOrder);
}
}
// either way, we insert the entry into the rundown
rundown.entries[entry.id] = entry;
return entry;
}
/**
* Applies a patch of changes to an existing entry
* @returns { entry: OntimeEntry, didInvalidate: boolean } - didInvalidate indicates whether the change warrants a recalculation of the cache
*/
function edit(rundown: Rundown, patch: PatchWithId): { entry: OntimeEntry; didInvalidate: boolean } {
const entry = rundown.entries[patch.id];
// apply the patch and replace the entry
const newEntry = applyPatchToEntry(entry, patch);
rundown.entries[entry.id] = newEntry;
// check whether the data warrants recalculation of cache
const didInvalidate = doesInvalidateMetadata(patch);
return { entry: newEntry, didInvalidate };
}
/**
* Deletes an entry from the rundown
* - if the entry is an ontime block, we delete it along with its children
* - if the entry is inside a block, we delete it and remove the reference from the parent block
*/
function remove(rundown: Rundown, entry: OntimeEntry) {
if (isOntimeBlock(entry)) {
// for ontime blocks, we need to iterate through the children and delete them
for (let i = 0; i < entry.events.length; i++) {
const nestedEntryId = entry.events[i];
deleteEntry(nestedEntryId);
}
} else if (entry.parent) {
// at this point, we are handling entries inside a block, so we need to remove the reference
const parentBlock = rundown.entries[entry.parent] as OntimeBlock;
if (parentBlock) {
// we call a mutation to the parent event to remove the entry from the events
const filteredEvents = deleteById(parentBlock.events, entry.id);
edit(rundown, { id: parentBlock.id, events: filteredEvents });
}
}
deleteEntry(entry.id);
function deleteEntry(idToDelete: EntryId) {
rundown.order = deleteById(rundown.order, idToDelete);
delete rundown.entries[idToDelete];
}
}
/**
* Removes all entries from the rundown
*/
function removeAll(rundown: Rundown): Rundown {
rundown.order = [];
rundown.flatOrder = [];
rundown.entries = {};
return rundown;
}
/**
* Reorders an entry in the rundown
* Handle moving across order lists
*/
function reorder(rundown: Rundown, eventFrom: OntimeEntry, eventTo: OntimeEntry, order: 'before' | 'after' | 'insert') {
// handle moving across parents
const fromParent: EntryId | null = (eventFrom as { parent?: EntryId })?.parent ?? null;
const toParent = (() => {
if (isOntimeBlock(eventTo)) {
if (order === 'insert') {
return eventTo.id;
}
return null;
}
return eventTo.parent ?? null;
})();
if (!isOntimeBlock(eventFrom)) {
eventFrom.parent = toParent;
}
const sourceArray = fromParent === null ? rundown.order : (rundown.entries[fromParent] as OntimeBlock).events;
const destinationArray = toParent === null ? rundown.order : (rundown.entries[toParent] as OntimeBlock).events;
const fromIndex = sourceArray.indexOf(eventFrom.id);
const toIndex = (() => {
const baseIndex = destinationArray.indexOf(eventTo.id);
if (order === 'before') return baseIndex;
// only add one if we are moving down
if (order === 'after') return baseIndex + (fromIndex < baseIndex ? 0 : 1);
// for insert we add in the end of the array
return destinationArray.length;
})();
// Remove from source array
sourceArray.splice(fromIndex, 1);
// Insert into destination array
destinationArray.splice(toIndex, 0, eventFrom.id);
}
/**
* Applies delay from given event ID
* Mutates the given rundown
*/
function applyDelay(rundown: Rundown, delay: OntimeDelay) {
const delayIndex = rundownMetadata.flatEntryOrder.indexOf(delay.id);
// if the delay is empty, or the last element
// there is nothing do apply
if (delay.duration === 0 || delayIndex === rundownMetadata.flatEntryOrder.length - 1) {
return;
}
/**
* We iterate through the rundown and apply the delay
* The delay values becomes part of the event schedule
* The delay is applied as if the rundown was flat
*/
let delayValue = delay.duration;
let lastEntry: OntimeEvent | null = null;
let isFirstEvent = true;
for (let i = delayIndex + 1; i < rundownMetadata.flatEntryOrder.length; i++) {
const currentId = rundownMetadata.flatEntryOrder[i];
const currentEntry = rundown.entries[currentId];
// we don't do operation on other event types
if (!isOntimeEvent(currentEntry)) {
continue;
}
// we need to remove the link in the first event to maintain the gap
let shouldUnlink = isFirstEvent;
isFirstEvent = false;
// if the event is not linked, we try and maintain gaps
if (lastEntry !== null) {
// when applying negative delays, we need to unlink the event
// if the previous event was fully consumed by the delay
if (currentEntry.linkStart && delayValue < 0 && lastEntry.timeStart + delayValue < 0) {
shouldUnlink = true;
}
if (currentEntry.gap > 0) {
delayValue = Math.max(delayValue - currentEntry.gap, 0);
}
if (delayValue === 0) {
// we can bail from continuing if there are no further delays to apply
break;
}
}
// save the current entry before making mutations on its values
lastEntry = { ...currentEntry };
if (shouldUnlink) {
currentEntry.linkStart = false;
shouldUnlink = false;
}
// event times move up by the delay value
// we dont update the delay value since we would need to iterate through the entire dataset
// this is handled by the rundownCache.generate function
currentEntry.timeStart = Math.max(0, currentEntry.timeStart + delayValue);
currentEntry.timeEnd = Math.max(currentEntry.duration, currentEntry.timeEnd + delayValue);
currentEntry.revision += 1;
}
}
/**
* Swaps the data between two events
* The schedule and metadata are preserved
* TODO: this logic is for now duplicate of Ontime-Utils.swapEventData
*/
function swap(rundown: Rundown, eventFrom: OntimeEvent, eventTo: OntimeEvent) {
rundown.entries[eventFrom.id] = {
...eventTo,
// events keep the ID
id: eventFrom.id,
// events keep the schedule
timeStart: eventFrom.timeStart,
timeEnd: eventFrom.timeEnd,
duration: eventFrom.duration,
linkStart: eventFrom.linkStart,
parent: eventFrom.parent,
// keep schedule metadata
delay: eventFrom.delay,
gap: eventFrom.gap,
dayOffset: eventFrom.dayOffset,
// keep revision number but increment it
revision: eventFrom.revision++,
};
rundown.entries[eventTo.id] = {
...eventFrom,
// events keep the ID
id: eventTo.id,
// events keep the schedule
timeStart: eventTo.timeStart,
timeEnd: eventTo.timeEnd,
duration: eventTo.duration,
linkStart: eventTo.linkStart,
parent: eventTo.parent,
// keep schedule metadata
delay: eventTo.delay,
gap: eventTo.gap,
dayOffset: eventTo.dayOffset,
// keep revision number but increment it
revision: eventTo.revision++,
};
}
/**
* Inserts a clone of the given entry into the rundown
* Handles cloning children if the entry is a block
*/
function clone(rundown: Rundown, entry: OntimeEntry): OntimeEntry {
if (isOntimeBlock(entry)) {
const newBlock = cloneBlock(entry, getUniqueId(rundown));
const nestedIds: EntryId[] = [];
for (let i = 0; i < entry.events.length; i++) {
const nestedEntryId = entry.events[i];
const nestedEntry = rundown.entries[nestedEntryId];
if (!nestedEntry) {
continue;
}
// clone the event and assign it to the new block
const newNestedEntry = cloneEntry(nestedEntry, getUniqueId(rundown));
(newNestedEntry as OntimeEvent | OntimeDelay).parent = newBlock.id;
nestedIds.push(newNestedEntry.id);
// we immediately insert the nested entries into the rundown
rundown.entries[newNestedEntry.id] = newNestedEntry;
}
// indexes + 1 since we are inserting after the cloned block
const atIndex = rundown.order.indexOf(entry.id) + 1;
newBlock.events = nestedIds;
newBlock.title = `${entry.title || 'Untitled'} (copy)`;
rundown.entries[newBlock.id] = newBlock;
rundown.order = insertAtIndex(atIndex, newBlock.id, rundown.order);
return newBlock;
} else {
return add(rundown, cloneEntry(entry, getUniqueId(rundown)), entry.id, entry.parent);
}
}
/**
* Groups a list of entries into a block
* It ensures that the entries get reassigned parent and the block gets a list of events
* The block will be created at the index of the first event in the order, not at the lowest index
* Mutates the given rundown
*/
function group(rundown: Rundown, entryIds: EntryId[]): OntimeBlock {
const newBlock = createBlock({ id: getUniqueId(rundown) });
const nestedEvents: EntryId[] = [];
let firstIndex = -1;
for (let i = 0; i < entryIds.length; i++) {
const entryId = entryIds[i];
const entry = rundown.entries[entryId];
if (!entry || isOntimeBlock(entry)) {
// invalid operation, we skip this entry
continue;
}
// the block will be created at the first selected event position
// note that this is not the lowest index
if (firstIndex === -1) {
firstIndex = rundown.flatOrder.indexOf(entryId);
}
nestedEvents.push(entryId);
entry.parent = newBlock.id;
rundown.flatOrder = rundown.flatOrder.filter((id) => id !== entryId);
rundown.order = rundown.order.filter((id) => id !== entryId);
}
newBlock.events = nestedEvents;
const insertIndex = Math.max(0, firstIndex);
// we have filtered the items from the order
// we will insert them now, with only the block at top level ...
rundown.order = insertAtIndex(insertIndex, newBlock.id, rundown.order);
rundown.entries[newBlock.id] = newBlock;
return newBlock;
}
/**
* Deletes a block and moves all its children to the top level order
*/
function ungroup(rundown: Rundown, block: OntimeBlock) {
// get the events from the block and merge them into the order where the block was
const nestedEvents = block.events;
const blockIndex = rundown.order.indexOf(block.id);
rundown.order.splice(blockIndex, 1, ...nestedEvents);
// delete block from entries and remove its reference from the child events
delete rundown.entries[block.id];
for (let i = 0; i < nestedEvents.length; i++) {
const eventId = nestedEvents[i];
const entry = rundown.entries[eventId];
if (!entry) {
throw new Error('Entry not found');
}
(entry as OntimeEvent | OntimeDelay).parent = null;
}
}
export const rundownMutation = {
add,
edit,
remove,
removeAll,
reorder,
applyDelay,
swap,
clone,
group,
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
*/
export function init(initialRundown: Readonly<Rundown>, initialCustomFields: Readonly<CustomFields>) {
const rundown = structuredClone(initialRundown);
const customFields = structuredClone(initialCustomFields);
const processedData = processRundown(rundown, customFields);
// update the cache values
cachedRundown.id = rundown.id;
cachedRundown.title = rundown.title;
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, 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
setImmediate(async () => {
await getDataProvider().setRundown(cachedRundown.id, cachedRundown);
});
return { rundown, rundownMetadata, customFields, revision: rundown.revision };
}
export const rundownCache = {
init,
get: () => {
return {
rundown: cachedRundown,
metadata: rundownMetadata,
customFields: projectCustomFields,
};
},
};
/**
* Utility updates cache after a mutation
* Handles calculating the rundown metadata
* @private should not be called outside of `rundown.dao.ts`, exported for testing
*/
export function processRundown(
initialRundown: Readonly<Rundown>,
customFields: Readonly<CustomFields>,
): ProcessedRundownMetadata {
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
const currentEntryId = initialRundown.order[i];
const currentEntry = initialRundown.entries[currentEntryId];
if (!currentEntry) {
continue;
}
const { processedEntry } = process(currentEntry, null);
// if the event is a block, we process the nested entries
// the code here is a copy of the processing of top level events
if (isOntimeBlock(processedEntry)) {
let totalBlockDuration = 0;
let blockStartTime = null;
let blockEndTime = null;
let isFirstLinked = false;
const blockEvents: EntryId[] = [];
// check if the block contains nested entries
for (let j = 0; j < processedEntry.events.length; j++) {
const nestedEntryId = processedEntry.events[j];
const nestedEntry = initialRundown.entries[nestedEntryId];
if (!nestedEntry) {
continue;
}
blockEvents.push(nestedEntry.id);
const { processedData: processedNestedData, processedEntry: processedNestedEntry } = process(
nestedEntry,
processedEntry.id,
);
// we dont extract metadata of skipped events,
// if this is not a playable event there is nothing else to do
if (!isOntimeEvent(processedNestedEntry) || !isPlayableEvent(processedNestedEntry)) {
continue;
}
// first start is always the first event
if (blockStartTime === null) {
blockStartTime = processedNestedEntry.timeStart;
isFirstLinked = Boolean(processedNestedEntry.linkStart);
}
// lastEntry is the event with the latest end time
blockEndTime = processedNestedData.lastEnd;
totalBlockDuration += processedNestedEntry.duration;
}
// update block metadata
processedEntry.duration = totalBlockDuration;
processedEntry.startTime = blockStartTime;
processedEntry.endTime = blockEndTime;
processedEntry.isFirstLinked = isFirstLinked;
processedEntry.events = blockEvents;
}
}
return getMetadata();
}
@@ -1,365 +0,0 @@
import {
DatabaseModel,
CustomFields,
ProjectRundowns,
Rundown,
OntimeEvent,
OntimeDelay,
OntimeBlock,
isOntimeEvent,
isOntimeDelay,
isOntimeBlock,
CustomFieldKey,
EntryId,
OntimeEntry,
PlayableEvent,
RundownEntries,
isPlayableEvent,
} from 'ontime-types';
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 type { ErrorEmitter } from '../../utils/parserUtils.js';
import { calculateDayOffset, createEvent } from './rundown.utils.js';
import { RundownMetadata } from './rundown.types.js';
/**
* Parse a rundowns object along with the project custom fields
* Returns a default rundown if none exists
*/
export function parseRundowns(
data: Partial<DatabaseModel>,
parsedCustomFields: Readonly<CustomFields>,
emitError?: ErrorEmitter,
): 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 {
[defaultRundown.id]: {
...defaultRundown,
},
};
}
const parsedRundowns: ProjectRundowns = {};
const iterableRundownsIds = Object.keys(data.rundowns);
// parse all the rundowns individually
for (const id of iterableRundownsIds) {
console.log('Found rundown, importing...');
const rundown = data.rundowns[id];
const parsedRundown = parseRundown(rundown, parsedCustomFields, emitError);
parsedRundowns[parsedRundown.id] = parsedRundown;
}
return parsedRundowns;
}
/**
* Parses and validates a single project rundown along with given project custom fields
*/
export function parseRundown(
rundown: Rundown,
parsedCustomFields: Readonly<CustomFields>,
emitError?: ErrorEmitter,
): Rundown {
const parsedRundown: Rundown = {
id: rundown.id || generateId(),
title: rundown.title ?? '',
entries: {},
order: [],
flatOrder: [],
revision: rundown.revision ?? 1,
};
let eventIndex = 0;
for (let i = 0; i < rundown.order.length; i++) {
const entryId = rundown.order[i];
const event = rundown.entries[entryId];
if (!event) {
emitError?.('Could not find referenced event, skipping');
continue;
}
if (parsedRundown.order.includes(event.id)) {
emitError?.('ID collision on event import, skipping');
continue;
}
const id = entryId;
let newEvent: OntimeEvent | OntimeDelay | OntimeBlock | null;
const nestedEntryIds: string[] = [];
if (isOntimeEvent(event)) {
newEvent = createEvent(event, eventIndex);
// skip if event is invalid
if (newEvent == null) {
emitError?.('Skipping event without payload');
continue;
}
// for every field in custom, check that a key exists in customfields
for (const field in newEvent.custom) {
if (!Object.hasOwn(parsedCustomFields, field)) {
emitError?.(`Custom field ${field} not found`);
delete newEvent.custom[field];
}
}
eventIndex += 1;
} else if (isOntimeDelay(event)) {
newEvent = { ...delayDef, duration: event.duration, id };
} else if (isOntimeBlock(event)) {
for (let i = 0; i < event.events.length; i++) {
const nestedEventId = event.events[i];
const nestedEvent = rundown.entries[nestedEventId];
if (isOntimeEvent(nestedEvent)) {
const newNestedEvent = createEvent(nestedEvent, eventIndex);
// skip if event is invalid
if (newNestedEvent == null) {
emitError?.('Skipping event without payload');
continue;
}
// for every field in custom, check that a key exists in customfields
for (const field in newNestedEvent.custom) {
if (!Object.hasOwn(parsedCustomFields, field)) {
emitError?.(`Custom field ${field} not found`);
delete newNestedEvent.custom[field];
}
}
eventIndex += 1;
if (newNestedEvent) {
nestedEntryIds.push(nestedEventId);
parsedRundown.entries[nestedEventId] = newNestedEvent;
}
}
}
newEvent = {
...blockDef,
title: event.title,
note: event.note,
events: event.events?.filter((eventId) => Object.hasOwn(rundown.entries, eventId)) ?? [],
skip: event.skip,
colour: event.colour,
custom: { ...event.custom },
id,
};
} else {
emitError?.('Unknown event type, skipping');
continue;
}
if (newEvent) {
parsedRundown.entries[id] = newEvent;
parsedRundown.order.push(id);
parsedRundown.flatOrder.push(id);
parsedRundown.flatOrder.push(...nestedEntryIds);
}
}
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,168 +1,45 @@
import { ErrorResponse, MessageResponse, OntimeEntry, ProjectRundownsList, Rundown } from 'ontime-types';
import { getErrorMessage } from 'ontime-utils';
import type { Request, Response } from 'express';
import express from 'express';
import { getCurrentRundown } from './rundown.dao.js';
import {
addEntry,
applyDelay,
batchEditEntries,
cloneEntry,
deleteAllEntries,
deleteEntries,
editEntry,
groupEntries,
reorderEntry,
swapEvents,
ungroupEntries,
} from './rundown.service.js';
deletesEventById,
rundownApplyDelay,
rundownBatchPut,
rundownDelete,
rundownGetAll,
rundownGetById,
rundownGetNormalised,
rundownGetPaginated,
rundownPost,
rundownPut,
rundownReorder,
rundownSwap,
} from './rundown.controller.js';
import {
paramsMustHaveEventId,
rundownArrayOfIds,
rundownBatchPutValidator,
rundownGetPaginatedQueryParams,
rundownPostValidator,
rundownPutValidator,
rundownReorderValidator,
rundownSwapValidator,
} from './rundown.validation.js';
import { paramsWithId } from '../validation-utils/validationFunction.js';
export const router = express.Router();
/**
* Returns all rundowns in the project
*/
router.get('/', async (_req: Request, res: Response<ProjectRundownsList>) => {
const rundown = getCurrentRundown();
router.get('/', rundownGetAll); // not used in Ontime frontend
router.get('/paginated', rundownGetPaginatedQueryParams, rundownGetPaginated); // not used in Ontime frontend
router.get('/normalised', rundownGetNormalised);
router.get('/:eventId', paramsMustHaveEventId, rundownGetById); // not used in Ontime frontend
// TODO: we currently make a project with only the current rundown
res.json([{ id: rundown.id, title: rundown.title, numEntries: rundown.order.length, revision: rundown.revision }]);
});
router.post('/', rundownPostValidator, rundownPost);
/**
* Returns the current rundown
*/
router.get('/current', async (_req: Request, res: Response<Rundown>) => {
const rundown = getCurrentRundown();
res.json(rundown);
});
router.put('/', rundownPutValidator, rundownPut);
router.put('/batch', rundownBatchPutValidator, rundownBatchPut);
router.post('/', rundownPostValidator, async (req: Request, res: Response<OntimeEntry | ErrorResponse>) => {
try {
const newEvent = await addEntry(req.body);
res.status(201).send(newEvent);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
});
router.patch('/reorder/', rundownReorderValidator, rundownReorder);
router.patch('/swap', rundownSwapValidator, rundownSwap);
router.patch('/applydelay/:eventId', paramsMustHaveEventId, rundownApplyDelay);
router.put('/', rundownPutValidator, async (req: Request, res: Response<OntimeEntry | ErrorResponse>) => {
try {
const event = await editEntry(req.body);
res.status(200).send(event);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
});
router.put('/batch', rundownBatchPutValidator, async (req: Request, res: Response<Rundown | ErrorResponse>) => {
try {
const rundown = await batchEditEntries(req.body.ids, req.body.data);
res.status(200).send(rundown);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
});
router.patch('/reorder', rundownReorderValidator, async (req: Request, res: Response<Rundown | ErrorResponse>) => {
try {
const { entryId, destinationId, order } = req.body;
const newRundown = await reorderEntry(entryId, destinationId, order);
res.status(200).send(newRundown);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
});
router.patch('/swap', rundownSwapValidator, async (req: Request, res: Response<Rundown | ErrorResponse>) => {
try {
const rundown = await swapEvents(req.body.from, req.body.to);
res.status(200).send(rundown);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
});
router.patch(
'/applydelay/:id',
paramsWithId,
async (req: Request, res: Response<Rundown | ErrorResponse>) => {
try {
const newRundown = await applyDelay(req.params.id);
res.status(200).send(newRundown);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
},
);
router.post('/clone/:id', paramsWithId, async (req: Request, res: Response<Rundown | ErrorResponse>) => {
try {
const newRundown = await cloneEntry(req.params.id);
res.status(200).send(newRundown);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
});
router.post('/group', rundownArrayOfIds, async (req: Request, res: Response<Rundown | ErrorResponse>) => {
try {
const newRundown = await groupEntries(req.body.ids);
res.status(200).send(newRundown);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
});
router.post(
'/ungroup/:id',
paramsWithId,
async (req: Request, res: Response<Rundown | ErrorResponse>) => {
try {
const newRundown = await ungroupEntries(req.params.id);
res.status(200).send(newRundown);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
},
);
router.delete('/', rundownArrayOfIds, async (req: Request, res: Response<MessageResponse | ErrorResponse>) => {
try {
await deleteEntries(req.body.ids);
res.status(204).send({ message: 'Events deleted' });
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
});
router.delete('/all', async (_req: Request, res: Response<Rundown | ErrorResponse>) => {
try {
const rundown = await deleteAllEntries();
res.status(204).send(rundown);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
});
router.delete('/', rundownArrayOfIds, deletesEventById);
router.delete('/all', rundownDelete);
@@ -1,578 +0,0 @@
import {
CustomField,
CustomFieldKey,
CustomFields,
EntryId,
EventPostPayload,
isOntimeBlock,
isOntimeDelay,
isOntimeEvent,
OntimeEntry,
OntimeEvent,
PatchWithId,
Rundown,
} from 'ontime-types';
import { customFieldLabelToKey } from 'ontime-utils';
import { updateRundownData } from '../../stores/runtimeState.js';
import { sendRefetch } from '../../adapters/websocketAux.js';
import { runtimeService } from '../../services/runtime-service/RuntimeService.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({ 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)) {
throw new Error(`Event with ID ${eventData.id} already exists`);
}
// if the user provides a parent (inside a group), we make sure it exists and it is a group
let parent: EntryId | null = null;
if ('parent' in eventData && eventData.parent != null) {
const maybeParent = rundown.entries[eventData.parent];
if (!maybeParent || !isOntimeBlock(maybeParent)) {
throw new Error(`Invalid parent event with ID ${eventData.parent}`);
}
parent = eventData.parent;
}
// normalise the position of the event in the rundown order
const afterId = getInsertAfterId(rundown, eventData?.after, eventData?.before);
// generate a fully formed entry from the patch
const newEntry = generateEvent(rundown, eventData, afterId);
// make mutations to rundown
rundownMutation.add(rundown, newEntry, afterId, parent);
const { rundownMetadata, revision } = commit();
// schedule the side effects
setImmediate(() => {
// notify runtime that rundown has changed
updateRuntimeOnChange(rundownMetadata);
// notify timer and external services of change
notifyChanges(rundownMetadata, revision, { timer: [newEntry.id], external: true });
});
return newEntry;
}
/**
* Applies a patch to an entry in the rundown
*/
export async function editEntry(patch: PatchWithId): Promise<OntimeEntry> {
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
const currentEntry = rundown.entries[patch.id];
/**
* We validate the patch before applying it
* - disallow edit an entry that does not exist
* - disallow setting the cue to empty string
* - disallow change the type of an entry
*/
// could the entry have been deleted?
if (!currentEntry) {
throw new Error('Entry not found');
}
// we dont allow the user to change the cue to empty string
if ((patch as Partial<OntimeEvent>)?.cue === '') {
throw new Error('Cue value invalid');
}
// we cannot allow patching to a different type
if (patch?.type && currentEntry.type !== patch.type) {
throw new Error('Invalid event type');
}
// if nothing changed, nothing to do
if (!hasChanges(currentEntry, patch)) {
return currentEntry;
}
const { entry, didInvalidate } = rundownMutation.edit(rundown, patch);
const { rundownMetadata, revision } = commit(didInvalidate);
// schedule the side effects
setImmediate(() => {
// notify runtime that rundown has changed
updateRuntimeOnChange(rundownMetadata);
// notify timer and external services of change
notifyChanges(rundownMetadata, revision, { timer: didInvalidate ? true : [entry.id], external: true });
});
return entry;
}
/**
* Applies a patch to several entries in the rundown
*/
export async function batchEditEntries(ids: EntryId[], patch: Partial<OntimeEntry>): Promise<Rundown> {
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
/**
* We can do some validation globally, but mostly we will validate each entry individually
* - disallow setting the cue to empty string
*/
if ('cue' in patch && patch.cue === '') {
throw new Error('Cue value invalid');
}
let batchDidInvalidate = false;
const changedIds: EntryId[] = [];
const patchedEntries: OntimeEntry[] = [];
for (let i = 0; i < ids.length; i++) {
const currentId = ids[i];
const currentEntry = rundown.entries[currentId];
/**
* Most of the validation needs to be done in regard to the change
* - cannot edit an entry that does not exist
* - disallow change the type of an entry
* - disallow change the ID of an entry
*/
// could the entry have been deleted?
if (!currentEntry) {
continue;
}
// we cannot allow patching to a different type
if (patch?.type && currentEntry.type !== patch.type) {
throw new Error('Invalid event type');
}
// if nothing changed, nothing to do
if (!hasChanges(currentEntry, patch)) {
continue;
}
const { entry, didInvalidate } = rundownMutation.edit(rundown, { ...patch, id: currentId });
changedIds.push(currentId);
patchedEntries.push(entry);
if (didInvalidate) {
batchDidInvalidate = true;
}
}
const { rundown: rundownResult, rundownMetadata, revision } = commit(batchDidInvalidate);
// schedule the side effects
setImmediate(() => {
// notify runtime that rundown has changed
updateRuntimeOnChange(rundownMetadata);
// notify timer and external services of change
notifyChanges(rundownMetadata, revision, { timer: batchDidInvalidate ? true : changedIds, external: true });
});
return rundownResult;
}
/**
* Deletes a known entry from the current rundown
*/
export async function deleteEntries(entryIds: EntryId[]): Promise<Rundown> {
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
for (let i = 0; i < entryIds.length; i++) {
const entry = rundown.entries[entryIds[i]];
if (!entry) {
continue;
}
rundownMutation.remove(rundown, entry);
}
const { rundown: rundownResult, rundownMetadata, revision } = commit();
// schedule the side effects
setImmediate(() => {
// notify runtime that rundown has changed
updateRuntimeOnChange(rundownMetadata);
// notify timer and external services of change
notifyChanges(rundownMetadata, revision, { timer: entryIds, external: true });
});
return rundownResult;
}
/**
* Deletes all entries from the current rundown
*/
export async function deleteAllEntries(): Promise<Rundown> {
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
rundownMutation.removeAll(rundown);
const { rundown: rundownResult, rundownMetadata, revision } = commit();
// schedule the side effects
setImmediate(() => {
// notify runtime that rundown has changed
updateRuntimeOnChange(rundownMetadata);
// notify timer and external services of change
notifyChanges(rundownMetadata, revision, { timer: true, external: true });
});
return rundownResult;
}
/**
* Moves an event to a new position in the rundown
* Handles moving across root orders (a block order and top level order)
* @throws if entryId or destinationId not found
*/
export function reorderEntry(entryId: EntryId, destinationId: EntryId, order: 'before' | 'after' | 'insert') {
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
// check that both entries exist
const eventFrom = rundown.entries[entryId];
const eventTo = rundown.entries[destinationId];
if (!eventFrom || !eventTo) {
throw new Error('Event not found');
}
rundownMutation.reorder(rundown, eventFrom, eventTo, order);
const { rundown: rundownResult, rundownMetadata, revision } = commit();
// schedule the side effects
setImmediate(() => {
// notify runtime that rundown has changed
updateRuntimeOnChange(rundownMetadata);
// notify timer and external services of change
notifyChanges(rundownMetadata, revision, { timer: true, external: true });
});
return rundownResult;
}
/**
* Applies a delay into the rundown effectively changing the schedule
* The applied delay is deleted
*/
export async function applyDelay(delayId: EntryId): Promise<Rundown> {
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
// check that delay exists
const delay = rundown.entries[delayId];
if (!delay || !isOntimeDelay(delay)) {
throw new Error('Given delay ID not found');
}
// apply the delay and delete the it
rundownMutation.applyDelay(rundown, delay);
rundownMutation.remove(rundown, delay);
const { rundown: rundownResult, rundownMetadata, revision } = commit();
// schedule the side effects
setImmediate(() => {
// notify runtime that rundown has changed
updateRuntimeOnChange(rundownMetadata);
// notify timer and external services of change
notifyChanges(rundownMetadata, revision, { timer: true, external: true });
});
return rundownResult;
}
/**
* Swaps the data between two events in the rundown
*/
export async function swapEvents(fromId: EntryId, toId: EntryId): Promise<Rundown> {
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
const eventFrom = rundown.entries[fromId];
const eventTo = rundown.entries[toId];
// check that both entries exist
if (!eventFrom || !eventTo) {
throw new Error('Event not found');
}
// we can only swap events
if (!isOntimeEvent(eventFrom) || !isOntimeEvent(eventTo)) {
throw new Error('Both entries must be events');
}
rundownMutation.swap(rundown, eventFrom, eventTo);
const { rundown: rundownResult, rundownMetadata, revision } = commit();
// schedule the side effects
setImmediate(() => {
// notify runtime that rundown has changed
updateRuntimeOnChange(rundownMetadata);
// notify timer and external services of change
notifyChanges(rundownMetadata, revision, { timer: true, external: true });
});
return rundownResult;
}
/**
* Clones an entry, ensuring that all dependencies are preserved
* @throws if the entry to clone does not exist
*/
export async function cloneEntry(entryId: EntryId): Promise<Rundown> {
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
const originalEntry = rundown.entries[entryId];
if (!originalEntry) {
throw new Error('Did not find event to clone');
}
const newEntry = rundownMutation.clone(rundown, originalEntry);
const { rundown: rundownResult, rundownMetadata, revision } = commit();
// schedule the side effects
setImmediate(() => {
// notify runtime that rundown has changed
updateRuntimeOnChange(rundownMetadata);
// notify timer and external services of change
if (isOntimeBlock(newEntry)) {
notifyChanges(rundownMetadata, revision, { timer: newEntry.events, external: true });
} else if (isOntimeEvent(newEntry)) {
notifyChanges(rundownMetadata, revision, { timer: [newEntry.id], external: true });
} else if (isOntimeDelay(newEntry)) {
notifyChanges(rundownMetadata, revision, { external: true });
}
notifyChanges(rundownMetadata, revision, { timer: true, external: true });
});
return rundownResult;
}
/**
* Groups a list of entries into a new block
*/
export async function groupEntries(entryIds: EntryId[]): Promise<Rundown> {
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
rundownMutation.group(rundown, entryIds);
const { rundown: rundownResult, rundownMetadata, revision } = commit();
// schedule the side effects
setImmediate(() => {
// notify runtime that rundown has changed
updateRuntimeOnChange(rundownMetadata);
// we dont need to notify the timer since the grouping does not affect the runtime
notifyChanges(rundownMetadata, revision, { external: true });
});
return rundownResult;
}
/**
* Deletes a block and moves all its children to the top level
*/
export async function ungroupEntries(blockId: EntryId): Promise<Rundown> {
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
const block = rundown.entries[blockId];
if (!block || !isOntimeBlock(block)) {
throw new Error(`Block with ID ${blockId} not found or is not a block`);
}
rundownMutation.ungroup(rundown, block);
const { rundown: rundownResult, rundownMetadata, revision } = commit();
// schedule the side effects
setImmediate(() => {
// notify runtime that rundown has changed
updateRuntimeOnChange(rundownMetadata);
// we dont need to notify the timer since the grouping does not affect the runtime
notifyChanges(rundownMetadata, revision, { external: true });
});
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
*
* @private - exported for testing
*/
export function updateRuntimeOnChange(rundownMetadata: RundownMetadata) {
// we only declare the amount of playable events
const numEvents = rundownMetadata.timedEventOrder.length;
// schedule an update for the end of the event loop
updateRundownData({
numEvents,
...rundownMetadata,
});
}
type NotifyChangesOptions = {
timer?: boolean | string[]; // whether to notify the timer, could be a yes / no or an array of affected IDs
external?: boolean; // whether to notify external services
reload?: boolean; // major change, clients should consider refetching everything
};
/**
* Notify services of changes in the rundown
*
* @private - exported for testing
*/
export function notifyChanges(rundownMetadata: RundownMetadata, revision: number, options: NotifyChangesOptions) {
// notify timer service of changed events
if (options.timer) {
// all events were deleted
if (rundownMetadata.playableEventOrder.length === 0) {
runtimeService.stop();
} else {
/**
* Timer can be
* - true: all events changed
* - an array of changed IDs
* - undefined: filtered above, no notification intended
*/
// timer can be true or an array of changed IDs
const affected = Array.isArray(options.timer) ? options.timer : undefined;
runtimeService.notifyOfChangedEvents(affected);
}
}
// notify external services of changes
if (options.external) {
const payload = {
target: 'RUNDOWN',
reload: options.reload,
revision,
};
sendRefetch(payload);
}
}
/**
* Sets a new rundown in the cache
* and marks it as the currently loaded one
*/
export async function initRundown(rundown: Readonly<Rundown>, customFields: Readonly<CustomFields>) {
const { rundownMetadata, revision } = rundownCache.init(rundown, customFields);
// notify runtime that rundown has changed
updateRuntimeOnChange(rundownMetadata);
// notify timer of change
notifyChanges(rundownMetadata, revision, { timer: true, external: true, reload: true });
}
@@ -1,18 +0,0 @@
import { CustomFieldKey, EntryId, MaybeNumber } from 'ontime-types';
export type RundownMetadata = {
totalDelay: number;
totalDuration: number;
totalDays: number;
firstStart: MaybeNumber;
lastEnd: MaybeNumber;
playableEventOrder: EntryId[]; // flat order of playable events
timedEventOrder: EntryId[]; // flat order of timed events
flatEntryOrder: EntryId[]; // flat order of entries
};
export type AssignedMap = Record<CustomFieldKey, EntryId[]>;
export type CustomFieldsMetadata = {
assigned: AssignedMap;
};
@@ -1,358 +0,0 @@
import {
EntryId,
isOntimeBlock,
isOntimeDelay,
isOntimeEvent,
OntimeBaseEvent,
OntimeBlock,
OntimeDelay,
OntimeEntry,
OntimeEvent,
Rundown,
SupportedEntry,
TimeStrategy,
} from 'ontime-types';
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';
type CompleteEntry<T> =
T extends Partial<OntimeEvent>
? OntimeEvent
: T extends Partial<OntimeDelay>
? OntimeDelay
: T extends Partial<OntimeBlock>
? OntimeBlock
: never;
/**
* Generates a fully formed RundownEntry of the patch type
*/
export function generateEvent<T extends Partial<OntimeEvent> | Partial<OntimeDelay> | Partial<OntimeBlock>>(
rundown: Rundown,
eventData: T,
afterId: EntryId | null,
): CompleteEntry<T> {
if (isOntimeEvent(eventData)) {
return createEvent(eventData, getCueCandidate(rundown.entries, rundown.order, afterId)) as CompleteEntry<T>;
}
const id = eventData.id || getUniqueId(rundown);
if (isOntimeDelay(eventData)) {
return { ...delayDef, duration: eventData.duration ?? 0, id } as CompleteEntry<T>;
}
// TODO(v4): allow user to provide a larger patch of the block entry
if (isOntimeBlock(eventData)) {
return createBlock({ id, title: eventData.title ?? '' }) as CompleteEntry<T>;
}
throw new Error('Invalid event type');
}
export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial<OntimeEvent>): OntimeEvent {
if (Object.keys(patchEvent).length === 0) {
return originalEvent;
}
const { timeStart, timeEnd, duration, timeStrategy } = validateTimes(
patchEvent?.timeStart ?? originalEvent.timeStart,
patchEvent?.timeEnd ?? originalEvent.timeEnd,
patchEvent?.duration ?? originalEvent.duration,
patchEvent?.timeStrategy ?? inferStrategy(patchEvent?.timeEnd, patchEvent?.duration, originalEvent.timeStrategy),
);
return {
id: originalEvent.id,
type: SupportedEntry.Event,
title: makeString(patchEvent.title, originalEvent.title),
timeStart,
timeEnd,
duration,
timeStrategy,
linkStart: typeof patchEvent.linkStart === 'boolean' ? patchEvent.linkStart : originalEvent.linkStart,
endAction: validateEndAction(patchEvent.endAction, originalEvent.endAction),
timerType: validateTimerType(patchEvent.timerType, originalEvent.timerType),
countToEnd: typeof patchEvent.countToEnd === 'boolean' ? patchEvent.countToEnd : originalEvent.countToEnd,
skip: typeof patchEvent.skip === 'boolean' ? patchEvent.skip : originalEvent.skip,
note: makeString(patchEvent.note, originalEvent.note),
colour: makeString(patchEvent.colour, originalEvent.colour),
delay: originalEvent.delay, // is regenerated if timer related data is changed
dayOffset: originalEvent.dayOffset, // is regenerated if timer related data is changed
gap: originalEvent.gap, // is regenerated if timer related data is changed
// short circuit empty string
cue: makeString(patchEvent.cue ?? null, originalEvent.cue),
parent: originalEvent.parent,
revision: originalEvent.revision,
timeWarning: patchEvent.timeWarning ?? originalEvent.timeWarning,
timeDanger: patchEvent.timeDanger ?? originalEvent.timeDanger,
custom: { ...originalEvent.custom, ...patchEvent.custom },
triggers: patchEvent.triggers ?? originalEvent.triggers,
};
}
/**
* Utility function for patching an existing event with new data
* Increments the revision of the event when applying the patch
*/
export function applyPatchToEntry<T extends OntimeEntry>(eventFromRundown: T, patch: Partial<T>): T {
if (isOntimeEvent(eventFromRundown)) {
const newEvent = createPatch(eventFromRundown, patch as Partial<OntimeEvent>);
newEvent.revision++;
return newEvent as T;
}
if (isOntimeBlock(eventFromRundown)) {
const newBlock: OntimeBlock = { ...eventFromRundown, ...patch };
newBlock.revision++;
return newBlock as T;
}
// only delay is left
return { ...eventFromRundown, ...patch } as T;
}
/**
* @description Enforces formatting for events
* @param {object} eventArgs - attributes of event
* @param {number} eventIndex - can be a string when we pass the a suggested cue name
* @returns {object|null} - formatted object or null in case is invalid
*/
export const createEvent = (eventArgs: Partial<OntimeEvent>, eventIndex: number | string): OntimeEvent | null => {
if (Object.keys(eventArgs).length === 0) {
return null;
}
const cue = typeof eventIndex === 'number' ? String(eventIndex + 1) : eventIndex;
const baseEvent = {
id: eventArgs?.id ?? generateId(),
cue,
...eventDef,
};
const event = createPatch(baseEvent, eventArgs);
return event;
};
/**
* Creates a new block from an optional patch
*/
export function createBlock(patch?: Partial<OntimeBlock>): OntimeBlock {
if (!patch) {
return { ...blockDef, id: generateId() };
}
return {
id: patch.id ?? generateId(),
type: SupportedEntry.Block,
title: patch.title ?? '',
note: patch.note ?? '',
events: patch.events ?? [],
skip: patch.skip ?? false,
colour: makeString(patch.colour, ''),
custom: patch.custom ?? {},
revision: 0,
startTime: null,
endTime: null,
duration: 0,
isFirstLinked: false,
};
}
/**
* Function infers strategy for a patch with only partial timer data
* @param end
* @param duration
* @param fallback
* @returns
*/
function inferStrategy(end: unknown, duration: unknown, fallback: TimeStrategy): TimeStrategy {
if (end && !duration) {
return TimeStrategy.LockEnd;
}
if (!end && duration) {
return TimeStrategy.LockDuration;
}
return fallback;
}
/**
* Whether a given ID is exists in the current rundown
*/
export function hasId(rundown: Rundown, id: EntryId): boolean {
return Object.hasOwn(rundown.entries, id);
}
/**
* Returns an ID guaranteed to be unique
*/
export function getUniqueId(rundown: Rundown): EntryId {
let id: EntryId;
do {
id = generateId();
} while (rundown.entries[id]);
return id;
}
/** List of event properties which do not need the rundown to be regenerated */
enum RegenerateWhitelist {
'id', // adding it for completeness, users cannot change ID
'type', // adding it for completeness, users cannot change ID
'cue',
'title',
'note',
'endAction',
'timerType',
'countToEnd',
'colour',
'timeWarning',
'timeDanger',
'custom',
'triggers',
}
/**
* given a patch, returns whether it invalidates the rundown metadata
*/
export function doesInvalidateMetadata(patch: Partial<OntimeEntry>): boolean {
return Object.keys(patch).some(willCauseRegeneration);
}
/**
* given a key, returns whether it is whitelisted
*/
export function willCauseRegeneration(key: string): boolean {
return !(key in RegenerateWhitelist);
}
/**
* Given an event and a patch to that event checks whether there are actual changes to the dataset
* @param existingEvent
* @param newEvent
* @returns
*/
export function hasChanges<T extends OntimeBaseEvent>(existingEvent: T, newEvent: Partial<T>): boolean {
return Object.keys(newEvent).some(
(key) => !Object.hasOwn(existingEvent, key) || existingEvent[key as keyof T] !== newEvent[key as keyof T],
);
}
/**
* Deletes the first instance of string from an array of strings
* Used for cases when we want to delete an ID from an array
*
* We keep this just for backend because the use of `toSpliced` does not have enough browser support
*/
export function deleteById(array: EntryId[], deleteId: EntryId): EntryId[] {
const deleteIndex = array.findIndex((id) => id === deleteId);
if (deleteIndex === -1) {
return array;
}
return array.toSpliced(deleteIndex, 1);
}
/**
* Gathers business logic for how to clone an OntimeEvent
*/
export function cloneEvent(entry: OntimeEvent, newId: EntryId): OntimeEvent {
const newEntry = structuredClone(entry);
newEntry.id = newId;
newEntry.revision = 0;
return newEntry;
}
/**
* Gathers business logic for how to clone an OntimeDelay
*/
export function cloneDelay(entry: OntimeDelay, newId: EntryId): OntimeDelay {
const newEntry = structuredClone(entry);
newEntry.id = newId;
return newEntry;
}
/**
* Gathers business logic for how to clone an OntimeBlock
*/
export function cloneBlock(entry: OntimeBlock, newId: EntryId): OntimeBlock {
const newEntry = structuredClone(entry);
newEntry.id = newId;
// in blocks, we need to remove the events references
newEntry.events = [];
newEntry.revision = 0;
return newEntry;
}
/**
* Receives an entry and chooses the correct cloning strategy
*/
export function cloneEntry<T extends OntimeEntry>(entry: T, newId: EntryId): T {
if (isOntimeEvent(entry)) {
return cloneEvent(entry, newId) as T;
} else if (isOntimeDelay(entry)) {
return cloneDelay(entry, newId) as T;
} else if (entry.type === 'block') {
return cloneBlock(entry as OntimeBlock, newId) as 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;
}
@@ -1,44 +1,90 @@
import { body, param } from 'express-validator';
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
import { body, param, query, validationResult } from 'express-validator';
import type { Request, Response, NextFunction } from 'express';
export const rundownPostValidator = [
body('type').isString().isIn(['event', 'delay', 'block']),
body('type').isString().exists().isIn(['event', 'delay', 'block']),
body('after').optional().isString(),
body('before').optional().isString(),
requestValidationFunction,
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
export const rundownPutValidator = [body('id').isString().notEmpty(), requestValidationFunction];
export const rundownPutValidator = [
body('id').isString().exists(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
export const rundownBatchPutValidator = [
body('data').isObject(),
body('ids').isArray().notEmpty(),
body('ids.*').isString(),
body('data').isObject().exists(),
body('ids').isArray().exists(),
requestValidationFunction,
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
export const rundownReorderValidator = [
body('entryId').isString().notEmpty(),
body('destinationId').isString().notEmpty(),
body('order').isIn(['before', 'after', 'insert']),
body('eventId').isString().exists(),
body('from').isNumeric().exists(),
body('to').isNumeric().exists(),
requestValidationFunction,
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
export const rundownSwapValidator = [
body('from').isString().notEmpty(),
body('to').isString().notEmpty(),
body('from').isString().exists(),
body('to').isString().exists(),
requestValidationFunction,
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
export const paramsMustHaveEntryId = [param('entryId').isString().notEmpty(), requestValidationFunction];
export const paramsMustHaveEventId = [
param('eventId').exists(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
export const rundownArrayOfIds = [
body('ids').isArray().notEmpty(),
body('ids').isArray().exists(),
body('ids.*').isString(),
requestValidationFunction,
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
export const rundownGetPaginatedQueryParams = [
query('offset').isNumeric().optional(),
query('limit').isNumeric().optional(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
@@ -0,0 +1,41 @@
import { getErrorMessage } from 'ontime-utils';
import { ErrorResponse, GetInfo, GetUrl, SessionStats } from 'ontime-types';
import type { Request, Response } from 'express';
import * as sessionService from './session.service.js';
export async function getSessionStats(_req: Request, res: Response<SessionStats | ErrorResponse>) {
try {
const stats = await sessionService.getSessionStats();
res.status(200).send(stats);
} catch (error) {
const message = getErrorMessage(error);
res.status(500).send({ message });
}
}
export async function getInfo(_req: Request, res: Response<GetInfo | ErrorResponse>) {
try {
const info = await sessionService.getInfo();
res.status(200).send(info);
} catch (error) {
const message = getErrorMessage(error);
res.status(500).send({ message });
}
}
export async function generateUrl(req: Request, res: Response<GetUrl | ErrorResponse>) {
try {
const url = sessionService.generateAuthenticatedUrl(
req.body.baseUrl,
req.body.path,
req.body.lock,
req.body.authenticate,
);
res.status(200).send({ url: url.toString() });
} catch (error) {
const message = getErrorMessage(error);
res.status(500).send({ message });
}
}
@@ -1,43 +1,10 @@
import express from 'express';
import type { Request, Response } from 'express';
import { getErrorMessage } from 'ontime-utils';
import type { ErrorResponse, GetInfo, GetUrl, SessionStats } from 'ontime-types';
import { getInfo, getSessionStats, generateUrl } from './session.controller.js';
import { validateGenerateUrl } from './session.validation.js';
import * as sessionService from './session.service.js';
export const router = express.Router();
router.get('/', async (_req: Request, res: Response<SessionStats | ErrorResponse>) => {
try {
const stats = await sessionService.getSessionStats();
res.status(200).send(stats);
} catch (error) {
const message = getErrorMessage(error);
res.status(500).send({ message });
}
});
router.get('/info', async (_req: Request, res: Response<GetInfo | ErrorResponse>) => {
try {
const info = await sessionService.getInfo();
res.status(200).send(info);
} catch (error) {
const message = getErrorMessage(error);
res.status(500).send({ message });
}
});
router.post('/url', validateGenerateUrl, (req: Request, res: Response<GetUrl | ErrorResponse>) => {
try {
const url = sessionService.generateAuthenticatedUrl(
req.body.baseUrl,
req.body.path,
req.body.lock,
req.body.authenticate,
);
res.status(200).send({ url: url.toString() });
} catch (error) {
const message = getErrorMessage(error);
res.status(500).send({ message });
}
});
router.get('/', getSessionStats);
router.get('/info', getInfo);
router.post('/url', validateGenerateUrl, generateUrl);
@@ -4,13 +4,12 @@ import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import { publicDir } from '../../setup/index.js';
import { socket } from '../../adapters/WebsocketAdapter.js';
import { getLastRequest } from '../../api-integration/integration.controller.js';
import { getCurrentProject } from '../../services/project-service/ProjectService.js';
import { getLastLoadedProject } from '../../services/app-state-service/AppStateService.js';
import { runtimeService } from '../../services/runtime-service/RuntimeService.js';
import { getNetworkInterfaces } from '../../utils/network.js';
import { getTimezoneLabel } from '../../utils/time.js';
import { password, routerPrefix } from '../../externals.js';
import { hashPassword } from '../../utils/hash.js';
import { ONTIME_VERSION } from '../../ONTIME_VERSION.js';
const startedAt = new Date();
@@ -18,7 +17,7 @@ const startedAt = new Date();
export async function getSessionStats(): Promise<SessionStats> {
const { connectedClients, lastConnection } = socket.getStats();
const lastRequest = getLastRequest();
const { filename } = await getCurrentProject();
const projectName = await getLastLoadedProject();
const { playback } = runtimeService.getRuntimeState();
return {
@@ -26,10 +25,9 @@ export async function getSessionStats(): Promise<SessionStats> {
connectedClients,
lastConnection: lastConnection !== null ? lastConnection.toISOString() : null,
lastRequest: lastRequest !== null ? lastRequest.toISOString() : null,
projectName: filename,
projectName,
playback,
timezone: getTimezoneLabel(startedAt),
version: ONTIME_VERSION,
};
}
@@ -1,12 +1,15 @@
import { body } from 'express-validator';
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
import type { Request, Response, NextFunction } from 'express';
import { body, validationResult } from 'express-validator';
export const validateGenerateUrl = [
body('baseUrl').isString().trim().notEmpty(),
body('path').isString().trim().notEmpty(),
body('lock').isBoolean(),
body('authenticate').isBoolean(),
body('baseUrl').exists().isString().notEmpty().trim(),
body('path').exists().isString().trim(),
body('lock').exists().isBoolean(),
body('authenticate').exists().isBoolean(),
requestValidationFunction,
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
@@ -1,22 +0,0 @@
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',
});
});
});
@@ -4,6 +4,7 @@ import { getErrorMessage, obfuscate } from 'ontime-utils';
import type { Request, Response } from 'express';
import { isDocker } from '../../setup/environment.js';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import * as appState from '../../services/app-state-service/AppStateService.js';
@@ -24,6 +25,9 @@ export async function getSettings(_req: Request, res: Response<Settings>) {
}
export async function postSettings(req: Request, res: Response<Settings | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const settings = getDataProvider().getSettings();
const editorKey = extractPin(req.body?.editorKey, settings.editorKey);
@@ -31,15 +35,13 @@ export async function postSettings(req: Request, res: Response<Settings | ErrorR
const serverPort = Number(req.body?.serverPort);
//TODO: should this not be part of the validator?
if (isNaN(serverPort)) {
res.status(400).send({ message: `Invalid value found for server port: ${req.body?.serverPort}` });
return;
return res.status(400).send({ message: `Invalid value found for server port: ${req.body?.serverPort}` });
}
const hasChangedPort = settings.serverPort !== serverPort;
if (isDocker && hasChangedPort) {
res.status(403).json({ message: 'Can`t change port when running inside docker' });
return;
return res.status(403).json({ message: 'Can`t change port when running inside docker' });
}
let timeFormat = settings.timeFormat;
@@ -1,25 +0,0 @@
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',
};
}
@@ -1,21 +1,31 @@
import { body } from 'express-validator';
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
import { body, validationResult } from 'express-validator';
import type { Request, Response, NextFunction } from 'express';
/**
* @description Validates object for POST /ontime/settings/welcomedialog
*/
export const validateWelcomeDialog = [body('show').isBoolean(), requestValidationFunction];
export const validateWelcomeDialog = [
body('show').exists().isBoolean(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
/**
* @description Validates object for POST /ontime/settings
*/
export const validateSettings = [
body().notEmpty().withMessage('No object found in request'),
body('editorKey').isString().isLength({ min: 0, max: 4 }).optional({ nullable: true }),
body('operatorKey').isString().isLength({ min: 0, max: 4 }).optional({ nullable: true }),
body('timeFormat').isString().isIn(['12', '24']),
body('language').isString(),
body('serverPort').isPort().optional(),
requestValidationFunction,
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
@@ -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, OntimeRundown } from 'ontime-types';
import { deleteFile } from '../../utils/parserUtils.js';
import {
revoke,
handleClientSecret,
@@ -18,18 +18,17 @@ import {
upload,
getWorksheetOptions,
} from '../../services/sheet-service/SheetService.js';
import { deleteFile } from '../../utils/fileManagement.js';
import { getErrorMessage } from 'ontime-utils';
export async function requestConnection(
req: Request,
res: Response<{ verification_url: string; user_code: string } | ErrorResponse>,
) {
const { sheetId } = req.params;
// the check for the file is done in the validation middleware
const filePath = (req.file as Express.Multer.File).path;
const file = req.file.path;
try {
const client = readFileSync(filePath, 'utf-8');
const client = readFileSync(file, 'utf-8');
const clientSecret = handleClientSecret(client);
const { verification_url, user_code } = await handleInitialConnection(clientSecret, sheetId);
@@ -40,7 +39,11 @@ export async function requestConnection(
}
// delete uploaded file after parsing
await deleteFile(filePath);
try {
deleteFile(file);
} catch (_error) {
/** we dont handle failure here */
}
}
export async function verifyAuthentication(
@@ -84,7 +87,7 @@ export async function readFromSheet(
req: Request,
res: Response<
| {
rundown: Rundown;
rundown: OntimeRundown;
customFields: CustomFields;
}
| ErrorResponse
@@ -1,10 +1,11 @@
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('application/json')) {
if (file.mimetype.includes(JSON_MIME)) {
cb(null, true);
} else {
cb(null, false);
@@ -2,10 +2,10 @@ import { isImportMap } from 'ontime-utils';
import { body, param, validationResult } from 'express-validator';
import { NextFunction, Request, Response } from 'express';
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
export const validateRequestConnection = [
param('sheetId')
.exists()
.isString()
.isLength({
min: 20,
@@ -15,29 +15,34 @@ export const validateRequestConnection = [
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
res.status(422).json({ errors: errors.array() });
return;
}
// check that the file exists
if (!req.file) {
res.status(422).json({ errors: 'File not found' });
return;
}
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
export const validateSheetId = [param('sheetId').isString().trim().notEmpty(), requestValidationFunction];
export const validateSheetId = [
param('sheetId').exists().isString(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
export const validateSheetOptions = [
param('sheetId').isString().trim().notEmpty(),
param('sheetId').exists().isString(),
body('options')
.exists()
.isObject()
.custom((content) => {
const isValid = isImportMap(content);
return isValid;
}),
requestValidationFunction,
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
@@ -1,47 +0,0 @@
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 type { ErrorResponse, URLPreset } from 'ontime-types';
import { getErrorMessage } from 'ontime-utils';
import type { Request, Response } from 'express';
import { failIsNotArray } from '../../utils/routerUtils.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
export async function getUrlPresets(_req: Request, res: Response<URLPreset[]>) {
const presets = getDataProvider().getUrlPresets();
res.status(200).send(presets as URLPreset[]);
}
export async function postUrlPresets(req: Request, res: Response<URLPreset[] | ErrorResponse>) {
if (failIsNotArray(req.body, res)) {
return;
}
try {
const newPresets: URLPreset[] = req.body.map((preset) => ({
enabled: preset.enabled,
alias: preset.alias,
pathAndParams: preset.pathAndParams,
}));
await getDataProvider().setUrlPresets(newPresets);
res.status(200).send(newPresets);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
@@ -1,30 +0,0 @@
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;
}
@@ -1,28 +1,8 @@
import express from 'express';
import type { Request, Response } from 'express';
import type { ErrorResponse, URLPreset } from 'ontime-types';
import { getErrorMessage } from 'ontime-utils';
import { getUrlPresets, postUrlPresets } from './urlPresets.controller.js';
import { validateUrlPresets } from './urlPresets.validation.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
export const router = express.Router();
router.get('/', (_req: Request, res: Response<URLPreset[]>) => {
const presets = getDataProvider().getUrlPresets();
res.status(200).send(presets as URLPreset[]);
});
router.post('/', validateUrlPresets, async (req: Request, res: Response<URLPreset[] | ErrorResponse>) => {
try {
const newPresets: URLPreset[] = req.body.map((preset: URLPreset) => ({
enabled: preset.enabled,
alias: preset.alias,
pathAndParams: preset.pathAndParams,
}));
await getDataProvider().setUrlPresets(newPresets);
res.status(200).send(newPresets);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
});
router.get('/', getUrlPresets);
router.post('/', validateUrlPresets, postUrlPresets);
@@ -1,14 +1,18 @@
import { body } from 'express-validator';
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
import { body, validationResult } from 'express-validator';
import { Request, Response, NextFunction } from 'express';
/**
* validate array of URL preset objects
*/
export const validateUrlPresets = [
body().isArray().withMessage('No array found in request'),
body().isArray(),
body('*.enabled').isBoolean(),
body('*.alias').isString().trim().notEmpty(),
body('*.pathAndParams').isString().trim().notEmpty(),
body('*.alias').isString().trim(),
body('*.pathAndParams').isString().trim(),
requestValidationFunction,
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
@@ -1,34 +0,0 @@
import type { Request, Response, NextFunction } from 'express';
import { param, validationResult } from 'express-validator';
/**
* Runs validation and any error are sent with status 422
*/
export function requestValidationFunction(req: Request, res: Response, next: NextFunction) {
const errors = validationResult(req);
if (!errors.isEmpty()) {
res.status(422).json({ errors: errors.array() });
return;
}
next();
}
/**
* Runs validation and any error are sent with status 422
* Also checks for the presses of a `file` in the body
*/
export function requestValidationFunctionWithFile(req: Request, res: Response, next: NextFunction) {
const errors = validationResult(req);
if (!errors.isEmpty()) {
res.status(422).json({ errors: errors.array() });
return;
}
// check that the file exists
if (!req.file) {
res.status(422).json({ errors: 'File not found' });
return;
}
next();
}
export const paramsWithId = [param('id').isString().trim().notEmpty(), requestValidationFunction];
@@ -1,10 +0,0 @@
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();
});
});
@@ -1,25 +0,0 @@
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,
};
}
@@ -1,16 +1,20 @@
import { body } from 'express-validator';
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
import { check, validationResult } from 'express-validator';
import { Request, Response, NextFunction } from 'express';
/**
* @description Validates object for POST /ontime/views
*/
export const validateViewSettings = [
body('dangerColor').isString().trim().withMessage('dangerColor value must be string'),
body('endMessage').isString().trim().withMessage('endMessage value must be string'),
body('freezeEnd').isBoolean().withMessage('freezeEnd value must be boolean'),
body('normalColor').isString().trim().withMessage('normalColor value must be string'),
body('overrideStyles').isBoolean().withMessage('overrideStyles value must be boolean'),
body('warningColor').isString().trim().withMessage('warningColor value must be string'),
check('dangerColor').exists().isString().trim().withMessage('dangerColor value must be string'),
check('endMessage').exists().isString().trim().withMessage('endMessage value must be string'),
check('freezeEnd').exists().isBoolean().withMessage('freezeEnd value must be boolean'),
check('normalColor').exists().isString().trim().withMessage('normalColor value must be string'),
check('overrideStyles').exists().isBoolean().withMessage('overrideStyles value must be boolean'),
check('warningColor').exists().isString().trim().withMessage('warningColor value must be string'),
requestValidationFunction,
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
@@ -0,0 +1,36 @@
import { handleLegacyMessageConversion } from '../integration.legacy.js';
describe('handleLegacyConversion', () => {
it('should return the payload as is if it is not a legacy message', () => {
expect(handleLegacyMessageConversion({})).toEqual({});
const newPayload = {
timer: {
text: 'text',
visible: true,
blink: true,
blackout: true,
},
external: 'text',
};
expect(handleLegacyMessageConversion(newPayload)).toEqual(newPayload);
});
it('should convert a legacy payload with external message', () => {
expect(handleLegacyMessageConversion({ external: { text: 'text', visible: true } })).toEqual({
external: 'text',
timer: {
secondarySource: 'external',
},
});
expect(handleLegacyMessageConversion({ external: { visible: true } })).toEqual({
timer: {
secondarySource: 'external',
},
});
expect(handleLegacyMessageConversion({ external: { text: 'text' } })).toEqual({
external: 'text',
});
});
});
@@ -1,5 +1,5 @@
import { MessageState, OffsetMode, OntimeEvent, PatchWithId, SimpleDirection, SimplePlayback } from 'ontime-types';
import { MILLIS_PER_HOUR } from 'ontime-utils';
import { MessageState, OffsetMode, OntimeEvent, SimpleDirection, SimplePlayback } from 'ontime-types';
import { MILLIS_PER_HOUR, MILLIS_PER_SECOND } from 'ontime-utils';
import { DeepPartial } from 'ts-essentials';
@@ -11,14 +11,15 @@ import { runtimeService } from '../services/runtime-service/RuntimeService.js';
import { eventStore } from '../stores/EventStore.js';
import * as assert from '../utils/assert.js';
import { isEmptyObject } from '../utils/parserUtils.js';
import { parseProperty } from './integration.utils.js';
import { parseProperty, updateEvent } from './integration.utils.js';
import { socket } from '../adapters/WebsocketAdapter.js';
import { throttle } from '../utils/throttle.js';
import { coerceEnum } from '../utils/coerceType.js';
import { editEntry } from '../api-data/rundown/rundown.service.js';
import { willCauseRegeneration } from '../api-data/rundown/rundown.utils.js';
import { willCauseRegeneration } from '../services/rundown-service/rundownCacheUtils.js';
const throttledEditEvent = throttle(editEntry, 20);
import { handleLegacyMessageConversion } from './integration.legacy.js';
import { coerceEnum } from '../utils/coerceType.js';
const throttledUpdateEvent = throttle(updateEvent, 20);
let lastRequest: Date | null = null;
export function dispatchFromAdapter(type: string, payload: unknown, _source?: 'osc' | 'ws' | 'http') {
@@ -57,7 +58,7 @@ const actionHandlers: Record<string, ActionHandler> = {
}
const data = payload[id as keyof typeof payload];
const patchEvent: PatchWithId<OntimeEvent> = { id };
const patchEvent: Partial<OntimeEvent> & { id: string } = { id };
let shouldThrottle = false;
@@ -77,13 +78,11 @@ const actionHandlers: Record<string, ActionHandler> = {
});
if (shouldThrottle) {
if (throttledEditEvent(patchEvent)) {
if (throttledUpdateEvent(patchEvent)) {
return { payload: 'throttled' };
}
} else {
editEntry(patchEvent).catch((_error) => {
/** No error handling */
});
updateEvent(patchEvent);
}
return { payload: 'success' };
},
@@ -91,9 +90,12 @@ const actionHandlers: Record<string, ActionHandler> = {
message: (payload) => {
assert.isObject(payload);
// TODO: remove this once we feel its been enough time, ontime 3.6.0, 20/09/2024
const migratedPayload = handleLegacyMessageConversion(payload);
const patch: DeepPartial<MessageState> = {
timer: 'timer' in payload ? validateTimerMessage(payload.timer) : undefined,
external: 'external' in payload ? validateMessage(payload.external) : undefined,
timer: 'timer' in migratedPayload ? validateTimerMessage(migratedPayload.timer) : undefined,
external: 'external' in migratedPayload ? validateMessage(migratedPayload.external) : undefined,
};
const newMessage = messageService.patch(patch);
@@ -190,24 +192,27 @@ const actionHandlers: Record<string, ActionHandler> = {
throw new Error('No matching method provided');
},
addtime: (payload) => {
const time = (() => {
if (payload && typeof payload === 'object') {
if ('add' in payload) return numberOrError(payload.add);
if ('remove' in payload) return numberOrError(payload.remove) * -1;
let time = 0;
if (payload && typeof payload === 'object') {
if ('add' in payload) {
time = numberOrError(payload.add);
} else if ('remove' in payload) {
time = numberOrError(payload.remove) * -1;
}
return numberOrError(payload);
})();
} else {
time = numberOrError(payload);
}
assert.isNumber(time);
if (time === 0) {
return { payload: 'success' };
}
if (Math.abs(time) > MILLIS_PER_HOUR) {
const timeToAdd = time * MILLIS_PER_SECOND; // frontend is seconds based
if (Math.abs(timeToAdd) > MILLIS_PER_HOUR) {
throw new Error(`Payload too large: ${time}`);
}
runtimeService.addTime(time);
runtimeService.addTime(timeToAdd);
return { payload: 'success' };
},
/* Extra timers */
@@ -233,11 +238,13 @@ const actionHandlers: Record<string, ActionHandler> = {
} else if (command && typeof command === 'object') {
const reply = { payload: {} };
if ('duration' in command) {
const timeInMs = numberOrError(command.duration);
// convert duration in seconds to ms
const timeInMs = numberOrError(command.duration) * 1000;
reply.payload = auxTimerService.setTime(timeInMs);
}
if ('addtime' in command) {
const timeInMs = numberOrError(command.addtime);
// convert addTime in seconds to ms
const timeInMs = numberOrError(command.addtime) * 1000;
reply.payload = auxTimerService.addTime(timeInMs);
}
if ('direction' in command) {
@@ -0,0 +1,67 @@
import { MessageState } from 'ontime-types';
import { DeepPartial } from 'ts-essentials';
export type LegacyMessageState = DeepPartial<{
timer: {
text: string;
visible: boolean;
blink: boolean;
blackout: boolean;
};
external: {
text: string;
visible: boolean;
};
}>;
function isLegacyMessageState(value: object): value is LegacyMessageState {
// @ts-expect-error -- good enough here
return value?.external?.text !== undefined || value?.external?.visible !== undefined;
}
/**
* This function is used to maintain support for legacy data in the /message endpoint
* The previous message endpoint expected a patch of the message state
* @example {
* timer: { blink: boolean, blackout: boolean, text: string, visible: boolean },
* external: { visible: boolean, text: string }
* }
*
* This change is introduced in version 3.6.0
*/
export function handleLegacyMessageConversion(payload: object): object | Partial<MessageState> {
// if it is not a legacy message, we pass it as is
if (!isLegacyMessageState(payload)) {
return payload;
}
/**
* The current migration only needs to handle the cases
* for the deprecated external message controls
*/
// Migrate external message
// 2.1 the user gives us the text and a visible flag
if (payload?.external?.text !== undefined && payload.external.visible !== undefined) {
return {
timer: { secondarySource: payload.external.visible ? 'external' : null },
external: payload.external.text,
} as Partial<MessageState>;
}
// 2.2 the user gives us the text
else if (payload?.external?.text !== undefined) {
return {
external: payload.external.text,
} as Partial<MessageState>;
}
// 2.3 the user gives us the visible flag
else if (payload?.external?.visible !== undefined) {
return {
timer: { secondarySource: payload.external.visible ? 'external' : null },
} as Partial<MessageState>;
}
// there should be no case for us to reach this since
// the type guard would have ensured one of the above states
return payload;
}
@@ -5,7 +5,7 @@
*
*/
import { ErrorResponse, LogOrigin } from 'ontime-types';
import { LogOrigin } from 'ontime-types';
import express, { type Request, type Response } from 'express';
@@ -27,19 +27,17 @@ integrationRouter.get('/', (_req: Request, res: Response<{ message: string }>) =
/**
* All calls are sent to the dispatcher
*/
integrationRouter.get('/*splat', (req: Request, res: Response<ErrorResponse | { payload: unknown }>) => {
integrationRouter.get('/*', (req: Request, res: Response) => {
let action = req.path.substring(1);
if (!action) {
res.status(400).json({ message: 'No action found' });
return;
return res.status(400).json({ error: 'No action found' });
}
try {
const actionArray = action.split('/');
const query = isEmptyObject(req.query) ? undefined : (req.query as object);
let payload: unknown = {};
let payload = {};
if (actionArray.length > 1) {
// @ts-expect-error -- we decide to give up on typing here
action = actionArray.shift();
payload = integrationPayloadFromPath(actionArray, query);
} else {
@@ -1,6 +1,8 @@
import { EndAction, TimerType, isKeyOfType } from 'ontime-types';
import { EndAction, OntimeEvent, TimerType, isKeyOfType, isOntimeEvent } from 'ontime-types';
import { MILLIS_PER_SECOND, maxDuration } from 'ontime-utils';
import { editEvent } from '../services/rundown-service/RundownService.js';
import { getEventWithId } from '../services/rundown-service/rundownUtils.js';
import { coerceBoolean, coerceColour, coerceEnum, coerceNumber, coerceString } from '../utils/coerceType.js';
import { getDataProvider } from '../classes/data-provider/DataProvider.js';
@@ -22,6 +24,7 @@ const propertyConversion = {
note: coerceString,
cue: coerceString,
isPublic: coerceBoolean,
skip: coerceBoolean,
colour: coerceColour,
@@ -55,3 +58,19 @@ export function parseProperty(property: string, value: unknown) {
const parserFn = propertyConversion[property];
return { [property]: parserFn(value) };
}
/**
* Updates a property of the event with the given id
* @param {Partial<OntimeEvent>} patchEvent
*/
export function updateEvent(patchEvent: Partial<OntimeEvent> & { id: string }) {
const event = getEventWithId(patchEvent?.id ?? '');
if (!event) {
throw new Error(`Event with ID ${patchEvent?.id} not found`);
}
if (!isOntimeEvent(event)) {
throw new Error('Can only update events');
}
editEvent(patchEvent);
}
+14 -10
View File
@@ -36,7 +36,7 @@ import { restoreService } from './services/RestoreService.js';
import * as messageService from './services/message-service/MessageService.js';
import { populateDemo } from './setup/loadDemo.js';
import { getState } from './stores/runtimeState.js';
import { initRundown } from './api-data/rundown/rundown.service.js';
import { initRundown } from './services/rundown-service/RundownService.js';
import { initialiseProject } from './services/project-service/ProjectService.js';
import { getShowWelcomeDialog } from './services/app-state-service/AppStateService.js';
import { oscServer } from './adapters/OscAdapter.js';
@@ -44,7 +44,7 @@ import { oscServer } from './adapters/OscAdapter.js';
// Utilities
import { clearUploadfolder } from './utils/upload.js';
import { generateCrashReport } from './utils/generateCrashReport.js';
import { timerConfig } from './setup/config.js';
import { timerConfig } from './config/config.js';
import { serverTryDesiredPort, getNetworkInterfaces } from './utils/network.js';
console.log('\n');
@@ -76,7 +76,7 @@ app.disable('x-powered-by');
// Implement middleware
app.use(cors()); // setup cors for all routes
app.options('*splat', cors()); // enable pre-flight cors
app.options('*', cors()); // enable pre-flight cors
app.use(bodyParser);
app.use(cookieParser());
@@ -97,7 +97,7 @@ app.use(`${prefix}/user`, express.static(publicDir.userDir));
// Base route for static files
app.use(`${prefix}`, authenticateAndRedirect, compressedStatic);
app.use(`${prefix}/*splat`, authenticateAndRedirect, compressedStatic);
app.use(`${prefix}/*`, authenticateAndRedirect, compressedStatic);
// Implement catch all
app.use((_error, response) => {
@@ -141,11 +141,8 @@ const checkStart = (currentState: OntimeStartOrder) => {
}
};
export const initAssets = async (escalateErrorFn?: (error: string, unrecoverable: boolean) => void) => {
export const initAssets = async () => {
checkStart(OntimeStartOrder.InitAssets);
// initialise logging service, escalateErrorFn only exists in electron
logger.init(escalateErrorFn);
await clearUploadfolder();
populateStyles();
await populateDemo();
@@ -156,8 +153,12 @@ export const initAssets = async (escalateErrorFn?: (error: string, unrecoverable
/**
* Starts servers
*/
export const startServer = async (): Promise<{ message: string; serverPort: number }> => {
export const startServer = async (
escalateErrorFn?: (error: string, unrecoverable: boolean) => void,
): Promise<{ message: string; serverPort: number }> => {
checkStart(OntimeStartOrder.InitServer);
// initialise logging service, escalateErrorFn only exists in electron
logger.init(escalateErrorFn);
const settings = getDataProvider().getSettings();
const { serverPort: desiredPort } = settings;
@@ -185,7 +186,9 @@ export const startServer = async (): Promise<{ message: string; serverPort: numb
block: null,
startedAt: null,
},
publicEventNow: state.publicEventNow,
eventNext: state.eventNext,
publicEventNext: state.publicEventNext,
auxtimer1: {
duration: timerConfig.auxTimerDefault,
current: timerConfig.auxTimerDefault,
@@ -206,6 +209,7 @@ export const startServer = async (): Promise<{ message: string; serverPort: numb
// load restore point if it exists
const maybeRestorePoint = await restoreService.load();
// TODO: pass event store to rundownservice
runtimeService.init(maybeRestorePoint);
const nif = getNetworkInterfaces();
@@ -242,7 +246,7 @@ export const startIntegrations = async () => {
* @param {number} exitCode
* @return {Promise<void>}
*/
const shutdown = async (exitCode = 0) => {
export const shutdown = async (exitCode = 0) => {
consoleHighlight(`Ontime shutting down with code ${exitCode}`);
// clear the restore file if it was a normal exit
+2 -2
View File
@@ -1,9 +1,9 @@
import { Log, LogLevel } from 'ontime-types';
import { generateId, millisToString } from 'ontime-utils';
import { clock } from '../services/Clock.js';
import { socket } from '../adapters/WebsocketAdapter.js';
import { consoleSubdued, consoleError } from '../utils/console.js';
import { timeNow } from '../utils/time.js';
import { isProduction } from '../setup/environment.js';
class Logger {
@@ -75,7 +75,7 @@ class Logger {
level,
origin,
text,
time: millisToString(timeNow()),
time: millisToString(clock.getSystemTime() || 0),
};
this._push(log);
}
@@ -1,13 +1,12 @@
import {
ProjectData,
OntimeRundown,
ViewSettings,
DatabaseModel,
Settings,
CustomFields,
URLPreset,
AutomationSettings,
Rundown,
ProjectRundowns,
} from 'ontime-types';
import type { Low } from 'lowdb';
@@ -23,9 +22,6 @@ type ReadonlyPromise<T> = Promise<Readonly<T>>;
let db = {} as Low<DatabaseModel>;
/**
* Initialises the JSON adapter to persist data to a file
*/
export async function initPersistence(filePath: string, fallbackData: DatabaseModel) {
// eslint-disable-next-line no-unused-labels -- dev code path
DEV: shouldCrashDev(!isPath(filePath), 'initPersistence should be called with a path');
@@ -49,7 +45,6 @@ export function getDataProvider() {
setCustomFields,
getCustomFields,
setRundown,
mergeRundown,
getSettings,
setSettings,
getUrlPresets,
@@ -83,28 +78,14 @@ async function setCustomFields(newData: CustomFields): ReadonlyPromise<CustomFie
return db.data.customFields;
}
async function mergeRundown(
newCustomFields: CustomFields,
newRundowns: ProjectRundowns,
): ReadonlyPromise<{ rundowns: ProjectRundowns; customFields: CustomFields }> {
db.data.customFields = { ...db.data.customFields, ...newCustomFields };
Object.entries(newRundowns).forEach(([id, rundown]) => {
// Note that entries with the same key will be overridden
db.data.rundowns[id] = rundown;
});
await persist();
return { rundowns: db.data.rundowns, customFields: db.data.customFields };
}
function getCustomFields(): Readonly<CustomFields> {
return db.data.customFields;
}
async function setRundown(rundownKey: string, newData: Rundown): ReadonlyPromise<Rundown> {
db.data.rundowns[rundownKey] = newData;
async function setRundown(newData: OntimeRundown): ReadonlyPromise<OntimeRundown> {
db.data.rundown = newData;
await persist();
return db.data.rundowns[rundownKey];
return db.data.rundown;
}
function getSettings(): Readonly<Settings> {
@@ -147,9 +128,8 @@ async function setAutomation(newData: AutomationSettings): ReadonlyPromise<Autom
return db.data.automation;
}
function getRundown(): Readonly<Rundown> {
const firstRundown = Object.keys(db.data.rundowns)[0];
return db.data.rundowns[firstRundown];
function getRundown(): Readonly<OntimeRundown> {
return db.data.rundown;
}
async function mergeIntoData(newData: Partial<DatabaseModel>): ReadonlyPromise<DatabaseModel> {
@@ -160,7 +140,7 @@ async function mergeIntoData(newData: Partial<DatabaseModel>): ReadonlyPromise<D
db.data.automation = mergedData.automation;
db.data.urlPresets = mergedData.urlPresets;
db.data.customFields = mergedData.customFields;
db.data.rundowns = mergedData.rundowns;
db.data.rundown = mergedData.rundown;
await persist();
return db.data;
@@ -8,7 +8,7 @@ export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseMode
const deepNewData = structuredClone(newData);
const {
rundowns = {},
rundown = deepExisting.rundown,
project = {},
settings = {},
viewSettings = {},
@@ -19,7 +19,7 @@ export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseMode
return {
...deepExisting,
rundowns: { ...existing.rundowns, ...rundowns },
rundown,
project: { ...deepExisting.project, ...project },
settings: { ...deepExisting.settings, ...settings },
viewSettings: { ...deepExisting.viewSettings, ...viewSettings },
@@ -1,48 +1,73 @@
import { DatabaseModel, Settings, URLPreset } from 'ontime-types';
import { demoDb } from '../../../models/demoProject.js';
import { makeOntimeEvent, makeRundown } from '../../../api-data/rundown/__mocks__/rundown.mocks.js';
import { DatabaseModel, OntimeRundown, Settings, URLPreset, ViewSettings } from 'ontime-types';
import { safeMerge } from '../DataProvider.utils.js';
describe('safeMerge', () => {
const existing = {
rundown: [],
project: {
title: 'existing title',
description: 'existing description',
publicUrl: 'existing public URL',
backstageUrl: 'existing backstageUrl',
publicInfo: 'existing backstageInfo',
backstageInfo: 'existing backstageInfo',
projectLogo: null,
custom: [
{
title: 'existing custom title',
value: 'existing custom value',
},
],
},
settings: {
app: 'ontime',
version: '2.0.0',
serverPort: 4001,
editorKey: null,
operatorKey: null,
timeFormat: '24',
language: 'en',
},
viewSettings: {
overrideStyles: false,
freezeEnd: false,
endMessage: 'existing endMessage',
normalColor: '#ffffffcc',
warningColor: '#FFAB33',
dangerColor: '#ED3333',
},
urlPresets: [],
customFields: {
lighting: { type: 'string', label: 'lighting', colour: 'red' },
vfx: { type: 'string', label: 'vfx', colour: 'blue' },
},
automation: {
enabledAutomations: false,
enabledOscIn: false,
oscPortIn: 8000,
triggers: [],
automations: {},
},
} as DatabaseModel;
it('returns existing data if new data is not provided', () => {
const mergedData = safeMerge(demoDb, {});
expect(mergedData).toEqual(demoDb);
const mergedData = safeMerge(existing, {});
expect(mergedData).toEqual(existing);
});
it('overrides a rundown with the same key', () => {
const newData = makeRundown({
id: 'demo',
entries: {
'1': makeOntimeEvent({ id: '1', title: 'new title' }),
'2': makeOntimeEvent({ id: '1', title: 'new title' }),
},
order: ['1', '2'],
});
const mergedData = safeMerge(demoDb, { rundowns: { demo: newData } });
expect(mergedData.rundowns.demo).toStrictEqual(newData);
});
it('merges a rundown with a new key', () => {
const newData = makeRundown({
id: 'rundown',
entries: {
'1': makeOntimeEvent({ id: '1', title: 'new title' }),
'2': makeOntimeEvent({ id: '1', title: 'new title' }),
},
order: ['1', '2'],
});
const mergedData = safeMerge(demoDb, { rundowns: { rundown: newData } });
expect(mergedData.rundowns.demo).toStrictEqual(demoDb.rundowns.demo);
expect(mergedData.rundowns.rundown).toStrictEqual(newData);
it('merges the rundown key', () => {
const newData = {
rundown: [{ title: 'item 1' }, { title: 'item 2' }] as OntimeRundown,
};
const mergedData = safeMerge(existing, newData);
expect(mergedData.rundown).toEqual(newData.rundown);
});
it('merges the project key', () => {
const mergedData = safeMerge(demoDb, {
const newData = {
project: {
title: 'new title',
backstageInfo: 'new backstage info',
publicInfo: 'new public info',
custom: [
{
title: 'new custom title',
@@ -50,13 +75,16 @@ describe('safeMerge', () => {
},
],
},
} as Partial<DatabaseModel>);
expect(mergedData.project).toStrictEqual({
};
// @ts-expect-error -- just testing
const mergedData = safeMerge(existing, newData);
expect(mergedData.project).toEqual({
title: 'new title',
description: 'Turin 2022',
backstageUrl: 'www.github.com/cpvalente/ontime',
backstageInfo: 'new backstage info',
description: 'existing description',
publicUrl: 'existing public URL',
publicInfo: 'new public info',
backstageUrl: 'existing backstageUrl',
backstageInfo: 'existing backstageInfo',
projectLogo: null,
custom: [
{
@@ -68,15 +96,16 @@ describe('safeMerge', () => {
});
it('merges the settings key', () => {
const mergedData = safeMerge(demoDb, {
const newData = {
settings: {
serverPort: 3000,
language: 'pt',
version: 'new',
} as Settings,
});
expect(mergedData.settings).toStrictEqual({
version: 'new',
};
const mergedData = safeMerge(existing, newData);
expect(mergedData.settings).toEqual({
app: 'ontime',
version: '2.0.0',
serverPort: 3000,
operatorKey: null,
editorKey: null,
@@ -86,6 +115,42 @@ describe('safeMerge', () => {
});
it('should merge the urlPresets key when present', () => {
const existingData = {
rundown: [],
project: {
title: '',
description: '',
publicUrl: '',
publicInfo: '',
backstageUrl: '',
backstageInfo: '',
projectLogo: null,
custom: [],
},
settings: {
app: 'ontime',
version: '2.0.0',
serverPort: 4001,
operatorKey: null,
editorKey: null,
timeFormat: '24',
language: 'en',
},
viewSettings: {
overrideStyles: false,
endMessage: '',
} as ViewSettings,
urlPresets: [],
customFields: {},
automation: {
enabledAutomations: false,
enabledOscIn: false,
oscPortIn: 8000,
triggers: [],
automations: {},
},
} as DatabaseModel;
const newData = {
urlPresets: [
{ enabled: true, alias: 'alias1', pathAndParams: '' },
@@ -93,9 +158,9 @@ describe('safeMerge', () => {
] as URLPreset[],
};
const mergedData = safeMerge(demoDb, newData);
const mergedData = safeMerge(existingData, newData);
expect(mergedData.urlPresets).toStrictEqual(newData.urlPresets);
expect(mergedData.urlPresets).toEqual(newData.urlPresets);
});
it('merges customFields into existing object', () => {
@@ -86,12 +86,6 @@ export class SimpleTimer {
public update(timeNow: number): SimpleTimerState {
if (this.state.playback === SimplePlayback.Start) {
// we know startedAt is not null since we are in play mode
// eslint-disable-next-line no-unused-labels -- dev code path
DEV: {
if (this.startedAt === null) {
throw new Error('SimpleTimer.update: invalid state received');
}
}
const elapsed = timeNow - this.startedAt;
if (this.state.direction === SimpleDirection.CountDown) {
this.state.current = this.state.duration - elapsed;
+9
View File
@@ -0,0 +1,9 @@
import { MILLIS_PER_MINUTE } from 'ontime-utils';
export const timerConfig = {
skipLimit: 1000, // threshold of skip for recalculating, values lower than updateRate can cause issues with rolling over midnight
updateRate: 32, // how often do we update the timer
notificationRate: 1000, // how often do we notify clients and integrations
triggerAhead: 10, // how far ahead do we trigger the end event
auxTimerDefault: 5 * MILLIS_PER_MINUTE, // default aux timer duration
};
+1 -3
View File
@@ -81,9 +81,7 @@ export function makeAuthenticateMiddleware(prefix: string) {
// we use query params for generating authenticated URLs and for clients like the companion module
// if the user gives is a token in the query params, we set the cookie to be used in further requests
if (req.query.token === hashedPassword) {
if (hashedPassword !== undefined) {
setSessionCookie(res, hashedPassword);
}
setSessionCookie(res, hashedPassword);
return next();
}
+5 -13
View File
@@ -1,28 +1,20 @@
import { DatabaseModel, Rundown } from 'ontime-types';
import { DatabaseModel } from 'ontime-types';
import { ONTIME_VERSION } from '../ONTIME_VERSION.js';
export const defaultRundown: Rundown = {
id: 'default',
title: 'Default',
order: [],
flatOrder: [],
entries: {},
revision: 0,
};
export const dbModel: DatabaseModel = {
rundowns: {
default: { ...defaultRundown },
},
rundown: [],
project: {
title: '',
description: '',
publicUrl: '',
publicInfo: '',
backstageUrl: '',
backstageInfo: '',
projectLogo: null,
custom: [],
},
settings: {
app: 'ontime',
version: ONTIME_VERSION,
serverPort: 4001,
editorKey: null,
+409 -502
View File
@@ -1,516 +1,423 @@
import { DatabaseModel, EndAction, SupportedEntry, TimeStrategy, TimerType } from 'ontime-types';
import { DatabaseModel, EndAction, SupportedEvent, TimeStrategy, TimerType } from 'ontime-types';
export const demoDb: DatabaseModel = {
rundowns: {
default: {
id: 'default',
title: 'Eurovision Demo',
order: [
'block',
'01e85',
'1c420',
'b7737',
'd3a80',
'8276c',
'2340b',
'cb90b',
'503c4',
'5e965',
'bab4a',
'd3eb1',
],
flatOrder: [
'block',
'32d31',
'21cd2',
'0b371',
'3cd28',
'e457f',
'01e85',
'1c420',
'b7737',
'd3a80',
'8276c',
'2340b',
'cb90b',
'503c4',
'5e965',
'bab4a',
'd3eb1',
],
entries: {
block: {
type: SupportedEntry.Block,
events: ['32d31', '21cd2', '0b371', '3cd28', 'e457f'],
id: 'block',
title: 'Test Block',
note: '',
skip: false,
colour: 'hotpink',
revision: 0,
startTime: null,
endTime: null,
duration: 0,
isFirstLinked: false,
custom: {
Song: 'Sekret',
Artist: 'Ronela Hajati',
},
},
'32d31': {
type: SupportedEntry.Event,
id: '32d31',
cue: 'SF1.01',
title: 'Albania',
note: 'SF1.01',
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: false,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 36000000,
timeEnd: 37200000,
duration: 1200000,
skip: false,
colour: '',
parent: null,
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
triggers: [],
custom: {
Song: 'Sekret',
Artist: 'Ronela Hajati',
},
},
'21cd2': {
type: SupportedEntry.Event,
id: '21cd2',
cue: 'SF1.02',
title: 'Latvia',
note: 'SF1.02',
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: false,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 37500000,
timeEnd: 38700000,
duration: 1200000,
skip: false,
colour: '',
parent: null,
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
triggers: [],
custom: {
Song: 'Eat Your Salad',
Artist: 'Citi Zeni',
},
},
'0b371': {
type: SupportedEntry.Event,
id: '0b371',
cue: 'SF1.03',
title: 'Lithuania',
note: 'SF1.03',
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: false,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 39000000,
timeEnd: 40200000,
duration: 1200000,
skip: false,
colour: '',
parent: null,
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
triggers: [],
custom: {
Song: 'Sentimentai',
Artist: 'Monika Liu',
},
},
'3cd28': {
type: SupportedEntry.Event,
id: '3cd28',
cue: 'SF1.04',
title: 'Switzerland',
note: 'SF1.04',
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: false,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 40500000,
timeEnd: 41700000,
duration: 1200000,
skip: false,
colour: '',
parent: null,
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
triggers: [],
custom: {
Song: 'Boys Do Cry',
Artist: 'Marius Bear',
},
},
e457f: {
type: SupportedEntry.Event,
id: 'e457f',
cue: 'SF1.05',
title: 'Slovenia',
note: 'SF1.05',
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: false,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 42000000,
timeEnd: 43200000,
duration: 1200000,
skip: false,
colour: '',
parent: null,
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
triggers: [],
custom: {
Song: 'Disko',
Artist: 'LPS',
},
},
/// <----- BLOCK
'01e85': {
// TODO: this should be a marker type
type: SupportedEntry.Block,
id: '01e85',
title: 'Lunch break',
note: '',
colour: '',
events: [],
skip: false,
custom: {},
revision: 0,
startTime: null,
endTime: null,
duration: 0,
isFirstLinked: false,
},
'1c420': {
type: SupportedEntry.Event,
id: '1c420',
cue: 'SF1.06',
title: 'Ukraine',
note: 'SF1.06',
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: false,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 47100000,
timeEnd: 48300000,
duration: 1200000,
skip: false,
colour: '',
parent: null,
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
triggers: [],
custom: {
Song: 'Stefania',
Artist: 'Kalush Orchestra',
},
},
b7737: {
type: SupportedEntry.Event,
id: 'b7737',
cue: 'SF1.07',
title: 'Bulgaria',
note: 'SF1.07',
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: false,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 48600000,
timeEnd: 49800000,
duration: 1200000,
skip: false,
colour: '',
parent: null,
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
triggers: [],
custom: {
Song: 'Intention',
Artist: 'Intelligent Music Project',
},
},
d3a80: {
type: SupportedEntry.Event,
id: 'd3a80',
cue: 'SF1.08',
title: 'Netherlands',
note: 'SF1.08',
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: false,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 50100000,
timeEnd: 51300000,
duration: 1200000,
skip: false,
colour: '',
parent: null,
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
triggers: [],
custom: {
Song: 'De Diepte',
Artist: 'S10',
},
},
'8276c': {
type: SupportedEntry.Event,
id: '8276c',
cue: 'SF1.09',
title: 'Moldova',
note: 'SF1.09',
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: false,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 51600000,
timeEnd: 52800000,
duration: 1200000,
skip: false,
colour: '',
parent: null,
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
triggers: [],
custom: {
Song: 'Trenuletul',
Artist: 'Zdob si Zdub',
},
},
'2340b': {
type: SupportedEntry.Event,
id: '2340b',
cue: 'SF1.10',
title: 'Portugal',
note: 'SF1.10',
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: false,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 53100000,
timeEnd: 54300000,
duration: 1200000,
skip: false,
colour: '',
parent: null,
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
triggers: [],
custom: {
Song: 'Saudade Saudade',
Artist: 'Maro',
},
},
/// <----- BLOCK
cb90b: {
// TODO: This should be a marker type
type: SupportedEntry.Block,
id: 'cb90b',
title: 'Afternoon break',
note: '',
colour: '',
events: [],
skip: false,
custom: {},
revision: 0,
startTime: null,
endTime: null,
duration: 0,
isFirstLinked: false,
},
'503c4': {
type: SupportedEntry.Event,
id: '503c4',
cue: 'SF1.11',
title: 'Croatia',
note: 'SF1.11',
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: false,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 56100000,
timeEnd: 57300000,
duration: 1200000,
skip: false,
colour: '',
parent: null,
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
triggers: [],
custom: {
Song: 'Guilty Pleasure',
Artist: 'Mia Dimsic',
},
},
'5e965': {
type: SupportedEntry.Event,
id: '5e965',
cue: 'SF1.12',
title: 'Denmark',
note: 'SF1.12',
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: false,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 57600000,
timeEnd: 58800000,
duration: 1200000,
skip: false,
colour: '',
parent: null,
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
triggers: [],
custom: {
Song: 'The Show',
Artist: 'Reddi',
},
},
bab4a: {
type: SupportedEntry.Event,
id: 'bab4a',
cue: 'SF1.13',
title: 'Austria',
note: 'SF1.13',
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: false,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 59100000,
timeEnd: 60300000,
duration: 1200000,
skip: false,
colour: '',
parent: null,
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
triggers: [],
custom: {
Song: 'Halo',
Artist: 'LUM!X & Pia Maria',
},
},
d3eb1: {
type: SupportedEntry.Event,
id: 'd3eb1',
cue: 'SF1.14',
title: 'Greece',
note: 'SF1.14',
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: false,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 60600000,
timeEnd: 61800000,
duration: 1200000,
skip: false,
colour: '',
parent: null,
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
triggers: [],
custom: {
Song: 'Die Together',
Artist: 'Amanda Tenfjord',
},
},
},
rundown: [
{
type: SupportedEvent.Event,
id: '32d31',
cue: 'SF1.01',
title: 'Albania',
note: 'SF1.01',
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 36000000,
timeEnd: 37200000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'Sekret',
artist: 'Ronela Hajati',
},
},
},
{
type: SupportedEvent.Event,
id: '21cd2',
cue: 'SF1.02',
title: 'Latvia',
note: 'SF1.02',
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 37500000,
timeEnd: 38700000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'Eat Your Salad',
artist: 'Citi Zeni',
},
},
{
type: SupportedEvent.Event,
id: '0b371',
cue: 'SF1.03',
title: 'Lithuania',
note: 'SF1.03',
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 39000000,
timeEnd: 40200000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'Sentimentai',
artist: 'Monika Liu',
},
},
{
type: SupportedEvent.Event,
id: '3cd28',
cue: 'SF1.04',
title: 'Switzerland',
note: 'SF1.04',
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 40500000,
timeEnd: 41700000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'Boys Do Cry',
artist: 'Marius Bear',
},
},
{
type: SupportedEvent.Event,
id: 'e457f',
cue: 'SF1.05',
title: 'Slovenia',
note: 'SF1.05',
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 42000000,
timeEnd: 43200000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'Disko',
artist: 'LPS',
},
},
{
type: SupportedEvent.Block,
id: '01e85',
title: 'Lunch break',
},
{
type: SupportedEvent.Event,
id: '1c420',
cue: 'SF1.06',
title: 'Ukraine',
note: 'SF1.06',
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 47100000,
timeEnd: 48300000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'Stefania',
artist: 'Kalush Orchestra',
},
},
{
type: SupportedEvent.Event,
id: 'b7737',
cue: 'SF1.07',
title: 'Bulgaria',
note: 'SF1.07',
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 48600000,
timeEnd: 49800000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'Intention',
artist: 'Intelligent Music Project',
},
},
{
type: SupportedEvent.Event,
id: 'd3a80',
cue: 'SF1.08',
title: 'Netherlands',
note: 'SF1.08',
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 50100000,
timeEnd: 51300000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'De Diepte',
artist: 'S10',
},
},
{
type: SupportedEvent.Event,
id: '8276c',
cue: 'SF1.09',
title: 'Moldova',
note: 'SF1.09',
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 51600000,
timeEnd: 52800000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'Trenuletul',
artist: 'Zdob si Zdub',
},
},
{
type: SupportedEvent.Event,
id: '2340b',
cue: 'SF1.10',
title: 'Portugal',
note: 'SF1.10',
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 53100000,
timeEnd: 54300000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'Saudade Saudade',
artist: 'Maro',
},
},
{
type: SupportedEvent.Block,
id: 'cb90b',
title: 'Afternoon break',
},
{
type: SupportedEvent.Event,
id: '503c4',
cue: 'SF1.11',
title: 'Croatia',
note: 'SF1.11',
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 56100000,
timeEnd: 57300000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'Guilty Pleasure',
artist: 'Mia Dimsic',
},
},
{
type: SupportedEvent.Event,
id: '5e965',
cue: 'SF1.12',
title: 'Denmark',
note: 'SF1.12',
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 57600000,
timeEnd: 58800000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'The Show',
artist: 'Reddi',
},
},
{
type: SupportedEvent.Event,
id: 'bab4a',
cue: 'SF1.13',
title: 'Austria',
note: 'SF1.13',
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 59100000,
timeEnd: 60300000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'Halo',
artist: 'LUM!X & Pia Maria',
},
},
{
type: SupportedEvent.Event,
id: 'd3eb1',
cue: 'SF1.14',
title: 'Greece',
note: 'SF1.14',
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 60600000,
timeEnd: 61800000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'Die Together',
artist: 'Amanda Tenfjord',
},
},
],
project: {
title: 'Eurovision Song Contest',
description: 'Turin 2022',
publicUrl: 'www.getontime.no',
publicInfo: 'Rehearsal Schedule - Turin 2022',
backstageUrl: 'www.github.com/cpvalente/ontime',
backstageInfo: 'Rehearsal Schedule - Turin 2022\nAll performers to wear full costumes for 1st rehearsal',
projectLogo: null,
custom: [],
},
settings: {
version: '-',
app: 'ontime',
version: '3.3.2',
serverPort: 4001,
editorKey: null,
operatorKey: null,
@@ -526,12 +433,12 @@ export const demoDb: DatabaseModel = {
warningColor: '#FFAB33',
},
customFields: {
Song: {
song: {
label: 'Song',
type: 'string',
colour: '#339E4E',
},
Artist: {
artist: {
label: 'Artist',
type: 'string',
colour: '#3E75E8',
+10 -24
View File
@@ -3,55 +3,41 @@ import {
OntimeBlock,
OntimeDelay,
OntimeEvent,
SupportedEntry,
SupportedEvent,
TimeStrategy,
TimerType,
} from 'ontime-types';
export const event: Omit<OntimeEvent, 'id' | 'cue'> = {
type: SupportedEntry.Event,
title: '',
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStrategy: TimeStrategy.LockDuration,
linkStart: false,
linkStart: null,
countToEnd: false,
timeStart: 0,
timeEnd: 0,
duration: 0,
isPublic: false,
skip: false,
colour: '',
type: SupportedEvent.Event,
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 120000,
timeDanger: 60000,
triggers: [],
custom: {},
// !==== RUNTIME METADATA ====! //
parent: null,
revision: 0, // calculated at runtime
delay: 0, // calculated at runtime
dayOffset: 0, // calculated at runtime
gap: 0, // calculated at runtime
};
export const delay: Omit<OntimeDelay, 'id'> = {
type: SupportedEntry.Delay,
duration: 0,
parent: null,
type: SupportedEvent.Delay,
};
export const block: Omit<OntimeBlock, 'id'> = {
type: SupportedEntry.Block,
title: '',
note: '',
events: [],
skip: false,
colour: '',
custom: {},
// !==== RUNTIME METADATA ====! //
revision: 0, // calculated at runtime
startTime: null, // calculated at runtime
endTime: null, // calculated at runtime
duration: 0, // calculated at runtime
isFirstLinked: false, // calculated at runtime
type: SupportedEvent.Block,
};
+60
View File
@@ -0,0 +1,60 @@
enum Source {
System = 'system',
MIDI = 'MIDI',
}
/**
* Service manages retrieving current time from a managed time source
*/
class Clock {
private static instance: Clock;
private readonly source: Source;
constructor(source?: Source) {
if (Clock.instance) {
return Clock.instance;
}
Clock.instance = this;
this.source = source || Source.System;
}
/**
* Get current time from source
*/
timeNow(): number {
switch (this.source) {
case Source.System:
return this.getSystemTime();
case Source.MIDI:
// @ts-expect-error -- not implemented
return this.getMidiTime();
default:
throw new Error('Invalid time source');
}
}
/**
* Get current time from system
*/
getSystemTime() {
const now = new Date();
// extract milliseconds since midnight
let elapsed = now.getHours() * 3600000;
elapsed += now.getMinutes() * 60000;
elapsed += now.getSeconds() * 1000;
elapsed += now.getMilliseconds();
return elapsed;
}
/**
* Get current time from MIDI
*/
getMidiTime() {
throw new Error('Not implemented');
}
}
export const clock = new Clock();
+2 -10
View File
@@ -1,11 +1,11 @@
import { timerConfig } from '../setup/config.js';
import * as runtimeState from '../stores/runtimeState.js';
import type { UpdateResult } from '../stores/runtimeState.js';
import { timerConfig } from '../config/config.js';
type UpdateCallbackFn = (updateResult: UpdateResult) => void;
/**
* Manages Ontime's main timer
* Service manages Ontime's main timer
*/
export class EventTimer {
private readonly _interval: NodeJS.Timeout;
@@ -43,14 +43,6 @@ export class EventTimer {
}
const state = runtimeState.getState();
// eslint-disable-next-line no-unused-labels -- dev code path
DEV: {
if (state.timer.current === null) {
throw new Error('EventTimer.start: invalid state received');
}
}
const endTime = state.timer.current - timerConfig.triggerAhead;
this.endCallback = setTimeout(() => this.update(), endTime);
return true;
@@ -1,7 +1,7 @@
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils';
import { loadRoll } from '../rollUtils.js';
import { prepareTimedEvents, makeOntimeEvent } from '../../api-data/rundown/__mocks__/rundown.mocks.js';
import { prepareTimedEvents, makeOntimeEvent } from '../rundown-service/__mocks__/rundown.mocks.js';
describe('loadRoll()', () => {
const eventlist = [
@@ -9,41 +9,49 @@ describe('loadRoll()', () => {
id: '1',
timeStart: 5,
timeEnd: 10,
isPublic: false,
},
{
id: '2',
timeStart: 10,
timeEnd: 20,
isPublic: false,
},
{
id: '3',
timeStart: 20,
timeEnd: 30,
isPublic: false,
},
{
id: '4',
timeStart: 30,
timeEnd: 40,
isPublic: false,
},
{
id: '5',
timeStart: 40,
timeEnd: 50,
isPublic: true,
},
{
id: '6',
timeStart: 50,
timeEnd: 60,
isPublic: false,
},
{
id: '7',
timeStart: 60,
timeEnd: 70,
isPublic: true,
},
{
id: '8',
timeStart: 70,
timeEnd: 80,
isPublic: false,
},
];
const timedEvents = prepareTimedEvents(eventlist);
@@ -147,26 +155,31 @@ describe('loadRoll() handle edge cases with midnight', () => {
id: '0',
timeStart: 9 * MILLIS_PER_HOUR,
timeEnd: 10 * MILLIS_PER_HOUR,
isPublic: true,
},
{
id: '1',
timeStart: 20 * MILLIS_PER_HOUR,
timeEnd: 22 * MILLIS_PER_HOUR,
isPublic: true,
},
{
id: '2',
timeStart: 22 * MILLIS_PER_HOUR,
timeEnd: 1 * MILLIS_PER_HOUR,
isPublic: true,
},
{
id: '3',
timeStart: 1 * MILLIS_PER_HOUR,
timeEnd: 1 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE,
isPublic: true,
},
{
id: '4',
timeStart: 1 * MILLIS_PER_HOUR,
timeEnd: 2 * MILLIS_PER_HOUR,
isPublic: true,
},
];
const timedEvents = prepareTimedEvents(eventlist);
@@ -298,6 +311,7 @@ describe('loadRoll() handle edge cases with before and after start', () => {
id: '1',
timeStart: 10 * MILLIS_PER_HOUR,
timeEnd: 11 * MILLIS_PER_HOUR,
isPublic: true,
}),
];
@@ -317,6 +331,7 @@ describe('loadRoll() handle edge cases with before and after start', () => {
id: '1',
timeStart: 10 * MILLIS_PER_HOUR,
timeEnd: 11 * MILLIS_PER_HOUR,
isPublic: true,
}),
];
@@ -336,6 +351,7 @@ describe('loadRoll() handle edge cases with before and after start', () => {
id: '1',
timeStart: 10 * MILLIS_PER_HOUR,
timeEnd: 2 * MILLIS_PER_HOUR,
isPublic: true,
}),
];
const expected = {
@@ -354,6 +370,7 @@ describe('loadRoll() handle edge cases with before and after start', () => {
id: '1',
timeStart: 72000000, // 20:00
timeEnd: 72010000, // 20:10
isPublic: true,
}),
];
const expected = {
@@ -372,16 +389,19 @@ describe('loadRoll() test that roll behaviour with overlapping times', () => {
id: '1',
timeStart: 10,
timeEnd: 10,
isPublic: false,
},
{
id: '2',
timeStart: 10,
timeEnd: 20,
isPublic: true,
},
{
id: '3',
timeStart: 10,
timeEnd: 30,
isPublic: false,
},
];
const timedEvents = prepareTimedEvents(eventlist);
@@ -452,6 +472,7 @@ describe('loadRoll() test that roll behaviour multi day event edge cases', () =>
id: '1',
timeStart: 66000000, // 19:20
timeEnd: 54600000, // 16:10
isPublic: false,
}),
];
const expected = {
@@ -470,6 +491,7 @@ describe('loadRoll() test that roll behaviour multi day event edge cases', () =>
id: '1',
timeStart: 67200000, // 19:40
timeEnd: 66900000, // 19:35
isPublic: false,
}),
];
const expected = {
@@ -737,7 +737,6 @@ describe('getRuntimeOffset()', () => {
},
runtime: {
actualStart: 150,
plannedStart: 100,
},
} as RuntimeState;
@@ -761,7 +760,6 @@ describe('getRuntimeOffset()', () => {
},
runtime: {
actualStart: 150,
plannedStart: 100,
},
} as RuntimeState;
@@ -786,7 +784,6 @@ describe('getRuntimeOffset()', () => {
},
runtime: {
actualStart: 100,
plannedStart: 100,
},
} as RuntimeState;
@@ -812,7 +809,6 @@ describe('getRuntimeOffset()', () => {
},
runtime: {
actualStart: 100,
plannedStart: 100,
},
} as RuntimeState;
@@ -829,7 +825,7 @@ describe('getRuntimeOffset()', () => {
timeEnd: 81000000,
duration: 3600000,
timeStrategy: 'lock-duration',
linkStart: false,
linkStart: null,
},
runtime: {
selectedEventIndex: 0,
@@ -867,7 +863,7 @@ describe('getRuntimeOffset()', () => {
timeEnd: 84600000,
duration: 3600000,
timeStrategy: 'lock-duration',
linkStart: false,
linkStart: null,
endAction: 'none',
timerType: 'count-down',
delay: 0,
@@ -910,10 +906,11 @@ describe('getRuntimeOffset()', () => {
timeEnd: 81000000, // 22:30:00
duration: 3600000, // 01:00:00
timeStrategy: TimeStrategy.LockEnd,
linkStart: false,
linkStart: null,
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: true,
isPublic: true,
skip: false,
note: '',
colour: '',
@@ -962,10 +959,11 @@ describe('getRuntimeOffset()', () => {
timeEnd: 81000000, // 22:30:00
duration: 3600000, // 01:00:00
timeStrategy: TimeStrategy.LockEnd,
linkStart: false,
linkStart: null,
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: true,
isPublic: true,
skip: false,
note: '',
colour: '',
@@ -1012,7 +1010,7 @@ describe('getRuntimeOffset()', () => {
timeEnd: 81000000, // 22:30:00
duration: 3600000, // 01:00:00
timeStrategy: TimeStrategy.LockEnd,
linkStart: false,
linkStart: null,
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: true,
@@ -1220,7 +1218,9 @@ describe('getTimerPhase()', () => {
const state = {
clock: 55691050,
eventNow: null,
publicEventNow: null,
eventNext: null,
publicEventNext: null,
runtime: {
selectedEventIndex: null,
numEvents: 1,
@@ -1259,7 +1259,9 @@ describe('getTimerPhase()', () => {
const state = {
clock: 55691050,
eventNow: null,
publicEventNow: null,
eventNext: null,
publicEventNext: null,
runtime: {
selectedEventIndex: null,
numEvents: 1,
@@ -45,6 +45,8 @@ export async function getShowWelcomeDialog(): Promise<boolean> {
}
export async function setShowWelcomeDialog(show: boolean): Promise<boolean> {
if (isTest) return;
config.data.showWelcomeDialog = show;
await config.write();
return show;
@@ -2,10 +2,10 @@ import { SimpleDirection, SimplePlayback, SimpleTimerState } from 'ontime-types'
import { SimpleTimer } from '../../classes/simple-timer/SimpleTimer.js';
import { eventStore } from '../../stores/EventStore.js';
import { timerConfig } from '../../setup/config.js';
import { timerConfig } from '../../config/config.js';
type EmitFn = (state: SimpleTimerState) => void;
type GetTimeFn = () => number;
export type EmitFn = (state: SimpleTimerState) => void;
export type GetTimeFn = () => number;
export class AuxTimerService {
private timer: SimpleTimer;
@@ -77,8 +77,7 @@ function broadcastReturn(_target: any, _propertyKey: string, descriptor: Propert
descriptor.value = function (...args: any[]) {
const result = originalMethod.apply(this, args);
// @ts-expect-error -- we can access private properties from the decorator
(this as AuxTimerService).emit(result);
this.emit(result);
return result;
};
@@ -31,8 +31,7 @@ export function clear() {
* Exposes the internal state of the message service
*/
export function getState(): MessageState {
// we know this exists at runtime
return storeGet('message') as MessageState;
return storeGet('message');
}
/**
@@ -1,16 +1,12 @@
import { RuntimeStore } from 'ontime-types';
import * as messageService from '../MessageService.js';
describe('MessageService', () => {
let store: Partial<RuntimeStore>;
beforeEach(() => {
// at runtime, the store is instantiated before the message service
store = {};
messageService.init(
(key, value) => (store[key] = value),
(key) => store[key],
);
const store = {};
const storeSetter = (key, value) => (store[key] = value);
const storeGetter = (key) => store[key];
messageService.init(storeSetter, storeGetter);
messageService.clear();
});
@@ -1,5 +1,5 @@
import { DatabaseModel, LogOrigin, ProjectData, ProjectFileListResponse } from 'ontime-types';
import { getErrorMessage, getFirstRundown } from 'ontime-utils';
import { getErrorMessage } from 'ontime-utils';
import { join } from 'path';
import { copyFile } from 'fs/promises';
@@ -8,7 +8,6 @@ import { logger } from '../../classes/Logger.js';
import { publicDir } from '../../setup/index.js';
import {
appendToName,
deleteFile,
dockerSafeRename,
ensureDirectory,
ensureJsonExtension,
@@ -17,15 +16,15 @@ import {
removeFileExtension,
} from '../../utils/fileManagement.js';
import { dbModel } from '../../models/dataModel.js';
import { parseRundowns } from '../../api-data/rundown/rundown.parser.js';
import { deleteFile } from '../../utils/parserUtils.js';
import { parseDatabaseModel } from '../../utils/parser.js';
import { parseRundown } from '../../utils/parserFunctions.js';
import { demoDb } from '../../models/demoProject.js';
import { config } from '../../setup/config.js';
import { getDataProvider, initPersistence } from '../../classes/data-provider/DataProvider.js';
import { safeMerge } from '../../classes/data-provider/DataProvider.utils.js';
import { initRundown } from '../../api-data/rundown/rundown.service.js';
import { parseDatabaseModel } from '../../api-data/db/db.parser.js';
import { parseCustomFields } from '../../api-data/custom-fields/customFields.parser.js';
import { initRundown } from '../rundown-service/RundownService.js';
import {
getLastLoadedProject,
isLastLoadedProject,
@@ -42,21 +41,6 @@ import {
parseJsonFile,
} from './projectServiceUtils.js';
type ProjectState =
| {
status: 'PENDING';
currentProjectName: undefined;
}
| {
status: 'INITIALIZED';
currentProjectName: string;
};
let currentProjectState: ProjectState = {
status: 'PENDING',
currentProjectName: undefined,
};
// init dependencies
init();
@@ -68,51 +52,22 @@ function init() {
ensureDirectory(publicDir.corruptDir);
}
export async function getCurrentProject(): Promise<{ filename: string; pathToFile: string }> {
if (currentProjectState.status === 'PENDING') {
await initialiseProject();
}
// we know the project is loaded since we force initialisation above
const pathToFile = getPathToProject(currentProjectState.currentProjectName as string);
export async function getCurrentProject() {
const filename = await getLastLoadedProject();
const pathToFile = getPathToProject(filename);
return { filename: currentProjectState.currentProjectName as string, pathToFile };
}
/**
* Private function loads a project file and handles necessary side effects
* @param projectData
* @param fileName file name of the project including the extension
*/
async function loadProject(projectData: DatabaseModel, fileName: string) {
// change LowDB to point to new file
await initPersistence(getPathToProject(fileName), projectData);
logger.info(LogOrigin.Server, `Loaded project ${fileName}`);
// stop the runtime service
runtimeService.stop();
// load the first rundown in the project
const firstRundown = getFirstRundown(projectData.rundowns);
await initRundown(firstRundown, projectData.customFields);
// persist the project selection
await setLastLoadedProject(fileName);
// update the service state
currentProjectState = {
status: 'INITIALIZED',
currentProjectName: fileName,
};
return fileName;
return { filename, pathToFile };
}
/**
* Loads the demo project
*/
export async function loadDemoProject(): Promise<string> {
return createProject(config.demoProject, demoDb);
const pathToNewFile = generateUniqueFileName(publicDir.projectsDir, config.demoProject);
await initPersistence(getPathToProject(pathToNewFile), demoDb);
const newName = getFileNameFromPath(pathToNewFile);
await setLastLoadedProject(newName);
return newName;
}
/**
@@ -120,14 +75,16 @@ export async function loadDemoProject(): Promise<string> {
* to be composed in the loading functions
*/
async function loadNewProject(): Promise<string> {
return createProject(config.newProject, dbModel);
const pathToNewFile = generateUniqueFileName(publicDir.projectsDir, config.newProject);
await initPersistence(getPathToProject(pathToNewFile), dbModel);
const newName = getFileNameFromPath(pathToNewFile);
await setLastLoadedProject(newName);
return newName;
}
/**
* Private function handles side effects on corrupted files
* Private function handles side effects on currupted files
* Corrupted files in this context contain data that failed domain validation
* @param filePath path to the project type include the fileName and extension
* @param fileName as extracted from filePath, includes extension
*/
async function handleCorruptedFile(filePath: string, fileName: string): Promise<string> {
// copy file to corrupted folder
@@ -150,34 +107,47 @@ export async function initialiseProject(): Promise<string> {
// check what was loaded before
const previousProject = await getLastLoadedProject();
// in normal circumstances we dont have a previous project if it is the first app start
// in which case we want to load a demo project
if (!previousProject) {
return loadDemoProject();
}
try {
const projectName = await loadProjectFile(previousProject);
return projectName;
} catch (error) {
// if we are here, most likely the json parsing failed and the file is corrupt
logger.warning(LogOrigin.Server, `Unable to load previous project ${previousProject}: ${getErrorMessage(error)}`);
try {
const pathToFile = getPathToProject(previousProject);
await moveCorruptFile(pathToFile, previousProject);
} catch (_) {
/* while we have to catch the error, we dont need to handle it */
}
// try and load the previous project
const filePath = doesProjectExist(previousProject);
if (filePath === null) {
logger.warning(LogOrigin.Server, `Previous project file ${previousProject} not found`);
return loadNewProject();
}
return loadNewProject();
try {
const fileData = await parseJsonFile(filePath);
const result = parseDatabaseModel(fileData);
let parsedFileName = previousProject;
let parsedFilePath = filePath;
if (result.errors.length > 0) {
logger.warning(LogOrigin.Server, 'Project loaded with errors');
parsedFileName = await handleCorruptedFile(filePath, previousProject);
parsedFilePath = getPathToProject(parsedFileName);
}
await initPersistence(parsedFilePath, result.data);
await setLastLoadedProject(parsedFileName);
return parsedFileName;
} catch (error) {
logger.warning(LogOrigin.Server, `Unable to load previous project ${previousProject}: ${getErrorMessage(error)}`);
await moveCorruptFile(filePath, previousProject).catch((_) => {
/* while we have to catch the error, we dont need to handle it */
});
return loadNewProject();
}
}
/**
* Loads a data from a file into the runtime
* @param fileName file name of the project including the extension
*/
export async function loadProjectFile(fileName: string): Promise<string> {
const filePath = doesProjectExist(fileName);
export async function loadProjectFile(name: string) {
const filePath = doesProjectExist(name);
if (filePath === null) {
throw new Error('Project file not found');
}
@@ -185,15 +155,31 @@ export async function loadProjectFile(fileName: string): Promise<string> {
// when loading a project file, we allow parsing to fail and interrupt the process
const fileData = await parseJsonFile(filePath);
const result = parseDatabaseModel(fileData);
let parsedFileName = fileName;
let parsedFileName = name;
let parsedFilePath = filePath;
if (result.errors.length > 0) {
logger.warning(LogOrigin.Server, 'Project loaded with errors');
parsedFileName = await handleCorruptedFile(filePath, fileName);
parsedFileName = await handleCorruptedFile(filePath, name);
parsedFilePath = getPathToProject(parsedFileName);
}
const projectName = await loadProject(result.data, parsedFileName);
return projectName;
// change LowDB to point to new file
await initPersistence(parsedFilePath, result.data);
logger.info(LogOrigin.Server, `Loaded project ${parsedFileName}`);
// persist the project selection
await setLastLoadedProject(parsedFileName);
// since load happens at runtime, we need to update the services that depend on the data
// apply data model
runtimeService.stop();
const { rundown, customFields } = result.data;
// apply the rundown
await initRundown(rundown, customFields);
}
/**
@@ -230,7 +216,7 @@ export async function duplicateProjectFile(originalFile: string, newFilename: st
/**
* Renames an existing project file
*/
export async function renameProjectFile(originalFile: string, newFilename: string): Promise<string> {
export async function renameProjectFile(originalFile: string, newFilename: string) {
const projectFilePath = doesProjectExist(originalFile);
if (projectFilePath === null) {
throw new Error('Project file not found');
@@ -248,31 +234,54 @@ export async function renameProjectFile(originalFile: string, newFilename: strin
const isLoaded = await isLastLoadedProject(originalFile);
if (isLoaded) {
const fileData = await parseJsonFile(pathToRenamed);
const projectData = parseDatabaseModel(fileData);
const result = parseDatabaseModel(fileData);
const newFileName = await loadProject(projectData.data, newFilename);
return newFileName;
// change LowDB to point to new file
await initPersistence(pathToRenamed, result.data);
logger.info(LogOrigin.Server, `Loaded project ${newFilename}`);
// persist the project selection
await setLastLoadedProject(newFilename);
// apply data model
runtimeService.stop();
const { rundown, customFields } = result.data;
// apply the rundown
await initRundown(rundown, customFields);
}
return newFilename;
}
/**
* Creates a new project file and applies its result
* @param fileName file name of the project including the extension
* @param initialData db to initialize the project with
*/
export async function createProject(fileName: string, initialData: Partial<DatabaseModel>): Promise<string> {
export async function createProject(filename: string, initialData: Partial<DatabaseModel>) {
const data = safeMerge(dbModel, initialData);
const fileNameWithExtension = generateUniqueFileName(publicDir.projectsDir, ensureJsonExtension(fileName));
await loadProject(data, fileNameWithExtension);
return fileNameWithExtension;
const fileNameWithExtension = ensureJsonExtension(filename);
const uniqueFileName = generateUniqueFileName(publicDir.projectsDir, fileNameWithExtension);
const newFile = getPathToProject(uniqueFileName);
// change LowDB to point to new file
await initPersistence(newFile, data);
// apply data to running services
// we dont need to parse since we are creating a new file
await patchCurrentProject(data);
// update app state to point to new value
setLastLoadedProject(uniqueFileName);
return uniqueFileName;
}
/**
* Deletes a project file
*/
export async function deleteProjectFile(filename: string) {
if (filename === currentProjectState.currentProjectName) {
const isPreviousProject = await isLastLoadedProject(filename);
if (isPreviousProject) {
throw new Error('Cannot delete currently loaded project');
}
@@ -291,26 +300,17 @@ export async function patchCurrentProject(data: Partial<DatabaseModel>) {
runtimeService.stop();
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- we need to remove the fields before merging
const { rundowns, customFields, ...rest } = data;
const { rundown, customFields, ...rest } = data;
// we can pass some stuff straight to the data provider
await getDataProvider().mergeIntoData(rest);
const newData = await getDataProvider().mergeIntoData(rest);
// ... but rundown and custom fields need to be checked
if (rundowns != null) {
const customFields = parseCustomFields(data);
const result = parseRundowns(data, customFields);
/**
* The user may have multiple rundowns
* We currently ignore all other rundowns
*/
const firstRundown = getFirstRundown(result);
await initRundown(firstRundown, customFields);
if (rundown != null) {
const result = parseRundown(data);
await initRundown(result.rundown, result.customFields);
}
const updatedData = await getDataProvider().getData();
return updatedData;
return newData;
}
/**

Some files were not shown because too many files have changed in this diff Show More