Files
ontime/apps/server/src/api-data/rundown/rundown.service.ts
T
2026-07-22 07:17:39 +02:00

901 lines
29 KiB
TypeScript

import {
CustomField,
CustomFieldKey,
CustomFields,
EntryId,
EventPostPayload,
ImportedFields,
InsertOptions,
LogOrigin,
OntimeEntry,
OntimeGroup,
PatchWithId,
ProjectRundowns,
RefetchKey,
Rundown,
RundownImportMergeStrategy,
isOntimeDelay,
isOntimeEvent,
isOntimeGroup,
} from 'ontime-types';
import { customFieldLabelToKey, generateId, getInsertAfterId, resolveInsertParent } from 'ontime-utils';
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import { logger } from '../../classes/Logger.js';
import { makeNewRundown } from '../../models/dataModel.js';
import { setLastLoadedRundown } from '../../services/app-state-service/AppStateService.js';
import { runtimeService } from '../../services/runtime-service/runtime.service.js';
import { updateRundownData } from '../../stores/runtimeState.js';
import { parseCustomFields } from '../custom-fields/customFields.parser.js';
import {
createTransaction,
customFieldMutation,
getCurrentRundownId,
rundownCache,
rundownMutation,
updateBackgroundRundown,
} from './rundown.dao.js';
import { parseRundown } from './rundown.parser.js';
import type { RundownMetadata } from './rundown.types.js';
import {
generateEvent,
getFirstInsertId,
getIntegerAndFraction,
getLastInsertId,
getPreviousInsertId,
hasChanges,
mergeRundownPreservingFields,
isLoadedPlayable,
} from './rundown.utils.js';
import { assertInsertAnchorExists, assertInsertAnchorInOrder, assertSingleInsertAnchor } from './rundown.validation.js';
/**
* creates a new entry with given data
*/
export async function addEntry(rundownId: string, eventData: EventPostPayload): Promise<OntimeEntry> {
const { rundown, commit } = createTransaction({ rundownId, mutableRundown: true });
assertSingleInsertAnchor(eventData);
// 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;
}
assertInsertAnchorInOrder(rundown, parent, eventData);
// normalise the position of the event in the rundown order
const insertPosition = (() => {
if (eventData.before !== undefined) {
const beforeId = getFirstInsertId(rundown, parent, eventData.before);
return { afterId: null, beforeId, cueAfterId: getPreviousInsertId(rundown, parent, beforeId) };
}
// if after is an ID, we will try and find the entry
if (eventData.after !== undefined && eventData.after !== true) {
const afterId = getInsertAfterId(rundown, parent, eventData.after);
return { afterId, beforeId: null, cueAfterId: afterId };
}
return { afterId: null, beforeId: null, cueAfterId: getLastInsertId(rundown, parent) };
})();
// generate a fully formed entry from the patch
const newEntry = generateEvent(rundown, eventData, insertPosition.cueAfterId, parent?.id);
// make mutations to rundown
rundownMutation.add(rundown, newEntry, parent, insertPosition.afterId, insertPosition.beforeId);
const { rundown: responseRundown, rundownMetadata, revision } = await commit();
// schedule the side effects
setImmediate(() => {
// notify runtime that rundown has changed
updateRuntimeOnChange(rundownMetadata);
// notify timer and external services of change
notifyChanges(rundown.id, rundownMetadata, revision, { timer: [newEntry.id], external: true });
});
return responseRundown.entries[newEntry.id] ?? newEntry;
}
/**
* Applies a patch to an entry in the rundown
*/
export async function editEntry(rundownId: string, patch: PatchWithId): Promise<OntimeEntry> {
const { rundown, commit } = createTransaction({ rundownId, mutableRundown: true });
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 { rundown: responseRundown, rundownMetadata, revision } = await commit(didInvalidate);
// schedule the side effects
setImmediate(() => {
// notify runtime that rundown has changed
updateRuntimeOnChange(rundownMetadata);
// notify timer and external services of change
notifyChanges(rundown.id, rundownMetadata, revision, {
timer: didInvalidate ? true : [entry.id],
external: true,
});
});
return responseRundown.entries[entry.id] ?? entry;
}
/**
* Applies a patch to several entries in the rundown
*/
export async function batchEditEntries(
rundownId: string,
ids: EntryId[],
patch: Partial<OntimeEntry>,
): Promise<Rundown> {
const { rundown, commit } = createTransaction({ rundownId, mutableRundown: true });
/**
* 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[] = [];
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 { didInvalidate } = rundownMutation.edit(rundown, { ...patch, id: currentId });
changedIds.push(currentId);
if (didInvalidate) {
batchDidInvalidate = true;
}
}
const { rundown: rundownResult, rundownMetadata, revision } = await commit(batchDidInvalidate);
// schedule the side effects
setImmediate(() => {
// notify runtime that rundown has changed
updateRuntimeOnChange(rundownMetadata);
// notify timer and external services of change
notifyChanges(rundown.id, rundownMetadata, revision, {
timer: batchDidInvalidate ? true : changedIds,
external: true,
});
});
return rundownResult;
}
/**
* Deletes a known entry from the current rundown
*/
export async function deleteEntries(rundownId: string, entryIds: EntryId[]): Promise<Rundown> {
const { rundown, commit } = createTransaction({ rundownId, mutableRundown: true });
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 } = await commit();
// schedule the side effects
setImmediate(() => {
// notify runtime that rundown has changed
updateRuntimeOnChange(rundownMetadata);
// notify timer and external services of change
notifyChanges(rundown.id, rundownMetadata, revision, { timer: entryIds, external: true });
});
return rundownResult;
}
/**
* Deletes all entries from the current rundown
*/
export async function deleteAllEntries(rundownId: string): Promise<Rundown> {
const { rundown, commit } = createTransaction({ rundownId, mutableRundown: true });
rundownMutation.removeAll(rundown);
const { rundown: rundownResult, rundownMetadata, revision } = await commit();
// schedule the side effects
setImmediate(() => {
// notify runtime that rundown has changed
updateRuntimeOnChange(rundownMetadata);
// notify timer and external services of change
notifyChanges(rundown.id, 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(
rundownId: string,
entryId: EntryId,
destinationId: EntryId,
order: 'before' | 'after' | 'insert',
) {
const { rundown, commit } = createTransaction({ rundownId, mutableRundown: true });
// 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 } = await commit();
// schedule the side effects
setImmediate(() => {
// notify runtime that rundown has changed
updateRuntimeOnChange(rundownMetadata);
// notify timer and external services of change
notifyChanges(rundown.id, rundownMetadata, revision, { timer: true, external: true });
});
return rundownResult;
}
/**
* @throws if an id is missing or not an Ontime event
*/
export async function renumberEntries(
rundownId: string,
ids: EntryId[],
prefix: string,
start: string,
increment: string,
): Promise<Rundown> {
const startNumber = getIntegerAndFraction(start);
const incrementNumber = getIntegerAndFraction(increment);
// if the prefix doesn't already include a separator or is empty, then insert a separator
if (prefix !== '' && !prefix.endsWith('-') && !prefix.endsWith(' ')) prefix += ' ';
const { rundown, commit } = createTransaction({ rundownId, mutableRundown: true, mutableCustomFields: false });
rundownMutation.renumber(rundown, ids, prefix, startNumber, incrementNumber);
const { rundown: rundownResult, rundownMetadata, revision } = await commit(false);
setImmediate(() => {
updateRuntimeOnChange(rundownMetadata);
notifyChanges(rundown.id, rundownMetadata, revision, { timer: ids, external: true });
});
return rundownResult;
}
/**
* Applies a delay into the rundown effectively changing the schedule
* The applied delay is deleted
*/
export async function applyDelay(rundownId: string, delayId: EntryId): Promise<Rundown> {
const { rundown, commit } = createTransaction({ rundownId, mutableRundown: true });
// 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 } = await commit();
// schedule the side effects
setImmediate(() => {
// notify runtime that rundown has changed
updateRuntimeOnChange(rundownMetadata);
// notify timer and external services of change
notifyChanges(rundown.id, rundownMetadata, revision, { timer: true, external: true });
});
return rundownResult;
}
/**
* Swaps the data between two events in the rundown
*/
export async function swapEvents(rundownId: string, fromId: EntryId, toId: EntryId): Promise<Rundown> {
const { rundown, commit } = createTransaction({ rundownId, mutableRundown: true });
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 } = await commit();
// schedule the side effects
setImmediate(() => {
// notify runtime that rundown has changed
updateRuntimeOnChange(rundownMetadata);
// notify timer and external services of change
notifyChanges(rundown.id, 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(rundownId: string, entryId: EntryId, options: InsertOptions): Promise<Rundown> {
assertSingleInsertAnchor(options);
const { rundown, commit } = createTransaction({ rundownId, mutableRundown: true });
assertInsertAnchorExists(rundown, options);
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 } = await 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(rundown.id, rundownMetadata, revision, { timer: newEntry.entries, external: true });
} else if (isOntimeEvent(newEntry)) {
notifyChanges(rundown.id, rundownMetadata, revision, { timer: [newEntry.id], external: true });
} else if (isOntimeDelay(newEntry)) {
notifyChanges(rundown.id, rundownMetadata, revision, { external: true });
}
});
return rundownResult;
}
/**
* Groups a list of entries into a new group
*/
export async function groupEntries(rundownId: string, entryIds: EntryId[]): Promise<Rundown> {
const { rundown, commit } = createTransaction({ rundownId, mutableRundown: true });
rundownMutation.group(rundown, entryIds);
const { rundown: rundownResult, rundownMetadata, revision } = await 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(rundown.id, rundownMetadata, revision, { external: true, timer: true });
});
return rundownResult;
}
/**
* Deletes a group and moves all its children to the top level
*/
export async function ungroupEntries(rundownId: string, groupId: EntryId): Promise<Rundown> {
const { rundown, commit } = createTransaction({ rundownId, mutableRundown: true });
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 } = await 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(rundown.id, 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 } = await 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 } = await commit(false);
// schedule the side effects
setImmediate(() => {
sendRefetch(RefetchKey.CustomFields);
notifyChanges(undefined, 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 } = await commit(false);
// schedule the side effects
setImmediate(() => {
sendRefetch(RefetchKey.CustomFields);
notifyChanges(undefined, 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 | null) {
if (!rundownMetadata) return;
// 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
* TODO: we could receive a runtime flag to call updateRuntimeOnChange
* instead of having it in every consumer
*/
function notifyChanges(
rundownId: string | undefined,
rundownMetadata: RundownMetadata | null,
revision: number,
options: NotifyChangesOptions,
) {
// notify timer service of changed event
if (rundownMetadata && options.timer && rundownId && isCurrentRundown(rundownId)) {
runtimeService.notifyOfChangedEvents(rundownMetadata);
}
if (options.reload) {
sendRefetch(RefetchKey.All);
} else if (options.external) {
sendRefetch(RefetchKey.Rundown, revision, rundownId);
}
}
export function isCurrentRundown(id: string) {
return id === getCurrentRundownId();
}
/**
* @throws if the provided id does not exist
*/
export async function loadRundown(id: string) {
const dataProvider = getDataProvider();
if (isCurrentRundown(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.
* Switching to a rundown always stops playback.
*/
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(rundown.id, rundownMetadata, revision, { timer: true, external: true, reload });
sendRefetch(RefetchKey.ProjectRundowns);
setLastLoadedRundown(rundown.id).catch((error) => {
logger.error(LogOrigin.Server, `Failed to persist last loaded rundown: ${error}`);
});
});
}
/**
* Applies a rebuilt version of the currently loaded rundown in place.
* Unlike switching rundowns, this maintains playback when possible
*/
function applyChangeToCurrentRundown(rundown: Readonly<Rundown>, customFields: Readonly<CustomFields>) {
const loadedEvent = runtimeService.getLoadedEventId();
if (loadedEvent && !isLoadedPlayable(loadedEvent, rundown)) {
runtimeService.stop();
}
const { rundownMetadata, revision } = rundownCache.init(rundown, customFields);
updateRuntimeOnChange(rundownMetadata);
setImmediate(() => {
// notifying the timer hot-reloads the playing event and keeps playback
notifyChanges(rundown.id, rundownMetadata, revision, { timer: true, external: true, reload: true });
sendRefetch(RefetchKey.ProjectRundowns);
});
}
export async function createNewRundown(title: string) {
const emptyRundown = makeNewRundown();
emptyRundown.title = title;
await getDataProvider().setRundown(emptyRundown.id, emptyRundown);
const projectRundowns = getDataProvider().getProjectRundowns();
setImmediate(() => {
sendRefetch(RefetchKey.ProjectRundowns);
});
return projectRundowns;
}
/**
* Renames an existing rundown
* @throws if the provided id does not exist
*/
export async function renameRundown(id: string, title: string) {
const dataProvider = getDataProvider();
const rundown = dataProvider.getRundown(id);
await dataProvider.setRundown(id, { ...rundown, title });
/**
* If we are modifying the loaded rundown we re-init it
* This is likely over-kill but the simplest way to ensure state consistency
*/
if (isCurrentRundown(id)) {
const rundown = dataProvider.getRundown(id);
const customField = dataProvider.getCustomFields();
// init rundown does its own refetch
await initRundown(rundown, customField);
} else {
setImmediate(() => {
sendRefetch(RefetchKey.ProjectRundowns);
});
}
return dataProvider.getProjectRundowns();
}
/**
* Duplicates an existing rundown without making it the loaded one
* @throws if the provided id does not exist
*/
export async function duplicateExistingRundown(id: string) {
const dataProvider = getDataProvider();
const rundown = dataProvider.getRundown(id);
const duplicatedRundown: Rundown = structuredClone(rundown);
duplicatedRundown.id = generateId();
duplicatedRundown.title = `Copy of ${rundown.title}`;
duplicatedRundown.revision = 0;
await dataProvider.setRundown(duplicatedRundown.id, duplicatedRundown);
setImmediate(() => {
sendRefetch(RefetchKey.ProjectRundowns);
});
return dataProvider.getProjectRundowns();
}
/**
* Validates an imported rundown against the resulting custom fields, then commits those custom
* fields. The rundown is validated before the custom-field mutation, so a payload that fails
* validation cannot leave a partial custom-field write behind.
* @throws if the rundown or custom fields fail validation
*/
async function parseImportAndCommitCustomFields(
source: Readonly<Rundown>,
incomingCustomFields: CustomFields,
): Promise<Rundown> {
const dataProvider = getDataProvider();
const parsedCustomFields = parseCustomFields({ customFields: incomingCustomFields });
const mergedCustomFields = { ...dataProvider.getCustomFields(), ...parsedCustomFields };
const parsed = parseRundown(source, mergedCustomFields);
await dataProvider.mergeIntoData({ customFields: parsedCustomFields });
return parsed;
}
/**
* Applies an imported rundown onto an existing rundown, keeping the existing identity while
* taking structure and order from the incoming data.
* - 'override' replaces all content with the incoming data
* - 'merge' updates matched entries with only the fields the spreadsheet provided, keeping the rest
* (e.g. automations) from the existing entry
*
* When targeting the loaded rundown this is treated as a change, not a switch, so playback is
* maintained when the playing event survives.
* @throws if the target rundown does not exist
*/
export async function applyImportToRundown(
strategy: RundownImportMergeStrategy,
targetRundownId: string,
incomingRundown: Rundown,
incomingCustomFields: CustomFields,
providedFields: ImportedFields,
): Promise<ProjectRundowns> {
const dataProvider = getDataProvider();
// throws if the rundown was deleted between preview and apply
const existing = dataProvider.getRundown(targetRundownId);
const source =
strategy === 'merge'
? mergeRundownPreservingFields(incomingRundown, existing, providedFields)
: { ...incomingRundown, id: existing.id, title: existing.title, revision: existing.revision + 1 };
const parsed = await parseImportAndCommitCustomFields(source, incomingCustomFields);
if (isCurrentRundown(targetRundownId)) {
// applying to the loaded rundown is a change, not a switch: maintain playback when possible
applyChangeToCurrentRundown(parsed, dataProvider.getCustomFields());
} else {
await dataProvider.setRundown(parsed.id, parsed);
setImmediate(() => {
sendRefetch(RefetchKey.ProjectRundowns);
});
}
return dataProvider.getProjectRundowns();
}
/**
* Creates a new rundown from an imported rundown and loads it,
* so the user immediately sees the imported data.
* Loading a rundown stops playback (as with any rundown switch).
*/
export async function createRundownFromImport(
incomingRundown: Rundown,
incomingCustomFields: CustomFields,
): Promise<ProjectRundowns> {
const dataProvider = getDataProvider();
// assign a fresh id so we never collide with an existing rundown
const parsed = await parseImportAndCommitCustomFields({ ...incomingRundown, id: generateId() }, incomingCustomFields);
parsed.revision = 0;
// initRundown persists the new rundown, makes it the loaded rundown and notifies clients
await initRundown(parsed, dataProvider.getCustomFields(), true);
return dataProvider.getProjectRundowns();
}
/**
* Deletes a rundown
* @throws if attempting to delete the loaded rundown or the last rundown in the project
*/
export async function deleteRundown(id: string) {
if (isCurrentRundown(id)) {
throw new Error('Cannot delete loaded rundown');
}
const dataProvider = getDataProvider();
if (Object.keys(dataProvider.getProjectRundowns()).length <= 1) {
throw new Error('Cannot delete the last rundown');
}
const projectRundowns = await dataProvider.deleteRundown(id);
setImmediate(() => {
sendRefetch(RefetchKey.ProjectRundowns);
});
return projectRundowns;
}