refactor: gather group metadata

This commit is contained in:
Carlos Valente
2025-03-28 19:16:38 +01:00
committed by Carlos Valente
parent 730cb95c04
commit 876d111c61
48 changed files with 1044 additions and 751 deletions
@@ -14,7 +14,7 @@ import { isEmptyObject } from '../utils/parserUtils.js';
import { parseProperty, updateEvent } from './integration.utils.js';
import { socket } from '../adapters/WebsocketAdapter.js';
import { throttle } from '../utils/throttle.js';
import { willCauseRegeneration } from '../services/rundown-service/rundownCacheUtils.js';
import { willCauseRegeneration } from '../services/rundown-service/rundownCache.utils.js';
import { coerceEnum } from '../utils/coerceType.js';
const throttledUpdateEvent = throttle(updateEvent, 20);
+36 -21
View File
@@ -2,15 +2,11 @@ import { DatabaseModel, EndAction, SupportedEvent, TimeStrategy, TimerType } fro
export const demoDb: DatabaseModel = {
rundowns: {
demo: {
id: 'demo',
default: {
id: 'default',
title: 'Eurovision Demo',
order: [
'32d31',
'21cd2',
'0b371',
'3cd28',
'e457f',
'block',
'01e85',
'1c420',
'b7737',
@@ -24,6 +20,25 @@ export const demoDb: DatabaseModel = {
'd3eb1',
],
entries: {
block: {
type: SupportedEvent.Block,
events: ['32d31', '21cd2', '0b371', '3cd28', 'e457f'],
id: 'block',
title: 'Test Block',
note: '',
skip: false,
colour: 'hotpink',
revision: 0,
startTime: null,
endTime: null,
duration: 0,
isFirstLinked: false,
numEvents: 0,
custom: {
song: 'Sekret',
artist: 'Ronela Hajati',
},
},
'32d31': {
type: SupportedEvent.Event,
id: '32d31',
@@ -33,7 +48,7 @@ export const demoDb: DatabaseModel = {
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: null,
linkStart: false,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 36000000,
timeEnd: 37200000,
@@ -62,7 +77,7 @@ export const demoDb: DatabaseModel = {
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: null,
linkStart: false,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 37500000,
timeEnd: 38700000,
@@ -91,7 +106,7 @@ export const demoDb: DatabaseModel = {
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: null,
linkStart: false,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 39000000,
timeEnd: 40200000,
@@ -120,7 +135,7 @@ export const demoDb: DatabaseModel = {
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: null,
linkStart: false,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 40500000,
timeEnd: 41700000,
@@ -149,7 +164,7 @@ export const demoDb: DatabaseModel = {
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: null,
linkStart: false,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 42000000,
timeEnd: 43200000,
@@ -196,7 +211,7 @@ export const demoDb: DatabaseModel = {
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: null,
linkStart: false,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 47100000,
timeEnd: 48300000,
@@ -225,7 +240,7 @@ export const demoDb: DatabaseModel = {
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: null,
linkStart: false,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 48600000,
timeEnd: 49800000,
@@ -254,7 +269,7 @@ export const demoDb: DatabaseModel = {
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: null,
linkStart: false,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 50100000,
timeEnd: 51300000,
@@ -283,7 +298,7 @@ export const demoDb: DatabaseModel = {
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: null,
linkStart: false,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 51600000,
timeEnd: 52800000,
@@ -312,7 +327,7 @@ export const demoDb: DatabaseModel = {
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: null,
linkStart: false,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 53100000,
timeEnd: 54300000,
@@ -359,7 +374,7 @@ export const demoDb: DatabaseModel = {
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: null,
linkStart: false,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 56100000,
timeEnd: 57300000,
@@ -388,7 +403,7 @@ export const demoDb: DatabaseModel = {
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: null,
linkStart: false,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 57600000,
timeEnd: 58800000,
@@ -417,7 +432,7 @@ export const demoDb: DatabaseModel = {
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: null,
linkStart: false,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 59100000,
timeEnd: 60300000,
@@ -446,7 +461,7 @@ export const demoDb: DatabaseModel = {
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: null,
linkStart: false,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 60600000,
timeEnd: 61800000,
+7 -5
View File
@@ -15,7 +15,7 @@ export const event: Omit<OntimeEvent, 'id' | 'cue'> = {
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStrategy: TimeStrategy.LockDuration,
linkStart: null,
linkStart: false,
countToEnd: false,
timeStart: 0,
timeEnd: 0,
@@ -23,14 +23,15 @@ export const event: Omit<OntimeEvent, 'id' | 'cue'> = {
isPublic: false,
skip: false,
colour: '',
timeWarning: 120000,
timeDanger: 60000,
custom: {},
// !==== RUNTIME METADATA ====! //
currentBlock: null,
revision: 0, // calculated at runtime
delay: 0, // calculated at runtime
dayOffset: 0, // calculated at runtime
gap: 0, // calculated at runtime
timeWarning: 120000,
timeDanger: 60000,
custom: {},
};
export const delay: Omit<OntimeDelay, 'id'> = {
@@ -45,11 +46,12 @@ export const block: Omit<OntimeBlock, 'id'> = {
events: [],
skip: false,
colour: '',
custom: {},
// !==== RUNTIME METADATA ====! //
revision: 0, // calculated at runtime
startTime: null, // calculated at runtime
endTime: null, // calculated at runtime
duration: 0, // calculated at runtime
isFirstLinked: false, // calculated at runtime
numEvents: 0, // calculated at runtime
custom: {},
};
@@ -825,7 +825,7 @@ describe('getRuntimeOffset()', () => {
timeEnd: 81000000,
duration: 3600000,
timeStrategy: 'lock-duration',
linkStart: null,
linkStart: false,
},
runtime: {
selectedEventIndex: 0,
@@ -863,7 +863,7 @@ describe('getRuntimeOffset()', () => {
timeEnd: 84600000,
duration: 3600000,
timeStrategy: 'lock-duration',
linkStart: null,
linkStart: false,
endAction: 'none',
timerType: 'count-down',
delay: 0,
@@ -906,7 +906,7 @@ describe('getRuntimeOffset()', () => {
timeEnd: 81000000, // 22:30:00
duration: 3600000, // 01:00:00
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
linkStart: false,
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: true,
@@ -959,7 +959,7 @@ describe('getRuntimeOffset()', () => {
timeEnd: 81000000, // 22:30:00
duration: 3600000, // 01:00:00
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
linkStart: false,
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: true,
@@ -1010,7 +1010,7 @@ describe('getRuntimeOffset()', () => {
timeEnd: 81000000, // 22:30:00
duration: 3600000, // 01:00:00
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
linkStart: false,
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: true,
@@ -9,6 +9,7 @@ const baseEvent = {
const baseBlock = {
type: SupportedEvent.Block,
events: [],
};
/**
@@ -12,10 +12,10 @@ describe('apply()', () => {
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' }),
'2': makeOntimeEvent({ id: '2', timeStart: 10, timeEnd: 20, duration: 10, linkStart: true }),
'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' }),
'4': makeOntimeEvent({ id: '4', timeStart: 20, timeEnd: 30, duration: 10, linkStart: false }),
'5': makeOntimeEvent({ id: '5', timeStart: 30, timeEnd: 40, duration: 10, linkStart: true }),
},
});
@@ -24,10 +24,10 @@ describe('apply()', () => {
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' },
'2': { id: '2', timeStart: 20, timeEnd: 30, duration: 10, revision: 2, linkStart: true },
'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' },
'4': { id: '4', timeStart: 30, timeEnd: 40, duration: 10, revision: 2, linkStart: false },
'5': { id: '5', timeStart: 40, timeEnd: 50, duration: 10, revision: 2, linkStart: true },
});
});
@@ -38,10 +38,10 @@ describe('apply()', () => {
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' }),
'2': makeOntimeEvent({ id: '2', timeStart: 10, timeEnd: 20, duration: 10, linkStart: true }),
'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' }),
'4': makeOntimeEvent({ id: '4', timeStart: 20, timeEnd: 30, duration: 10, linkStart: false }),
'5': makeOntimeEvent({ id: '5', timeStart: 30, timeEnd: 40, duration: 10, linkStart: true }),
},
});
@@ -50,10 +50,10 @@ describe('apply()', () => {
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 },
'2': { id: '2', timeStart: 0, timeEnd: 10, duration: 10, revision: 2, linkStart: false },
'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' },
'4': { id: '4', timeStart: 10, timeEnd: 20, duration: 10, revision: 2, linkStart: false },
'5': { id: '5', timeStart: 20, timeEnd: 30, duration: 10, revision: 2, linkStart: true },
});
});
@@ -63,7 +63,7 @@ describe('apply()', () => {
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' }),
'2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, linkStart: true }),
},
});
@@ -84,7 +84,7 @@ describe('apply()', () => {
timeStart: 50,
timeEnd: 100,
duration: 50,
linkStart: null,
linkStart: false,
revision: 2,
},
});
@@ -96,7 +96,7 @@ describe('apply()', () => {
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' }),
'2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: true }),
},
});
@@ -115,7 +115,7 @@ describe('apply()', () => {
timeStart: 150,
timeEnd: 200,
duration: 50,
linkStart: null,
linkStart: false,
revision: 2,
},
});
@@ -127,7 +127,7 @@ describe('apply()', () => {
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' }),
'2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: true }),
},
});
@@ -146,7 +146,7 @@ describe('apply()', () => {
timeStart: 150,
timeEnd: 200,
duration: 50,
linkStart: '1',
linkStart: true,
revision: 2,
},
});
@@ -158,7 +158,7 @@ describe('apply()', () => {
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' }),
'2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: true }),
},
});
@@ -171,7 +171,7 @@ describe('apply()', () => {
timeStart: 50,
timeEnd: 100,
duration: 50,
linkStart: null,
linkStart: false,
revision: 2,
},
});
@@ -190,7 +190,7 @@ describe('apply()', () => {
// 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' }),
'5': makeOntimeEvent({ id: '5', timeStart: 350, timeEnd: 400, duration: 50, linkStart: true }),
},
});
@@ -205,7 +205,7 @@ describe('apply()', () => {
// gap (delay is 0)
'4': { id: '4', timeStart: 300, timeEnd: 350, duration: 50, revision: 1 },
// linked
'5': { id: '5', timeStart: 350, timeEnd: 400, duration: 50, revision: 1, linkStart: '4' },
'5': { id: '5', timeStart: 350, timeEnd: 400, duration: 50, revision: 1, linkStart: true },
});
});
@@ -278,7 +278,7 @@ describe('apply()', () => {
'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' }),
'2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: true }),
},
});
@@ -299,7 +299,7 @@ describe('apply()', () => {
timeStart: 150,
timeEnd: 200,
duration: 50,
linkStart: null,
linkStart: false,
revision: 2,
},
});
@@ -1,6 +1,8 @@
import { CustomFields, OntimeEvent, SupportedEvent, TimeStrategy } from 'ontime-types';
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, dayInMs } from 'ontime-utils';
import { demoDb } from '../../../models/demoProject.js';
import {
add,
batchEdit,
@@ -15,6 +17,7 @@ import {
customFieldChangelog,
} from '../rundownCache.js';
import { makeOntimeBlock, makeOntimeDelay, makeOntimeEvent, makeRundown } from '../__mocks__/rundown.mocks.js';
import { ProcessedRundownMetadata } from '../rundownCache.utils.js';
beforeAll(() => {
vi.mock('../../../classes/data-provider/DataProvider.js', () => {
@@ -30,6 +33,23 @@ beforeAll(() => {
});
describe('generate()', () => {
test('benchmark function execution time', () => {
const rundown = demoDb.rundowns.default;
const t1 = performance.now();
let result: ProcessedRundownMetadata | null = null;
for (let i = 0; i < 100; i++) {
result = generate(rundown);
}
const t2 = performance.now();
console.warn(
'Rundown generation took',
t2 - t1,
'milliseconds for 100x',
Object.keys(result?.entries ?? {}).length,
'events',
);
});
it('creates normalised versions of a given rundown', () => {
const rundown = makeRundown({
order: ['1', '2', '3'],
@@ -43,9 +63,9 @@ describe('generate()', () => {
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);
expect(initResult.rundown['2'].type).toBe(SupportedEvent.Block);
expect(initResult.rundown['3'].type).toBe(SupportedEvent.Delay);
expect(initResult.entries['1'].type).toBe(SupportedEvent.Event);
expect(initResult.entries['2'].type).toBe(SupportedEvent.Block);
expect(initResult.entries['3'].type).toBe(SupportedEvent.Delay);
});
it('calculates delays versions of a given rundown', () => {
@@ -59,7 +79,7 @@ describe('generate()', () => {
const initResult = generate(rundown);
expect(initResult.order.length).toBe(2);
expect((initResult.rundown['2'] as OntimeEvent).delay).toBe(100);
expect((initResult.entries['2'] as OntimeEvent).delay).toBe(100);
expect(initResult.totalDelay).toBe(100);
});
@@ -79,10 +99,10 @@ describe('generate()', () => {
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);
expect((initResult.rundown['3'] as OntimeEvent).delay).toBe(100);
expect((initResult.rundown['4'] as OntimeEvent).delay).toBe(0);
expect((initResult.entries['1'] as OntimeEvent).delay).toBe(0);
expect((initResult.entries['2'] as OntimeEvent).delay).toBe(200);
expect((initResult.entries['3'] as OntimeEvent).delay).toBe(100);
expect((initResult.entries['4'] as OntimeEvent).delay).toBe(0);
expect(initResult.totalDelay).toBe(0);
expect(initResult.totalDuration).toBe(700 - 100);
});
@@ -167,10 +187,10 @@ describe('generate()', () => {
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);
expect((initResult.rundown['3'] as OntimeEvent).delay).toBe(-200);
expect((initResult.rundown['4'] as OntimeEvent).delay).toBe(-200);
expect((initResult.entries['1'] as OntimeEvent).delay).toBe(0);
expect((initResult.entries['2'] as OntimeEvent).delay).toBe(-200);
expect((initResult.entries['3'] as OntimeEvent).delay).toBe(-200);
expect((initResult.entries['4'] as OntimeEvent).delay).toBe(-200);
expect(initResult.totalDelay).toBe(-200);
expect(initResult.totalDuration).toBe(700 - 100);
});
@@ -191,7 +211,7 @@ describe('generate()', () => {
timeStart: 11,
duration: 1,
timeEnd: 12,
linkStart: '1',
linkStart: true,
timeStrategy: TimeStrategy.LockEnd,
}),
block: makeOntimeBlock({ id: 'block' }),
@@ -201,7 +221,7 @@ describe('generate()', () => {
timeStart: 21,
duration: 1,
timeEnd: 22,
linkStart: '2',
linkStart: true,
timeStrategy: TimeStrategy.LockEnd,
}),
},
@@ -209,16 +229,13 @@ describe('generate()', () => {
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);
expect((initResult.rundown['2'] as OntimeEvent).duration).toBe(10);
expect((initResult.entries['2'] as OntimeEvent).timeStart).toBe(2);
expect((initResult.entries['2'] as OntimeEvent).timeEnd).toBe(12);
expect((initResult.entries['2'] as OntimeEvent).duration).toBe(10);
expect((initResult.rundown['3'] as OntimeEvent).timeStart).toBe(12);
expect((initResult.rundown['3'] as OntimeEvent).timeEnd).toBe(22);
expect((initResult.rundown['3'] as OntimeEvent).duration).toBe(10);
expect(initResult.links['1']).toBe('2');
expect(initResult.links['2']).toBe('3');
expect((initResult.entries['3'] as OntimeEvent).timeStart).toBe(12);
expect((initResult.entries['3'] as OntimeEvent).timeEnd).toBe(22);
expect((initResult.entries['3'] as OntimeEvent).duration).toBe(10);
});
it('links times across events, reordered', () => {
@@ -226,16 +243,14 @@ describe('generate()', () => {
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' }),
'3': makeOntimeEvent({ id: '3', timeStart: 21, timeEnd: 22, linkStart: true }),
'2': makeOntimeEvent({ id: '2', timeStart: 11, timeEnd: 12, linkStart: true }),
},
});
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');
expect(initResult.links['3']).toBe('2');
expect((initResult.entries['3'] as OntimeEvent).timeStart).toBe(2);
});
it('calculates total duration', () => {
@@ -333,7 +348,7 @@ describe('generate()', () => {
timeEnd: 600000,
duration: 600000,
timeStrategy: TimeStrategy.LockDuration,
linkStart: null,
linkStart: false,
}),
'2': makeOntimeEvent({
id: '2',
@@ -341,7 +356,7 @@ describe('generate()', () => {
timeEnd: 601000,
duration: 85801000, // <------------- value out of sync
timeStrategy: TimeStrategy.LockEnd,
linkStart: '1',
linkStart: true,
}),
'3': makeOntimeEvent({
id: '3',
@@ -349,51 +364,37 @@ describe('generate()', () => {
timeEnd: 602000,
duration: 0,
timeStrategy: TimeStrategy.LockEnd,
linkStart: '2',
linkStart: true,
}),
},
});
const initResult = generate(rundown);
expect(initResult.rundown).toMatchObject({
expect(initResult.entries).toMatchObject({
'1': {
timeStart: 0,
timeEnd: 600000,
duration: 600000,
timeStrategy: 'lock-duration',
linkStart: null,
linkStart: false,
},
'2': {
timeStart: 600000,
timeEnd: 601000,
duration: 1000,
timeStrategy: 'lock-end',
linkStart: '1',
linkStart: true,
},
'3': {
timeStart: 601000,
timeEnd: 602000,
duration: 1000,
timeStrategy: 'lock-end',
linkStart: '2',
linkStart: true,
},
});
});
it('deletes links if invalid', () => {
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);
});
describe('custom properties feature', () => {
it('creates a map of custom properties', () => {
const customProperties: CustomFields = {
@@ -433,8 +434,8 @@ describe('generate()', () => {
lighting: ['1', '2'],
sound: ['2'],
});
expect((initResult.rundown['1'] as OntimeEvent).custom).toMatchObject({ lighting: 'event 1 lx' });
expect((initResult.rundown['2'] as OntimeEvent).custom).toMatchObject({
expect((initResult.entries['1'] as OntimeEvent).custom).toMatchObject({ lighting: 'event 1 lx' });
expect((initResult.entries['2'] as OntimeEvent).custom).toMatchObject({
lighting: 'event 2 lx',
sound: 'event 2 sound',
});
@@ -442,6 +443,106 @@ describe('generate()', () => {
});
});
describe('generate() v4', () => {
describe('handle of event groups', () => {
it('correctly parses group metadata', () => {
const rundown = makeRundown({
order: ['1'],
entries: {
'1': makeOntimeBlock({ id: '1', events: ['100', '200', '300'] }),
'100': makeOntimeEvent({ id: '100', timeStart: 100, timeEnd: 200, duration: 100, linkStart: false }),
'200': makeOntimeEvent({ id: '200', timeStart: 200, timeEnd: 300, duration: 100 }),
'300': makeOntimeEvent({ id: '300', timeStart: 300, timeEnd: 400, duration: 100 }),
},
});
const generatedRundown = generate(rundown);
expect(generatedRundown.order).toMatchObject(['1']);
expect(generatedRundown.totalDuration).toBe(300);
expect(generatedRundown.totalDelay).toBe(0);
expect(generatedRundown.entries).toMatchObject({
'1': {
type: SupportedEvent.Block,
events: ['100', '200', '300'],
startTime: 100,
endTime: 400,
duration: 300,
isFirstLinked: false,
numEvents: 3,
},
'100': { type: SupportedEvent.Event, currentBlock: '1' },
'200': { type: SupportedEvent.Event, currentBlock: '1' },
'300': { type: SupportedEvent.Event, currentBlock: '1' },
});
});
it('treats groups as invisible for gap calculations', () => {
const rundown = makeRundown({
order: ['0', '1', '2', '3'],
entries: {
'0': makeOntimeEvent({ id: '0', timeStart: 0, timeEnd: 10, duration: 10, linkStart: false }),
'1': makeOntimeBlock({ id: '1', events: ['101', '102', '103'] }),
'101': makeOntimeEvent({ id: '101', timeStart: 100, timeEnd: 200, duration: 100, linkStart: false }),
'102': makeOntimeEvent({ id: '102', timeStart: 200, timeEnd: 300, duration: 100, linkStart: true }),
'103': makeOntimeEvent({ id: '103', timeStart: 300, timeEnd: 400, duration: 100, linkStart: true }),
'2': makeOntimeBlock({ id: '2', events: ['201', '202', '203'] }),
'201': makeOntimeEvent({ id: '201', timeStart: 500, timeEnd: 600, duration: 100, linkStart: false }),
'202': makeOntimeEvent({ id: '202', timeStart: 600, timeEnd: 700, duration: 100, linkStart: true }),
'203': makeOntimeEvent({ id: '203', timeStart: 700, timeEnd: 800, duration: 100, linkStart: true }),
'3': makeOntimeBlock({ id: '3', events: ['301', '302', '303'] }),
'301': makeOntimeEvent({ id: '301', timeStart: 900, timeEnd: 1000, duration: 100, linkStart: false }),
'302': makeOntimeEvent({ id: '302', timeStart: 1000, timeEnd: 1100, duration: 100, linkStart: true }),
'303': makeOntimeEvent({ id: '303', timeStart: 1100, timeEnd: 1200, duration: 100, linkStart: true }),
},
});
const generatedRundown = generate(rundown);
expect(generatedRundown.order).toMatchObject(['0', '1', '2', '3']);
expect(generatedRundown.totalDuration).toBe(1200);
expect(generatedRundown.totalDelay).toBe(0);
expect(generatedRundown.entries).toMatchObject({
'0': { type: SupportedEvent.Event, currentBlock: null },
'1': {
type: SupportedEvent.Block,
events: ['101', '102', '103'],
startTime: 100,
endTime: 400,
duration: 300,
isFirstLinked: false,
numEvents: 3,
},
'101': { currentBlock: '1', gap: 90, linkStart: false },
'102': { currentBlock: '1' },
'103': { currentBlock: '1' },
'2': {
type: SupportedEvent.Block,
events: ['201', '202', '203'],
startTime: 500,
endTime: 800,
duration: 300,
isFirstLinked: false,
numEvents: 3,
},
'201': { id: '201', timeStart: 500, timeEnd: 600, duration: 100, gap: 100, linkStart: false },
'202': { id: '202', timeStart: 600, timeEnd: 700, duration: 100 },
'203': { id: '203', timeStart: 700, timeEnd: 800, duration: 100 },
'3': {
type: SupportedEvent.Block,
events: ['301', '302', '303'],
startTime: 900,
endTime: 1200,
duration: 300,
isFirstLinked: false,
numEvents: 3,
},
'301': { id: '301', timeStart: 900, timeEnd: 1000, duration: 100, gap: 100, linkStart: false },
'302': { id: '302', timeStart: 1000, timeEnd: 1100, duration: 100 },
'303': { id: '303', timeStart: 1100, timeEnd: 1200, duration: 100 },
});
});
});
});
describe('add() mutation', () => {
test('adds an event to the rundown', () => {
const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' });
@@ -605,7 +706,7 @@ describe('swap() mutation', () => {
});
});
describe('custom fields', () => {
describe('custom fields flow', () => {
describe('createCustomField()', () => {
it('creates a field from given parameters', () => {
const expected = {
@@ -639,8 +740,7 @@ describe('custom fields', () => {
};
const customField = editCustomField('Sound', { label: 'Sound', type: 'string', colour: 'green' });
expect(customFieldChangelog).toStrictEqual(new Map());
expect(customFieldChangelog).toStrictEqual({});
expect(customField).toStrictEqual(expected);
});
@@ -689,10 +789,10 @@ describe('custom fields', () => {
vi.useFakeTimers();
const customField = editCustomField('Video', { label: 'AV', type: 'string', colour: 'red' });
expect(customField).toStrictEqual(expectedAfter);
expect(customFieldChangelog).toStrictEqual(new Map([['Video', 'AV']]));
expect(customFieldChangelog).toStrictEqual({ Video: 'AV' });
editCustomField('AV', { label: 'Video' });
vi.runAllTimers();
expect(customFieldChangelog).toStrictEqual(new Map());
expect(customFieldChangelog).toStrictEqual({});
vi.useRealTimers();
});
});
@@ -1,55 +1,13 @@
import {
CustomFields,
EndAction,
OntimeEvent,
RundownEntries,
SupportedEvent,
TimeStrategy,
TimerType,
} from 'ontime-types';
import { CustomFields, EndAction, OntimeEvent, SupportedEvent, TimeStrategy, TimerType } from 'ontime-types';
import {
addToCustomAssignment,
calculateDayOffset,
handleCustomField,
handleLink,
hasChanges,
isDataStale,
} from '../rundownCache.utils.js';
import { MILLIS_PER_HOUR } from 'ontime-utils';
import { makeOntimeBlock, makeOntimeEvent } from '../__mocks__/rundown.mocks.js';
describe('handleLink()', () => {
it('populates data in object and updates link map', () => {
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(mutableEvent, entries[1] as OntimeEvent, links);
expect(result).toBeUndefined();
expect(mutableEvent.timeStart).toBe(100);
expect(mutableEvent.linkStart).toBe('1');
expect(links).toStrictEqual({ '1': '2' });
});
it('removes link if linked event is not found', () => {
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(mutableEvent, null, links);
expect(result).toBeUndefined();
expect(mutableEvent.timeStart).toBe(0);
expect(mutableEvent.linkStart).toBe('true');
expect(links).toStrictEqual({});
});
});
import { makeOntimeEvent } from '../__mocks__/rundown.mocks.js';
describe('addToCustomAssignment()', () => {
it('adds given entry to assignedCustomFields', () => {
@@ -77,18 +35,17 @@ describe('handleCustomField()', () => {
label: 'sound',
},
} as CustomFields;
const customFieldChangelog = new Map<string, string>();
const customFieldChangelog = {};
// @ts-expect-error -- partial event for testing
const event: OntimeEvent = {
const event = makeOntimeEvent({
type: SupportedEvent.Event,
id: '2',
timeStart: 0,
linkStart: '1',
linkStart: true,
custom: {
lighting: 'on',
},
};
});
const assignedCustomFields = {};
const result = handleCustomField(customFields, customFieldChangelog, event, assignedCustomFields);
@@ -113,18 +70,17 @@ describe('handleCustomField()', () => {
},
} as CustomFields;
const customFieldChangelog = new Map([['sound', 'video']]);
const customFieldChangelog = { sound: 'video' };
// @ts-expect-error -- partial event for testing
const event: OntimeEvent = {
const event = makeOntimeEvent({
type: SupportedEvent.Event,
id: '2',
timeStart: 0,
linkStart: '1',
linkStart: true,
custom: {
sound: 'on',
},
};
});
const assignedCustomFields = {};
const result = handleCustomField(customFields, customFieldChangelog, event, assignedCustomFields);
@@ -149,17 +105,16 @@ describe('handleCustomField()', () => {
},
} as CustomFields;
const customFieldChangelog = new Map([['field1', 'newField1']]);
const customFieldChangelog = { field1: 'newField1' };
// @ts-expect-error -- partial event for testing
const mutableEvent: OntimeEvent = {
const mutableEvent = makeOntimeEvent({
type: SupportedEvent.Event,
id: 'event1',
custom: {
field1: 'value1',
field2: 'value2',
},
};
});
const assignedCustomFields = {};
@@ -186,7 +141,7 @@ describe('isDataStale()', () => {
{ timeStart: 10 },
{ timeEnd: 10 },
{ duration: 10 },
{ linkStart: '1' },
{ linkStart: true },
{ timerStrategy: TimeStrategy.LockDuration },
];
@@ -68,7 +68,7 @@ export function apply(delayId: EntryId, rundown: Rundown): Rundown {
lastEntry = { ...currentEntry };
if (shouldUnlink) {
currentEntry.linkStart = null;
currentEntry.linkStart = false;
shouldUnlink = false;
}
@@ -0,0 +1,20 @@
import { CustomFieldLabel, EntryId, MaybeNumber } from 'ontime-types';
export type RundownMetadata = {
totalDelay: number;
totalDuration: number;
totalDays: number;
firstStart: MaybeNumber;
lastEnd: MaybeNumber;
playableEventOrder: EntryId[];
timedEventOrder: EntryId[];
flatEventOrder: EntryId[];
/**
* Keep track of which custom fields are used.
* This will be handy for when we delete custom fields
* since we can clear the custom fields from every event where they are used
*/
assignedCustomFields: Record<CustomFieldLabel, string[]>;
};
@@ -4,33 +4,24 @@ import {
CustomFields,
EntryId,
isOntimeBlock,
isOntimeDelay,
isOntimeEvent,
isPlayableEvent,
MaybeNumber,
OntimeBlock,
OntimeEvent,
OntimeEntry,
PlayableEvent,
Rundown,
RundownEntries,
} from 'ontime-types';
import {
generateId,
insertAtIndex,
reorderArray,
swapEventData,
getTimeFromPrevious,
isNewLatest,
customFieldLabelToKey,
} from 'ontime-utils';
import { generateId, insertAtIndex, reorderArray, swapEventData, customFieldLabelToKey } from 'ontime-utils';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import { createPatch } from '../../utils/parser.js';
import type { RundownMetadata } from './rundown.types.js';
import { apply } from './delayUtils.js';
import { calculateDayOffset, handleCustomField, handleLink, hasChanges, isDataStale } from './rundownCacheUtils.js';
import { hasChanges, isDataStale, makeRundownMetadata, type ProcessedRundownMetadata } from './rundownCache.utils.js';
/** We hold the currently selected rundown and its metadata in memory */
let currentRundownId: EntryId = '';
let currentRundown: Rundown = {
id: '',
@@ -39,17 +30,26 @@ let currentRundown: Rundown = {
entries: {},
revision: 0,
};
let persistedCustomFields: CustomFields = {};
let projectCustomFields: CustomFields = {};
let rundownMetadata: RundownMetadata = {
totalDelay: 0,
totalDuration: 0,
totalDays: 0,
firstStart: null,
lastEnd: null,
playableEventOrder: [],
timedEventOrder: [],
flatEventOrder: [],
assignedCustomFields: {},
};
/**
* Get the cached rundown without triggering regeneration
*/
export const getCurrentRundown = (): Rundown => currentRundown;
export const getCustomFields = (): CustomFields => persistedCustomFields;
let playableEventsOrder: EntryId[] = [];
let timedEventsOrder: EntryId[] = [];
let flatIndexOrder: EntryId[] = [];
export const getCustomFields = (): CustomFields => projectCustomFields;
/**
* all mutating functions will set this value if there is a need for re-generation
@@ -62,161 +62,111 @@ function setIsStale() {
isStale = true;
}
let totalDelay = 0;
let totalDuration = 0;
let totalDays = 0;
let firstStart: MaybeNumber = null;
let lastEnd: MaybeNumber = null;
let links: Record<EntryId, EntryId> = {};
/**
* Object that contains reference of renamed custom fields
* Used to rename the custom fields in the events
* @private exported only to simplify testing
* @example
* {
* oldLabel: newLabel
* lighting: lx
* }
*/
export 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, EntryId[]> = {};
export let customFieldChangelog: Record<string, string> = {};
/**
* Receives a rundown which will be processed and used as the new current rundown
*/
export async function init(initialRundown: Rundown, customFields: Readonly<CustomFields>) {
export async function init(initialRundown: Readonly<Rundown>, customFields: Readonly<CustomFields>) {
// TODO: do we need to clone?
currentRundown = structuredClone(initialRundown);
currentRundownId = initialRundown.id;
persistedCustomFields = structuredClone(customFields);
projectCustomFields = structuredClone(customFields);
generate();
// TODO: we may not need to persist this data since it should come from the database
// update the persisted data
await getDataProvider().setRundown(currentRundownId, currentRundown);
await getDataProvider().setCustomFields(customFields);
}
/**
* Utility generate cache
* @private should not be called outside of `rundownCache.ts`
* @private should not be called outside of `rundownCache.ts`, exported for testing
*/
export function generate(initialRundown: Rundown = currentRundown, customFields: CustomFields = persistedCustomFields) {
export function generate(
initialRundown: Readonly<Rundown> = currentRundown,
customFields: Readonly<CustomFields> = projectCustomFields,
): ProcessedRundownMetadata {
// The stale state can only be cleared inside generate()
function clearIsStale() {
isStale = false;
}
// we decided to re-write this dataset for every change
// instead of maintaining logic to update it
assignedCustomFields = {};
playableEventsOrder = [];
timedEventsOrder = [];
flatIndexOrder = [];
links = {};
firstStart = null;
lastEnd = null;
totalDuration = 0;
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;
const { process, getMetadata } = makeRundownMetadata(customFields, customFieldChangelog);
for (let i = 0; i < initialRundown.order.length; i++) {
// we assign a reference to the current entry, this will be mutated in place
const currentEntryId = initialRundown.order[i];
const currentEntry = initialRundown.entries[currentEntryId];
flatIndexOrder.push(currentEntryId);
const { processedEntry } = process(currentEntry, null);
if (isOntimeEvent(currentEntry)) {
currentEntry.delay = 0;
currentEntry.gap = 0;
timedEventsOrder.push(currentEntryId);
// if the event is a block, we process the nested entries
// the code here is a copy of the processing of top level events
if (isOntimeBlock(processedEntry)) {
let totalBlockDuration = 0;
let blockStartTime = null;
let blockEndTime = null;
let isFirstLinked = false;
// 1. handle links - mutates currentEntry and links
handleLink(currentEntry, previousEntry, links);
// check if the block contains events
for (let i = 0; i < processedEntry.events.length; i++) {
const nestedEntryId = processedEntry.events[i];
const nestedEntry = initialRundown.entries[nestedEntryId];
const { processedData: processedNestedData, processedEntry: processedNestedEntry } = process(
nestedEntry,
processedEntry.id,
);
// 2. handle custom fields - mutates currentEntry
handleCustomField(customFields, customFieldChangelog, currentEntry, assignedCustomFields);
totalDays += calculateDayOffset(currentEntry, lastEntry);
currentEntry.dayOffset = totalDays;
// 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;
// we dont extract metadata of skipped events,
// if this is not a playable event there is nothing else to do
if (!isOntimeEvent(processedNestedEntry) || !isPlayableEvent(processedNestedEntry)) {
continue;
}
currentEntry.gap = getTimeFromPrevious(currentEntry, lastEntry);
if (currentEntry.gap === 0) {
// event starts on previous finish, we add its duration
totalDuration += currentEntry.duration;
} else if (currentEntry.gap > 0) {
// event has a gap, we add the gap and the duration
totalDuration += currentEntry.gap + currentEntry.duration;
} else if (currentEntry.gap < 0) {
// there is an overlap, we remove the overlap from the duration
// ensuring that the sum is not negative (ie: fully overlapped events)
// NOTE: we add the gap since it is a negative number
totalDuration += Math.max(currentEntry.duration + currentEntry.gap, 0);
// first start is always the first event
if (blockStartTime === null) {
blockStartTime = processedNestedEntry.timeStart;
isFirstLinked = Boolean(processedNestedEntry.linkStart);
}
// remove eventual gaps from the accumulated delay
// we only affect positive delays (time forwards)
if (totalDelay > 0 && currentEntry.gap > 0) {
totalDelay = Math.max(totalDelay - currentEntry.gap, 0);
}
// 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;
}
blockEndTime = processedNestedData.lastEnd;
totalBlockDuration += processedNestedEntry.duration;
}
} else if (isOntimeDelay(currentEntry)) {
// calculate delays
// !!! this must happen after handling the links
totalDelay += currentEntry.duration;
} else if (isOntimeBlock(currentEntry)) {
// calculate block - nothing yet
} else {
// unknown - type skip it
// this is needed to get the type guard working when we assign the entry to the rundown
continue;
}
// add id to order
parsedOrder.push(currentEntry.id);
// add entry to rundown
parsedEntries[currentEntry.id] = currentEntry;
// update block metadata
processedEntry.duration = totalBlockDuration;
processedEntry.startTime = blockStartTime;
processedEntry.endTime = blockEndTime;
processedEntry.isFirstLinked = isFirstLinked;
processedEntry.numEvents = processedEntry.events.length;
}
}
lastEnd = lastEntry?.timeEnd ?? null;
const processedData = getMetadata();
clearIsStale();
customFieldChangelog.clear();
customFieldChangelog = {};
// update the cache values
currentRundown.entries = parsedEntries;
currentRundown.order = parsedOrder;
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- we are not interested in the iteration data
const { entries, order, previousEvent, latestEvent, ...metadata } = processedData;
currentRundown.entries = entries;
currentRundown.order = order;
rundownMetadata = metadata;
// The return value is used for testing
return { rundown: parsedEntries, order: parsedOrder, links, totalDelay, totalDuration, assignedCustomFields };
return processedData;
}
/** Returns an ID guaranteed to be unique */
@@ -271,33 +221,22 @@ export function get(): Readonly<RundownCache> {
entries: currentRundown.entries,
order: currentRundown.order,
revision: currentRundown.revision,
totalDelay,
totalDuration,
totalDelay: rundownMetadata.totalDelay,
totalDuration: rundownMetadata.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(): Readonly<RundownMetadata> {
export function getMetadata(): Readonly<RundownMetadata & { revision: number }> {
if (isStale) {
generate();
}
return {
firstStart,
lastEnd,
totalDelay,
totalDuration,
...rundownMetadata,
revision: currentRundown.revision,
};
}
@@ -317,8 +256,8 @@ export function getEventOrder(): Readonly<RundownOrder> {
}
return {
order: currentRundown.order,
timedEventsOrder,
playableEventsOrder,
timedEventsOrder: rundownMetadata.timedEventOrder,
playableEventsOrder: rundownMetadata.playableEventOrder,
};
}
@@ -536,11 +475,8 @@ export function swap({ rundown, fromId, toId }: SwapArgs): MutatingReturn {
* Utility for invalidating service cache if a custom field is used
*/
function invalidateIfUsed(label: CustomFieldLabel) {
if (label in assignedCustomFields) {
setIsStale();
}
// if the field was in use, we mark the cache as stale
if (label in assignedCustomFields) {
if (label in rundownMetadata.assignedCustomFields) {
setIsStale();
}
// ... and schedule a cache update
@@ -556,7 +492,7 @@ function invalidateIfUsed(label: CustomFieldLabel) {
*/
function scheduleCustomFieldPersist() {
setImmediate(async () => {
await getDataProvider().setCustomFields(persistedCustomFields);
await getDataProvider().setCustomFields(projectCustomFields);
});
}
@@ -572,29 +508,29 @@ export function createCustomField(field: CustomField): CustomFields {
}
// check if label already exists
const alreadyExists = Object.hasOwn(persistedCustomFields, key);
const alreadyExists = Object.hasOwn(projectCustomFields, key);
if (alreadyExists) {
throw new Error('Label already exists');
}
// update object and persist
persistedCustomFields[key] = { label, type, colour };
projectCustomFields[key] = { label, type, colour };
scheduleCustomFieldPersist();
return persistedCustomFields;
return projectCustomFields;
}
/**
* Edits an existing custom field in the database
*/
export function editCustomField(key: string, newField: Partial<CustomField>): CustomFields {
if (!(key in persistedCustomFields)) {
if (!(key in projectCustomFields)) {
throw new Error('Could not find label');
}
const existingField = persistedCustomFields[key];
const existingField = projectCustomFields[key];
if (newField.type !== undefined && existingField.type !== newField.type) {
throw new Error('Change of field type is not allowed');
}
@@ -607,29 +543,29 @@ export function editCustomField(key: string, newField: Partial<CustomField>): Cu
if (newKey === null) {
throw new Error('Unable to convert label to a valid key');
}
persistedCustomFields[newKey] = { ...existingField, ...newField };
projectCustomFields[newKey] = { ...existingField, ...newField };
if (key !== newKey) {
delete persistedCustomFields[key];
customFieldChangelog.set(key, newKey);
delete projectCustomFields[key];
customFieldChangelog[key] = newKey;
}
scheduleCustomFieldPersist();
invalidateIfUsed(key);
return persistedCustomFields;
return projectCustomFields;
}
/**
* Deletes a custom field from the database
*/
export function removeCustomField(label: string): CustomFields {
if (label in persistedCustomFields) {
delete persistedCustomFields[label];
if (label in projectCustomFields) {
delete projectCustomFields[label];
}
scheduleCustomFieldPersist();
invalidateIfUsed(label);
return persistedCustomFields;
return projectCustomFields;
}
@@ -1,38 +1,19 @@
import { OntimeEvent, CustomFieldLabel, CustomFields, OntimeEntry, OntimeBaseEvent } from 'ontime-types';
import { dayInMs, getLinkedTimes } from 'ontime-utils';
import {
OntimeEvent,
CustomFieldLabel,
CustomFields,
OntimeEntry,
OntimeBaseEvent,
EntryId,
isOntimeEvent,
isPlayableEvent,
isOntimeDelay,
PlayableEvent,
RundownEntries,
} from 'ontime-types';
import { dayInMs, getLinkedTimes, getTimeFrom, isNewLatest } from 'ontime-utils';
/**
* 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(
mutableEvent: OntimeEvent,
previousEvent: OntimeEvent | null,
links: Record<string, string>,
): void {
if (!mutableEvent.linkStart) {
return;
}
/**
* 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;
}
const timePatch = getLinkedTimes(mutableEvent, previousEvent);
mutableEvent.linkStart = previousEvent.id;
links[previousEvent.id] = mutableEvent.id;
// use object.assign to force mutation
Object.assign(mutableEvent, timePatch);
}
import type { RundownMetadata } from './rundown.types.js';
/**
* Utility function to add an entry, mutates given assignedCustomFields in place
@@ -52,19 +33,19 @@ export function addToCustomAssignment(
/**
* Sanitises custom fields and updates values if necessary
* Mudates in place mutableEvent and assignedCustomFields
* Mutates in place mutableEvent and assignedCustomFields
*/
export function handleCustomField(
customFields: CustomFields,
customFieldChangelog: Map<string, string>,
customFieldChangelog: Record<string, string>,
mutableEvent: OntimeEvent,
assignedCustomFields: Record<string, string[]>,
) {
for (const field in mutableEvent.custom) {
// rename the property if it is in the changelog
if (customFieldChangelog.has(field)) {
if (field in customFieldChangelog) {
const oldData = mutableEvent.custom[field];
const newLabel = customFieldChangelog.get(field) as string; // it os OK to cast to string here since we already checked that it existed
const newLabel = customFieldChangelog[field];
mutableEvent.custom[newLabel] = oldData;
delete mutableEvent.custom[field];
@@ -101,17 +82,15 @@ enum RegenerateWhitelist {
/**
* given a patch, returns whether all keys are whitelisted
* @param path
*/
export function isDataStale(patch: Partial<OntimeEntry>): boolean {
return Object.keys(patch).some((key) => !(key in RegenerateWhitelist));
return Object.keys(patch).some(willCauseRegeneration);
}
/**
* given a key, returns whether it is whitelisted
* @param path
*/
export function willCauseRegeneration(key: keyof OntimeEvent): boolean {
export function willCauseRegeneration(key: string): boolean {
return !(key in RegenerateWhitelist);
}
@@ -159,3 +138,143 @@ export function calculateDayOffset(
return 0;
}
export type ProcessedRundownMetadata = RundownMetadata & {
entries: RundownEntries;
order: EntryId[];
previousEvent: PlayableEvent | null; // The playableEvent from the previous iteration
latestEvent: PlayableEvent | null; // The playableEvent most forwards in time processed so far
};
export function makeRundownMetadata(customFields: CustomFields, customFieldChangelog: Record<string, string>) {
let rundownMeta: ProcessedRundownMetadata = {
totalDelay: 0,
totalDuration: 0,
totalDays: 0,
firstStart: null,
lastEnd: null,
assignedCustomFields: {},
playableEventOrder: [],
timedEventOrder: [],
flatEventOrder: [],
entries: {},
order: [],
previousEvent: null,
latestEvent: null,
};
function process<T extends OntimeEntry>(
entry: T,
childOfBlock: EntryId | null,
): { processedData: ProcessedRundownMetadata; processedEntry: T } {
const data = processEntry(rundownMeta, customFields, customFieldChangelog, entry, childOfBlock);
rundownMeta = data.processedData;
return data;
}
function getMetadata(): ProcessedRundownMetadata {
return rundownMeta;
}
return { process, getMetadata };
}
function processEntry<T extends OntimeEntry>(
rundownMetadata: ProcessedRundownMetadata,
customFields: CustomFields,
customFieldChangelog: Record<string, string>,
entry: T,
childOfBlock: EntryId | null,
): { processedData: ProcessedRundownMetadata; processedEntry: T } {
const processedData = { ...rundownMetadata };
const currentEntry = structuredClone(entry);
processedData.flatEventOrder.push(currentEntry.id);
if (isOntimeEvent(currentEntry)) {
if (!childOfBlock) {
processedData.timedEventOrder.push(currentEntry.id);
}
/**
* 1.Checks that link can be established (ie, events exist and are valid)
* and populates the time data from link
* The linked event is always the previous playable event
* If no previous event exists, the link is removed
*/
if (currentEntry.linkStart) {
if (processedData.previousEvent) {
const timePatch = getLinkedTimes(currentEntry, processedData.previousEvent);
currentEntry.timeStart = timePatch.timeStart;
currentEntry.timeEnd = timePatch.timeEnd;
currentEntry.duration = timePatch.duration;
} else {
currentEntry.linkStart = false;
}
}
// 2. handle custom fields - mutates currentEntry
handleCustomField(customFields, customFieldChangelog, currentEntry, processedData.assignedCustomFields);
processedData.totalDays += calculateDayOffset(currentEntry, processedData.previousEvent);
currentEntry.dayOffset = processedData.totalDays;
currentEntry.delay = 0; // this means we dont calculate delays or gaps for skipped events
currentEntry.gap = 0; // this means we dont calculate delays or gaps for skipped events
currentEntry.currentBlock = childOfBlock;
// update rundown metadata, it only concerns playable events
if (isPlayableEvent(currentEntry)) {
if (!childOfBlock) {
processedData.playableEventOrder.push(currentEntry.id);
}
// first start is always the first event
if (processedData.firstStart === null) {
processedData.firstStart = currentEntry.timeStart;
}
currentEntry.gap = getTimeFrom(currentEntry, processedData.latestEvent);
if (currentEntry.gap === 0) {
// event starts on previous finish, we add its duration
processedData.totalDuration += currentEntry.duration;
} else if (currentEntry.gap > 0) {
// event has a gap, we add the gap and the duration
processedData.totalDuration += currentEntry.gap + currentEntry.duration;
} else if (currentEntry.gap < 0) {
// there is an overlap, we remove the overlap from the duration
// ensuring that the sum is not negative (ie: fully overlapped events)
// NOTE: we add the gap since it is a negative number
processedData.totalDuration += Math.max(currentEntry.duration + currentEntry.gap, 0);
}
// remove eventual gaps from the accumulated delay
// we only affect positive delays (time forwards)
if (processedData.totalDelay > 0 && currentEntry.gap > 0) {
processedData.totalDelay = Math.max(processedData.totalDelay - currentEntry.gap, 0);
}
// current event delay is the current accumulated delay
currentEntry.delay = processedData.totalDelay;
// assign data for next iteration
processedData.previousEvent = currentEntry;
// lastEntry is the event with the latest end time
if (isNewLatest(currentEntry, processedData.latestEvent)) {
processedData.latestEvent = currentEntry;
processedData.lastEnd = currentEntry.timeEnd;
}
}
} else if (isOntimeDelay(currentEntry)) {
// !!! this must happen after handling the links
processedData.totalDelay += currentEntry.duration;
}
if (!childOfBlock) {
processedData.order.push(currentEntry.id);
}
processedData.entries[currentEntry.id] = currentEntry;
return { processedData, processedEntry: currentEntry };
}
@@ -9,13 +9,15 @@ import { ImportMap, getErrorMessage } from 'ontime-utils';
import { sheets, type sheets_v4 } from '@googleapis/sheets';
import { Credentials, OAuth2Client } from 'google-auth-library';
// TODO: rewrite logic to use fetch and remove dependency
import got from 'got';
import { parseExcel } from '../../utils/parser.js';
import { logger } from '../../classes/Logger.js';
import { parseRundown } from '../../utils/parserFunctions.js';
import { getRundown } from '../rundown-service/rundownUtils.js';
import { getCustomFields } from '../rundown-service/rundownCache.js';
import { parseRundowns } from '../../utils/parserFunctions.js';
import { getCurrentRundown, getCustomFields } from '../rundown-service/rundownCache.js';
import { getRundownOrThrow } from '../rundown-service/rundownUtils.js';
import { cellRequestFromEvent, type ClientSecret, getA1Notation, isClientSecret } from './sheetUtils.js';
import { catchCommonImportXlsxError } from './googleApi.utils.js';
@@ -28,7 +28,7 @@ describe('cellRequestFromEvent()', () => {
timeStart: 46800000,
timeEnd: 57600000,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
linkStart: false,
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
@@ -81,7 +81,7 @@ describe('cellRequestFromEvent()', () => {
countToEnd: false,
duration: 10800000,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
linkStart: false,
isPublic: false,
skip: false,
colour: 'red',
@@ -132,7 +132,7 @@ describe('cellRequestFromEvent()', () => {
countToEnd: false,
duration: 10800000,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
linkStart: false,
isPublic: true,
skip: false,
colour: 'red',
@@ -181,7 +181,7 @@ describe('cellRequestFromEvent()', () => {
timerType: TimerType.CountDown,
countToEnd: false,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
linkStart: false,
duration: 10800000,
isPublic: true,
skip: false,
@@ -218,7 +218,7 @@ describe('cellRequestFromEvent()', () => {
countToEnd: false,
duration: 10800000,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
linkStart: false,
isPublic: true,
skip: false,
colour: 'red',
@@ -255,7 +255,7 @@ describe('cellRequestFromEvent()', () => {
countToEnd: false,
duration: 10800000,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
linkStart: false,
isPublic: true,
skip: false,
colour: 'red',
+11 -11
View File
@@ -31,9 +31,9 @@ describe('test parseDatabaseModel() with demo project (valid)', () => {
const filteredDemoProject = structuredClone(demoDb);
const { data } = parseDatabaseModel(filteredDemoProject);
it('has 16 events', () => {
expect(data.rundowns.demo.order.length).toBe(16);
expect(Object.keys(data.rundowns.demo.entries).length).toBe(16);
it('has 17 events with 12 top level events', () => {
expect(data.rundowns.default.order.length).toBe(12);
expect(Object.keys(data.rundowns.default.entries).length).toBe(17);
});
it('is the same as the demo project since all data is valid', () => {
@@ -663,7 +663,7 @@ describe('parseExcel()', () => {
});
});
it('parses link start and checks that is applicable', () => {
it('parses link start', () => {
const testData = [
['Time Start', 'Time End', 'ID', 'Link Start', 'Timer type'],
['4:30:00', '9:45:00', 'A', '', 'count-down'],
@@ -688,22 +688,22 @@ describe('parseExcel()', () => {
expect(result.rundown.entries).toMatchObject({
A: {
linkStart: null,
linkStart: false,
},
B: {
linkStart: 'true', // <--- this will be populated by the cache generation
linkStart: true,
},
C: {
linkStart: 'true', // <--- this will be populated by the cache generation
linkStart: true,
},
D: {
linkStart: null,
linkStart: false,
},
BLOCK: {
type: SupportedEvent.Block,
},
E: {
linkStart: 'true', // <--- this will be populated by the cache generation
linkStart: true,
},
});
});
@@ -775,7 +775,7 @@ describe('parseExcel()', () => {
expect(parsedData.rundown.entries['MEET3']).toMatchObject({
duration: 90 * MILLIS_PER_MINUTE,
linkStart: null,
linkStart: false,
timeWarning: 11 * MILLIS_PER_MINUTE,
timeDanger: 5 * MILLIS_PER_MINUTE,
});
@@ -783,7 +783,7 @@ describe('parseExcel()', () => {
expect(parsedData.rundown.entries['MEET4']).toMatchObject({
duration: 30 * MILLIS_PER_MINUTE,
timeWarning: 11 * MILLIS_PER_MINUTE,
linkStart: 'true', // if we get a boolean, we should just use that
linkStart: true,
});
});
});
@@ -12,6 +12,7 @@ import {
parseViewSettings,
sanitiseCustomFields,
} from '../parserFunctions.js';
import { makeOntimeBlock, makeOntimeEvent } from '../../services/rundown-service/__mocks__/rundown.mocks.js';
describe('parseRundowns()', () => {
it('returns a default project rundown if nothing is given', () => {
@@ -166,6 +167,43 @@ describe('parseRundown()', () => {
expect(parsedRundown.order.length).toEqual(2);
expect(Object.keys(parsedRundown.entries).length).toEqual(2);
});
it('handles empty events', () => {
const rundown = {
id: 'test',
title: '',
order: ['1', '2', '3', '4'],
entries: {
'1': { id: '1', type: SupportedEvent.Event } as OntimeEvent,
'2': { id: '2', type: SupportedEvent.Event } as OntimeEvent,
'not-mentioned': {} as OntimeEvent,
},
revision: 1,
} as Rundown;
const parsedRundown = parseRundown(rundown, {});
expect(parsedRundown.order.length).toEqual(2);
expect(Object.keys(parsedRundown.entries).length).toEqual(2);
});
it('parses events nested in blocks', () => {
const rundown = {
id: 'test',
title: '',
order: ['block'],
entries: {
block: makeOntimeBlock({ id: 'block', events: ['1', '2'] }),
'1': makeOntimeEvent({ id: '1' }),
'2': makeOntimeEvent({ id: '2' }),
},
revision: 1,
} as Rundown;
const parsedRundown = parseRundown(rundown, {});
expect(parsedRundown.order.length).toEqual(1);
expect(parsedRundown.entries.block).toMatchObject({ events: ['1', '2'] });
expect(Object.keys(parsedRundown.entries).length).toEqual(3);
});
});
describe('parseProject()', () => {
@@ -381,123 +419,3 @@ describe('sanitiseCustomFields()', () => {
expect(sanitationResult).toStrictEqual(expectedCustomFields);
});
});
describe('parseRundown() linking', () => {
it('returns linked events', () => {
const rundown: Rundown = {
id: '',
title: '',
revision: 1,
order: ['1', '2'],
entries: {
'1': {
id: '1',
type: SupportedEvent.Event,
skip: false,
} as OntimeEvent,
'2': {
id: '2',
type: SupportedEvent.Event,
linkStart: 'true',
skip: false,
} as OntimeEvent,
},
};
const result = parseRundown(rundown, {});
expect(result).toMatchObject({
order: ['1', '2'],
entries: {
'2': {
linkStart: '1',
},
},
});
});
it('returns unlinked if no previous', () => {
const rundown: Rundown = {
id: '',
title: '',
revision: 1,
order: ['1', '2'],
entries: {
'2': {
id: '2',
type: SupportedEvent.Event,
linkStart: 'true',
skip: false,
} as OntimeEvent,
},
};
const result = parseRundown(rundown, {});
expect(result).toMatchObject({
order: ['2'],
entries: {
'2': {
linkStart: null,
},
},
});
});
it('returns linked events past blocks and delays', () => {
const rundown: Rundown = {
id: '',
title: '',
revision: 1,
order: ['1', 'delay1', '2', 'block1', '3'],
entries: {
'1': {
id: '1',
type: SupportedEvent.Event,
skip: false,
} as OntimeEvent,
delay1: {
id: 'delay1',
type: SupportedEvent.Delay,
duration: 0,
},
'2': {
id: '2',
type: SupportedEvent.Event,
linkStart: 'true',
skip: false,
} as OntimeEvent,
block1: {
id: 'block1',
type: SupportedEvent.Block,
title: '',
} as OntimeBlock,
'3': {
id: '3',
type: SupportedEvent.Event,
linkStart: 'true',
skip: false,
} as OntimeEvent,
},
};
const result = parseRundown(rundown, {});
expect(result).toMatchObject({
order: rundown.order,
entries: {
'1': {
id: '1',
cue: '1',
},
'2': {
id: '2',
cue: '2',
linkStart: '1',
},
'3': {
id: '3',
cue: '3',
linkStart: '2',
},
},
});
});
});
+2 -3
View File
@@ -6,7 +6,6 @@ import {
type ImportMap,
isKnownTimerType,
validateEndAction,
validateLinkStart,
validateTimerType,
validateTimes,
} from 'ontime-utils';
@@ -246,7 +245,7 @@ export const parseExcel = (
} else if (j === timeStartIndex) {
entry.timeStart = parseExcelDate(column);
} else if (j === linkStartIndex) {
entry.linkStart = parseBooleanString(column) ? 'true' : null;
entry.linkStart = parseBooleanString(column);
} else if (j === timeEndIndex) {
entry.timeEnd = parseExcelDate(column);
} else if (j === durationIndex) {
@@ -412,7 +411,7 @@ export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial<Onti
timeEnd,
duration,
timeStrategy,
linkStart: validateLinkStart(patchEvent.linkStart, originalEvent.linkStart),
linkStart: typeof patchEvent.linkStart === 'boolean' ? patchEvent.linkStart : originalEvent.linkStart,
endAction: validateEndAction(patchEvent.endAction, originalEvent.endAction),
timerType: validateTimerType(patchEvent.timerType, originalEvent.timerType),
countToEnd: typeof patchEvent.countToEnd === 'boolean' ? patchEvent.countToEnd : originalEvent.countToEnd,
+30 -9
View File
@@ -75,11 +75,11 @@ export function parseRundown(
};
let eventIndex = 0;
let previousId: string | null = null;
for (let i = 0; i < rundown.order.length; i++) {
const entryId = rundown.order[i];
const event = rundown.entries[entryId];
if (event === undefined) {
emitError?.('Could not find referenced event, skipping');
continue;
@@ -94,13 +94,7 @@ export function parseRundown(
let newEvent: OntimeEvent | OntimeDelay | OntimeBlock | null;
if (isOntimeEvent(event)) {
const maybeEvent = { ...event };
if (event.linkStart) {
maybeEvent.linkStart = previousId;
}
newEvent = createEvent(maybeEvent, eventIndex);
newEvent = createEvent(event, eventIndex);
// skip if event is invalid
if (newEvent == null) {
emitError?.('Skipping event without payload');
@@ -115,11 +109,38 @@ export function parseRundown(
}
}
previousId = id;
eventIndex += 1;
} else if (isOntimeDelay(event)) {
newEvent = { ...delayDef, duration: event.duration, id };
} else if (isOntimeBlock(event)) {
for (let i = 0; i < event.events.length; i++) {
const nestedEventId = event.events[i];
const nestedEvent = rundown.entries[nestedEventId];
if (isOntimeEvent(nestedEvent)) {
const newNestedEvent = createEvent(nestedEvent, eventIndex);
// skip if event is invalid
if (newNestedEvent == null) {
emitError?.('Skipping event without payload');
continue;
}
// for every field in custom, check that a key exists in customfields
for (const field in newNestedEvent.custom) {
if (!Object.hasOwn(parsedCustomFields, field)) {
emitError?.(`Custom field ${field} not found`);
delete newNestedEvent.custom[field];
}
}
eventIndex += 1;
if (newNestedEvent) {
parsedRundown.entries[nestedEventId] = newNestedEvent;
}
}
}
newEvent = {
...blockDef,
title: event.title,