mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-15 04:13:47 +00:00
624 lines
19 KiB
TypeScript
624 lines
19 KiB
TypeScript
import {
|
|
CustomField,
|
|
CustomFieldKey,
|
|
CustomFields,
|
|
EntryId,
|
|
EventPostPayload,
|
|
isOntimeGroup,
|
|
isOntimeDelay,
|
|
isOntimeEvent,
|
|
OntimeGroup,
|
|
OntimeEntry,
|
|
PatchWithId,
|
|
RefetchKey,
|
|
Rundown,
|
|
LogOrigin,
|
|
ProjectRundowns,
|
|
InsertOptions,
|
|
} from 'ontime-types';
|
|
import { customFieldLabelToKey, getInsertAfterId, resolveInsertParent } from 'ontime-utils';
|
|
|
|
import { updateRundownData } from '../../stores/runtimeState.js';
|
|
import { runtimeService } from '../../services/runtime-service/runtime.service.js';
|
|
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
|
|
import { setLastLoadedRundown } from '../../services/app-state-service/AppStateService.js';
|
|
import { logger } from '../../classes/Logger.js';
|
|
|
|
import {
|
|
createTransaction,
|
|
customFieldMutation,
|
|
getCurrentRundown,
|
|
rundownCache,
|
|
rundownMutation,
|
|
updateBackgroundRundown,
|
|
} from './rundown.dao.js';
|
|
import type { RundownMetadata } from './rundown.types.js';
|
|
import { generateEvent, hasChanges } from './rundown.utils.js';
|
|
import { getDataProvider } from '../../classes/data-provider/DataProvider.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`);
|
|
}
|
|
|
|
// resolve the parent, either from the payload or inferred from sibling position
|
|
const parentId = resolveInsertParent(rundown, eventData);
|
|
let parent: OntimeGroup | null = null;
|
|
if (parentId) {
|
|
const maybeParent = rundown.entries[parentId];
|
|
if (!maybeParent || !isOntimeGroup(maybeParent)) {
|
|
throw new Error(`Invalid parent event with ID ${parentId}`);
|
|
}
|
|
parent = maybeParent;
|
|
}
|
|
|
|
// normalise the position of the event in the rundown order
|
|
const afterId = getInsertAfterId(rundown, parent, 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 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 group order and top level order)
|
|
* @throws if entryId or destinationId not found
|
|
*/
|
|
export async 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
|
|
* Handles cloning children if the entry is a group
|
|
* @throws if the entry to clone does not exist
|
|
*/
|
|
export async function cloneEntry(entryId: EntryId, options: InsertOptions): Promise<Rundown> {
|
|
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
|
|
const originalEntry = rundown.entries[entryId];
|
|
|
|
if (!originalEntry) {
|
|
throw new Error('Could not find entry to clone');
|
|
}
|
|
|
|
const newEntry = rundownMutation.clone(rundown, originalEntry, options);
|
|
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 (isOntimeGroup(newEntry)) {
|
|
notifyChanges(rundownMetadata, revision, { timer: newEntry.entries, external: true });
|
|
} else if (isOntimeEvent(newEntry)) {
|
|
notifyChanges(rundownMetadata, revision, { timer: [newEntry.id], external: true });
|
|
} else if (isOntimeDelay(newEntry)) {
|
|
notifyChanges(rundownMetadata, revision, { external: true });
|
|
}
|
|
});
|
|
|
|
return rundownResult;
|
|
}
|
|
|
|
/**
|
|
* Groups a list of entries into a new group
|
|
*/
|
|
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 need to notify the timer since we might be grouping a running event
|
|
notifyChanges(rundownMetadata, revision, { external: true, timer: true });
|
|
});
|
|
|
|
return rundownResult;
|
|
}
|
|
|
|
/**
|
|
* Deletes a group and moves all its children to the top level
|
|
*/
|
|
export async function ungroupEntries(groupId: EntryId): Promise<Rundown> {
|
|
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
|
|
|
|
const group = rundown.entries[groupId];
|
|
if (!group || !isOntimeGroup(group)) {
|
|
throw new Error(`Group with ID ${groupId} not found or is not a group`);
|
|
}
|
|
|
|
rundownMutation.ungroup(rundown, group);
|
|
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);
|
|
|
|
setImmediate(() => {
|
|
sendRefetch(RefetchKey.CustomFields);
|
|
});
|
|
|
|
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>,
|
|
projectRundowns: ProjectRundowns,
|
|
): Promise<CustomFields> {
|
|
const { customFields, 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 ...
|
|
if (oldKey !== newKey) {
|
|
// ... reassign references in the active rundown
|
|
customFieldMutation.renameUsages(rundown, oldKey, newKey);
|
|
|
|
// ... reassign references in the background rundowns
|
|
for (const rundownId of Object.keys(projectRundowns)) {
|
|
if (rundownId !== rundown.id) {
|
|
const backgroundRundown = structuredClone(projectRundowns[rundownId]);
|
|
customFieldMutation.renameUsages(backgroundRundown, oldKey, newKey);
|
|
await updateBackgroundRundown(rundownId, backgroundRundown);
|
|
}
|
|
}
|
|
|
|
// ... delete the old key
|
|
customFieldMutation.remove(customFields, oldKey);
|
|
}
|
|
|
|
// 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(() => {
|
|
sendRefetch(RefetchKey.CustomFields);
|
|
notifyChanges(rundownMetadata, revision, { timer: true, external: true });
|
|
});
|
|
|
|
return resultCustomFields;
|
|
}
|
|
|
|
/**
|
|
* Deletes an existing custom field
|
|
*/
|
|
export async function deleteCustomField(key: CustomFieldKey, projectRundowns: ProjectRundowns): Promise<CustomFields> {
|
|
const { customFields, rundown, commit } = createTransaction({
|
|
mutableRundown: true,
|
|
mutableCustomFields: true,
|
|
});
|
|
if (!(key in customFields)) {
|
|
return customFields;
|
|
}
|
|
|
|
// remove references in the active rundown
|
|
customFieldMutation.removeUsages(rundown, key);
|
|
|
|
// remove references in the background rundowns
|
|
for (const rundownId of Object.keys(projectRundowns)) {
|
|
if (rundownId !== rundown.id) {
|
|
const backgroundRundown = structuredClone(projectRundowns[rundownId]);
|
|
customFieldMutation.removeUsages(backgroundRundown, key);
|
|
await updateBackgroundRundown(rundownId, backgroundRundown);
|
|
}
|
|
}
|
|
|
|
// delete the old key
|
|
customFieldMutation.remove(customFields, 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(() => {
|
|
sendRefetch(RefetchKey.CustomFields);
|
|
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
|
|
*/
|
|
function notifyChanges(rundownMetadata: RundownMetadata, revision: number, options: NotifyChangesOptions) {
|
|
// notify timer service of changed events
|
|
if (options.timer) {
|
|
runtimeService.notifyOfChangedEvents(rundownMetadata);
|
|
}
|
|
|
|
// notify external services of changes
|
|
if (options.reload) {
|
|
sendRefetch(RefetchKey.All);
|
|
} else if (options.external) {
|
|
sendRefetch(RefetchKey.Rundown, revision);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @throws if the provided id does not exist
|
|
*/
|
|
export async function loadRundown(id: string) {
|
|
const dataProvider = getDataProvider();
|
|
if (id === getCurrentRundown().id) {
|
|
return dataProvider.getProjectRundowns();
|
|
}
|
|
const rundown = dataProvider.getRundown(id);
|
|
const customField = dataProvider.getCustomFields();
|
|
await initRundown(rundown, customField);
|
|
return dataProvider.getProjectRundowns();
|
|
}
|
|
|
|
/**
|
|
* 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>,
|
|
reload: boolean = false,
|
|
) {
|
|
runtimeService.stop();
|
|
const { rundownMetadata, revision } = rundownCache.init(rundown, customFields);
|
|
logger.info(LogOrigin.Server, `Switch to rundown: ${rundown.id}`);
|
|
// notify runtime that rundown has changed
|
|
updateRuntimeOnChange(rundownMetadata);
|
|
|
|
setImmediate(() => {
|
|
notifyChanges(rundownMetadata, revision, { timer: true, external: true, reload });
|
|
setLastLoadedRundown(rundown.id).catch((error) => {
|
|
logger.error(LogOrigin.Server, `Failed to persist last loaded rundown: ${error}`);
|
|
});
|
|
});
|
|
}
|