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
This commit is contained in:
Carlos Valente
2025-06-06 21:08:30 +02:00
committed by arc-alex
parent f3b4ea0155
commit 2498e59156
75 changed files with 2060 additions and 2480 deletions
@@ -9,15 +9,21 @@ import {
isOntimeEvent,
isOntimeDelay,
isOntimeBlock,
CustomFieldKey,
EntryId,
OntimeEntry,
PlayableEvent,
RundownEntries,
isPlayableEvent,
} from 'ontime-types';
import { isObjectEmpty, generateId } from 'ontime-utils';
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 { ErrorEmitter } from '../../utils/parser.js';
import { parseCustomFields } from '../../utils/parserFunctions.js';
import type { ErrorEmitter } from '../../utils/parserUtils.js';
import { createEvent } from './rundown.utils.js';
import { calculateDayOffset, createEvent } from './rundown.utils.js';
import { RundownMetadata } from './rundown.types.js';
/**
* Parse a rundowns object along with the project custom fields
@@ -25,21 +31,16 @@ import { createEvent } from './rundown.utils.js';
*/
export function parseRundowns(
data: Partial<DatabaseModel>,
parsedCustomFields: Readonly<CustomFields>,
emitError?: ErrorEmitter,
): { customFields: CustomFields; rundowns: ProjectRundowns } {
// check custom fields first
const parsedCustomFields = parseCustomFields(data, emitError);
): 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 {
customFields: parsedCustomFields,
rundowns: {
default: {
...defaultRundown,
},
[defaultRundown.id]: {
...defaultRundown,
},
};
}
@@ -55,7 +56,7 @@ export function parseRundowns(
parsedRundowns[parsedRundown.id] = parsedRundown;
}
return { customFields: parsedCustomFields, rundowns: parsedRundowns };
return parsedRundowns;
}
/**
@@ -81,7 +82,7 @@ export function parseRundown(
const entryId = rundown.order[i];
const event = rundown.entries[entryId];
if (event === undefined) {
if (!event) {
emitError?.('Could not find referenced event, skipping');
continue;
}
@@ -170,3 +171,195 @@ export function parseRundown(
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 };
}