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
@@ -1,5 +1,5 @@
import { DatabaseModel, LogOrigin, ProjectData, ProjectFileListResponse } from 'ontime-types';
import { getErrorMessage } from 'ontime-utils';
import { getErrorMessage, getFirstRundown } from 'ontime-utils';
import { join } from 'path';
import { copyFile } from 'fs/promises';
@@ -8,6 +8,7 @@ import { logger } from '../../classes/Logger.js';
import { publicDir } from '../../setup/index.js';
import {
appendToName,
deleteFile,
dockerSafeRename,
ensureDirectory,
ensureJsonExtension,
@@ -16,14 +17,14 @@ import {
removeFileExtension,
} from '../../utils/fileManagement.js';
import { dbModel } from '../../models/dataModel.js';
import { deleteFile } from '../../utils/parserUtils.js';
import { parseDatabaseModel } from '../../utils/parser.js';
import { parseRundowns } from '../../api-data/rundown/rundown.parser.js';
import { demoDb } from '../../models/demoProject.js';
import { config } from '../../setup/config.js';
import { getDataProvider, initPersistence } from '../../classes/data-provider/DataProvider.js';
import { safeMerge } from '../../classes/data-provider/DataProvider.utils.js';
import { initRundown } from '../../api-data/rundown/rundown.service.js';
import { parseDatabaseModel } from '../../api-data/db/db.parser.js';
import { parseCustomFields } from '../../api-data/custom-fields/customFields.parser.js';
import {
getLastLoadedProject,
@@ -31,7 +32,6 @@ import {
setLastLoadedProject,
} from '../app-state-service/AppStateService.js';
import { runtimeService } from '../runtime-service/RuntimeService.js';
import { getFirstRundown } from '../rundown-service/rundownUtils.js';
import {
copyCorruptFile,
@@ -93,6 +93,7 @@ async function loadProject(projectData: DatabaseModel, fileName: string) {
// load the first rundown in the project
const firstRundown = getFirstRundown(projectData.rundowns);
await initRundown(firstRundown, projectData.customFields);
// persist the project selection
@@ -296,13 +297,16 @@ export async function patchCurrentProject(data: Partial<DatabaseModel>) {
// ... but rundown and custom fields need to be checked
if (rundowns != null) {
const result = parseRundowns(data);
const customFields = parseCustomFields(data);
const result = parseRundowns(data, customFields);
/**
* The user may have multiple rundowns
* We currently ignore all other rundowns
*/
const firstRundown = getFirstRundown(result.rundowns);
await initRundown(firstRundown, result.customFields);
const firstRundown = getFirstRundown(result);
await initRundown(firstRundown, customFields);
}
const updatedData = await getDataProvider().getData();
@@ -1,74 +0,0 @@
import { CustomFields, Rundown } from 'ontime-types';
import { RefetchTargets, sendRefetch } from '../../adapters/websocketAux.js';
import { updateRundownData } from '../../stores/runtimeState.js';
import { runtimeService } from '../runtime-service/RuntimeService.js';
import * as cache from './rundownCache.js';
/**
* Forces update in the store
* Called when we make changes to the rundown object
*/
function updateRuntimeOnChange() {
const { timedEventsOrder } = cache.getEventOrder();
const numEvents = timedEventsOrder.length;
const metadata = cache.getMetadata();
// schedule an update for the end of the event loop
setImmediate(() =>
updateRundownData({
numEvents,
...metadata,
}),
);
}
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(options: NotifyChangesOptions) {
if (options.timer) {
const { playableEventsOrder } = cache.getEventOrder();
if (playableEventsOrder.length === 0) {
runtimeService.stop();
} else {
// notify timer service of changed events
// timer can be true or an array of changed IDs
const affected = Array.isArray(options.timer) ? options.timer : undefined;
runtimeService.notifyOfChangedEvents(affected);
}
}
if (options.external) {
// advice socket subscribers of change
const payload = {
target: RefetchTargets.Rundown,
changes: Array.isArray(options.timer) ? options.timer : undefined,
reload: options.reload,
revision: cache.getMetadata().revision,
};
sendRefetch(payload);
}
}
/**
* 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>) {
await cache.init(rundown, customFields);
// notify runtime that rundown has changed
updateRuntimeOnChange();
// notify timer of change
notifyChanges({ timer: true, external: true, reload: true });
}
@@ -1,54 +0,0 @@
import { SupportedEntry, OntimeEvent, OntimeDelay, OntimeBlock, Rundown } from 'ontime-types';
import { defaultRundown } from '../../../models/dataModel.js';
const baseEvent = {
type: SupportedEntry.Event,
skip: false,
revision: 1,
};
const baseBlock = {
type: SupportedEntry.Block,
events: [],
};
/**
* Utility to create a Ontime event
*/
export function makeOntimeEvent(patch: Partial<OntimeEvent>): OntimeEvent {
return {
...baseEvent,
...patch,
} as OntimeEvent;
}
/**
* Utility to create a delay event
*/
export function makeOntimeDelay(patch: Partial<OntimeDelay>): OntimeDelay {
return { id: 'delay', type: SupportedEntry.Delay, duration: 0, ...patch } as OntimeDelay;
}
/**
* Utility to create a block event
*/
export function makeOntimeBlock(patch: Partial<OntimeBlock>): OntimeBlock {
return { id: 'block', ...baseBlock, ...patch } as OntimeBlock;
}
/**
* Utility to create a rundown object
*/
export function makeRundown(patch: Partial<Rundown>): Rundown {
return {
...defaultRundown,
...patch,
};
}
/**
* Utility to generate a rundown of OntimeEvents form partial objects
*/
export function prepareTimedEvents(events: Partial<OntimeEvent>[]): OntimeEvent[] {
return events.map(makeOntimeEvent);
}
@@ -1,127 +0,0 @@
import { createCustomField, editCustomField, removeCustomField, customFieldChangelog } from '../rundownCache.js';
beforeAll(() => {
vi.mock('../../../classes/data-provider/DataProvider.js', () => {
return {
getDataProvider: vi.fn().mockImplementation(() => {
return {
setCustomFields: vi.fn().mockImplementation((newData) => newData),
setRundown: vi.fn().mockImplementation((newData) => newData),
};
}),
};
});
});
describe('custom fields flow', () => {
describe('createCustomField()', () => {
it('creates a field from given parameters', () => {
const expected = {
Lighting: {
label: 'Lighting',
type: 'string',
colour: 'blue',
},
};
const customField = createCustomField({ label: 'Lighting', type: 'string', colour: 'blue' });
expect(customField).toStrictEqual(expected);
});
});
describe('editCustomField()', () => {
it('edits a field with a given label', () => {
createCustomField({ label: 'Sound', type: 'string', colour: 'blue' });
const expected = {
Lighting: {
label: 'Lighting',
type: 'string',
colour: 'blue',
},
Sound: {
label: 'Sound',
type: 'string',
colour: 'green',
},
};
const customField = editCustomField('Sound', { label: 'Sound', type: 'string', colour: 'green' });
expect(customFieldChangelog).toStrictEqual({});
expect(customField).toStrictEqual(expected);
});
it('renames a field to a new label', () => {
const created = createCustomField({ label: 'Video', type: 'string', colour: 'red' });
const expected = {
Lighting: {
label: 'Lighting',
type: 'string',
colour: 'blue',
},
Sound: {
label: 'Sound',
type: 'string',
colour: 'green',
},
Video: {
label: 'Video',
type: 'string',
colour: 'red',
},
};
expect(created).toStrictEqual(expected);
const expectedAfter = {
Lighting: {
label: 'Lighting',
type: 'string',
colour: 'blue',
},
Sound: {
label: 'Sound',
type: 'string',
colour: 'green',
},
AV: {
label: 'AV',
type: 'string',
colour: 'red',
},
};
// We need to flush all scheduled tasks for the generate function to settle
vi.useFakeTimers();
const customField = editCustomField('Video', { label: 'AV', type: 'string', colour: 'red' });
expect(customField).toStrictEqual(expectedAfter);
expect(customFieldChangelog).toStrictEqual({ Video: 'AV' });
editCustomField('AV', { label: 'Video' });
vi.runAllTimers();
expect(customFieldChangelog).toStrictEqual({});
vi.useRealTimers();
});
});
describe('removeCustomField()', () => {
it('deletes a field with a given label', () => {
const expected = {
Lighting: {
label: 'Lighting',
type: 'string',
colour: 'blue',
},
Video: {
label: 'Video',
type: 'string',
colour: 'red',
},
};
const customField = removeCustomField('Sound');
expect(customField).toStrictEqual(expected);
});
});
});
@@ -1,188 +0,0 @@
import { CustomFields, SupportedEntry } from 'ontime-types';
import { addToCustomAssignment, calculateDayOffset, handleCustomField } from '../rundownCache.utils.js';
import { MILLIS_PER_HOUR } from 'ontime-utils';
import { makeOntimeEvent } from '../__mocks__/rundown.mocks.js';
describe('addToCustomAssignment()', () => {
it('adds given entry to assignedCustomFields', () => {
const assignedCustomFields = {};
addToCustomAssignment('label1', 'eventId 1', assignedCustomFields);
expect(assignedCustomFields).toStrictEqual({ label1: ['eventId 1'] });
addToCustomAssignment('label1', 'eventId 2', assignedCustomFields);
expect(assignedCustomFields).toStrictEqual({ label1: ['eventId 1', 'eventId 2'] });
});
});
describe('handleCustomField()', () => {
it('creates a map of where custom fields are used', () => {
const customFields = {
lighting: {
type: 'string',
colour: 'red',
label: 'lighting',
},
sound: {
type: 'string',
colour: 'red',
label: 'sound',
},
} as CustomFields;
const customFieldChangelog = {};
const event = makeOntimeEvent({
type: SupportedEntry.Event,
id: '2',
timeStart: 0,
linkStart: true,
custom: {
lighting: 'on',
},
});
const assignedCustomFields = {};
const result = handleCustomField(customFields, customFieldChangelog, event, assignedCustomFields);
expect(result).toBeUndefined();
expect(assignedCustomFields).toStrictEqual({ lighting: ['2'] });
expect(event.custom).toStrictEqual({
lighting: 'on',
});
});
it('renames a field if in changelog', () => {
const customFields = {
lighting: {
type: 'string',
colour: 'red',
label: 'lighting',
},
video: {
type: 'string',
colour: 'red',
label: 'video',
},
} as CustomFields;
const customFieldChangelog = { sound: 'video' };
const event = makeOntimeEvent({
type: SupportedEntry.Event,
id: '2',
timeStart: 0,
linkStart: true,
custom: {
sound: 'on',
},
});
const assignedCustomFields = {};
const result = handleCustomField(customFields, customFieldChangelog, event, assignedCustomFields);
expect(result).toBeUndefined();
expect(assignedCustomFields).toStrictEqual({ video: ['2'] });
expect(event.custom).toStrictEqual({
video: 'on',
});
});
it('processes all fields', () => {
const customFields = {
field1: {
type: 'string',
colour: 'red',
label: 'field1',
},
field2: {
type: 'string',
colour: 'red',
label: 'field2',
},
} as CustomFields;
const customFieldChangelog = { field1: 'newField1' };
const mutableEvent = makeOntimeEvent({
type: SupportedEntry.Event,
id: 'event1',
custom: {
field1: 'value1',
field2: 'value2',
},
});
const assignedCustomFields = {};
handleCustomField(customFields, customFieldChangelog, mutableEvent, assignedCustomFields);
// Check that field1 has been renamed to newField1 and the value reassigned
expect(mutableEvent.custom['newField1']).toStrictEqual('value1');
expect(mutableEvent.custom['field1']).toBeUndefined();
// Check that field2 has been processed
expect(mutableEvent.custom['field2']).toStrictEqual('value2');
// Check that assignedCustomFields has been updated correctly
expect(assignedCustomFields).toStrictEqual({
newField1: ['event1'],
field2: ['event1'],
});
});
});
describe('calculateDayOffset', () => {
it('returns 0 if there is no previous event', () => {
expect(calculateDayOffset({ timeStart: 0 }, null)).toBe(0);
});
it('returns 0 if the previous event duration is 0', () => {
expect(calculateDayOffset({ timeStart: 0 }, { timeStart: 0, duration: 0 })).toBe(0);
});
it('returns 0 if event starts after previous', () => {
expect(calculateDayOffset({ timeStart: 11 }, { timeStart: 10, duration: 2 })).toBe(0);
});
it('returns 1 if event starts before previous', () => {
expect(calculateDayOffset({ timeStart: 9 }, { timeStart: 10, duration: 2 })).toBe(1);
});
it('returns 1 if event starts at the same time as one before', () => {
expect(calculateDayOffset({ timeStart: 10 }, { timeStart: 10, duration: 2 })).toBe(1);
});
it('should account for an event that crossed midnight and there is a overlap', () => {
expect(
calculateDayOffset(
{ timeStart: MILLIS_PER_HOUR }, // starts at 01:00:00
{ timeStart: 20 * MILLIS_PER_HOUR, duration: 6 * MILLIS_PER_HOUR }, // ends at 02:00:00
),
).toBe(1);
});
it('should account for an event that crossed midnight and there is a gap', () => {
expect(
calculateDayOffset(
{ timeStart: 2 * MILLIS_PER_HOUR }, // starts at 02:00:00
{ timeStart: 23 * MILLIS_PER_HOUR, duration: 2 * MILLIS_PER_HOUR }, // ends at 01:00:00
),
).toBe(1);
});
it('should account for an event that crossed midnight with no overlaps or gaps', () => {
expect(
calculateDayOffset(
{ timeStart: 2 * MILLIS_PER_HOUR }, // starts at 02:00:00
{ timeStart: 20 * MILLIS_PER_HOUR, duration: 6 * MILLIS_PER_HOUR }, // ends at 02:00:00
),
).toBe(1);
});
it('should account for an event that finishes exactly at midnight', () => {
expect(
calculateDayOffset(
{ timeStart: 2 * MILLIS_PER_HOUR }, // starts at 02:00:00
{ timeStart: 23 * MILLIS_PER_HOUR, duration: 6 * MILLIS_PER_HOUR }, // ends at 24:00:00
),
).toBe(1);
});
});
@@ -1,24 +0,0 @@
import { makeRundown } from '../../../api-data/rundown/__mocks__/rundown.mocks.js';
import { getPreviousId } from '../rundownUtils.js';
describe('getPreviousId', () => {
const rundown = makeRundown({
flatOrder: ['a', 'b', 'c', 'd'],
});
it('returns afterId if provided', () => {
expect(getPreviousId(rundown, 'b')).toBe('b');
});
it('returns the previous id before beforeId if provided', () => {
expect(getPreviousId(rundown, undefined, 'c')).toBe('b');
});
it('returns undefined if neither afterId nor beforeId is provided', () => {
expect(getPreviousId(rundown)).toBeNull();
});
it('returns undefined if beforeId is not found', () => {
expect(getPreviousId(rundown, undefined, 'z')).toBeNull();
});
});
@@ -1,462 +0,0 @@
import {
CustomField,
CustomFieldLabel,
CustomFields,
EntryId,
isOntimeBlock,
isOntimeEvent,
isPlayableEvent,
OntimeBlock,
OntimeEntry,
Rundown,
RundownEntries,
} from 'ontime-types';
import { generateId, insertAtIndex, customFieldLabelToKey } from 'ontime-utils';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import type { RundownMetadata } from '../../api-data/rundown/rundown.types.js';
import { makeRundownMetadata, type ProcessedRundownMetadata } from './rundownCache.utils.js';
let currentRundownId: EntryId = '';
let currentRundown: Rundown = {
id: '',
title: '',
order: [],
flatOrder: [],
entries: {},
revision: 0,
};
let projectCustomFields: CustomFields = {};
let rundownMetadata: RundownMetadata = {
totalDelay: 0,
totalDuration: 0,
totalDays: 0,
firstStart: null,
lastEnd: null,
playableEventOrder: [],
timedEventOrder: [],
flatEntryOrder: [],
assignedCustomFields: {},
};
/**
* Get the cached rundown without triggering regeneration
*/
export const getCurrentRundown = (): Rundown => currentRundown;
export const getCustomFields = (): CustomFields => projectCustomFields;
/**
* all mutating functions will set this value if there is a need for re-generation
* but will only be cleared by the generate function
*/
let isStale = true;
/** Allows safely setting the stale state without accidentally clearing it */
function setIsStale() {
isStale = true;
}
/**
* Object that contains reference of renamed custom fields
* Used to rename the custom fields in the events
* @private exported only to simplify testing
* @example
* {
* oldLabel: newLabel
* lighting: lx
* }
*/
export let customFieldChangelog: Record<string, string> = {};
/**
* Receives a rundown which will be processed and used as the new current rundown
*/
export async function init(initialRundown: Readonly<Rundown>, customFields: Readonly<CustomFields>) {
// we clone this objects since we use mutating logic in the cache
currentRundown = structuredClone(initialRundown);
currentRundownId = initialRundown.id;
projectCustomFields = structuredClone(customFields);
updateCache();
currentRundownId;
}
/**
* Utility generate cache
* @private should not be called outside of `rundownCache.ts`, exported for testing
*/
export function generate(
initialRundown: Readonly<Rundown>,
customFields: Readonly<CustomFields>,
): ProcessedRundownMetadata {
const { process, getMetadata } = makeRundownMetadata(customFields, customFieldChangelog);
for (let i = 0; i < initialRundown.order.length; i++) {
// we assign a reference to the current entry, this will be mutated in place
const currentEntryId = initialRundown.order[i];
const currentEntry = initialRundown.entries[currentEntryId];
if (!currentEntry) {
continue;
}
const { processedEntry } = process(currentEntry, null);
// if the event is a block, we process the nested entries
// the code here is a copy of the processing of top level events
if (isOntimeBlock(processedEntry)) {
let totalBlockDuration = 0;
let blockStartTime = null;
let blockEndTime = null;
let isFirstLinked = false;
const blockEvents: EntryId[] = [];
// check if the block contains events
for (let i = 0; i < processedEntry.events.length; i++) {
const nestedEntryId = processedEntry.events[i];
const nestedEntry = initialRundown.entries[nestedEntryId];
if (!nestedEntry) {
continue;
}
blockEvents.push(nestedEntry.id);
const { processedData: processedNestedData, processedEntry: processedNestedEntry } = process(
nestedEntry,
processedEntry.id,
);
// we dont extract metadata of skipped events,
// if this is not a playable event there is nothing else to do
if (!isOntimeEvent(processedNestedEntry) || !isPlayableEvent(processedNestedEntry)) {
continue;
}
// first start is always the first event
if (blockStartTime === null) {
blockStartTime = processedNestedEntry.timeStart;
isFirstLinked = Boolean(processedNestedEntry.linkStart);
}
// lastEntry is the event with the latest end time
blockEndTime = processedNestedData.lastEnd;
totalBlockDuration += processedNestedEntry.duration;
}
// update block metadata
processedEntry.duration = totalBlockDuration;
processedEntry.startTime = blockStartTime;
processedEntry.endTime = blockEndTime;
processedEntry.isFirstLinked = isFirstLinked;
processedEntry.events = blockEvents;
}
}
return getMetadata();
}
/**
* Runs the generate function in the currently loaded rundown and updates caches
*/
export function updateCache() {
// The stale state can only be cleared inside updateCache()
function clearIsStale() {
isStale = false;
}
const processedData = generate(currentRundown, projectCustomFields);
// update the cache values
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- we are not interested in the iteration data
const { previousEvent, latestEvent, ...metadata } = processedData;
currentRundown.entries = metadata.entries;
currentRundown.order = metadata.order;
currentRundown.flatOrder = metadata.flatEntryOrder;
rundownMetadata = metadata;
clearIsStale();
customFieldChangelog = {};
}
/**
* Whether a given ID is exists in the current rundown
*/
export function hasId(id: EntryId): boolean {
return Object.hasOwn(currentRundown.entries, id);
}
/** Returns an ID guaranteed to be unique */
export function getUniqueId(): string {
if (isStale) {
updateCache();
}
let id = '';
do {
id = generateId();
} while (hasId(id));
return id;
}
/** Returns index of an entry with a given id */
export function getIndexOf(entryId: EntryId) {
if (isStale) {
updateCache();
}
return currentRundown.order.indexOf(entryId);
}
/** Returns id of an entry at a given index */
export function getIdOf(index: number) {
if (isStale) {
updateCache();
}
return currentRundown.order.at(index);
}
type RundownCache = {
id: string;
title: string;
order: EntryId[];
entries: RundownEntries;
revision: number;
totalDelay: number;
totalDuration: number;
};
/**
* Returns the full rundown cache.
* Will triggering regeneration if data is stale.
*/
export function get(): Readonly<RundownCache> {
if (isStale) {
updateCache();
}
return {
id: currentRundown.id,
title: currentRundown.title,
entries: currentRundown.entries,
order: currentRundown.order,
revision: currentRundown.revision,
totalDelay: rundownMetadata.totalDelay,
totalDuration: rundownMetadata.totalDuration,
};
}
/**
* Returns calculated metadata from rundown
* Will triggering regeneration if data is stale.
*/
export function getMetadata(): Readonly<RundownMetadata & { revision: number }> {
if (isStale) {
updateCache();
}
return {
...rundownMetadata,
revision: currentRundown.revision,
};
}
export type RundownOrder = {
order: EntryId[];
flatOrder: EntryId[];
timedEventsOrder: EntryId[];
playableEventsOrder: EntryId[];
};
/**
* Exposes the order of events
*/
export function getEventOrder(): Readonly<RundownOrder> {
if (isStale) {
updateCache();
}
return {
order: currentRundown.order,
flatOrder: currentRundown.flatOrder,
timedEventsOrder: rundownMetadata.timedEventOrder,
playableEventsOrder: rundownMetadata.playableEventOrder,
};
}
type CommonParams = { rundown: Rundown };
type MutationParams<T> = T & CommonParams;
type MutatingReturn = {
newRundown: Rundown;
newEvent?: OntimeEntry;
changeList?: EntryId[];
didMutate: boolean;
};
type MutatingFn<T extends object> = (params: MutationParams<T>) => MutatingReturn;
/**
* Decorators injects data into mutation
* ensures order of operations when performing mutations
*/
export function mutateCache<T extends object>(mutation: MutatingFn<T>) {
function scopedMutation(params: T) {
// we work on a copy of the rundown
const rundownCopy = structuredClone(currentRundown);
const { newEvent, newRundown, changeList, didMutate } = mutation({ ...params, rundown: rundownCopy });
// early return without calling side effects
if (!didMutate) {
return { newEvent, newRundown, changeList, didMutate };
}
newRundown.revision += 1;
currentRundown = newRundown;
// schedule a non priority cache update
setImmediate(() => {
get();
});
// defer writing to the database
setImmediate(async () => {
await getDataProvider().setRundown(currentRundownId, currentRundown);
});
return { newEvent, newRundown, didMutate };
}
return scopedMutation;
}
type AddArgs = MutationParams<{ afterId?: string; parent: EntryId | null; entry: OntimeEntry }>;
/**
* Add entry to rundown, handles the following cases:
* - 1. add entry in block, after a given entry
* - 2. add entry in block, at the beginning
* - 3. add entry to the rundown, after a given entry
* - 4. add entry to the rundown, at the beginning
*/
export function add({ rundown, afterId, parent, entry }: AddArgs): Required<MutatingReturn> {
if (parent) {
const parentBlock = rundown.entries[parent] as OntimeBlock;
if (afterId) {
const atEventsIndex = parentBlock.events.indexOf(afterId) + 1;
const atFlatIndex = rundown.flatOrder.indexOf(afterId) + 1;
parentBlock.events = insertAtIndex(atEventsIndex, entry.id, parentBlock.events);
rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder);
} else {
parentBlock.events = insertAtIndex(0, entry.id, parentBlock.events);
const atFlatIndex = rundown.flatOrder.indexOf(parent) + 1;
rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder);
}
} else {
if (afterId) {
const atOrderIndex = rundown.order.indexOf(afterId) + 1;
const atFlatIndex = rundown.flatOrder.indexOf(afterId) + 1;
rundown.order = insertAtIndex(atOrderIndex, entry.id, rundown.order);
rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder);
} else {
rundown.order = insertAtIndex(0, entry.id, rundown.order);
rundown.flatOrder = insertAtIndex(0, entry.id, rundown.flatOrder);
}
}
// either way, we insert the entry into the rundown
rundown.entries[entry.id] = entry;
setIsStale();
return { newRundown: rundown, changeList: [], newEvent: entry, didMutate: true };
}
/**
* Utility for invalidating service cache if a custom field is used
*/
function invalidateIfUsed(label: CustomFieldLabel) {
// if the field was in use, we mark the cache as stale
if (label in rundownMetadata.assignedCustomFields) {
setIsStale();
}
// ... and schedule a cache update
// schedule a non priority cache update
setImmediate(async () => {
updateCache();
await getDataProvider().setRundown(currentRundownId, currentRundown);
});
}
/**
* Utility for scheduling a non priority custom field persist
*/
function scheduleCustomFieldPersist() {
setImmediate(async () => {
await getDataProvider().setCustomFields(projectCustomFields);
});
}
/**
* Sanitises and creates a custom field in the database
*/
export function createCustomField(field: CustomField): CustomFields {
const { label, type, colour } = field;
const key = customFieldLabelToKey(label);
if (key === null) {
throw new Error('Unable to convert label to a valid key');
}
// check if label already exists
const alreadyExists = Object.hasOwn(projectCustomFields, key);
if (alreadyExists) {
throw new Error('Label already exists');
}
// update object and persist
projectCustomFields[key] = { label, type, colour };
scheduleCustomFieldPersist();
return projectCustomFields;
}
/**
* Edits an existing custom field in the database
*/
export function editCustomField(key: string, newField: Partial<CustomField>): CustomFields {
if (!(key in projectCustomFields)) {
throw new Error('Could not find label');
}
const existingField = projectCustomFields[key];
if (newField.type !== undefined && existingField.type !== newField.type) {
throw new Error('Change of field type is not allowed');
}
if (newField.label === undefined) {
throw new Error('Missing label');
}
const newKey = customFieldLabelToKey(newField.label);
if (newKey === null) {
throw new Error('Unable to convert label to a valid key');
}
projectCustomFields[newKey] = { ...existingField, ...newField };
if (key !== newKey) {
delete projectCustomFields[key];
customFieldChangelog[key] = newKey;
}
scheduleCustomFieldPersist();
invalidateIfUsed(key);
return projectCustomFields;
}
/**
* Deletes a custom field from the database
*/
export function removeCustomField(label: string): CustomFields {
if (label in projectCustomFields) {
delete projectCustomFields[label];
}
scheduleCustomFieldPersist();
invalidateIfUsed(label);
return projectCustomFields;
}
@@ -1,251 +0,0 @@
import {
OntimeEvent,
CustomFieldLabel,
CustomFields,
OntimeEntry,
EntryId,
isOntimeEvent,
isPlayableEvent,
isOntimeDelay,
PlayableEvent,
RundownEntries,
} from 'ontime-types';
import { dayInMs, getLinkedTimes, getTimeFrom, isNewLatest } from 'ontime-utils';
import type { RundownMetadata } from '../../api-data/rundown/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];
}
}
}
/**
* 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
previousEntry: OntimeEntry | null; // The entry processed in the previous iteration
};
/**
* 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, customFieldChangelog: Record<string, string>) {
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, customFieldChangelog, 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,
customFieldChangelog: Record<string, string>,
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, 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.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 };
}
@@ -1,188 +0,0 @@
import {
OntimeEvent,
Rundown,
OntimeEntry,
PlayableEvent,
EntryId,
RundownEntries,
ProjectRundowns,
} from 'ontime-types';
import { getCurrentRundown } from '../../api-data/rundown/rundown.dao.js';
import * as cache from './rundownCache.js';
/**
* returns the the project rundown and the order arrays
*/
export function getRundownData() {
return {
rundown: getCurrentRundown(),
rundownOrder: cache.getEventOrder(),
};
}
/**
* returns all events of type OntimeEvent
*/
export function getTimedEvents(): OntimeEvent[] {
const { entries } = cache.get();
const { timedEventsOrder } = cache.getEventOrder();
return makeFlatRundownFromOrder(timedEventsOrder, entries);
}
/**
* Utility flattens a normalised rundown
*/
function makeFlatRundownFromOrder<T>(order: EntryId[], events: RundownEntries): T[] {
return order.map((id) => events[id] as T);
}
/**
* returns an event given its index after filtering for OntimeEvents
*/
export function getEventAtIndex(eventIndex: number): OntimeEvent | undefined {
const { timedEventsOrder } = cache.getEventOrder();
const eventId = timedEventsOrder[eventIndex];
if (!eventId) {
return undefined;
}
const { entries } = getCurrentRundown();
return entries[eventId] as OntimeEvent | undefined;
}
/**
* returns first event that matches a given ID
*/
export function getEntryWithId(entryId: EntryId): OntimeEntry | undefined {
const { entries } = getCurrentRundown();
return entries[entryId];
}
/**
* Utility returns the first playable event in rundown
*/
export function getFirstPlayable(playableOrder: EntryId[]): PlayableEvent | undefined {
const firstEventId = playableOrder.at(0);
if (!firstEventId) return;
return getEntryWithId(firstEventId) as PlayableEvent | undefined;
}
/**
* returns first event that matches a given cue
*/
export function getNextEventWithCue(targetCue: string, currentEventIndex = 0): OntimeEvent | undefined {
const { playableEventsOrder } = cache.getEventOrder();
const lowerCaseCue = targetCue.toLowerCase();
for (let i = currentEventIndex; i < playableEventsOrder.length; i++) {
const eventId = playableEventsOrder[i];
const event = getEntryWithId(eventId) as PlayableEvent | undefined;
if (event?.cue.toLowerCase() === lowerCaseCue) {
return event;
}
}
}
/**
* finds the previous event
*/
export function findPrevious(currentEventId?: string): OntimeEvent | undefined {
const { playableEventsOrder } = cache.getEventOrder();
if (!playableEventsOrder.length) {
return;
}
// if there is no event running, go to first
if (!currentEventId) {
return getFirstPlayable(playableEventsOrder);
}
const currentIndex = playableEventsOrder.findIndex((eventId) => eventId === currentEventId);
const newIndex = Math.max(currentIndex - 1, 0);
const previousEventId = playableEventsOrder.at(newIndex);
if (!previousEventId) {
return getFirstPlayable(playableEventsOrder);
}
return getEntryWithId(previousEventId) as PlayableEvent | undefined;
}
/**
* finds the next event
*/
export function findNext(currentEventId?: string): PlayableEvent | undefined {
const { playableEventOrder } = cache.getMetadata();
if (!playableEventOrder.length) {
return;
}
// if there is no event running, go to first
if (!currentEventId) {
return getFirstPlayable(playableEventOrder);
}
const currentIndex = playableEventOrder.findIndex((eventId) => eventId === currentEventId);
const newIndex = Math.min(currentIndex + 1, playableEventOrder.length - 1);
const nextEventId = playableEventOrder.at(newIndex);
if (!nextEventId) {
return getFirstPlayable(playableEventOrder);
}
return getEntryWithId(nextEventId) as PlayableEvent | undefined;
}
export function filterTimedEvents(rundown: Rundown, timedEventOrder: EntryId[]): OntimeEvent[] {
return timedEventOrder.map((id) => rundown.entries[id] as OntimeEvent);
}
/**
* Gets the first rundown in the project
* We know that the project has at least one rundown
*/
export function getFirstRundown(rundowns: ProjectRundowns): Rundown {
const firstKey = Object.keys(rundowns)[0];
// eslint-disable-next-line no-unused-labels -- dev code path
DEV: {
if (!firstKey) {
throw new Error('rundownUtils.getFirstRundown() No rundowns found');
}
}
return rundowns[firstKey];
}
/**
* Returns a rundown given its ID
*/
export function getRundownOrThrow(rundowns: ProjectRundowns, rundownId: string): Rundown {
if (!rundowns[rundownId]) {
throw new Error(`Rundown with ID ${rundownId} not found`);
}
return rundowns[rundownId];
}
/**
* Receives an insertion order and returns the reference to an event ID
* after which we will insert the new event
*/
export function getPreviousId(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;
}
@@ -23,23 +23,22 @@ import { eventStore } from '../../stores/EventStore.js';
import { triggerReportEntry } from '../../api-data/report/report.service.js';
import { timerConfig } from '../../setup/config.js';
import { triggerAutomations } from '../../api-data/automation/automation.service.js';
import { getCurrentRundown } from '../../api-data/rundown/rundown.dao.js';
import { getCurrentRundown, getEntryWithId, getRundownMetadata } from '../../api-data/rundown/rundown.dao.js';
import { EventTimer } from '../EventTimer.js';
import { RestorePoint, restoreService } from '../RestoreService.js';
import {
findNext,
findPrevious,
getEventAtIndex,
getNextEventWithCue,
getEntryWithId,
getTimedEvents,
getRundownData,
} from '../rundown-service/rundownUtils.js';
import { skippedOutOfEvent } from '../timerUtils.js';
import { getEventOrder } from '../rundown-service/rundownCache.js';
import { getForceUpdate, getShouldClockUpdate, getShouldTimerUpdate } from './rundownService.utils.js';
import {
filterTimedEvents,
findNextPlayableId,
findNextPlayableWithCue,
findPreviousPlayableId,
getEventAtIndex,
getForceUpdate,
getShouldClockUpdate,
getShouldTimerUpdate,
} from './rundownService.utils.js';
type RuntimeStateEventKeys = keyof Pick<RuntimeState, 'eventNext' | 'eventNow' | 'publicEventNow' | 'publicEventNext'>;
@@ -198,7 +197,10 @@ class RuntimeService {
}
private isNewNext() {
const timedEvents = getTimedEvents();
const rundown = getCurrentRundown();
const { timedEventOrder } = getRundownMetadata();
const timedEvents = filterTimedEvents(rundown, timedEventOrder);
const state = runtimeState.getState();
const now = state.eventNow?.id;
const next = state.eventNext?.id;
@@ -271,8 +273,8 @@ class RuntimeService {
runtimeState.updateLoaded(eventNow);
} else {
const rundown = getCurrentRundown();
const { timedEventsOrder } = getEventOrder();
runtimeState.updateAll(rundown, timedEventsOrder);
const { timedEventOrder } = getRundownMetadata();
runtimeState.updateAll(rundown, timedEventOrder);
}
return;
}
@@ -281,7 +283,9 @@ class RuntimeService {
// Maybe the event will become the next
isNext = this.isNewNext();
if (isNext) {
const timedEvents = getTimedEvents();
const rundown = getCurrentRundown();
const { timedEventOrder } = getRundownMetadata();
const timedEvents = filterTimedEvents(rundown, timedEventOrder);
runtimeState.loadNext(timedEvents);
}
}
@@ -299,8 +303,10 @@ class RuntimeService {
}
const previousState = runtimeState.getState();
const { rundown, rundownOrder } = getRundownData();
const success = runtimeState.load(event, rundown, rundownOrder.timedEventsOrder, initialData);
// we can ignore events which are not playable
const rundown = getCurrentRundown();
const rundownMetadata = getRundownMetadata();
const success = runtimeState.load(event, rundown, rundownMetadata.playableEventOrder, initialData);
if (success) {
logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`);
@@ -342,14 +348,20 @@ class RuntimeService {
*/
@broadcastResult
public startByIndex(eventIndex: number): boolean {
const event = getEventAtIndex(eventIndex);
const rundown = getCurrentRundown();
const { timedEventOrder } = getRundownMetadata();
const event = getEventAtIndex(rundown, timedEventOrder, eventIndex);
if (!event) {
return false;
}
const loaded = this.loadEvent(event);
if (!loaded) {
return false;
}
return this.handleStart();
}
@@ -360,10 +372,21 @@ class RuntimeService {
*/
@broadcastResult
public startByCue(cue: string): boolean {
const event = getNextEventWithCue(cue); //TODO: add index
const state = runtimeState.getState();
const rundown = getCurrentRundown();
const { playableEventOrder } = getRundownMetadata();
const event = findNextPlayableWithCue(
rundown,
playableEventOrder,
cue,
state.runtime.selectedEventIndex ?? undefined,
);
if (!event) {
return false;
}
const loaded = this.loadEvent(event);
if (!loaded) {
return false;
@@ -392,7 +415,11 @@ class RuntimeService {
*/
@broadcastResult
public loadByIndex(eventIndex: number): boolean {
const event = getEventAtIndex(eventIndex);
const rundown = getCurrentRundown();
const { timedEventOrder } = getRundownMetadata();
const event = getEventAtIndex(rundown, timedEventOrder, eventIndex);
if (!event) {
return false;
}
@@ -406,10 +433,21 @@ class RuntimeService {
*/
@broadcastResult
public loadByCue(cue: string): boolean {
const event = getNextEventWithCue(cue); //TODO: add index
const state = runtimeState.getState();
const rundown = getCurrentRundown();
const { playableEventOrder } = getRundownMetadata();
const event = findNextPlayableWithCue(
rundown,
playableEventOrder,
cue,
state.runtime.selectedEventIndex ?? undefined,
);
if (!event) {
return false;
}
return this.loadEvent(event);
}
@@ -421,10 +459,16 @@ class RuntimeService {
*/
private handleLoadPrevious(): boolean {
const state = runtimeState.getState();
const previousEvent = findPrevious(state.eventNow?.id);
if (previousEvent) {
return this.loadEvent(previousEvent);
const { playableEventOrder } = getRundownMetadata();
const previousId = findPreviousPlayableId(playableEventOrder, state.eventNow?.id);
if (previousId) {
const previousEvent = getEntryWithId(previousId);
if (previousEvent && isOntimeEvent(previousEvent)) {
return this.loadEvent(previousEvent);
}
}
return false;
}
@@ -446,8 +490,15 @@ class RuntimeService {
*/
private handleLoadNext(): boolean {
const state = runtimeState.getState();
const nextEvent = findNext(state.eventNow?.id);
if (nextEvent) {
const { playableEventOrder } = getRundownMetadata();
const nextId = findNextPlayableId(playableEventOrder, state.eventNow?.id);
if (nextId) {
const nextEvent = getEntryWithId(nextId);
if (!nextEvent || !isOntimeEvent(nextEvent)) {
return false;
}
if (state.timer.playback === Playback.Roll) {
return this.loadEvent(nextEvent, { firstStart: state.runtime.actualStart });
}
@@ -457,7 +508,6 @@ class RuntimeService {
logger.info(LogOrigin.Playback, 'No next event found! Continuing playback');
return false;
}
/**
* Loads event after currently selected
* @return {boolean} success
@@ -585,10 +635,10 @@ class RuntimeService {
*/
private rollLoaded(offset?: number) {
const rundown = getCurrentRundown();
const { timedEventsOrder } = getEventOrder();
const { timedEventOrder } = getRundownMetadata();
try {
runtimeState.roll(rundown, timedEventsOrder, offset);
runtimeState.roll(rundown, timedEventOrder, offset);
} catch (error) {
logger.error(LogOrigin.Server, `Roll: ${error}`);
}
@@ -608,8 +658,10 @@ class RuntimeService {
}
try {
const { rundown, rundownOrder } = getRundownData();
const result = runtimeState.roll(rundown, rundownOrder.timedEventsOrder);
const rundown = getCurrentRundown();
const rundownMetadata = getRundownMetadata();
const result = runtimeState.roll(rundown, rundownMetadata.playableEventOrder);
const newState = runtimeState.getState();
if (result.eventId !== previousState.eventNow?.id) {
logger.info(LogOrigin.Playback, `Loaded event with ID ${result.eventId}`);
@@ -660,8 +712,10 @@ class RuntimeService {
return;
}
const { rundown, rundownOrder } = getRundownData();
runtimeState.resume(restorePoint, event, rundown, rundownOrder.timedEventsOrder);
const rundown = getCurrentRundown();
const rundownMetadata = getRundownMetadata();
runtimeState.resume(restorePoint, event, rundown, rundownMetadata.playableEventOrder);
logger.info(LogOrigin.Playback, 'Resuming playback');
}
@@ -1,5 +1,5 @@
import { millisToSeconds } from 'ontime-utils';
import { MaybeNumber, TimerType } from 'ontime-types';
import { EntryId, isOntimeEvent, isPlayableEvent, MaybeNumber, OntimeEvent, Rundown, TimerType } from 'ontime-types';
import { timerConfig } from '../../setup/config.js';
@@ -33,3 +33,97 @@ export function getForceUpdate(previousUpdate: number, now: number): boolean {
const hasExceededRate = now - previousUpdate >= timerConfig.notificationRate;
return isClockBehind || hasExceededRate;
}
/**
* finds the previous playable event, if it exists
*/
export function findPreviousPlayableId(playableEventsOrder: EntryId[], currentEventId?: string): EntryId | undefined {
if (!playableEventsOrder.length) {
return;
}
// if there is no event running, go to first
if (!currentEventId) {
return getFirstPlayableId(playableEventsOrder);
}
const currentIndex = playableEventsOrder.findIndex((eventId) => eventId === currentEventId);
if (currentIndex < 1) {
return getFirstPlayableId(playableEventsOrder);
}
return playableEventsOrder.at(currentIndex - 1);
}
/**
* finds the next event playable event, if it exists
*/
export function findNextPlayableId(playableEventsOrder: EntryId[], currentEventId?: string): EntryId | undefined {
if (!playableEventsOrder.length) {
return;
}
// if there is no event running, go to first
if (!currentEventId) {
return getFirstPlayableId(playableEventsOrder);
}
const currentIndex = playableEventsOrder.findIndex((eventId) => eventId === currentEventId);
if (currentIndex === -1 || currentIndex >= playableEventsOrder.length - 1) {
return getFirstPlayableId(playableEventsOrder);
}
return playableEventsOrder.at(currentIndex + 1);
}
/**
* returns first event that matches a given cue
*/
export function findNextPlayableWithCue(
rundown: Rundown,
playableEventsOrder: EntryId[],
targetCue: string,
currentEventIndex = 0,
): OntimeEvent | undefined {
const lowerCaseCue = targetCue.toLowerCase();
for (let i = currentEventIndex; i < playableEventsOrder.length; i++) {
const eventId = playableEventsOrder[i];
const event = rundown.entries[eventId];
if (isOntimeEvent(event) && isPlayableEvent(event) && event.cue.toLowerCase() === lowerCaseCue) {
return event;
}
}
}
/**
* Utility returns the first playable event in rundown, if it exists
*/
export function getFirstPlayableId(playableOrder: EntryId[]): EntryId | undefined {
return playableOrder.at(0);
}
/**
* This is a utility function to return an event at a given index
* It uses the timedEventOrder so that the index is the same as the one in the UI
*/
export function getEventAtIndex(
rundown: Rundown,
timedEventOrder: EntryId[],
eventIndex: number,
): OntimeEvent | undefined {
const eventId = timedEventOrder[eventIndex];
if (!eventId) {
return undefined;
}
return rundown.entries[eventId] as OntimeEvent | undefined;
}
/**
* TODO(v4): we dont need this function
*/
export function filterTimedEvents(rundown: Rundown, timedEventOrder: EntryId[]): OntimeEvent[] {
return timedEventOrder.map((id) => rundown.entries[id] as OntimeEvent);
}
@@ -12,12 +12,11 @@ import { Credentials, OAuth2Client } from 'google-auth-library';
// TODO: rewrite logic to use fetch and remove dependency
import got from 'got';
import { parseExcel } from '../../utils/parser.js';
import { logger } from '../../classes/Logger.js';
import { parseRundowns } from '../../api-data/rundown/rundown.parser.js';
import { getCurrentRundown, getProjectCustomFields } from '../../api-data/rundown/rundown.dao.js';
import { getRundownOrThrow } from '../rundown-service/rundownUtils.js';
import { parseExcel } from '../../api-data/excel/excel.parser.js';
import { parseCustomFields } from '../../api-data/custom-fields/customFields.parser.js';
import { cellRequestFromEvent, type ClientSecret, getA1Notation, isClientSecret } from './sheetUtils.js';
import { catchCommonImportXlsxError } from './googleApi.utils.js';
@@ -381,6 +380,12 @@ export async function upload(sheetId: string, options: ImportMap) {
}
}
/**
* Imports a sheet as a rundown
* @throws if the client is not authenticated
* @throws if the response from Google Sheets fails
* @throws if the sheet does not contain any data
*/
export async function download(
sheetId: string,
options: ImportMap,
@@ -418,10 +423,18 @@ export async function download(
},
customFields: dataFromSheet.customFields,
};
const { customFields, rundowns } = parseRundowns(dataModel);
const rundown = getRundownOrThrow(rundowns, rundownId);
if (rundown.order.length < 1) {
const customFields = parseCustomFields(dataModel);
const rundowns = parseRundowns(dataModel, customFields);
const importedRundown = rundowns[rundownId];
if (!importedRundown) {
throw new Error(`Sheet: Rundown with ID ${rundownId} not found in the worksheet`);
}
if (importedRundown.order.length < 1) {
throw new Error('Sheet: Could not find data to import in the worksheet');
}
return { rundown: rundowns[rundownId], customFields };
}