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

* refactor: create transaction system and apply to adding entry

* refactor: migrate edit mutations to transaction

* refactor: migrate delete mutation to transaction

* refactor: migrate reorder mutation to transaction

* refactor: migrate apply delay to transaction

* refactor: migrate swapEvents to transaction

* refactor: migrate clone to transaction

* refactor: migrate group/ungroup transactions

* refactor: simplify mutations

* refactor: migrate getters

* chore: add tests to processRundown()
This commit is contained in:
Carlos Valente
2025-06-05 06:14:29 +02:00
committed by arc-alex
parent 33ac05ebee
commit aebf949883
37 changed files with 3204 additions and 2704 deletions
@@ -1,7 +1,7 @@
import { PlayableEvent, TimerLifeCycle } from 'ontime-types';
import { makeRuntimeStateData } from '../../../stores/__mocks__/runtimeState.mocks.js';
import { makeOntimeEvent } from '../../../services/rundown-service/__mocks__/rundown.mocks.js';
import { makeOntimeEvent } from '../../rundown/__mocks__/rundown.mocks.js';
import { deleteAllTriggers, addTrigger, addAutomation } from '../automation.dao.js';
import { testConditions, triggerAutomations } from '../automation.service.js';
@@ -3,15 +3,13 @@ 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';
import { createCustomField, editCustomField, removeCustomField } from '../../services/rundown-service/rundownCache.js';
import { getProjectCustomFields } from '../rundown/rundown.dao.js';
export async function getCustomFields(_req: Request, res: Response<CustomFields>) {
const customFields = getCustomFieldsFromCache();
const customFields = getProjectCustomFields();
res.json(customFields);
}
@@ -14,9 +14,9 @@ import type { WorkBook } from 'xlsx';
import { parseExcel } from '../../utils/parser.js';
import { parseCustomFields } from '../../utils/parserFunctions.js';
import { deleteFile } from '../../utils/parserUtils.js';
import { getCustomFields } from '../../services/rundown-service/rundownCache.js';
import { parseRundown } from '../rundown/rundown.parser.js';
import { getProjectCustomFields } from '../rundown/rundown.dao.js';
let excelData: WorkBook = xlsx.utils.book_new();
@@ -45,7 +45,7 @@ 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, getCustomFields(), options.worksheet, options);
const dataFromExcel = parseExcel(arrayOfData, getProjectCustomFields(), options.worksheet, options);
const parsedCustomFields = parseCustomFields(dataFromExcel);
// we run the parsed data through an extra step to ensure the objects shape
@@ -0,0 +1,54 @@
import { SupportedEntry, OntimeEvent, OntimeDelay, OntimeBlock, Rundown } 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,
};
}
/**
* 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,7 +1,7 @@
import { SupportedEntry, OntimeEvent, OntimeBlock, Rundown } from 'ontime-types';
import { defaultRundown } from '../../../models/dataModel.js';
import { makeOntimeBlock, makeOntimeEvent } from '../../../services/rundown-service/__mocks__/rundown.mocks.js';
import { makeOntimeBlock, makeOntimeEvent } from '../__mocks__/rundown.mocks.js';
import { parseRundowns, parseRundown } from '../rundown.parser.js';
@@ -1,6 +1,8 @@
import { TimeStrategy, EndAction, TimerType, OntimeEvent } from 'ontime-types';
import { assertType } from 'vitest';
import { createEvent } from '../rundown.utils.js';
import { createEvent, deleteById, doesInvalidateMetadata, hasChanges } from '../rundown.utils.js';
describe('test event validator', () => {
it('validates a good object', () => {
@@ -78,3 +80,79 @@ describe('test event validator', () => {
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,
isPublic: false,
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']);
});
});
@@ -1,164 +0,0 @@
import { ErrorResponse, MessageResponse, OntimeEntry, ProjectRundownsList, Rundown } from 'ontime-types';
import { getErrorMessage } from 'ontime-utils';
import type { Request, Response } from 'express';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import {
addEvent,
applyDelay,
batchEditEvents,
deleteAllEntries,
deleteEvent,
editEvent,
ungroupEntries,
groupEntries,
swapEvents,
cloneEntry,
} from '../../services/rundown-service/RundownService.js';
import { getEntryWithId, getCurrentRundown } from '../../services/rundown-service/rundownUtils.js';
export async function rundownGetAll(_req: Request, res: Response<ProjectRundownsList>) {
const rundown = getCurrentRundown();
res.json([{ id: rundown.id, title: rundown.title, numEntries: rundown.order.length, revision: rundown.revision }]);
}
export async function rundownGetCurrent(_req: Request, res: Response<Rundown>) {
const cachedRundown = getCurrentRundown();
res.json(cachedRundown);
}
export async function rundownGetById(req: Request, res: Response<OntimeEntry | ErrorResponse>) {
const { eventId } = req.params;
try {
const event = getEntryWithId(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 rundownPost(req: Request, res: Response<OntimeEntry | 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<OntimeEntry | 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 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.entryId);
res.status(200).send({ message: 'Delay applied' });
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
export async function rundownCloneEntry(req: Request, res: Response<Rundown | ErrorResponse>) {
try {
const newRundown = await cloneEntry(req.params.entryId);
res.status(200).send(newRundown);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
export async function rundownUngroupEntries(req: Request, res: Response<Rundown | ErrorResponse>) {
try {
const newRundown = await ungroupEntries(req.params.entryId);
res.status(200).send(newRundown);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
export async function rundownAddToBlock(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 });
}
}
export async function rundownDelete(_req: Request, res: Response<MessageResponse | ErrorResponse>) {
try {
await deleteAllEntries();
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 });
}
}
@@ -0,0 +1,598 @@
/**
* 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 {
CustomFields,
EntryId,
isOntimeBlock,
isOntimeEvent,
isPlayableEvent,
OntimeBlock,
OntimeDelay,
OntimeEntry,
OntimeEvent,
PatchWithId,
Rundown,
} from 'ontime-types';
import { insertAtIndex } from 'ontime-utils';
import { makeRundownMetadata, ProcessedRundownMetadata } from '../../services/rundown-service/rundownCache.utils.js';
import { customFieldChangelog } from '../../services/rundown-service/rundownCache.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import type { RundownMetadata } from './rundown.types.js';
import {
applyPatchToEntry,
cloneBlock,
cloneEntry,
createBlock,
deleteById,
doesInvalidateMetadata,
getUniqueId,
} from './rundown.utils.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: [],
assignedCustomFields: {},
};
/**
* 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 getProjectCustomFields = (): Readonly<CustomFields> => projectCustomFields;
export function createTransaction() {
const rundown = structuredClone(cachedRundown);
const customFields = projectCustomFields;
function commit(shouldProcess: boolean = true) {
// schedule a database update
setImmediate(async () => {
await getDataProvider().setRundown(cachedRundown.id, cachedRundown);
});
const revision = rundown.revision + 1;
cachedRundown.revision = revision;
/**
* Some mutations do not require processing the rundown
* We simply increment the revision and return the rundown
*/
if (!shouldProcess) {
cachedRundown.entries = rundown.entries;
cachedRundown.order = rundown.order;
cachedRundown.flatOrder = rundown.flatOrder;
return { rundown, rundownMetadata, customFields: projectCustomFields, revision: cachedRundown.revision };
}
const processedData = processRundown(rundown, projectCustomFields);
// update the cache values
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- we are not interested in the iteration data
const { previousEvent, latestEvent, previousEntry, entries, order, ...metadata } = processedData;
cachedRundown.entries = entries;
cachedRundown.order = order;
cachedRundown.flatOrder = metadata.flatEntryOrder; // TODO: remove in favour of the metadata flatEntryOrder
rundownMetadata = metadata;
return { rundown, rundownMetadata, customFields: projectCustomFields, revision: cachedRundown.revision };
}
return {
customFields,
rundown,
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 === rundown.order.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 duplcate 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,
};
/**
* 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, ...metadata } = processedData;
cachedRundown.entries = entries;
cachedRundown.order = order;
cachedRundown.flatOrder = metadata.flatEntryOrder; // TODO: remove in favour of the metadata flatEntryOrder
cachedRundown.revision = rundown.revision;
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, customFieldChangelog);
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,26 +1,23 @@
import { ErrorResponse, Rundown } from 'ontime-types';
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 { reorderEntry } from '../../services/rundown-service/RundownService.js';
import { getCurrentRundown } from './rundown.dao.js';
import {
deletesEventById,
rundownAddToBlock,
rundownApplyDelay,
rundownBatchPut,
rundownCloneEntry,
rundownDelete,
rundownUngroupEntries,
rundownGetAll,
rundownGetById,
rundownGetCurrent,
rundownPost,
rundownPut,
rundownSwap,
} from './rundown.controller.js';
addEntry,
applyDelay,
batchEditEntries,
cloneEntry,
deleteAllEntries,
deleteEntries,
editEntry,
groupEntries,
reorderEntry,
swapEvents,
ungroupEntries,
} from './rundown.service.js';
import {
paramsMustHaveEntryId,
rundownArrayOfIds,
@@ -33,14 +30,53 @@ import {
export const router = express.Router();
router.get('/', rundownGetAll);
router.get('/current', rundownGetCurrent);
router.get('/:eventId', paramsMustHaveEntryId, rundownGetById); // not used in Ontime frontend
/**
* Returns all rundowns in the project
*/
router.get('/', async (_req: Request, res: Response<ProjectRundownsList>) => {
const rundown = getCurrentRundown();
router.post('/', rundownPostValidator, rundownPost);
// 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.put('/', rundownPutValidator, rundownPut);
router.put('/batch', rundownBatchPutValidator, rundownBatchPut);
/**
* Returns the current rundown
*/
router.get('/current', async (_req: Request, res: Response<Rundown>) => {
const rundown = getCurrentRundown();
res.json(rundown);
});
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.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 {
@@ -52,11 +88,81 @@ router.patch('/reorder', rundownReorderValidator, async (req: Request, res: Resp
res.status(400).send({ message });
}
});
router.patch('/swap', rundownSwapValidator, rundownSwap);
router.patch('/applydelay/:entryId', paramsMustHaveEntryId, rundownApplyDelay);
router.post('/clone/:entryId', paramsMustHaveEntryId, rundownCloneEntry);
router.post('/ungroup/:entryId', paramsMustHaveEntryId, rundownUngroupEntries);
router.post('/group', rundownArrayOfIds, rundownAddToBlock);
router.delete('/', rundownArrayOfIds, deletesEventById);
router.delete('/all', rundownDelete);
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/:entryId',
paramsMustHaveEntryId,
async (req: Request, res: Response<Rundown | ErrorResponse>) => {
try {
const newRundown = await applyDelay(req.params.entryId);
res.status(200).send(newRundown);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
},
);
router.post('/clone/:entryId', paramsMustHaveEntryId, async (req: Request, res: Response<Rundown | ErrorResponse>) => {
try {
const newRundown = await cloneEntry(req.params.entryId);
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/:entryId',
paramsMustHaveEntryId,
async (req: Request, res: Response<Rundown | ErrorResponse>) => {
try {
const newRundown = await ungroupEntries(req.params.entryId);
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 });
}
});
@@ -0,0 +1,475 @@
import {
CustomFields,
EntryId,
EventPostPayload,
isOntimeBlock,
isOntimeDelay,
isOntimeEvent,
OntimeEntry,
PatchWithId,
Rundown,
} from 'ontime-types';
import { getPreviousId } from '../../services/rundown-service/rundownUtils.js';
import { updateRundownData } from '../../stores/runtimeState.js';
import { sendRefetch } from '../../adapters/websocketAux.js';
import { runtimeService } from '../../services/runtime-service/RuntimeService.js';
import { createTransaction, rundownCache, rundownMutation } from './rundown.dao.js';
import { RundownMetadata } from './rundown.types.js';
import { generateEvent, hasChanges } from './rundown.utils.js';
/**
* creates a new entry with given data
*/
export async function addEntry(eventData: EventPostPayload): Promise<OntimeEntry> {
const { rundown, commit } = createTransaction();
// 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 = getPreviousId(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();
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 (isOntimeEvent(patch) && patch?.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: [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();
/**
* 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: 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();
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();
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();
// 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();
// 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();
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();
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();
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();
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;
}
/**
* 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 });
}
@@ -0,0 +1,20 @@
import { CustomFieldLabel, 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
/**
* Keep track of which custom fields are used.
* This will be handy for when we delete custom fields
* since we can clear the custom fields from every event where they are used
*/
assignedCustomFields: Record<CustomFieldLabel, string[]>;
};
@@ -1,9 +1,57 @@
import { OntimeBlock, OntimeEvent, SupportedEntry, TimeStrategy } from 'ontime-types';
import { generateId, validateEndAction, validateTimerType, validateTimes } from 'ontime-utils';
import {
EntryId,
isOntimeBlock,
isOntimeDelay,
isOntimeEvent,
OntimeBaseEvent,
OntimeBlock,
OntimeDelay,
OntimeEntry,
OntimeEvent,
Rundown,
SupportedEntry,
TimeStrategy,
} from 'ontime-types';
import { generateId, getCueCandidate, validateEndAction, validateTimerType, validateTimes } from 'ontime-utils';
import { event as eventDef, block as blockDef } from '../../models/eventsDefinition.js';
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;
@@ -46,6 +94,26 @@ export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial<Onti
};
}
/**
* 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
@@ -110,3 +178,125 @@ function inferStrategy(end: unknown, duration: unknown, fallback: TimeStrategy):
}
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',
'isPublic',
'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}`);
}
@@ -25,7 +25,8 @@ export const rundownPutValidator = [
export const rundownBatchPutValidator = [
body('data').isObject().exists(),
body('ids').isArray().exists(),
body('ids').isArray().notEmpty(),
body('ids.*').isString(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);