mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-09 17:33:55 +00:00
Revert "Session version" (#1649)
* Revert "refactor: add version to session endpoint" This reverts commitcd40a6e55e. * Revert "Remove public event feature (#1645)" This reverts commit08d9e24871. * Revert "Fix: rearrange playing event (#1640)" This reverts commit0649678dca. * Revert "refactor: style tweaks to edit css modal" This reverts commita00ec2d02a. * Revert "refactor: remove usages of framer-motion" This reverts commit54a74ccc2a. * Revert "Upgrade expressjs (#1633)" This reverts commit6f3ab274bd. * Revert "Refactor: require trigger in all events objects (#1636)" This reverts commit90870ecfb6. * Revert "Fix: Correct boundary condition in applyDelay" This reverts commitb640e0e181. * Revert "let vite be the proxy to the dev server (#1630)" This reverts commitc41fe824cf. * Revert "refactor: migrate custom fields to transactions" This reverts commit62c8319d70. * Revert "refactor: simplify validations" This reverts commitb1d23467a2. * Revert "refactor: create transaction system and apply to adding entry (#1620)" This reverts commit9a62daf047. * Revert "Refactor: better rounding (#1594)" This reverts commitb9ab1c6fd7. * Revert "refactor: improve reorder logic" This reverts commitc1054711b0. * Revert "chore: simplify URLs" This reverts commita4d4f29a37. * Revert "refactor: order is single source of truth" This reverts commit2793aadea0. * Revert "refactor: refetch targets is enum" This reverts commite7cfb7d9d9. * Revert "refactor: small ux improvements" This reverts commit256a851c9b. * Revert "refactor: remove trivially inferred numEvents" This reverts commit4ab9c81cb8. * Revert "feat: duplicate groups" This reverts commit2f13d6c89e. * Revert "feat: create group from entry selection" This reverts commitf1f7bad25e. * Revert "feat: create block from rundown empty" This reverts commita8b52a48f7. * Revert "fix: collapsed blocks dont render children" This reverts commitcd0999b2ab. * Revert "refactor: type cleanup and test improvements" This reverts commitb4c60f3f04. * Revert "feat: allow dissolving a block" This reverts commit5cefad3666. * Revert "fix: uncontrolled prop on controlled component" This reverts commit7a6ecd8c34. * Revert "refactor: improve return of reorder" This reverts commitba96ecfd91. * Revert "refactor: mutations on batch elements must have IDs" This reverts commit7bed3757f2. * Revert "chore: upgrade dependencies" This reverts commite2e755b1d2. * Revert "refactor: extract utility to merge two arrays" This reverts commita77d23109d. * Revert "refactor: change network mode defaults" This reverts commit0eb3b8d382. * Revert "fix: delete nested events" This reverts commit0021185288. * Revert "fix: add event at end of block" This reverts commit94c72ff4f6. * Revert "refactor: make finder available in exported rundown" This reverts commite4c08dc9b2. * Revert "assert non null and update test (#1604)" This reverts commitec74af0d62. * Revert "Fix project renumber (#1597)" This reverts commitb6d72dd082. * Revert "Refactor: WebSocket from flush queue to one patch (#1595)" This reverts commit31c311daf0. * Revert "Refactor: ms for api calls (#1593)" This reverts commite9b3cc6090. * Revert "fix test (#1601)" This reverts commit543b04a097. * Revert "fix: rebase master" This reverts commitd39b85b6e6. * Revert "chore: correct test path" This reverts commitbbe107bb2b. * Revert "refactor: extract rundown parsing" This reverts commitc616240db1. * Revert "chore: improve convention entry <> event" This reverts commit166be66ce3. * Revert "refactor: maintain flat orders" This reverts commit4180d0a337. * Revert "refactor: implement operations on nested events" This reverts commit78108e316c. * Revert "refactor: process events in rundown" This reverts commit3bb8b70915. * Revert "chore: improve convention entry <> event" This reverts commit1c4f13a0ed. * Revert "chore: rename currentBlock > parent" This reverts commit3ca0abad53. * Revert "refactor: fix delay positioning in gaps" This reverts commit030c8f897f. * Revert "refactor(e2e): skip flaky test" This reverts commit68175cfa3b. * Revert "refactor: improve project loading" This reverts commitfd8f757851. * Revert "refactor: gather group metadata" This reverts commit876d111c61. * Revert "refactor: swap maintains schedule" This reverts commit730cb95c04. * Revert "chore: rename files" This reverts commit3c388d4fb5. * Revert "refactor: restructure model to contain an object of rundowns" This reverts commit2e23718d73. * Revert "refactor: clearer relationship on rundown elements" This reverts commit89ea8c470b. * Revert "refactor: use strict typing" This reverts commit178640bfc4. * Revert "refactor: remove stop as a possible end action" This reverts commit4ed38340e0. * Revert "refactor: restructure model to contain an object of rundowns" This reverts commit69eb9a5eff. * Revert "chore: remove IDE files" This reverts commit351425127a. * Revert "refactor: remove unused and legacy code" This reverts commit95f2ba37cc.
This commit is contained in:
committed by
GitHub
parent
ad6804019b
commit
722e045b20
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
+15
-15
@@ -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
@@ -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);
|
||||
+10
-5
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
@@ -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
Reference in New Issue
Block a user