Files
ontime/apps/server/src/services/rundown-service/rundownCache.utils.ts
T
2025-05-14 20:07:42 +02:00

281 lines
8.8 KiB
TypeScript

import {
OntimeEvent,
CustomFieldLabel,
CustomFields,
OntimeEntry,
OntimeBaseEvent,
EntryId,
isOntimeEvent,
isPlayableEvent,
isOntimeDelay,
PlayableEvent,
RundownEntries,
} from 'ontime-types';
import { dayInMs, getLinkedTimes, getTimeFrom, isNewLatest } from 'ontime-utils';
import type { RundownMetadata } from './rundown.types.js';
/**
* Utility function to add an entry, mutates given assignedCustomFields in place
* @param label
* @param eventId
*/
export function addToCustomAssignment(
label: CustomFieldLabel,
eventId: string,
assignedCustomFields: Record<string, string[]>,
) {
if (!Array.isArray(assignedCustomFields[label])) {
assignedCustomFields[label] = [];
}
assignedCustomFields[label].push(eventId);
}
/**
* Sanitises custom fields and updates values if necessary
* Mutates in place mutableEvent and assignedCustomFields
*/
export function handleCustomField(
customFields: CustomFields,
customFieldChangelog: Record<string, string>,
mutableEvent: OntimeEvent,
assignedCustomFields: Record<string, string[]>,
) {
for (const field in mutableEvent.custom) {
// rename the property if it is in the changelog
if (field in customFieldChangelog) {
const oldData = mutableEvent.custom[field];
const newLabel = customFieldChangelog[field];
mutableEvent.custom[newLabel] = oldData;
delete mutableEvent.custom[field];
addToCustomAssignment(newLabel, mutableEvent.id, assignedCustomFields);
continue;
}
if (field in customFields) {
// add field to assignment map
addToCustomAssignment(field, mutableEvent.id, assignedCustomFields);
} else {
// delete data if it is not declared in project level custom fields
delete mutableEvent.custom[field];
}
}
}
/** List of event properties which do not need the rundown to be regenerated */
enum RegenerateWhitelist {
'id',
'cue',
'title',
'note',
'endAction',
'timerType',
'countToEnd',
'isPublic',
'colour',
'timeWarning',
'timeDanger',
'custom',
'triggers',
}
/**
* given a patch, returns whether all keys are whitelisted
*/
export function isDataStale(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],
);
}
/**
* 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;
}
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
};
export function makeRundownMetadata(customFields: CustomFields, customFieldChangelog: Record<string, string>) {
let rundownMeta: ProcessedRundownMetadata = {
totalDelay: 0,
totalDuration: 0,
totalDays: 0,
firstStart: null,
lastEnd: null,
assignedCustomFields: {},
playableEventOrder: [],
timedEventOrder: [],
flatEventOrder: [],
entries: {},
order: [],
previousEvent: null,
latestEvent: null,
};
function process<T extends OntimeEntry>(
entry: T,
childOfBlock: EntryId | null,
): { processedData: ProcessedRundownMetadata; processedEntry: T } {
const data = processEntry(rundownMeta, customFields, customFieldChangelog, entry, childOfBlock);
rundownMeta = data.processedData;
return data;
}
function getMetadata(): ProcessedRundownMetadata {
return rundownMeta;
}
return { process, getMetadata };
}
function processEntry<T extends OntimeEntry>(
rundownMetadata: ProcessedRundownMetadata,
customFields: CustomFields,
customFieldChangelog: Record<string, string>,
entry: T,
childOfBlock: EntryId | null,
): { processedData: ProcessedRundownMetadata; processedEntry: T } {
const processedData = { ...rundownMetadata };
const currentEntry = structuredClone(entry);
processedData.flatEventOrder.push(currentEntry.id);
if (isOntimeEvent(currentEntry)) {
if (!childOfBlock) {
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, customFieldChangelog, 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.currentBlock = childOfBlock;
// update rundown metadata, it only concerns playable events
if (isPlayableEvent(currentEntry)) {
if (!childOfBlock) {
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) {
processedData.totalDelay = Math.max(processedData.totalDelay - currentEntry.gap, 0);
}
// 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;
}
if (!childOfBlock) {
processedData.order.push(currentEntry.id);
}
processedData.entries[currentEntry.id] = currentEntry;
return { processedData, processedEntry: currentEntry };
}