Files
ontime/apps/server/src/api-data/rundown/rundown.utils.ts
T
Carlos Valente 2498e59156 refactor: migrate custom fields to transactions
refactor: extract functions to api domain

refactor: strict custom field parsing

refactor: remove rundown cache utilities

refactor: directory restructure
2025-06-09 14:12:59 +02:00

361 lines
10 KiB
TypeScript

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,
isPublic: typeof patchEvent.isPublic === 'boolean' ? patchEvent.isPublic : originalEvent.isPublic,
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',
'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}`);
}
/**
* 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;
}