refactor: restructure model to contain an object of rundowns

This commit is contained in:
Carlos Valente
2025-03-15 09:04:33 +01:00
committed by arc-alex
parent 0a80d6db31
commit abd9b127db
111 changed files with 4357 additions and 4515 deletions
@@ -18,7 +18,7 @@ import {
import { dbModel } from '../../models/dataModel.js';
import { deleteFile } from '../../utils/parserUtils.js';
import { parseDatabaseModel } from '../../utils/parser.js';
import { parseRundown } from '../../utils/parserFunctions.js';
import { parseRundowns } from '../../utils/parserFunctions.js';
import { demoDb } from '../../models/demoProject.js';
import { config } from '../../setup/config.js';
import { getDataProvider, initPersistence } from '../../classes/data-provider/DataProvider.js';
@@ -40,6 +40,7 @@ import {
moveCorruptFile,
parseJsonFile,
} from './projectServiceUtils.js';
import { getFirstRundown } from '../rundown-service/rundownUtils.js';
// init dependencies
init();
@@ -83,7 +84,7 @@ async function loadNewProject(): Promise<string> {
}
/**
* Private function handles side effects on currupted files
* Private function handles side effects on corrupted files
* Corrupted files in this context contain data that failed domain validation
*/
async function handleCorruptedFile(filePath: string, fileName: string): Promise<string> {
@@ -176,10 +177,11 @@ export async function loadProjectFile(name: string) {
// apply data model
runtimeService.stop();
const { rundown, customFields } = result.data;
const { rundowns, customFields } = result.data;
// apply the rundown
await initRundown(rundown, customFields);
const firstRundown = getFirstRundown(rundowns);
await initRundown(firstRundown, customFields);
}
/**
@@ -246,10 +248,11 @@ export async function renameProjectFile(originalFile: string, newFilename: strin
// apply data model
runtimeService.stop();
const { rundown, customFields } = result.data;
const { rundowns, customFields } = result.data;
// apply the rundown
await initRundown(rundown, customFields);
const firstRundown = getFirstRundown(rundowns);
await initRundown(firstRundown, customFields);
}
}
@@ -300,17 +303,23 @@ export async function patchCurrentProject(data: Partial<DatabaseModel>) {
runtimeService.stop();
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- we need to remove the fields before merging
const { rundown, customFields, ...rest } = data;
const { rundowns, customFields, ...rest } = data;
// we can pass some stuff straight to the data provider
const newData = await getDataProvider().mergeIntoData(rest);
await getDataProvider().mergeIntoData(rest);
// ... but rundown and custom fields need to be checked
if (rundown != null) {
const result = parseRundown(data);
await initRundown(result.rundown, result.customFields);
if (rundowns != null) {
const result = parseRundowns(data);
/**
* The user may have multiple rundowns
* We currently ignore all other rundowns
*/
const firstRundown = getFirstRundown(result.rundowns);
initRundown(firstRundown, result.customFields);
}
return newData;
const updatedData = await getDataProvider().getData();
return updatedData;
}
/**
@@ -44,12 +44,12 @@ describe('duplicateProjectFile', () => {
await expect(duplicateProjectFile('does not exist', 'doesnt matter')).rejects.toThrow('Project file not found');
});
it('throws an error if new file name is already a project', () => {
it('throws an error if new file name is already a project', async () => {
// current project exists
(doesProjectExist as Mock).mockReturnValueOnce('thisoneexists');
// new project exists
(doesProjectExist as Mock).mockReturnValueOnce('existingproject');
expect(duplicateProjectFile('thisoneexists', 'existingproject')).rejects.toThrow(
await expect(duplicateProjectFile('thisoneexists', 'existingproject')).rejects.toThrow(
'Project file with name existingproject already exists',
);
});
@@ -66,7 +66,7 @@ describe('renameProjectFile', () => {
(doesProjectExist as Mock).mockReturnValueOnce('this one exists');
// new project exists
(doesProjectExist as Mock).mockReturnValueOnce('existingproject');
expect(renameProjectFile('this one exists', 'existingproject')).rejects.toThrow(
await expect(renameProjectFile('this one exists', 'existingproject')).rejects.toThrow(
'Project file with name existingproject already exists',
);
});
@@ -4,13 +4,14 @@ import {
OntimeBlock,
OntimeDelay,
OntimeEvent,
OntimeRundownEntry,
OntimeEntry,
isOntimeBlock,
isOntimeDelay,
isOntimeEvent,
OntimeRundown,
PatchWithId,
EventPostPayload,
Rundown,
EntryId,
} from 'ontime-types';
import { getCueCandidate } from 'ontime-utils';
@@ -22,7 +23,6 @@ import { updateRundownData } from '../../stores/runtimeState.js';
import { runtimeService } from '../runtime-service/RuntimeService.js';
import * as cache from './rundownCache.js';
import { getPlayableEvents, getTimedEvents } from './rundownUtils.js';
type CompleteEntry<T> =
T extends Partial<OntimeEvent>
@@ -33,15 +33,23 @@ type CompleteEntry<T> =
? OntimeBlock
: never;
/**
* Generates a fully formed RundownEntry of the patch type
*/
function generateEvent<T extends Partial<OntimeEvent> | Partial<OntimeDelay> | Partial<OntimeBlock>>(
eventData: T,
afterId?: string,
): CompleteEntry<T> {
// TODO: could we keep the UI ID to avoid the flash on create?
// we discard any UI provided IDs and add our own
const id = cache.getUniqueId();
if (isOntimeEvent(eventData)) {
return createEvent(eventData, getCueCandidate(cache.getPersistedRundown(), afterId)) as CompleteEntry<T>;
const currentRundown = cache.getCurrentRundown();
return createEvent(
eventData,
getCueCandidate(currentRundown.entries, currentRundown.order, afterId),
) as CompleteEntry<T>;
}
if (isOntimeDelay(eventData)) {
@@ -56,19 +64,17 @@ function generateEvent<T extends Partial<OntimeEvent> | Partial<OntimeDelay> | P
}
/**
* @description creates a new event with given data
* @param {object} eventData
* @return {OntimeRundownEntry}
* creates a new event with given data
*/
export async function addEvent(eventData: EventPostPayload): Promise<OntimeRundownEntry> {
export async function addEvent(eventData: EventPostPayload): Promise<OntimeEntry> {
// if the user didnt provide an index, we add the event to start
let atIndex = 0;
let afterId: string | undefined = eventData?.after;
if (eventData?.after !== undefined) {
const previousIndex = cache.getIndexOf(eventData.after);
if (afterId) {
const previousIndex = cache.getIndexOf(afterId);
if (previousIndex < 0) {
logger.warning(LogOrigin.Server, `Could not find event with id ${eventData.after}`);
logger.warning(LogOrigin.Server, `Could not find event with id ${afterId}`);
} else {
atIndex = previousIndex + 1;
}
@@ -79,7 +85,7 @@ export async function addEvent(eventData: EventPostPayload): Promise<OntimeRundo
} else {
atIndex = previousIndex;
if (previousIndex > 0) {
afterId = cache.getPersistedRundown()[atIndex - 1].id;
afterId = cache.getIdOf(atIndex - 1);
}
}
}
@@ -95,14 +101,14 @@ export async function addEvent(eventData: EventPostPayload): Promise<OntimeRundo
updateRuntimeOnChange();
// notify timer and external services of change
notifyChanges({ timer: [eventData.id], external: true });
notifyChanges({ timer: [eventToAdd.id], external: true });
return newEvent;
// we know this mutation returns an OntimeEntry
return newEvent as OntimeEntry;
}
/**
* deletes event by its ID
* @param eventId
*/
export async function deleteEvent(eventIds: string[]) {
const scopedMutation = cache.mutateCache(cache.remove);
@@ -194,9 +200,9 @@ export async function reorderEvent(eventId: string, from: number, to: number) {
return reorderedItem;
}
export async function applyDelay(eventId: string) {
export async function applyDelay(delayId: EntryId) {
const scopedMutation = cache.mutateCache(cache.applyDelay);
await scopedMutation({ eventId });
await scopedMutation({ delayId });
// notify runtime that rundown has changed
updateRuntimeOnChange();
@@ -227,8 +233,8 @@ export async function swapEvents(from: string, to: string) {
* Called when we make changes to the rundown object
*/
function updateRuntimeOnChange() {
const timedEvents = getTimedEvents();
const numEvents = timedEvents.length;
const { timedEventsOrder } = cache.getEventOrder();
const numEvents = timedEventsOrder.length;
const metadata = cache.getMetadata();
// schedule an update for the end of the event loop
@@ -251,9 +257,9 @@ type NotifyChangesOptions = {
*/
function notifyChanges(options: NotifyChangesOptions) {
if (options.timer) {
const playableEvents = getPlayableEvents();
const { playableEventsOrder } = cache.getEventOrder();
if (playableEvents.length === 0) {
if (playableEventsOrder.length === 0) {
runtimeService.stop();
} else {
// notify timer service of changed events
@@ -279,7 +285,7 @@ function notifyChanges(options: NotifyChangesOptions) {
* Overrides the rundown with the given
* @param rundown
*/
export async function initRundown(rundown: Readonly<OntimeRundown>, customFields: Readonly<CustomFields>) {
export async function initRundown(rundown: Readonly<Rundown>, customFields: Readonly<CustomFields>) {
await cache.init(rundown, customFields);
// notify runtime that rundown has changed
@@ -1,4 +1,5 @@
import { SupportedEvent, OntimeEvent, OntimeDelay } from 'ontime-types';
import { SupportedEvent, OntimeEvent, OntimeDelay, OntimeBlock, Rundown } from 'ontime-types';
import { defaultRundown } from '../../../models/dataModel.js';
const baseEvent = {
type: SupportedEvent.Event,
@@ -6,6 +7,10 @@ const baseEvent = {
revision: 1,
};
const baseBlock = {
type: SupportedEvent.Block,
};
/**
* Utility to create a Ontime event
*/
@@ -19,8 +24,25 @@ export function makeOntimeEvent(patch: Partial<OntimeEvent>): OntimeEvent {
/**
* Utility to create a delay event
*/
export function makeOntimeDelay(duration: number): OntimeDelay {
return { id: 'delay', type: SupportedEvent.Delay, duration };
export function makeOntimeDelay(patch: Partial<OntimeDelay>): OntimeDelay {
return { id: 'delay', type: SupportedEvent.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,
};
}
/**
@@ -1,61 +1,84 @@
import { OntimeBlock, OntimeEvent, OntimeRundown, SupportedEvent } from 'ontime-types';
import { OntimeEvent, SupportedEvent } from 'ontime-types';
import { MILLIS_PER_HOUR } from 'ontime-utils';
import { apply } from '../delayUtils.js';
import { makeOntimeDelay, makeOntimeEvent } from '../__mocks__/rundown.mocks.js';
import { makeOntimeBlock, makeOntimeDelay, makeOntimeEvent, makeRundown } from '../__mocks__/rundown.mocks.js';
describe('apply()', () => {
it('applies a positive delay to the rundown', () => {
const testRundown = [
makeOntimeDelay(10),
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 10, duration: 10 }),
makeOntimeEvent({ id: '2', timeStart: 10, timeEnd: 20, duration: 10, linkStart: '1' }),
{ id: '3', type: SupportedEvent.Block } as OntimeBlock,
makeOntimeEvent({ id: '4', timeStart: 20, timeEnd: 30, duration: 10, linkStart: null }),
makeOntimeEvent({ id: '5', timeStart: 30, timeEnd: 40, duration: 10, linkStart: '4' }),
];
const testRundown = makeRundown({
revision: 0,
order: ['delay', '1', '2', '3', '4', '5'],
entries: {
delay: makeOntimeDelay({ id: 'delay', duration: 10 }),
'1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 10, duration: 10 }),
'2': makeOntimeEvent({ id: '2', timeStart: 10, timeEnd: 20, duration: 10, linkStart: '1' }),
'3': makeOntimeBlock({ id: '3' }),
'4': makeOntimeEvent({ id: '4', timeStart: 20, timeEnd: 30, duration: 10, linkStart: null }),
'5': makeOntimeEvent({ id: '5', timeStart: 30, timeEnd: 40, duration: 10, linkStart: '4' }),
},
});
const updatedRundown = apply('delay', testRundown);
expect(updatedRundown).not.toBe(testRundown);
expect(updatedRundown).toMatchObject([
{ id: '1', timeStart: 10, timeEnd: 20, duration: 10, revision: 2 },
{ id: '2', timeStart: 20, timeEnd: 30, duration: 10, revision: 2, linkStart: '1' },
{ id: '3' },
{ id: '4', timeStart: 30, timeEnd: 40, duration: 10, revision: 2, linkStart: null },
{ id: '5', timeStart: 40, timeEnd: 50, duration: 10, revision: 2, linkStart: '4' },
]);
apply('delay', testRundown);
expect(testRundown.revision).toBe(1);
expect(testRundown.order).toMatchObject(['1', '2', '3', '4', '5']);
expect(testRundown.entries).toMatchObject({
'1': { id: '1', timeStart: 10, timeEnd: 20, duration: 10, revision: 2 },
'2': { id: '2', timeStart: 20, timeEnd: 30, duration: 10, revision: 2, linkStart: '1' },
'3': { id: '3' },
'4': { id: '4', timeStart: 30, timeEnd: 40, duration: 10, revision: 2, linkStart: null },
'5': { id: '5', timeStart: 40, timeEnd: 50, duration: 10, revision: 2, linkStart: '4' },
});
});
it('applies negative delays', () => {
const testRundown = [
makeOntimeDelay(-10),
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 10, duration: 10 }),
makeOntimeEvent({ id: '2', timeStart: 10, timeEnd: 20, duration: 10, linkStart: '1' }),
{ id: '3', type: SupportedEvent.Block } as OntimeBlock,
makeOntimeEvent({ id: '4', timeStart: 20, timeEnd: 30, duration: 10, linkStart: null }),
makeOntimeEvent({ id: '5', timeStart: 30, timeEnd: 40, duration: 10, linkStart: '4' }),
];
const testRundown = makeRundown({
revision: 0,
order: ['delay', '1', '2', '3', '4', '5'],
entries: {
delay: makeOntimeDelay({ id: 'delay', duration: -10 }),
'1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 10, duration: 10 }),
'2': makeOntimeEvent({ id: '2', timeStart: 10, timeEnd: 20, duration: 10, linkStart: '1' }),
'3': makeOntimeBlock({ id: '3' }),
'4': makeOntimeEvent({ id: '4', timeStart: 20, timeEnd: 30, duration: 10, linkStart: null }),
'5': makeOntimeEvent({ id: '5', timeStart: 30, timeEnd: 40, duration: 10, linkStart: '4' }),
},
});
const updatedRundown = apply('delay', testRundown);
expect(updatedRundown).toMatchObject([
{ id: '1', timeStart: 0, timeEnd: 10, duration: 10, revision: 2 },
{ id: '2', timeStart: 0, timeEnd: 10, duration: 10, revision: 2, linkStart: null },
{ id: '3' },
{ id: '4', timeStart: 10, timeEnd: 20, duration: 10, revision: 2, linkStart: null },
{ id: '5', timeStart: 20, timeEnd: 30, duration: 10, revision: 2, linkStart: '4' },
]);
apply('delay', testRundown);
expect(testRundown.revision).toBe(1);
expect(testRundown.order).toMatchObject(['1', '2', '3', '4', '5']);
expect(testRundown.entries).toMatchObject({
'1': { id: '1', timeStart: 0, timeEnd: 10, duration: 10, revision: 2 },
'2': { id: '2', timeStart: 0, timeEnd: 10, duration: 10, revision: 2, linkStart: null },
'3': { id: '3' },
'4': { id: '4', timeStart: 10, timeEnd: 20, duration: 10, revision: 2, linkStart: null },
'5': { id: '5', timeStart: 20, timeEnd: 30, duration: 10, revision: 2, linkStart: '4' },
});
});
it('should account for minimum duration and start when applying negative delays', () => {
const testRundown: OntimeRundown = [
makeOntimeDelay(-50),
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }),
makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, linkStart: '1' }),
];
const testRundown = makeRundown({
order: ['delay', '1', '2'],
entries: {
delay: makeOntimeDelay({ id: 'delay', duration: -50 }),
'1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }),
'2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, linkStart: '1' }),
},
});
const expected = [
{ id: '1', type: SupportedEvent.Event, timeStart: 0, timeEnd: 100, duration: 100, revision: 2 } as OntimeEvent,
{
apply('delay', testRundown);
expect(testRundown.order).toMatchObject(['1', '2']);
expect(testRundown.entries).toMatchObject({
'1': {
id: '1',
type: SupportedEvent.Event,
timeStart: 0,
timeEnd: 100,
duration: 100,
revision: 2,
} as OntimeEvent,
'2': {
id: '2',
type: SupportedEvent.Event,
timeStart: 50,
@@ -63,173 +86,222 @@ describe('apply()', () => {
duration: 50,
linkStart: null,
revision: 2,
} as OntimeEvent,
];
const updatedRundown = apply('delay', testRundown);
expect(updatedRundown).toMatchObject(expected);
},
});
});
it('unlinks events to maintain gaps when applying positive delays', () => {
const testRundown = [
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }),
makeOntimeDelay(50),
makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: '1' }),
];
const testRundown = makeRundown({
order: ['1', 'delay', '2'],
entries: {
'1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }),
delay: makeOntimeDelay({ id: 'delay', duration: 50 }),
'2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: '1' }),
},
});
expect(apply('delay', testRundown)).toMatchObject([
{ id: '1', type: SupportedEvent.Event, timeStart: 0, timeEnd: 100, duration: 100, revision: 1 } as OntimeEvent,
{
apply('delay', testRundown);
expect(testRundown.order).toMatchObject(['1', '2']);
expect(testRundown.entries).toMatchObject({
'1': {
id: '1',
timeStart: 0,
timeEnd: 100,
duration: 100,
revision: 1,
},
'2': {
id: '2',
type: SupportedEvent.Event,
timeStart: 150,
timeEnd: 200,
duration: 50,
linkStart: null,
revision: 2,
} as OntimeEvent,
]);
},
});
});
it('maintains links if there is no gap', () => {
const testRundown = [
makeOntimeDelay(50),
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }),
makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: '1' }),
];
const testRundown = makeRundown({
order: ['delay', '1', '2'],
entries: {
delay: makeOntimeDelay({ id: 'delay', duration: 50 }),
'1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }),
'2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: '1' }),
},
});
expect(apply('delay', testRundown)).toMatchObject([
{ id: '1', type: SupportedEvent.Event, timeStart: 50, timeEnd: 150, duration: 100, revision: 2 } as OntimeEvent,
{
apply('delay', testRundown);
expect(testRundown.order).toMatchObject(['1', '2']);
expect(testRundown.entries).toMatchObject({
'1': {
id: '1',
timeStart: 50,
timeEnd: 150,
duration: 100,
revision: 2,
},
'2': {
id: '2',
type: SupportedEvent.Event,
timeStart: 150,
timeEnd: 200,
duration: 50,
linkStart: '1',
revision: 2,
} as OntimeEvent,
]);
},
});
});
it('unlinks events to maintain gaps when applying negative delays', () => {
const testRundown = [
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }),
makeOntimeDelay(-50),
makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: '1' }),
];
const testRundown = makeRundown({
order: ['1', 'delay', '2'],
entries: {
'1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }),
delay: makeOntimeDelay({ id: 'delay', duration: -50 }),
'2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: '1' }),
},
});
expect(apply('delay', testRundown)).toMatchObject([
{ id: '1', type: SupportedEvent.Event, timeStart: 0, timeEnd: 100, duration: 100, revision: 1 } as OntimeEvent,
{
apply('delay', testRundown);
expect(testRundown.order).toMatchObject(['1', '2']);
expect(testRundown.entries).toMatchObject({
'1': { id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 },
'2': {
id: '2',
type: SupportedEvent.Event,
timeStart: 50,
timeEnd: 100,
duration: 50,
linkStart: null,
revision: 2,
} as OntimeEvent,
]);
},
});
});
it('gaps reduce positive delay', () => {
const testRundown: OntimeRundown = [
makeOntimeDelay(100),
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }),
// gap 50
makeOntimeEvent({ id: '2', timeStart: 150, timeEnd: 200, duration: 50, gap: 50 }),
// gap 0
makeOntimeEvent({ id: '3', timeStart: 200, timeEnd: 250, duration: 50, gap: 0 }),
// gap 50
makeOntimeEvent({ id: '4', timeStart: 300, timeEnd: 350, duration: 50, gap: 50 }),
// linked
makeOntimeEvent({ id: '5', timeStart: 350, timeEnd: 400, duration: 50, linkStart: '4' }),
];
const testRundown = makeRundown({
order: ['delay', '1', '2', '3', '4', '5'],
entries: {
delay: makeOntimeDelay({ id: 'delay', duration: 100 }),
'1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }),
// gap 50
'2': makeOntimeEvent({ id: '2', timeStart: 150, timeEnd: 200, duration: 50, gap: 50 }),
// gap 0
'3': makeOntimeEvent({ id: '3', timeStart: 200, timeEnd: 250, duration: 50, gap: 0 }),
// gap 50
'4': makeOntimeEvent({ id: '4', timeStart: 300, timeEnd: 350, duration: 50, gap: 50 }),
// linked
'5': makeOntimeEvent({ id: '5', timeStart: 350, timeEnd: 400, duration: 50, linkStart: '4' }),
},
});
const updatedRundown = apply('delay', testRundown);
expect(updatedRundown).toMatchObject([
{ id: '1', timeStart: 0 + 100, timeEnd: 100 + 100, duration: 100, revision: 2 },
apply('delay', testRundown);
expect(testRundown.order).toMatchObject(['1', '2', '3', '4', '5']);
expect(testRundown.entries).toMatchObject({
'1': { id: '1', timeStart: 0 + 100, timeEnd: 100 + 100, duration: 100, revision: 2 },
// gap 50 (100 - 50)
{ id: '2', timeStart: 150 + 50, timeEnd: 200 + 50, duration: 50, revision: 2 },
'2': { id: '2', timeStart: 150 + 50, timeEnd: 200 + 50, duration: 50, revision: 2 },
// gap 50 (50 - 50)
{ id: '3', timeStart: 200 + 50, timeEnd: 250 + 50, duration: 50, revision: 2, gap: 0 },
'3': { id: '3', timeStart: 200 + 50, timeEnd: 250 + 50, duration: 50, revision: 2, gap: 0 },
// gap (delay is 0)
{ id: '4', timeStart: 300, timeEnd: 350, duration: 50, revision: 1 },
'4': { id: '4', timeStart: 300, timeEnd: 350, duration: 50, revision: 1 },
// linked
{ id: '5', timeStart: 350, timeEnd: 400, duration: 50, revision: 1, linkStart: '4' },
]);
'5': { id: '5', timeStart: 350, timeEnd: 400, duration: 50, revision: 1, linkStart: '4' },
});
});
it('gaps reduce positive delay (2)', () => {
const testRundown: OntimeRundown = [
makeOntimeDelay(2 * MILLIS_PER_HOUR),
makeOntimeEvent({
id: '1',
gap: 0,
dayOffset: 0,
timeStart: 46800000, // 13:00:00
timeEnd: 50400000, // 14:00:00
duration: MILLIS_PER_HOUR,
}),
// gap 1h
makeOntimeEvent({
id: '2',
gap: 1 * MILLIS_PER_HOUR,
dayOffset: 0,
timeStart: 54000000, // 15:00:00
timeEnd: 57600000, // 16:00:00
duration: MILLIS_PER_HOUR,
}),
];
const testRundown = makeRundown({
order: ['delay', '1', '2'],
entries: {
delay: makeOntimeDelay({ id: 'delay', duration: 2 * MILLIS_PER_HOUR }),
'1': makeOntimeEvent({
id: '1',
gap: 0,
dayOffset: 0,
timeStart: 46800000, // 13:00:00
timeEnd: 50400000, // 14:00:00
duration: MILLIS_PER_HOUR,
}),
// gap 1h
'2': makeOntimeEvent({
id: '2',
gap: 1 * MILLIS_PER_HOUR,
dayOffset: 0,
timeStart: 54000000, // 15:00:00
timeEnd: 57600000, // 16:00:00
duration: MILLIS_PER_HOUR,
}),
},
});
const updatedRundown = apply('delay', testRundown);
expect(updatedRundown).toMatchObject([
{ id: '1', timeStart: 54000000 /* 16 */, revision: 2 },
apply('delay', testRundown);
expect(testRundown.order).toMatchObject(['1', '2']);
expect(testRundown.entries).toMatchObject({
'1': { id: '1', timeStart: 54000000 /* 16 */, revision: 2 },
// gap 1h (2h - 1h)
{ id: '2', timeStart: 57600000 /* 16 */, revision: 2 },
]);
'2': { id: '2', timeStart: 57600000 /* 16 */, revision: 2 },
});
});
it('removes empty delays without applying changes', () => {
const testRundown: OntimeRundown = [
makeOntimeDelay(0),
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }),
];
const testRundown = makeRundown({
order: ['delay', '1'],
entries: {
delay: makeOntimeDelay({ id: 'delay', duration: 0 }),
'1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }),
},
});
const updatedRundown = apply('delay', testRundown);
expect(updatedRundown).toMatchObject([{ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }]);
apply('delay', testRundown);
expect(testRundown.order).toMatchObject(['1']);
expect(testRundown.entries).toMatchObject({ '1': { id: '1', timeStart: 0, timeEnd: 100, duration: 100 } });
});
it('removes delays in last position without applying changes', () => {
const testRundown: OntimeRundown = [
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }),
makeOntimeDelay(100),
];
const testRundown = makeRundown({
order: ['1', 'delay'],
entries: {
'1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }),
delay: makeOntimeDelay({ id: 'delay', duration: 100 }),
},
});
const updatedRundown = apply('delay', testRundown);
expect(updatedRundown).toMatchObject([{ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }]);
apply('delay', testRundown);
expect(testRundown.order).toMatchObject(['1']);
expect(testRundown.entries).toMatchObject({ '1': { id: '1', timeStart: 0, timeEnd: 100, duration: 100 } });
});
it('unlinks events to across blocks is it is the first event after the delay', () => {
const testRundown = [
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }),
makeOntimeDelay(50),
{ id: 'block', type: SupportedEvent.Block } as OntimeBlock,
makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: '1' }),
];
expect(apply('delay', testRundown)).toMatchObject([
{ id: '1', type: SupportedEvent.Event, timeStart: 0, timeEnd: 100, duration: 100, revision: 1 } as OntimeEvent,
{ id: 'block', type: SupportedEvent.Block },
{
const testRundown = makeRundown({
order: ['1', 'delay', 'block', '2'],
entries: {
'1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }),
delay: makeOntimeDelay({ id: 'delay', duration: 50 }),
block: makeOntimeBlock({ id: 'block' }),
'2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: '1' }),
},
});
apply('delay', testRundown);
expect(testRundown.order).toMatchObject(['1', 'block', '2']);
expect(testRundown.entries).toMatchObject({
'1': {
id: '1',
timeStart: 0,
timeEnd: 100,
duration: 100,
revision: 1,
},
block: { id: 'block' },
'2': {
id: '2',
type: SupportedEvent.Event,
timeStart: 150,
timeEnd: 200,
duration: 50,
linkStart: null,
revision: 2,
} as OntimeEvent,
]);
},
});
});
});
@@ -1,13 +1,4 @@
import {
CustomFields,
EventCustomFields,
OntimeBlock,
OntimeDelay,
OntimeEvent,
OntimeRundown,
SupportedEvent,
TimeStrategy,
} from 'ontime-types';
import { CustomFields, OntimeEvent, SupportedEvent, TimeStrategy } from 'ontime-types';
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, dayInMs } from 'ontime-utils';
import {
@@ -23,6 +14,7 @@ import {
removeCustomField,
customFieldChangelog,
} from '../rundownCache.js';
import { makeOntimeBlock, makeOntimeDelay, makeOntimeEvent, makeRundown } from '../__mocks__/rundown.mocks.js';
beforeAll(() => {
vi.mock('../../../classes/data-provider/DataProvider.js', () => {
@@ -39,13 +31,16 @@ beforeAll(() => {
describe('generate()', () => {
it('creates normalised versions of a given rundown', () => {
const testRundown: OntimeRundown = [
{ type: SupportedEvent.Event, id: '1' } as OntimeEvent,
{ type: SupportedEvent.Block, id: '2' } as OntimeBlock,
{ type: SupportedEvent.Delay, id: '3' } as OntimeDelay,
];
const rundown = makeRundown({
order: ['1', '2', '3'],
entries: {
'1': makeOntimeEvent({ id: '1' }),
'2': makeOntimeBlock({ id: '2' }),
'3': makeOntimeDelay({ id: '3' }),
},
});
const initResult = generate(testRundown);
const initResult = generate(rundown);
expect(initResult.order.length).toBe(3);
expect(initResult.order).toStrictEqual(['1', '2', '3']);
expect(initResult.rundown['1'].type).toBe(SupportedEvent.Event);
@@ -54,29 +49,35 @@ describe('generate()', () => {
});
it('calculates delays versions of a given rundown', () => {
const testRundown: OntimeRundown = [
{ type: SupportedEvent.Delay, id: '1', duration: 100 } as OntimeDelay,
{ type: SupportedEvent.Event, id: '2', timeStart: 1, timeEnd: 100 } as OntimeEvent,
];
const rundown = makeRundown({
order: ['1', '2'],
entries: {
'1': makeOntimeDelay({ id: '1', duration: 100 }),
'2': makeOntimeEvent({ id: '2', timeStart: 1, timeEnd: 100 }),
},
});
const initResult = generate(testRundown);
const initResult = generate(rundown);
expect(initResult.order.length).toBe(2);
expect((initResult.rundown['2'] as OntimeEvent).delay).toBe(100);
expect(initResult.totalDelay).toBe(100);
});
it('accounts for gaps in rundown when calculating delays', () => {
const testRundown: OntimeRundown = [
{ type: SupportedEvent.Event, id: '1', timeStart: 100, timeEnd: 200, duration: 100 } as OntimeEvent,
{ type: SupportedEvent.Delay, id: 'delay', duration: 200 } as OntimeDelay,
{ type: SupportedEvent.Event, id: '2', timeStart: 200, timeEnd: 300, duration: 100 } as OntimeEvent,
{ type: SupportedEvent.Block, id: 'block', title: 'break' } as OntimeBlock,
{ type: SupportedEvent.Event, id: '3', timeStart: 400, timeEnd: 500, duration: 100 } as OntimeEvent,
{ type: SupportedEvent.Block, id: 'another-block', title: 'another-break' } as OntimeBlock,
{ type: SupportedEvent.Event, id: '4', timeStart: 600, timeEnd: 700, duration: 100 } as OntimeEvent,
];
const rundown = makeRundown({
order: ['1', 'delay', '2', 'block', '3', 'another-block', '4'],
entries: {
'1': makeOntimeEvent({ id: '1', timeStart: 100, timeEnd: 200, duration: 100 }),
delay: makeOntimeDelay({ id: 'delay', duration: 200 }),
'2': makeOntimeEvent({ id: '2', timeStart: 200, timeEnd: 300, duration: 100 }),
block: makeOntimeBlock({ id: 'block', title: 'break' }),
'3': makeOntimeEvent({ id: '3', timeStart: 400, timeEnd: 500, duration: 100 }),
'another-block': makeOntimeBlock({ id: 'another-block', title: 'another-break' }),
'4': makeOntimeEvent({ id: '4', timeStart: 600, timeEnd: 700, duration: 100 }),
},
});
const initResult = generate(testRundown);
const initResult = generate(rundown);
expect(initResult.order.length).toBe(7);
expect((initResult.rundown['1'] as OntimeEvent).delay).toBe(0);
expect((initResult.rundown['2'] as OntimeEvent).delay).toBe(200);
@@ -87,76 +88,84 @@ describe('generate()', () => {
});
it('accounts for overlaps in rundown', () => {
const testRundown: OntimeRundown = [
{ type: SupportedEvent.Event, id: '1', timeStart: 9000, timeEnd: 10000, duration: 1000 } as OntimeEvent,
{ type: SupportedEvent.Event, id: '2', timeStart: 9250, timeEnd: 9500, duration: 250 } as OntimeEvent,
{ type: SupportedEvent.Event, id: '3', timeStart: 9500, timeEnd: 10500, duration: 1000 } as OntimeEvent,
];
const rundown = makeRundown({
order: ['1', '2', '3'],
entries: {
'1': makeOntimeEvent({ id: '1', timeStart: 9000, timeEnd: 10000, duration: 1000 }),
'2': makeOntimeEvent({ id: '2', timeStart: 9250, timeEnd: 9500, duration: 250 }),
'3': makeOntimeEvent({ id: '3', timeStart: 9500, timeEnd: 10500, duration: 1000 }),
},
});
const initResult = generate(testRundown);
const initResult = generate(rundown);
expect(initResult.totalDuration).toBe(10500 - 9000); // last end - first start
});
it('accounts for overlaps in rundown (with added gap)', () => {
const testRundown: OntimeRundown = [
{ type: SupportedEvent.Event, id: '1', timeStart: 9000, timeEnd: 10000, duration: 1000 } as OntimeEvent,
{ type: SupportedEvent.Event, id: '2', timeStart: 9250, timeEnd: 9500, duration: 250 } as OntimeEvent,
{ type: SupportedEvent.Event, id: '3', timeStart: 9500, timeEnd: 10500, duration: 1000 } as OntimeEvent,
{ type: SupportedEvent.Event, id: '4', timeStart: 15000, timeEnd: 20000, duration: 5000 } as OntimeEvent,
];
const rundown = makeRundown({
order: ['1', '2', '3', '4'],
entries: {
'1': makeOntimeEvent({ id: '1', timeStart: 9000, timeEnd: 10000, duration: 1000 }),
'2': makeOntimeEvent({ id: '2', timeStart: 9250, timeEnd: 9500, duration: 250 }),
'3': makeOntimeEvent({ id: '3', timeStart: 9500, timeEnd: 10500, duration: 1000 }),
'4': makeOntimeEvent({ id: '4', timeStart: 15000, timeEnd: 20000, duration: 5000 }),
},
});
const initResult = generate(testRundown);
const initResult = generate(rundown);
expect(initResult.totalDuration).toBe(20000 - 9000); // last end - first start
});
it('accounts for overlaps in rundown (with multiple days)', () => {
const testRundown: OntimeRundown = [
{
type: SupportedEvent.Event,
id: '1',
timeStart: 9 * MILLIS_PER_HOUR,
timeEnd: 10 * MILLIS_PER_HOUR,
duration: MILLIS_PER_HOUR,
} as OntimeEvent,
{
type: SupportedEvent.Event,
id: '2',
timeStart: 9 * MILLIS_PER_HOUR + 15 * MILLIS_PER_MINUTE,
timeEnd: 9 * MILLIS_PER_HOUR + 45 * MILLIS_PER_MINUTE,
duration: 30 * MILLIS_PER_MINUTE,
} as OntimeEvent,
{
type: SupportedEvent.Event,
id: '3',
timeStart: 9 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE,
timeEnd: 10 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE,
duration: MILLIS_PER_HOUR,
} as OntimeEvent,
{
type: SupportedEvent.Event,
id: '4',
timeStart: 9 * MILLIS_PER_HOUR,
timeEnd: 10 * MILLIS_PER_HOUR,
duration: MILLIS_PER_HOUR,
} as OntimeEvent,
];
const rundown = makeRundown({
order: ['1', '2', '3', '4'],
entries: {
'1': makeOntimeEvent({
id: '1',
timeStart: 9 * MILLIS_PER_HOUR,
timeEnd: 10 * MILLIS_PER_HOUR,
duration: MILLIS_PER_HOUR,
}),
'2': makeOntimeEvent({
id: '2',
timeStart: 9 * MILLIS_PER_HOUR + 15 * MILLIS_PER_MINUTE,
timeEnd: 9 * MILLIS_PER_HOUR + 45 * MILLIS_PER_MINUTE,
duration: 30 * MILLIS_PER_MINUTE,
}),
'3': makeOntimeEvent({
id: '3',
timeStart: 9 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE,
timeEnd: 10 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE,
duration: MILLIS_PER_HOUR,
}),
'4': makeOntimeEvent({
id: '4',
timeStart: 9 * MILLIS_PER_HOUR,
timeEnd: 10 * MILLIS_PER_HOUR,
duration: MILLIS_PER_HOUR,
}),
},
});
const initResult = generate(testRundown);
const initResult = generate(rundown);
expect(initResult.totalDuration).toBe(dayInMs + MILLIS_PER_HOUR); // day + last end - first start
});
it('handles negative delays', () => {
const testRundown: OntimeRundown = [
{ type: SupportedEvent.Event, id: '1', timeStart: 100, timeEnd: 200, duration: 100 } as OntimeEvent,
{ type: SupportedEvent.Delay, id: 'delay', duration: -200 } as OntimeDelay,
{ type: SupportedEvent.Event, id: '2', timeStart: 200, timeEnd: 300, duration: 100 } as OntimeEvent,
{ type: SupportedEvent.Block, id: 'block', title: 'break' } as OntimeBlock,
{ type: SupportedEvent.Event, id: '3', timeStart: 400, timeEnd: 500, duration: 100 } as OntimeEvent,
{ type: SupportedEvent.Block, id: 'another-block', title: 'another-break' } as OntimeBlock,
{ type: SupportedEvent.Event, id: '4', timeStart: 600, timeEnd: 700, duration: 100 } as OntimeEvent,
];
const rundown = makeRundown({
order: ['1', 'delay', '2', 'block', '3', 'another-block', '4'],
entries: {
'1': makeOntimeEvent({ id: '1', timeStart: 100, timeEnd: 200, duration: 100 }),
delay: makeOntimeDelay({ id: 'delay', duration: -200 }),
'2': makeOntimeEvent({ id: '2', timeStart: 200, timeEnd: 300, duration: 100 }),
block: makeOntimeBlock({ id: 'block', title: 'break' }),
'3': makeOntimeEvent({ id: '3', timeStart: 400, timeEnd: 500, duration: 100 }),
'another-block': makeOntimeBlock({ id: 'another-block', title: 'another-break' }),
'4': makeOntimeEvent({ id: '4', timeStart: 600, timeEnd: 700, duration: 100 }),
},
});
const initResult = generate(testRundown);
const initResult = generate(rundown);
expect(initResult.order.length).toBe(7);
expect((initResult.rundown['1'] as OntimeEvent).delay).toBe(0);
expect((initResult.rundown['2'] as OntimeEvent).delay).toBe(-200);
@@ -167,38 +176,38 @@ describe('generate()', () => {
});
it('links times across events', () => {
const testRundown: OntimeRundown = [
{
type: SupportedEvent.Event,
id: '1',
timeStart: 1,
duration: 1,
timeEnd: 2,
timeStrategy: TimeStrategy.LockEnd,
} as OntimeEvent,
{
type: SupportedEvent.Event,
id: '2',
timeStart: 11,
duration: 1,
timeEnd: 12,
linkStart: '1',
timeStrategy: TimeStrategy.LockEnd,
} as OntimeEvent,
{ type: SupportedEvent.Block, id: 'block' } as OntimeBlock,
{ type: SupportedEvent.Delay, id: 'delay' } as OntimeDelay,
{
type: SupportedEvent.Event,
id: '3',
timeStart: 21,
duration: 1,
timeEnd: 22,
linkStart: '2',
timeStrategy: TimeStrategy.LockEnd,
} as OntimeEvent,
];
const rundown = makeRundown({
order: ['1', '2', 'block', 'delay', '3'],
entries: {
'1': makeOntimeEvent({
id: '1',
timeStart: 1,
duration: 1,
timeEnd: 2,
timeStrategy: TimeStrategy.LockEnd,
}),
'2': makeOntimeEvent({
id: '2',
timeStart: 11,
duration: 1,
timeEnd: 12,
linkStart: '1',
timeStrategy: TimeStrategy.LockEnd,
}),
block: makeOntimeBlock({ id: 'block' }),
delay: makeOntimeDelay({ id: 'delay' }),
'3': makeOntimeEvent({
id: '3',
timeStart: 21,
duration: 1,
timeEnd: 22,
linkStart: '2',
timeStrategy: TimeStrategy.LockEnd,
}),
},
});
const initResult = generate(testRundown);
const initResult = generate(rundown);
expect(initResult.order.length).toBe(5);
expect((initResult.rundown['2'] as OntimeEvent).timeStart).toBe(2);
expect((initResult.rundown['2'] as OntimeEvent).timeEnd).toBe(12);
@@ -213,13 +222,16 @@ describe('generate()', () => {
});
it('links times across events, reordered', () => {
const testRundown: OntimeRundown = [
{ type: SupportedEvent.Event, id: '1', timeStart: 1, timeEnd: 2 } as OntimeEvent,
{ type: SupportedEvent.Event, id: '3', timeStart: 21, timeEnd: 22, linkStart: '2' } as OntimeEvent,
{ type: SupportedEvent.Event, id: '2', timeStart: 11, timeEnd: 12, linkStart: '1' } as OntimeEvent,
];
const rundown = makeRundown({
order: ['1', '3', '2'],
entries: {
'1': makeOntimeEvent({ id: '1', timeStart: 1, timeEnd: 2 }),
'3': makeOntimeEvent({ id: '3', timeStart: 21, timeEnd: 22, linkStart: '2' }),
'2': makeOntimeEvent({ id: '2', timeStart: 11, timeEnd: 12, linkStart: '1' }),
},
});
const initResult = generate(testRundown);
const initResult = generate(rundown);
expect(initResult.order.length).toBe(3);
expect((initResult.rundown['3'] as OntimeEvent).timeStart).toBe(2);
expect(initResult.links['1']).toBe('3');
@@ -227,159 +239,156 @@ describe('generate()', () => {
});
it('calculates total duration', () => {
const testRundown: OntimeRundown = [
{ type: SupportedEvent.Event, id: '1', timeStart: 100, timeEnd: 200, duration: 100 } as OntimeEvent,
{ type: SupportedEvent.Event, id: '2', timeStart: 200, timeEnd: 300, duration: 100 } as OntimeEvent,
{
type: SupportedEvent.Event,
id: 'skipped',
skip: true,
timeStart: 300,
timeEnd: 400,
duration: 100,
} as OntimeEvent,
{ type: SupportedEvent.Event, id: '3', timeStart: 400, timeEnd: 500, duration: 100 } as OntimeEvent,
];
const rundown = makeRundown({
order: ['1', '2', 'skipped', '3'],
entries: {
'1': makeOntimeEvent({ id: '1', timeStart: 100, timeEnd: 200, duration: 100 }),
'2': makeOntimeEvent({ id: '2', timeStart: 200, timeEnd: 300, duration: 100 }),
skipped: makeOntimeEvent({ id: 'skipped', skip: true, timeStart: 300, timeEnd: 400, duration: 100 }),
'3': makeOntimeEvent({ id: '2', timeStart: 400, timeEnd: 500, duration: 100 }),
},
});
const initResult = generate(testRundown);
const initResult = generate(rundown);
expect(initResult.order.length).toBe(4);
expect(initResult.totalDuration).toBe(500 - 100);
});
it('calculates total duration with 0 duration events without causing a next day', () => {
const testRundown: OntimeRundown = [
{ type: SupportedEvent.Event, id: '1', timeStart: 100, timeEnd: 100, duration: 0 } as OntimeEvent,
{ type: SupportedEvent.Event, id: '2', timeStart: 100, timeEnd: 300, duration: 200 } as OntimeEvent,
{
type: SupportedEvent.Event,
id: 'skipped',
skip: true,
timeStart: 300,
timeEnd: 400,
duration: 0,
} as OntimeEvent,
{ type: SupportedEvent.Event, id: '3', timeStart: 400, timeEnd: 500, duration: 100 } as OntimeEvent,
];
const rundown = makeRundown({
order: ['1', '2', 'skipped', '3'],
entries: {
'1': makeOntimeEvent({ id: '1', timeStart: 100, timeEnd: 100, duration: 0 }),
'2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 300, duration: 200 }),
skipped: makeOntimeEvent({ id: 'skipped', skip: true, timeStart: 300, timeEnd: 400, duration: 0 }),
'3': makeOntimeEvent({ id: '2', timeStart: 400, timeEnd: 500, duration: 100 }),
},
});
const initResult = generate(testRundown);
const initResult = generate(rundown);
expect(initResult.order.length).toBe(4);
expect(initResult.totalDuration).toBe(500 - 100);
});
it('calculates total duration across days with gap', () => {
const testRundown: OntimeRundown = [
{
type: SupportedEvent.Event,
id: '1',
timeStart: 9 * MILLIS_PER_HOUR,
timeEnd: 23 * MILLIS_PER_HOUR,
duration: (23 - 9) * MILLIS_PER_HOUR,
} as OntimeEvent,
{
type: SupportedEvent.Event,
id: '2',
timeStart: 9 * MILLIS_PER_HOUR,
timeEnd: 23 * MILLIS_PER_HOUR,
duration: (23 - 9) * MILLIS_PER_HOUR,
} as OntimeEvent,
{
type: SupportedEvent.Event,
id: '3',
timeStart: 9 * MILLIS_PER_HOUR,
timeEnd: 23 * MILLIS_PER_HOUR,
duration: (23 - 9) * MILLIS_PER_HOUR,
} as OntimeEvent,
];
const rundown = makeRundown({
order: ['1', '2', '3'],
entries: {
'1': makeOntimeEvent({
id: '1',
timeStart: 9 * MILLIS_PER_HOUR,
timeEnd: 23 * MILLIS_PER_HOUR,
duration: (23 - 9) * MILLIS_PER_HOUR,
}),
'2': makeOntimeEvent({
id: '2',
timeStart: 9 * MILLIS_PER_HOUR,
timeEnd: 23 * MILLIS_PER_HOUR,
duration: (23 - 9) * MILLIS_PER_HOUR,
}),
'3': makeOntimeEvent({
id: '2',
timeStart: 9 * MILLIS_PER_HOUR,
timeEnd: 23 * MILLIS_PER_HOUR,
duration: (23 - 9) * MILLIS_PER_HOUR,
}),
},
});
const initResult = generate(testRundown);
const initResult = generate(rundown);
expect(initResult.totalDuration).toBe((23 - 9 + 48) * MILLIS_PER_HOUR);
});
it('calculates total duration across days', () => {
const testRundown: OntimeRundown = [
{
type: SupportedEvent.Event,
id: '1',
timeStart: 12 * MILLIS_PER_HOUR,
timeEnd: 22 * MILLIS_PER_HOUR,
duration: 10 * MILLIS_PER_HOUR,
} as OntimeEvent,
{
type: SupportedEvent.Event,
id: '2',
timeStart: 22 * MILLIS_PER_HOUR,
timeEnd: 8 * MILLIS_PER_HOUR,
duration: (24 - 22 + 8) * MILLIS_PER_HOUR,
} as OntimeEvent,
];
const rundown = makeRundown({
order: ['1', '2'],
entries: {
'1': makeOntimeEvent({
id: '1',
timeStart: 12 * MILLIS_PER_HOUR,
timeEnd: 22 * MILLIS_PER_HOUR,
duration: 10 * MILLIS_PER_HOUR,
}),
'2': makeOntimeEvent({
id: '2',
timeStart: 22 * MILLIS_PER_HOUR,
timeEnd: 8 * MILLIS_PER_HOUR,
duration: (24 - 22 + 8) * MILLIS_PER_HOUR,
}),
},
});
const initResult = generate(testRundown);
const initResult = generate(rundown);
const expectedDuration = 8 * MILLIS_PER_HOUR + (dayInMs - 12 * MILLIS_PER_HOUR);
expect(initResult.totalDuration).toBe(expectedDuration);
});
it('handles updating event sequence', () => {
const testRundown: OntimeRundown = [
{
type: SupportedEvent.Event,
id: '97cc3e',
timeStart: 0,
timeEnd: 600000,
duration: 600000,
timeStrategy: TimeStrategy.LockDuration,
linkStart: null,
} as OntimeEvent,
{
type: SupportedEvent.Event,
id: 'e01948',
timeStart: 600000,
timeEnd: 601000,
duration: 85801000, // <------------- value out of sync
timeStrategy: TimeStrategy.LockEnd,
linkStart: '97cc3e',
} as OntimeEvent,
{
type: SupportedEvent.Event,
id: '25c1af',
timeStart: 100, // <------------- value out of sync
timeEnd: 602000,
duration: 0,
timeStrategy: TimeStrategy.LockEnd,
linkStart: 'e01948',
} as OntimeEvent,
];
const rundown = makeRundown({
order: ['1', '2', '3'],
entries: {
'1': makeOntimeEvent({
id: '1',
timeStart: 0,
timeEnd: 600000,
duration: 600000,
timeStrategy: TimeStrategy.LockDuration,
linkStart: null,
}),
'2': makeOntimeEvent({
id: '2',
timeStart: 600000,
timeEnd: 601000,
duration: 85801000, // <------------- value out of sync
timeStrategy: TimeStrategy.LockEnd,
linkStart: '1',
}),
'3': makeOntimeEvent({
id: '3',
timeStart: 100, // <------------- value out of sync
timeEnd: 602000,
duration: 0,
timeStrategy: TimeStrategy.LockEnd,
linkStart: '2',
}),
},
});
const initResult = generate(testRundown);
const initResult = generate(rundown);
expect(initResult.rundown).toMatchObject({
'97cc3e': {
'1': {
timeStart: 0,
timeEnd: 600000,
duration: 600000,
timeStrategy: 'lock-duration',
linkStart: null,
},
e01948: {
'2': {
timeStart: 600000,
timeEnd: 601000,
duration: 1000,
timeStrategy: 'lock-end',
linkStart: '97cc3e',
linkStart: '1',
},
'25c1af': {
'3': {
timeStart: 601000,
timeEnd: 602000,
duration: 1000,
timeStrategy: 'lock-end',
linkStart: 'e01948',
linkStart: '2',
},
});
});
it('deletes links if invalid', () => {
const testRundown: OntimeRundown = [
{ type: SupportedEvent.Event, id: '1', timeStart: 1, linkStart: '10' } as OntimeEvent,
];
const initResult = generate(testRundown);
const rundown = makeRundown({
order: ['1'],
entries: {
'1': makeOntimeEvent({ id: '1', timeStart: 1, linkStart: '10' }),
},
});
const initResult = generate(rundown);
expect(initResult.order.length).toBe(1);
expect((initResult.rundown['1'] as OntimeEvent).timeStart).toBe(1);
expect(Object.keys(initResult.links).length).toBe(0);
@@ -399,24 +408,26 @@ describe('generate()', () => {
colour: 'red',
},
};
const testRundown: OntimeRundown = [
{
type: SupportedEvent.Event,
id: '1',
custom: {
lighting: 'event 1 lx',
} as EventCustomFields,
} as OntimeEvent,
{
type: SupportedEvent.Event,
id: '2',
custom: {
lighting: 'event 2 lx',
sound: 'event 2 sound',
} as EventCustomFields,
} as OntimeEvent,
];
const initResult = generate(testRundown, customProperties);
const rundown = makeRundown({
order: ['1', '2'],
entries: {
'1': makeOntimeEvent({
id: '1',
custom: {
lighting: 'event 1 lx',
},
}),
'2': makeOntimeEvent({
id: '2',
custom: {
lighting: 'event 2 lx',
sound: 'event 2 sound',
},
}),
},
});
const initResult = generate(rundown, customProperties);
expect(initResult.order.length).toBe(2);
expect(initResult.assignedCustomFields).toMatchObject({
lighting: ['1', '2'],
@@ -433,47 +444,64 @@ describe('generate()', () => {
describe('add() mutation', () => {
test('adds an event to the rundown', () => {
const mockEvent = { id: 'mock', cue: 'mock', type: SupportedEvent.Event } as OntimeEvent;
const testRundown: OntimeRundown = [];
const { newRundown } = add({ atIndex: 0, event: mockEvent, rundown: testRundown });
expect(newRundown.length).toBe(1);
expect(newRundown[0]).toMatchObject(mockEvent);
const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' });
const rundown = makeRundown({});
const { newRundown } = add({ atIndex: 0, event: mockEvent, rundown });
expect(newRundown.order.length).toBe(1);
expect(newRundown.entries['mock']).toMatchObject(mockEvent);
});
});
describe('remove() mutation', () => {
test('deletes an event from the rundown', () => {
const mockEvent = { id: 'mock', cue: 'mock', type: SupportedEvent.Event } as OntimeEvent;
const testRundown: OntimeRundown = [mockEvent];
const { newRundown } = remove({ eventIds: [mockEvent.id], rundown: testRundown });
expect(newRundown.length).toBe(0);
const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' });
const rundown = makeRundown({
order: ['mock'],
entries: {
mock: mockEvent,
},
});
const { newRundown } = remove({ eventIds: [mockEvent.id], rundown });
expect(newRundown.order.length).toBe(0);
});
test('deletes multiple events from the rundown', () => {
const testRundown: OntimeRundown = [
{ type: SupportedEvent.Event, id: '1' } as OntimeEvent,
{ type: SupportedEvent.Block, id: '2' } as OntimeBlock,
{ type: SupportedEvent.Delay, id: '3' } as OntimeDelay,
{ type: SupportedEvent.Event, id: '4' } as OntimeEvent,
{ type: SupportedEvent.Event, id: '5' } as OntimeEvent,
{ type: SupportedEvent.Event, id: '6' } as OntimeEvent,
];
const { newRundown } = remove({ eventIds: ['1', '2', '3'], rundown: testRundown });
expect(newRundown.length).toBe(3);
expect(newRundown.at(0)?.id).toBe('4');
const rundown = makeRundown({
order: ['1', '2', '3', '4', '5', '6'],
entries: {
'1': makeOntimeEvent({ id: '1' }),
'2': makeOntimeBlock({ id: '2' }),
'3': makeOntimeDelay({ id: '3' }),
'4': makeOntimeEvent({ id: '4' }),
'5': makeOntimeEvent({ id: '5' }),
'6': makeOntimeEvent({ id: '6' }),
},
});
const { newRundown } = remove({ eventIds: ['1', '2', '3'], rundown });
expect(newRundown.order.length).toBe(3);
expect(newRundown.entries[newRundown.order[0]].id).toBe('4');
});
});
describe('edit() mutation', () => {
test('edits an event in the rundown', () => {
const mockEvent = { id: 'mock', cue: 'mock', type: SupportedEvent.Event } as OntimeEvent;
const mockEventPatch = { cue: 'patched' } as OntimeEvent;
const testRundown: OntimeRundown = [mockEvent];
const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' });
const mockEventPatch = makeOntimeEvent({ cue: 'patched' });
const rundown = makeRundown({
order: ['mock'],
entries: {
mock: mockEvent,
},
});
const { newRundown, newEvent } = edit({
eventId: mockEvent.id,
patch: mockEventPatch,
rundown: testRundown,
rundown,
});
expect(newRundown.length).toBe(1);
expect(newRundown.order.length).toBe(1);
expect(newEvent).toMatchObject({
id: 'mock',
cue: 'patched',
@@ -484,73 +512,96 @@ describe('edit() mutation', () => {
describe('batchEdit() mutation', () => {
it('should correctly apply the patch to the events with the given IDs', () => {
const testRundown: OntimeRundown = [
{ id: '1', type: SupportedEvent.Event, cue: 'data1' } as OntimeEvent,
{ id: '2', type: SupportedEvent.Event, cue: 'data2' } as OntimeEvent,
{ id: '3', type: SupportedEvent.Event, cue: 'data3' } as OntimeEvent,
];
const rundown = makeRundown({
order: ['1', '2', '3'],
entries: {
'1': makeOntimeEvent({ id: '1', cue: 'data1' }),
'2': makeOntimeEvent({ id: '2', cue: 'data2' }),
'3': makeOntimeEvent({ id: '3', cue: 'data3' }),
},
});
const eventIds = ['1', '3'];
const patch = { cue: 'newData' };
const { newRundown } = batchEdit({ rundown: testRundown, eventIds, patch });
const { newRundown } = batchEdit({ rundown, eventIds, patch });
expect(newRundown).toMatchObject([
{ id: '1', type: SupportedEvent.Event, cue: 'newData' },
{ id: '2', type: SupportedEvent.Event, cue: 'data2' },
{ id: '3', type: SupportedEvent.Event, cue: 'newData' },
]);
expect(newRundown.entries).toMatchObject({
'1': { id: '1', type: SupportedEvent.Event, cue: 'newData' },
'2': { id: '2', type: SupportedEvent.Event, cue: 'data2' },
'3': { id: '3', type: SupportedEvent.Event, cue: 'newData' },
});
});
});
describe('reorder() mutation', () => {
it('should correctly reorder two events', () => {
const testRundown: OntimeRundown = [
{ id: '1', type: SupportedEvent.Event, cue: 'data1', revision: 0 } as OntimeEvent,
{ id: '2', type: SupportedEvent.Event, cue: 'data2', revision: 0 } as OntimeEvent,
{ id: '3', type: SupportedEvent.Event, cue: 'data3', revision: 0 } as OntimeEvent,
];
const { newRundown } = reorder({
rundown: testRundown,
eventId: testRundown[0].id,
from: 0,
to: testRundown.length - 1,
const rundown = makeRundown({
order: ['1', '2', '3'],
entries: {
'1': makeOntimeEvent({ id: '1', cue: 'data1', revision: 0 }),
'2': makeOntimeEvent({ id: '2', cue: 'data2', revision: 0 }),
'3': makeOntimeEvent({ id: '3', cue: 'data3', revision: 0 }),
},
});
expect(newRundown).toMatchObject([
{ id: '2', type: SupportedEvent.Event, cue: 'data2', revision: 1 },
{ id: '3', type: SupportedEvent.Event, cue: 'data3', revision: 1 },
{ id: '1', type: SupportedEvent.Event, cue: 'data1', revision: 1 },
]);
// move first event to the end
const { newRundown } = reorder({
rundown: rundown,
eventId: rundown.order[0],
from: 0,
to: rundown.order.length - 1,
});
expect(newRundown.order).toStrictEqual(['2', '3', '1']);
expect(newRundown.entries).toMatchObject({
'2': { id: '2', cue: 'data2', revision: 1 },
'3': { id: '3', cue: 'data3', revision: 1 },
'1': { id: '1', cue: 'data1', revision: 1 },
});
});
});
describe('swap() mutation', () => {
it('should correctly swap data between events', () => {
const testRundown: OntimeRundown = [
{ id: '1', type: SupportedEvent.Event, cue: 'data1', timeStart: 1, revision: 0 } as OntimeEvent,
{ id: '2', type: SupportedEvent.Event, cue: 'data2', timeStart: 2, revision: 0 } as OntimeEvent,
{ id: '3', type: SupportedEvent.Event, cue: 'data3', timeStart: 3, revision: 0 } as OntimeEvent,
];
const { newRundown } = swap({
rundown: testRundown,
fromId: testRundown[0].id,
toId: testRundown[1].id,
const rundown = makeRundown({
order: ['1', '2', '3'],
entries: {
'1': makeOntimeEvent({ id: '1', cue: 'data1', timeStart: 1, revision: 4 }),
'2': makeOntimeEvent({ id: '2', cue: 'data2', timeStart: 2, revision: 8 }),
'3': makeOntimeEvent({ id: '3', cue: 'data3', timeStart: 3, revision: 12 }),
},
});
expect((newRundown[0] as OntimeEvent).id).toBe('1');
expect((newRundown[0] as OntimeEvent).cue).toBe('data2');
expect((newRundown[0] as OntimeEvent).timeStart).toBe(1);
expect((newRundown[0] as OntimeEvent).revision).toBe(1);
// swap first and second event
const { newRundown } = swap({
rundown: rundown,
fromId: rundown.order[0],
toId: rundown.order[1],
});
expect((newRundown[1] as OntimeEvent).id).toBe('2');
expect((newRundown[1] as OntimeEvent).cue).toBe('data1');
expect((newRundown[1] as OntimeEvent).timeStart).toBe(2);
expect((newRundown[1] as OntimeEvent).revision).toBe(1);
expect(newRundown.order).toStrictEqual(['1', '2', '3']);
expect((newRundown[2] as OntimeEvent).id).toBe('3');
expect((newRundown[2] as OntimeEvent).cue).toBe('data3');
expect((newRundown[2] as OntimeEvent).timeStart).toBe(3);
expect((newRundown[2] as OntimeEvent).revision).toBe(0);
expect(newRundown.entries['1']).toMatchObject({
id: '1',
cue: 'data2',
timeStart: 1,
revision: 5,
});
expect(newRundown.entries['2']).toMatchObject({
id: '2',
cue: 'data1',
timeStart: 2,
revision: 9,
});
expect(newRundown.entries['3']).toMatchObject({
id: '3',
cue: 'data3',
timeStart: 3,
revision: 12,
});
});
});
@@ -2,7 +2,7 @@ import {
CustomFields,
EndAction,
OntimeEvent,
OntimeRundown,
RundownEntries,
SupportedEvent,
TimeStrategy,
TimerType,
@@ -10,46 +10,25 @@ import {
import {
addToCustomAssignment,
calculateDayOffset,
getLink,
handleCustomField,
handleLink,
hasChanges,
isDataStale,
} from '../rundownCacheUtils.js';
import { MILLIS_PER_HOUR } from 'ontime-utils';
describe('getLink()', () => {
it('should return null if there is no link', () => {
const rundown = [
{ type: SupportedEvent.Block, id: 'block' },
{ type: SupportedEvent.Event, id: '1' },
] as OntimeRundown;
const result = getLink(1, rundown);
expect(result).toBeNull();
});
it('returns previous event', () => {
const rundown = [
{ type: SupportedEvent.Event, id: '1', timeEnd: 100 },
{ type: SupportedEvent.Event, id: '2', timeStart: 0, linkStart: '1' },
] as OntimeRundown;
const result = getLink(1, rundown);
expect(result.id).toBe('1');
});
});
import { makeOntimeBlock, makeOntimeEvent } from '../__mocks__/rundown.mocks.js';
describe('handleLink()', () => {
it('populates data in object and updates link map', () => {
const rundown = [
{ type: SupportedEvent.Event, id: '1', timeEnd: 100 },
{ type: SupportedEvent.Event, id: '2', timeStart: 0, linkStart: '1' },
] as OntimeRundown;
const mutableEvent = { ...rundown[1] } as OntimeEvent;
const entries: RundownEntries = {
'1': makeOntimeEvent({ id: '1', timeEnd: 100 }),
'2': makeOntimeEvent({ id: '2', timeStart: 0, linkStart: '1' }),
};
const mutableEvent = { ...entries[2] } as OntimeEvent;
const links = {};
const result = handleLink(1, rundown, mutableEvent, links);
const result = handleLink(mutableEvent, entries[1] as OntimeEvent, links);
expect(result).toBeUndefined();
expect(mutableEvent.timeStart).toBe(100);
expect(mutableEvent.linkStart).toBe('1');
@@ -57,17 +36,17 @@ describe('handleLink()', () => {
});
it('removes link if linked event is not found', () => {
const rundown = [
{ type: SupportedEvent.Block, id: '1' },
{ type: SupportedEvent.Event, id: '2', timeStart: 0, linkStart: '1' },
] as OntimeRundown;
const mutableEvent = { ...rundown[1] } as OntimeEvent;
const entries: RundownEntries = {
'1': makeOntimeBlock({ id: '1' }),
'2': makeOntimeEvent({ id: '2', timeStart: 0, linkStart: '1' }),
};
const mutableEvent = { ...entries[2] } as OntimeEvent;
const links = {};
const result = handleLink(1, rundown, mutableEvent, links);
const result = handleLink(mutableEvent, null, links);
expect(result).toBeUndefined();
expect(mutableEvent.timeStart).toBe(0);
expect(mutableEvent.linkStart).toBe(null);
expect(mutableEvent.linkStart).toBe('true');
expect(links).toStrictEqual({});
});
});
@@ -252,7 +231,7 @@ describe('hasChanges()', () => {
describe('calculateDayOffset', () => {
it('returns 0 if there is no previous event', () => {
expect(calculateDayOffset({ timeStart: 0 })).toBe(0);
expect(calculateDayOffset({ timeStart: 0 }, null)).toBe(0);
});
it('returns 0 if the previous event duration is 0', () => {
@@ -1,38 +1,41 @@
import { OntimeRundown, isOntimeDelay, isOntimeEvent, OntimeEvent } from 'ontime-types';
import { Rundown, EntryId, isOntimeDelay, isOntimeEvent, OntimeEvent } from 'ontime-types';
import { deleteAtIndex } from 'ontime-utils';
/**
* Applies delay from given event ID, deletes the delay event after
* @throws {Error} if event ID not found or is not a delay
* Mutates the given rundown
* @throws if event ID not found or is not a delay
*/
export function apply(eventId: string, rundown: OntimeRundown): OntimeRundown {
const delayIndex = rundown.findIndex((event) => event.id === eventId);
const delayEvent = rundown.at(delayIndex);
export function apply(delayId: EntryId, rundown: Rundown): Rundown {
const delayEvent = rundown.entries[delayId];
if (!delayEvent) {
throw new Error('Given event ID not found');
if (!delayEvent || !isOntimeDelay(delayEvent)) {
throw new Error('Given delay ID not found');
}
if (!isOntimeDelay(delayEvent)) {
throw new Error('Given event ID is not a delay');
}
const delayIndex = rundown.order.findIndex((entryId) => entryId === delayId);
// if the delay is empty, or the last element, we can just delete it
if (delayEvent.duration === 0 || delayIndex === rundown.length - 1) {
return deleteAtIndex(delayIndex, rundown);
// if the delay is empty, or the last element
// we can just delete it with no further operations
if (delayEvent.duration === 0 || delayIndex === rundown.order.length - 1) {
delete rundown.entries[delayId];
rundown.order = deleteAtIndex(delayIndex, rundown.order);
return rundown;
}
/**
* We apply the delay to the rundown
* This logic is mostly in sync with rundownCache.generate
* The difference is that here it will become part of the schedule,
* so we cant leave the work for the generate function
*/
const updatedRundown = structuredClone(rundown);
let delayValue = delayEvent.duration;
let lastEntry: OntimeEvent | null = null;
let isFirstEvent = true;
for (let i = delayIndex + 1; i < updatedRundown.length; i++) {
const currentEntry = updatedRundown[i];
for (let i = delayIndex + 1; i < rundown.order.length; i++) {
const currentId = rundown.order[i];
const currentEntry = rundown.entries[currentId];
// we don't do operation on other event types
if (!isOntimeEvent(currentEntry)) {
@@ -77,5 +80,9 @@ export function apply(eventId: string, rundown: OntimeRundown): OntimeRundown {
currentEntry.revision += 1;
}
return deleteAtIndex(delayIndex, updatedRundown);
delete rundown.entries[delayId];
rundown.order = deleteAtIndex(delayIndex, rundown.order);
rundown.revision += 1;
return rundown;
}
@@ -2,15 +2,18 @@ import {
CustomField,
CustomFieldLabel,
CustomFields,
EntryId,
isOntimeBlock,
isOntimeDelay,
isOntimeEvent,
isPlayableEvent,
MaybeNumber,
OntimeBlock,
OntimeEvent,
OntimeRundown,
OntimeRundownEntry,
OntimeEntry,
PlayableEvent,
Rundown,
RundownEntries,
} from 'ontime-types';
import {
generateId,
@@ -21,26 +24,32 @@ import {
isNewLatest,
customFieldLabelToKey,
} from 'ontime-utils';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import { createPatch } from '../../utils/parser.js';
import { apply } from './delayUtils.js';
import { calculateDayOffset, handleCustomField, handleLink, hasChanges, isDataStale } from './rundownCacheUtils.js';
type EventID = string;
type NormalisedRundown = Record<EventID, OntimeRundownEntry>;
let persistedRundown: OntimeRundown = [];
let currentRundownId: EntryId = '';
let currentRundown: Rundown = {
id: '',
title: '',
order: [],
entries: {},
revision: 0,
};
let persistedCustomFields: CustomFields = {};
/**
* Get the cached rundown without triggering regeneration
*/
export const getPersistedRundown = (): OntimeRundown => persistedRundown;
export const getCurrentRundown = (): Rundown => currentRundown;
export const getCustomFields = (): CustomFields => persistedCustomFields;
let normalisedRundown: NormalisedRundown = {};
let order: EventID[] = [];
let revision = 0;
let playableEventsOrder: EntryId[] = [];
let timedEventsOrder: EntryId[] = [];
let flatIndexOrder: EntryId[] = [];
/**
* all mutating functions will set this value if there is a need for re-generation
@@ -59,7 +68,7 @@ let totalDays = 0;
let firstStart: MaybeNumber = null;
let lastEnd: MaybeNumber = null;
let links: Record<EventID, EventID> = {};
let links: Record<EntryId, EntryId> = {};
/**
* Object that contains reference of renamed custom fields
@@ -76,13 +85,17 @@ export const customFieldChangelog = new Map<string, string>();
* Keep track of which custom fields are used.
* This will be handy for when we delete custom fields
*/
let assignedCustomFields: Record<CustomFieldLabel, EventID[]> = {};
let assignedCustomFields: Record<CustomFieldLabel, EntryId[]> = {};
export async function init(initialRundown: Readonly<OntimeRundown>, customFields: Readonly<CustomFields>) {
persistedRundown = structuredClone(initialRundown) as OntimeRundown;
/**
* Receives a rundown which will be processed and used as the new current rundown
*/
export async function init(initialRundown: Rundown, customFields: Readonly<CustomFields>) {
currentRundown = structuredClone(initialRundown);
currentRundownId = initialRundown.id;
persistedCustomFields = structuredClone(customFields);
generate();
await getDataProvider().setRundown(persistedRundown);
await getDataProvider().setRundown(currentRundownId, currentRundown);
await getDataProvider().setCustomFields(customFields);
}
@@ -90,10 +103,7 @@ export async function init(initialRundown: Readonly<OntimeRundown>, customFields
* Utility generate cache
* @private should not be called outside of `rundownCache.ts`
*/
export function generate(
initialRundown: OntimeRundown = persistedRundown,
customFields: CustomFields = persistedCustomFields,
) {
export function generate(initialRundown: Rundown = currentRundown, customFields: CustomFields = persistedCustomFields) {
function clearIsStale() {
isStale = false;
}
@@ -102,8 +112,10 @@ export function generate(
// instead of maintaining logic to update it
assignedCustomFields = {};
normalisedRundown = {};
order = [];
playableEventsOrder = [];
timedEventsOrder = [];
flatIndexOrder = [];
links = {};
firstStart = null;
lastEnd = null;
@@ -111,20 +123,30 @@ export function generate(
totalDays = 0;
totalDelay = 0;
// temporary parsed rundown
const parsedEntries: RundownEntries = {};
const parsedOrder: EntryId[] = [];
/** A playableEvent from the previous iteration */
let previousEntry: PlayableEvent | null = null;
/** The playableEvent most forwards in time processed so far */
let lastEntry: PlayableEvent | null = null;
for (let i = 0; i < initialRundown.length; i++) {
for (let i = 0; i < initialRundown.order.length; i++) {
// we assign a reference to the current entry, this will be mutated in place
const currentEntry = initialRundown[i];
const currentEntryId = initialRundown.order[i];
const currentEntry = initialRundown.entries[currentEntryId];
flatIndexOrder.push(currentEntryId);
if (isOntimeEvent(currentEntry)) {
currentEntry.delay = 0;
currentEntry.gap = 0;
timedEventsOrder.push(currentEntryId);
// 1. handle links - mutates updatedEvent
handleLink(i, initialRundown, currentEntry, links);
// 1. handle links - mutates currentEntry and links
handleLink(currentEntry, previousEntry, links);
// 2. handle custom fields - mutates updatedEvent
// 2. handle custom fields - mutates currentEntry
handleCustomField(customFields, customFieldChangelog, currentEntry, assignedCustomFields);
totalDays += calculateDayOffset(currentEntry, lastEntry);
@@ -132,6 +154,7 @@ export function generate(
// update rundown metadata, it only concerns playable events
if (isPlayableEvent(currentEntry)) {
playableEventsOrder.push(currentEntryId);
// fist start is always the first event
if (firstStart === null) {
firstStart = currentEntry.timeStart;
@@ -160,6 +183,7 @@ export function generate(
// current event delay is the current accumulated delay
currentEntry.delay = totalDelay;
previousEntry = currentEntry;
// lastEntry is the event with the latest end time
if (isNewLatest(currentEntry, lastEntry)) {
lastEntry = currentEntry;
@@ -178,17 +202,21 @@ export function generate(
}
// add id to order
order.push(currentEntry.id);
parsedOrder.push(currentEntry.id);
// add entry to rundown
normalisedRundown[currentEntry.id] = currentEntry;
parsedEntries[currentEntry.id] = currentEntry;
}
lastEnd = lastEntry?.timeEnd ?? null;
clearIsStale();
customFieldChangelog.clear();
//The return value is used for testing
return { rundown: normalisedRundown, order, links, totalDelay, totalDuration, assignedCustomFields };
// update the cache values
currentRundown.entries = parsedEntries;
currentRundown.order = parsedOrder;
// The return value is used for testing
return { rundown: parsedEntries, order: parsedOrder, links, totalDelay, totalDuration, assignedCustomFields };
}
/** Returns an ID guaranteed to be unique */
@@ -199,21 +227,31 @@ export function getUniqueId(): string {
let id = '';
do {
id = generateId();
} while (Object.hasOwn(normalisedRundown, id));
} while (Object.hasOwn(currentRundown.entries, id));
return id;
}
/** Returns index of an event with a given id */
export function getIndexOf(eventId: string) {
export function getIndexOf(eventId: EntryId) {
if (isStale) {
generate();
}
return order.indexOf(eventId);
return currentRundown.order.indexOf(eventId);
}
/** Returns id of an event at a given index */
export function getIdOf(index: number) {
if (isStale) {
generate();
}
return currentRundown.order.at(index);
}
type RundownCache = {
rundown: NormalisedRundown;
order: string[];
id: string;
title: string;
order: EntryId[];
entries: RundownEntries;
revision: number;
totalDelay: number;
totalDuration: number;
@@ -228,19 +266,29 @@ export function get(): Readonly<RundownCache> {
generate();
}
return {
rundown: normalisedRundown,
order,
revision,
id: currentRundown.id,
title: currentRundown.title,
entries: currentRundown.entries,
order: currentRundown.order,
revision: currentRundown.revision,
totalDelay,
totalDuration,
};
}
export type RundownMetadata = {
firstStart: MaybeNumber;
lastEnd: MaybeNumber;
totalDelay: number;
totalDuration: number;
revision: number;
};
/**
* Returns calculated metadata from rundown
* Will triggering regeneration if data is stale.
*/
export function getMetadata() {
export function getMetadata(): Readonly<RundownMetadata> {
if (isStale) {
generate();
}
@@ -250,15 +298,35 @@ export function getMetadata() {
lastEnd,
totalDelay,
totalDuration,
revision,
revision: currentRundown.revision,
};
}
type CommonParams = { rundown: OntimeRundown };
export type RundownOrder = {
order: EntryId[];
timedEventsOrder: EntryId[];
playableEventsOrder: EntryId[];
};
/**
* Exposes the order of events
*/
export function getEventOrder(): Readonly<RundownOrder> {
if (isStale) {
generate();
}
return {
order: currentRundown.order,
timedEventsOrder,
playableEventsOrder,
};
}
type CommonParams = { rundown: Rundown };
type MutationParams<T> = T & CommonParams;
type MutatingReturn = {
newRundown: OntimeRundown;
newEvent?: OntimeRundownEntry;
newRundown: Rundown;
newEvent?: OntimeEntry;
didMutate: boolean;
};
type MutatingFn<T extends object> = (params: MutationParams<T>) => MutatingReturn;
@@ -269,15 +337,17 @@ type MutatingFn<T extends object> = (params: MutationParams<T>) => MutatingRetur
*/
export function mutateCache<T extends object>(mutation: MutatingFn<T>) {
function scopedMutation(params: T) {
const { newEvent, newRundown, didMutate } = mutation({ ...params, rundown: persistedRundown });
// we work on a copy of the rundown
const rundownCopy = structuredClone(currentRundown);
const { newEvent, newRundown, didMutate } = mutation({ ...params, rundown: rundownCopy });
// early return without calling side effects
if (!didMutate) {
return { newEvent, newRundown, didMutate };
}
revision = revision + 1;
persistedRundown = newRundown;
newRundown.revision += 1;
currentRundown = newRundown;
// schedule a non priority cache update
setImmediate(() => {
@@ -286,7 +356,7 @@ export function mutateCache<T extends object>(mutation: MutatingFn<T>) {
// defer writing to the database
setImmediate(async () => {
await getDataProvider().setRundown(persistedRundown);
await getDataProvider().setRundown(currentRundownId, currentRundown);
});
return { newEvent, newRundown, didMutate };
@@ -295,70 +365,91 @@ export function mutateCache<T extends object>(mutation: MutatingFn<T>) {
return scopedMutation;
}
type AddArgs = MutationParams<{ atIndex: number; event: OntimeRundownEntry }>;
type AddArgs = MutationParams<{ atIndex: number; event: OntimeEntry }>;
/**
* Add entry to rundown
*/
export function add({ rundown, atIndex, event }: AddArgs): Required<MutatingReturn> {
const newEvent: OntimeRundownEntry = { ...event };
const newRundown = insertAtIndex(atIndex, newEvent, rundown);
const newEvent: OntimeEntry = { ...event };
rundown.entries[newEvent.id] = newEvent;
rundown.order = insertAtIndex(atIndex, newEvent.id, rundown.order);
setIsStale();
return { newRundown, newEvent, didMutate: true };
return { newRundown: rundown, newEvent, didMutate: true };
}
type RemoveArgs = MutationParams<{ eventIds: string[] }>;
type RemoveArgs = MutationParams<{ eventIds: EntryId[] }>;
/**
* Remove entry to rundown
*/
export function remove({ rundown, eventIds }: RemoveArgs): MutatingReturn {
const newRundown = rundown.filter((event) => !eventIds.includes(event.id));
const didMutate = rundown.length !== newRundown.length;
const previousLength = rundown.order.length;
rundown.order = rundown.order.filter((id) => !eventIds.includes(id));
for (const id of eventIds) {
delete rundown.entries[id];
}
const didMutate = rundown.order.length !== previousLength;
if (didMutate) setIsStale();
return { newRundown, didMutate };
return { newRundown: rundown, didMutate };
}
export function removeAll(): MutatingReturn {
setIsStale();
return { newRundown: [], didMutate: true };
return {
newRundown: {
id: '',
title: '',
order: [],
entries: {},
revision: 0,
},
didMutate: true,
};
}
/**
* Utility function for patching an existing event with new data
*/
function makeEvent(eventFromRundown: OntimeRundownEntry, patch: Partial<OntimeRundownEntry>): OntimeRundownEntry {
function makeEvent<T extends OntimeEntry>(eventFromRundown: T, patch: Partial<T>): T {
if (isOntimeEvent(eventFromRundown)) {
const newEvent = createPatch(eventFromRundown, patch as OntimeEvent);
const newEvent = createPatch(eventFromRundown, patch as Partial<OntimeEvent>);
newEvent.revision++;
return newEvent;
return newEvent as T;
}
// TODO: exhaustive check
return { ...eventFromRundown, ...patch } as OntimeRundownEntry;
if (isOntimeBlock(eventFromRundown)) {
const newEvent: OntimeBlock = { ...eventFromRundown, ...patch };
newEvent.revision++;
return newEvent as T;
}
return { ...eventFromRundown, ...patch } as T;
}
type EditArgs = MutationParams<{ eventId: string; patch: Partial<OntimeRundownEntry> }>;
type EditArgs = MutationParams<{ eventId: EntryId; patch: Partial<OntimeEntry> }>;
/**
* Apply patch to an entry with given id
*/
export function edit({ rundown, eventId, patch }: EditArgs): Required<MutatingReturn> {
const indexAt = rundown.findIndex((event) => event.id === eventId);
if (indexAt < 0) {
throw new Error('Event not found');
const entry = rundown.entries[eventId];
if (!entry) {
// there should be no reason for the entry not to be found
// check if it exists in the rundown order
rundown.order = rundown.order.filter((id) => id !== eventId);
throw new Error('Entry not found');
}
if (patch?.type && rundown[indexAt].type !== patch.type) {
// we cannot allow patching to a different type
if (patch?.type && entry.type !== patch.type) {
throw new Error('Invalid event type');
}
const eventInMemory = rundown[indexAt];
if (!hasChanges(eventInMemory, patch)) {
return { newRundown: rundown, newEvent: eventInMemory, didMutate: false };
// if nothing changed, nothing to do
if (!hasChanges(entry, patch)) {
return { newRundown: rundown, newEvent: entry, didMutate: false };
}
const newEvent = makeEvent(eventInMemory, patch);
const newRundown = [...rundown];
newRundown[indexAt] = newEvent;
const newEvent = makeEvent(entry, patch);
rundown.entries[newEvent.id] = newEvent;
// check whether the data warrants recalculation of cache
const makeStale = isDataStale(patch);
@@ -366,91 +457,77 @@ export function edit({ rundown, eventId, patch }: EditArgs): Required<MutatingRe
if (makeStale) {
setIsStale();
} else {
normalisedRundown[newEvent.id] = newEvent;
rundown.entries[newEvent.id] = newEvent;
}
return { newRundown, newEvent, didMutate: true };
return { newRundown: rundown, newEvent, didMutate: true };
}
type BatchEditArgs = MutationParams<{ eventIds: string[]; patch: Partial<OntimeRundownEntry> }>;
type BatchEditArgs = MutationParams<{ eventIds: EntryId[]; patch: Partial<OntimeEntry> }>;
/**
* Apply patch to multiple entries
*/
export function batchEdit({ rundown, eventIds, patch }: BatchEditArgs): MutatingReturn {
const ids = new Set(eventIds);
const newRundown = [];
for (let i = 0; i < rundown.length; i++) {
if (ids.has(rundown[i].id)) {
if (patch?.type && rundown[i].type !== patch.type) {
continue;
}
const newEvent = makeEvent(rundown[i], patch);
newRundown.push(newEvent);
} else {
newRundown.push(rundown[i]);
}
for (const eventId of eventIds) {
edit({ rundown, eventId, patch });
}
setIsStale();
return { newRundown, didMutate: true };
return { newRundown: rundown, didMutate: true };
}
type ReorderArgs = MutationParams<{ eventId: string; from: number; to: number }>;
type ReorderArgs = MutationParams<{ eventId: EntryId; from: number; to: number }>;
/**
* Redorder two entries
* Reorder two entries
*/
export function reorder({ rundown, eventId, from, to }: ReorderArgs): Required<MutatingReturn> {
const event = rundown[from];
if (!event || eventId !== event.id) {
const eventFrom = rundown.entries[eventId];
if (!eventFrom) {
throw new Error('Event not found');
}
const newRundown = reorderArray(rundown, from, to);
rundown.order = reorderArray(rundown.order, from, to);
// increment revision of all events in between
for (let i = from; i <= to; i++) {
const event = newRundown.at(i);
if (isOntimeEvent(event)) {
event.revision += 1;
const eventId = rundown.order[i];
const entry = rundown.entries[eventId];
if (isOntimeEvent(entry) || isOntimeBlock(entry)) {
entry.revision += 1;
}
}
setIsStale();
return { newRundown, newEvent: newRundown.at(from) as OntimeRundownEntry, didMutate: true };
return { newRundown: rundown, newEvent: eventFrom, didMutate: true };
}
type ApplyDelayArgs = MutationParams<{ eventId: string }>;
type ApplyDelayArgs = MutationParams<{ delayId: EntryId }>;
/**
* Apply a delay
*/
export function applyDelay({ rundown, eventId }: ApplyDelayArgs): MutatingReturn {
const newRundown = apply(eventId, rundown);
export function applyDelay({ rundown, delayId }: ApplyDelayArgs): MutatingReturn {
apply(delayId, rundown);
setIsStale();
return { newRundown, didMutate: true };
return { newRundown: rundown, didMutate: true };
}
type SwapArgs = MutationParams<{ fromId: string; toId: string }>;
type SwapArgs = MutationParams<{ fromId: EntryId; toId: EntryId }>;
/**
* Swap two entries
*/
export function swap({ rundown, fromId, toId }: SwapArgs): MutatingReturn {
const indexA = rundown.findIndex((event) => event.id === fromId);
const eventA = rundown.at(indexA);
const fromEvent = rundown.entries[fromId];
const toEvent = rundown.entries[toId];
const indexB = rundown.findIndex((event) => event.id === toId);
const eventB = rundown.at(indexB);
if (!isOntimeEvent(eventA) || !isOntimeEvent(eventB)) {
if (!isOntimeEvent(fromEvent) || !isOntimeEvent(toEvent)) {
throw new Error('Swap only available for OntimeEvents');
}
const { newA, newB } = swapEventData(eventA, eventB);
const newRundown = [...rundown];
const [newFrom, newTo] = swapEventData(fromEvent, toEvent);
newRundown[indexA] = newA;
(newRundown[indexA] as OntimeEvent).revision += 1;
newRundown[indexB] = newB;
(newRundown[indexB] as OntimeEvent).revision += 1;
rundown.entries[fromId] = newFrom;
rundown.entries[toId] = newTo;
setIsStale();
return { newRundown, didMutate: true };
return { newRundown: rundown, didMutate: true };
}
/**
@@ -468,7 +545,7 @@ function invalidateIfUsed(label: CustomFieldLabel) {
// schedule a non priority cache update
setImmediate(async () => {
generate();
await getDataProvider().setRundown(persistedRundown);
await getDataProvider().setRundown(currentRundownId, currentRundown);
});
}
@@ -1,57 +1,35 @@
import {
OntimeEvent,
isOntimeEvent,
OntimeRundown,
CustomFieldLabel,
CustomFields,
OntimeRundownEntry,
OntimeBaseEvent,
} from 'ontime-types';
import { OntimeEvent, CustomFieldLabel, CustomFields, OntimeEntry, OntimeBaseEvent } from 'ontime-types';
import { dayInMs, getLinkedTimes } from 'ontime-utils';
/**
* Get linked event
*/
export function getLink(currentIndex: number, rundown: OntimeRundown): OntimeEvent | null {
// currently the link is the previous event
for (let i = currentIndex - 1; i >= 0; i--) {
const event = rundown[i];
if (isOntimeEvent(event) && !event.skip) {
return event;
}
}
return null;
}
/**
* Populates data from link, if necessary
* Mutates in place mutableEvent
* Mutates in place links
* Checks that link can be established (ie, events exist and are valid)
* and populates the time data from link
* With the current implementation, the links is always the previous playable event
* Mutates mutableEvent in place
* Mutates links in place
*/
export function handleLink(
currentIndex: number,
rundown: OntimeRundown,
mutableEvent: OntimeEvent,
previousEvent: OntimeEvent | null,
links: Record<string, string>,
): void {
if (!mutableEvent.linkStart) {
return;
}
const linkedEvent = getLink(currentIndex, rundown);
if (!linkedEvent) {
mutableEvent.linkStart = null;
/**
* If no previous event exist, we dont remove the link
* this means that the event will keep the behaviour in case a new event is added before
* However, we do add its ID to the links and prevent out-of-sync data
*/
if (!previousEvent) {
mutableEvent.linkStart = 'true';
return;
}
// sometimes the client cannot set the previous event
if (mutableEvent.linkStart === 'true') {
mutableEvent.linkStart = linkedEvent.id;
}
links[linkedEvent.id] = mutableEvent.id;
const timePatch = getLinkedTimes(mutableEvent, linkedEvent);
const timePatch = getLinkedTimes(mutableEvent, previousEvent);
mutableEvent.linkStart = previousEvent.id;
links[previousEvent.id] = mutableEvent.id;
// use object.assign to force mutation
Object.assign(mutableEvent, timePatch);
}
@@ -125,7 +103,7 @@ enum RegenerateWhitelist {
* given a patch, returns whether all keys are whitelisted
* @param path
*/
export function isDataStale(patch: Partial<OntimeRundownEntry>): boolean {
export function isDataStale(patch: Partial<OntimeEntry>): boolean {
return Object.keys(patch).some((key) => !(key in RegenerateWhitelist));
}
@@ -157,7 +135,7 @@ export function hasChanges<T extends OntimeBaseEvent>(existingEvent: T, newEvent
*/
export function calculateDayOffset(
current: Pick<OntimeEvent, 'timeStart'>,
previous?: Pick<OntimeEvent, 'timeStart' | 'duration'>,
previous: Pick<OntimeEvent, 'timeStart' | 'duration'> | null,
) {
// if there is no previous there can't be a day offset
if (!previous) {
@@ -1,61 +1,90 @@
import { OntimeEvent, OntimeRundown, RundownCached, OntimeRundownEntry, PlayableEvent } from 'ontime-types';
import { filterPlayable, filterTimedEvents } from 'ontime-utils';
import {
OntimeEvent,
Rundown,
OntimeEntry,
PlayableEvent,
EntryId,
RundownEntries,
ProjectRundowns,
} from 'ontime-types';
import * as cache from './rundownCache.js';
/**
* returns the normalised rundown
* returns entire unfiltered rundown
*/
export function getNormalisedRundown(): RundownCached {
return cache.get();
export function getCurrentRundown(): Rundown {
return cache.getCurrentRundown();
}
/**
* returns entire unfiltered rundown
* returns the the project rundown and the order arrays
*/
export function getRundown(): OntimeRundown {
return cache.getPersistedRundown();
export function getRundownData() {
return {
rundown: cache.getCurrentRundown(),
rundownOrder: cache.getEventOrder(),
};
}
/**
* returns all events of type OntimeEvent
*/
export function getTimedEvents(): OntimeEvent[] {
return filterTimedEvents(getRundown());
const { entries } = cache.get();
const { timedEventsOrder } = cache.getEventOrder();
return makeFlatRundownFromOrder(timedEventsOrder, entries);
}
/**
* returns all events that can be loaded
* Utility flattens a normalised rundown
*/
export function getPlayableEvents(): PlayableEvent[] {
return filterPlayable(getRundown());
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 timedEvents = getTimedEvents();
return timedEvents.at(eventIndex);
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 getEventWithId(eventId: string): OntimeRundownEntry | undefined {
const rundown = getRundown();
return rundown.find((event) => event.id === eventId);
export function getEventWithId(eventId: string): OntimeEntry | undefined {
const { entries } = getCurrentRundown();
return entries[eventId];
}
/**
* Utility returns the first playable event in rundown
*/
export function getFirstPlayable(playableOrder: EntryId[]): PlayableEvent | undefined {
const firstEventId = playableOrder.at(0);
if (!firstEventId) return;
return getEventWithId(firstEventId) as PlayableEvent | undefined;
}
/**
* returns first event that matches a given cue
*/
export function getNextEventWithCue(targetCue: string, currentEventIndex = 0): OntimeEvent | undefined {
const playableEvents = getPlayableEvents();
const { playableEventsOrder } = cache.getEventOrder();
const lowerCaseCue = targetCue.toLowerCase();
for (let i = currentEventIndex; i < playableEvents.length; i++) {
const event = playableEvents.at(i);
for (let i = currentEventIndex; i < playableEventsOrder.length; i++) {
const eventId = playableEventsOrder[i];
const event = getEventWithId(eventId) as PlayableEvent | undefined;
if (event?.cue.toLowerCase() === lowerCaseCue) {
return event;
}
@@ -65,39 +94,74 @@ export function getNextEventWithCue(targetCue: string, currentEventIndex = 0): O
/**
* finds the previous event
*/
export function findPrevious(currentEventId?: string): OntimeEvent | null {
const playableEvents = getPlayableEvents();
if (!playableEvents || !playableEvents.length) {
return null;
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 playableEvents.at(0) ?? null;
return getFirstPlayable(playableEventsOrder);
}
const currentIndex = playableEvents.findIndex((event) => event.id === currentEventId);
const currentIndex = playableEventsOrder.findIndex((eventId) => eventId === currentEventId);
const newIndex = Math.max(currentIndex - 1, 0);
const previousEvent = playableEvents.at(newIndex) ?? null;
return previousEvent;
const previousEventId = playableEventsOrder.at(newIndex);
if (!previousEventId) {
return getFirstPlayable(playableEventsOrder);
}
return getEventWithId(previousEventId) as PlayableEvent | undefined;
}
/**
* finds the next event
*/
export function findNext(currentEventId?: string): PlayableEvent | null {
const playableEvents = getPlayableEvents();
if (!playableEvents.length) {
return null;
export function findNext(currentEventId?: string): PlayableEvent | undefined {
const { playableEventsOrder } = cache.getEventOrder();
if (!playableEventsOrder.length) {
return;
}
// if there is no event running, go to first
if (!currentEventId) {
return playableEvents.at(0) ?? null;
return getFirstPlayable(playableEventsOrder);
}
const currentIndex = playableEvents.findIndex((event) => event.id === currentEventId);
const newIndex = currentIndex + 1;
const nextEvent = playableEvents.at(newIndex);
return nextEvent ?? null;
const currentIndex = playableEventsOrder.findIndex((eventId) => eventId === currentEventId);
const newIndex = Math.min(currentIndex + 1, playableEventsOrder.length - 1);
const nextEventId = playableEventsOrder.at(newIndex);
if (!nextEventId) {
return getFirstPlayable(playableEventsOrder);
}
return getEventWithId(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 ensure that the projects always have a rundown
*/
export function getFirstRundown(rundowns: ProjectRundowns): Rundown {
const firstKey = Object.keys(rundowns)[0];
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];
}
@@ -31,13 +31,15 @@ import {
getEventAtIndex,
getNextEventWithCue,
getEventWithId,
getRundown,
getCurrentRundown,
getTimedEvents,
getRundownData,
} from '../rundown-service/rundownUtils.js';
import { getForceUpdate, getShouldClockUpdate, getShouldTimerUpdate } from './rundownService.utils.js';
import { skippedOutOfEvent } from '../timerUtils.js';
import { triggerAutomations } from '../../api-data/automation/automation.service.js';
import { getEventOrder } from '../rundown-service/rundownCache.js';
type RuntimeStateEventKeys = keyof Pick<RuntimeState, 'eventNext' | 'eventNow' | 'publicEventNow' | 'publicEventNext'>;
@@ -270,8 +272,9 @@ class RuntimeService {
if (onlyChangedNow) {
runtimeState.updateLoaded(eventNow);
} else {
const rundown = getRundown();
runtimeState.updateAll(rundown);
const rundown = getCurrentRundown();
const { timedEventsOrder } = getEventOrder();
runtimeState.updateAll(rundown, timedEventsOrder);
}
return;
}
@@ -298,8 +301,8 @@ class RuntimeService {
}
const previousState = runtimeState.getState();
const rundown = getRundown();
const success = runtimeState.load(event, rundown, initialData);
const { rundown, rundownOrder } = getRundownData();
const success = runtimeState.load(event, rundown, rundownOrder.timedEventsOrder, initialData);
if (success) {
logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`);
@@ -583,9 +586,11 @@ class RuntimeService {
* Handles special case to call roll on a loaded event which we do not want to discard
*/
private rollLoaded(offset?: number) {
const rundown = getRundown();
const rundown = getCurrentRundown();
const { timedEventsOrder } = getEventOrder();
try {
runtimeState.roll(rundown, offset);
runtimeState.roll(rundown, timedEventsOrder, offset);
} catch (error) {
logger.error(LogOrigin.Server, `Roll: ${error}`);
}
@@ -605,8 +610,8 @@ class RuntimeService {
}
try {
const rundown = getRundown();
const result = runtimeState.roll(rundown);
const { rundown, rundownOrder } = getRundownData();
const result = runtimeState.roll(rundown, rundownOrder.timedEventsOrder);
const newState = runtimeState.getState();
if (result.eventId !== previousState.eventNow?.id) {
logger.info(LogOrigin.Playback, `Loaded event with ID ${result.eventId}`);
@@ -657,8 +662,8 @@ class RuntimeService {
return;
}
const rundown = getRundown();
runtimeState.resume(restorePoint, event, rundown);
const { rundown, rundownOrder } = getRundownData();
runtimeState.resume(restorePoint, event, rundown, rundownOrder.timedEventsOrder);
logger.info(LogOrigin.Playback, 'Resuming playback');
}
@@ -4,7 +4,7 @@
* @link https://developers.google.com/identity/protocols/oauth2/limited-input-device
*/
import { AuthenticationStatus, CustomFields, LogOrigin, MaybeString, OntimeRundown } from 'ontime-types';
import { AuthenticationStatus, CustomFields, DatabaseModel, LogOrigin, MaybeString, Rundown } from 'ontime-types';
import { ImportMap, getErrorMessage } from 'ontime-utils';
import { sheets, type sheets_v4 } from '@googleapis/sheets';
@@ -13,8 +13,8 @@ import got from 'got';
import { parseExcel } from '../../utils/parser.js';
import { logger } from '../../classes/Logger.js';
import { parseRundown } from '../../utils/parserFunctions.js';
import { getRundown } from '../rundown-service/rundownUtils.js';
import { parseRundowns } from '../../utils/parserFunctions.js';
import { getCurrentRundown, getRundownOrThrow } from '../rundown-service/rundownUtils.js';
import { getCustomFields } from '../rundown-service/rundownCache.js';
import { cellRequestFromEvent, type ClientSecret, getA1Notation, validateClientSecret } from './sheetUtils.js';
@@ -292,8 +292,8 @@ export async function upload(sheetId: string, options: ImportMap) {
throw new Error(`Sheet read failed: ${readResponse.statusText}`);
}
const { rundownMetadata } = parseExcel(readResponse.data.values, getCustomFields(), options);
const rundown = getRundown();
const { rundownMetadata } = parseExcel(readResponse.data.values, getCustomFields(), 'not-used', options);
const rundown = getCurrentRundown();
const titleRow = Object.values(rundownMetadata)[0]['row'];
const updateRundown = Array<sheets_v4.Schema$Request>();
@@ -322,16 +322,17 @@ export async function upload(sheetId: string, options: ImportMap) {
range: {
dimension: 'ROWS',
startIndex: titleRow + 1,
endIndex: titleRow + rundown.length,
endIndex: titleRow + rundown.order.length,
sheetId: worksheetId,
},
},
});
// update the corresponding row with event data
rundown.forEach((entry, index) =>
updateRundown.push(cellRequestFromEvent(entry, index, worksheetId, rundownMetadata)),
);
rundown.order.forEach((entryId, index) => {
const entry = rundown.entries[entryId];
return updateRundown.push(cellRequestFromEvent(entry, index, worksheetId, rundownMetadata));
});
const writeResponse = await sheets({ version: 'v4', auth: currentAuthClient }).spreadsheets.batchUpdate({
spreadsheetId: sheetId,
@@ -353,7 +354,7 @@ export async function download(
sheetId: string,
options: ImportMap,
): Promise<{
rundown: OntimeRundown;
rundown: Rundown;
customFields: CustomFields;
}> {
const { range } = await verifyWorksheet(sheetId, options.worksheet);
@@ -369,10 +370,19 @@ export async function download(
throw new Error(`Sheet read failed: ${googleResponse.statusText}`);
}
const dataFromSheet = parseExcel(googleResponse.data.values, getCustomFields(), options);
const { customFields, rundown } = parseRundown(dataFromSheet);
if (rundown.length < 1) {
const dataFromSheet = parseExcel(googleResponse.data.values, getCustomFields(), 'Rundown', options);
const rundownId = dataFromSheet.rundown.id;
const dataModel: Pick<DatabaseModel, 'rundowns' | 'customFields'> = {
rundowns: {
[rundownId]: dataFromSheet.rundown,
},
customFields: dataFromSheet.customFields,
};
const { customFields, rundowns } = parseRundowns(dataModel);
const rundown = getRundownOrThrow(rundowns, rundownId);
if (rundown.order.length < 1) {
throw new Error('Sheet: Could not find data to import in the worksheet');
}
return { rundown, customFields };
return { rundown: rundowns[rundownId], customFields };
}
@@ -39,6 +39,7 @@ describe('cellRequestFromEvent()', () => {
delay: 0,
gap: 0,
dayOffset: 0,
currentBlock: null,
revision: 0,
id: '1358',
timeWarning: 0,
@@ -84,6 +85,7 @@ describe('cellRequestFromEvent()', () => {
isPublic: false,
skip: false,
colour: 'red',
currentBlock: null,
revision: 0,
delay: 0,
gap: 0,
@@ -134,6 +136,7 @@ describe('cellRequestFromEvent()', () => {
isPublic: true,
skip: false,
colour: 'red',
currentBlock: null,
revision: 0,
delay: 0,
gap: 0,
@@ -186,6 +189,7 @@ describe('cellRequestFromEvent()', () => {
delay: 0,
gap: 0,
dayOffset: 0,
currentBlock: null,
revision: 0,
id: '1358',
timeWarning: 0,
@@ -218,6 +222,7 @@ describe('cellRequestFromEvent()', () => {
isPublic: true,
skip: false,
colour: 'red',
currentBlock: null,
revision: 0,
delay: 0,
gap: 0,
@@ -254,6 +259,7 @@ describe('cellRequestFromEvent()', () => {
isPublic: true,
skip: false,
colour: 'red',
currentBlock: null,
revision: 0,
delay: 0,
gap: 0,
@@ -1,4 +1,4 @@
import { isOntimeBlock, isOntimeEvent, OntimeEvent, OntimeRundownEntry } from 'ontime-types';
import { isOntimeBlock, isOntimeEvent, OntimeEvent, OntimeEntry } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import type { sheets_v4 } from '@googleapis/sheets';
@@ -74,14 +74,14 @@ export function getA1Notation(row: number, column: number): string {
/**
* @description - creates updateCells request from ontime event
* @param {OntimeRundownEntry} event
* @param {OntimeEntry} event
* @param {number} index - index of the event
* @param {number} worksheetId
* @param {object} metadata - object with all the cell positions of the title of each attribute
* @returns {sheets_v4.Schema} - list of update requests
*/
export function cellRequestFromEvent(
event: OntimeRundownEntry,
event: OntimeEntry,
index: number,
worksheetId: number,
metadata: object,
@@ -125,7 +125,7 @@ export function cellRequestFromEvent(
};
}
function getCellData(key: keyof OntimeEvent | 'blank', event: OntimeRundownEntry) {
function getCellData(key: keyof OntimeEvent | 'blank', event: OntimeEntry) {
if (isOntimeEvent(event)) {
if (key === 'blank') {
return {};