refactor: extract rundown parsing

refactor: implement groups in editor
This commit is contained in:
Carlos Valente
2025-04-17 12:56:47 +02:00
committed by Carlos Valente
parent 166be66ce3
commit c616240db1
36 changed files with 1170 additions and 772 deletions
+2 -79
View File
@@ -1,5 +1,5 @@
/* eslint-disable no-console -- we are mocking the console */
import { assertType, vi } from 'vitest';
import { vi } from 'vitest';
import { CustomFields, DatabaseModel, OntimeEvent, SupportedEntry, TimerType } from 'ontime-types';
import { ImportMap, MILLIS_PER_MINUTE } from 'ontime-utils';
@@ -7,7 +7,7 @@ import { ImportMap, MILLIS_PER_MINUTE } from 'ontime-utils';
import { dbModel } from '../../models/dataModel.js';
import { demoDb } from '../../models/demoProject.js';
import { createEvent, getCustomFieldData, parseExcel, parseDatabaseModel } from '../parser.js';
import { getCustomFieldData, parseExcel, parseDatabaseModel } from '../parser.js';
import { makeString } from '../parserUtils.js';
import { parseUrlPresets, parseViewSettings } from '../parserFunctions.js';
@@ -64,83 +64,6 @@ describe('test parseDatabaseModel() edge cases', () => {
});
});
describe('test event validator', () => {
it('validates a good object', () => {
const event = {
title: 'test',
};
const validated = createEvent(event, 1);
expect(validated).toEqual(
expect.objectContaining({
title: expect.any(String),
note: expect.any(String),
timeStart: expect.any(Number),
timeEnd: expect.any(Number),
countToEnd: expect.any(Boolean),
isPublic: expect.any(Boolean),
skip: expect.any(Boolean),
revision: expect.any(Number),
type: expect.any(String),
id: expect.any(String),
cue: '2',
colour: expect.any(String),
custom: expect.any(Object),
}),
);
});
it('fails an empty object', () => {
const event = {};
const validated = createEvent(event, 1);
expect(validated).toEqual(null);
});
it('makes objects strings', () => {
const event = {
title: 2,
note: '1899-12-30T08:00:10.000Z',
};
// @ts-expect-error -- we know this is wrong, testing imports outside domain
const validated = createEvent(event, 1);
if (validated === null) {
throw new Error('unexpected value');
}
expect(typeof validated.title).toEqual('string');
expect(typeof validated.note).toEqual('string');
});
it('enforces numbers on times', () => {
const event = {
timeStart: false,
timeEnd: '2',
};
// @ts-expect-error -- we know this is wrong, testing imports outside domain
const validated = createEvent(event);
if (validated === null) {
throw new Error('unexpected value');
}
assertType<number>(validated.timeStart);
assertType<number>(validated.timeEnd);
assertType<number>(validated.duration);
expect(validated.timeStart).toEqual(0);
expect(validated.timeEnd).toEqual(2);
expect(validated.duration).toEqual(2);
});
it('handles bad objects', () => {
const event = {
title: {},
};
// @ts-expect-error -- we know this is wrong, testing imports outside domain
const validated = createEvent(event);
if (validated === null) {
throw new Error('unexpected value');
}
expect(typeof validated.title).toEqual('string');
});
});
describe('test aliases import', () => {
it('imports a well defined urlPreset', () => {
const testData = {
@@ -1,217 +1,13 @@
import { CustomFields, OntimeBlock, OntimeEvent, Rundown, Settings, SupportedEntry, URLPreset } from 'ontime-types';
import { defaultRundown } from '../../models/dataModel.js';
import { CustomFields, Settings, URLPreset } from 'ontime-types';
import {
parseCustomFields,
parseProject,
parseRundown,
parseRundowns,
parseSettings,
parseUrlPresets,
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', () => {
const errorEmitter = vi.fn();
const result = parseRundowns({}, errorEmitter);
expect(result.customFields).toEqual({});
expect(result.rundowns).toStrictEqual({ default: defaultRundown });
// one for not having custom fields
// one for not having a rundown
expect(errorEmitter).toHaveBeenCalledTimes(2);
});
it('ensures the rundown IDs are consistent', () => {
const errorEmitter = vi.fn();
const r1 = { ...defaultRundown, id: '1' };
const r2 = { ...defaultRundown, id: '2' };
const result = parseRundowns(
{
rundowns: {
'1': r1,
'3': r2,
},
},
errorEmitter,
);
expect(result.rundowns).toMatchObject({
'1': r1,
'2': r2,
});
// one for not having a rundown
expect(errorEmitter).toHaveBeenCalledTimes(1);
});
});
describe('parseRundown()', () => {
it('parses data, skipping invalid results', () => {
const errorEmitter = vi.fn();
const rundown = {
id: '',
title: '',
order: ['1', '2', '3', '4'],
flatOrder: ['1', '2', '3', '4'],
entries: {
'1': { id: '1', type: SupportedEntry.Event, title: 'test', skip: false } as OntimeEvent, // OK
'2': { id: '1', type: SupportedEntry.Block, title: 'test 2', skip: false } as OntimeBlock, // duplicate ID
'3': {} as OntimeEvent, // no data
'4': { id: '4', title: 'test 2', skip: false } as OntimeEvent, // no type
},
revision: 1,
} as Rundown;
const parsedRundown = parseRundown(rundown, {}, errorEmitter);
expect(parsedRundown.id).not.toBe('');
expect(parsedRundown.id).toBeTypeOf('string');
expect(parsedRundown.order.length).toEqual(1);
expect(parsedRundown.order).toEqual(['1']);
expect(parsedRundown.entries).toMatchObject({
'1': {
id: '1',
type: SupportedEntry.Event,
title: 'test',
skip: false,
},
});
expect(errorEmitter).toHaveBeenCalled();
});
it('stringifies necessary values', () => {
const rundown = {
id: '',
title: '',
order: ['1', '2'],
flatOrder: ['1', '2'],
entries: {
// @ts-expect-error -- testing external data which could be incorrect
'1': { id: '1', type: SupportedEntry.Event, cue: 101 } as OntimeEvent,
// @ts-expect-error -- testing external data which could be incorrect
'2': { id: '2', type: SupportedEntry.Event, cue: 101.1 } as OntimeEvent,
},
revision: 1,
} as Rundown;
expect(parseRundown(rundown, {})).toMatchObject({
entries: {
'1': {
cue: '101',
},
'2': {
cue: '101.1',
},
},
});
});
it('detects duplicate Ids', () => {
const rundown = {
id: '',
title: '',
order: ['1', '1'],
flatOrder: ['1', '1'],
entries: {
'1': { id: '1', type: SupportedEntry.Event } as OntimeEvent,
'2': { id: '2', type: SupportedEntry.Event } as OntimeEvent,
},
revision: 1,
} as Rundown;
const parsedRundown = parseRundown(rundown, {});
expect(parsedRundown.order.length).toEqual(1);
expect(Object.keys(parsedRundown.entries).length).toEqual(1);
});
it('completes partial datasets', () => {
const rundown = {
id: 'test',
title: '',
order: ['1', '2'],
flatOrder: ['1', '2'],
entries: {
'1': { id: '1', type: SupportedEntry.Event } as OntimeEvent,
'2': { id: '2', type: SupportedEntry.Event } as OntimeEvent,
},
revision: 1,
} as Rundown;
const parsedRundown = parseRundown(rundown, {});
expect(parsedRundown.order.length).toEqual(2);
expect(parsedRundown.entries).toMatchObject({
'1': {
title: '',
cue: '1',
custom: {},
},
'2': {
title: '',
cue: '2',
custom: {},
},
});
});
it('handles empty events', () => {
const rundown = {
id: 'test',
title: '',
order: ['1', '2', '3', '4'],
flatOrder: ['1', '2', '3', '4'],
entries: {
'1': { id: '1', type: SupportedEntry.Event } as OntimeEvent,
'2': { id: '2', type: SupportedEntry.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('handles empty events', () => {
const rundown = {
id: 'test',
title: '',
order: ['1', '2', '3', '4'],
flatOrder: ['1', '2', '3', '4'],
entries: {
'1': { id: '1', type: SupportedEntry.Event } as OntimeEvent,
'2': { id: '2', type: SupportedEntry.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'],
flatOrder: ['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()', () => {
it('returns an a base model if nothing is given', () => {
+2 -86
View File
@@ -7,7 +7,6 @@ import {
isKnownTimerType,
validateEndAction,
validateTimerType,
validateTimes,
} from 'ontime-utils';
import {
CustomFields,
@@ -20,17 +19,16 @@ import {
Rundown,
SupportedEntry,
TimerType,
TimeStrategy,
} from 'ontime-types';
import { Merge } from 'ts-essentials';
import { parseAutomationSettings } from '../api-data/automation/automation.parser.js';
import { parseRundowns } from '../api-data/rundown/rundown.parser.js';
import { logger } from '../classes/Logger.js';
import { event as eventDef } from '../models/eventsDefinition.js';
import { makeString } from './parserUtils.js';
import { parseProject, parseRundowns, parseSettings, parseUrlPresets, parseViewSettings } from './parserFunctions.js';
import { parseProject, parseSettings, parseUrlPresets, parseViewSettings } from './parserFunctions.js';
import { parseExcelDate } from './time.js';
import { is } from './is.js';
@@ -374,85 +372,3 @@ export function parseDatabaseModel(jsonData: Partial<DatabaseModel>): { data: Da
return { data, errors };
}
/**
* Function infers strategy for a patch with only partial timer data
* @param end
* @param duration
* @param fallback
* @returns
*/
function inferStrategy(end: unknown, duration: unknown, fallback: TimeStrategy): TimeStrategy {
if (end && !duration) {
return TimeStrategy.LockEnd;
}
if (!end && duration) {
return TimeStrategy.LockDuration;
}
return fallback;
}
export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial<OntimeEvent>): OntimeEvent {
if (Object.keys(patchEvent).length === 0) {
return originalEvent;
}
const { timeStart, timeEnd, duration, timeStrategy } = validateTimes(
patchEvent?.timeStart ?? originalEvent.timeStart,
patchEvent?.timeEnd ?? originalEvent.timeEnd,
patchEvent?.duration ?? originalEvent.duration,
patchEvent?.timeStrategy ?? inferStrategy(patchEvent?.timeEnd, patchEvent?.duration, originalEvent.timeStrategy),
);
return {
id: originalEvent.id,
type: SupportedEntry.Event,
title: makeString(patchEvent.title, originalEvent.title),
timeStart,
timeEnd,
duration,
timeStrategy,
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,
isPublic: typeof patchEvent.isPublic === 'boolean' ? patchEvent.isPublic : originalEvent.isPublic,
skip: typeof patchEvent.skip === 'boolean' ? patchEvent.skip : originalEvent.skip,
note: makeString(patchEvent.note, originalEvent.note),
colour: makeString(patchEvent.colour, originalEvent.colour),
delay: originalEvent.delay, // is regenerated if timer related data is changed
dayOffset: originalEvent.dayOffset, // is regenerated if timer related data is changed
gap: originalEvent.gap, // is regenerated if timer related data is changed
// short circuit empty string
cue: makeString(patchEvent.cue ?? null, originalEvent.cue),
parent: originalEvent.parent,
revision: originalEvent.revision,
timeWarning: patchEvent.timeWarning ?? originalEvent.timeWarning,
timeDanger: patchEvent.timeDanger ?? originalEvent.timeDanger,
custom: { ...originalEvent.custom, ...patchEvent.custom },
triggers: patchEvent.triggers ?? originalEvent.triggers,
};
}
/**
* @description Enforces formatting for events
* @param {object} eventArgs - attributes of event
* @param {number} eventIndex - can be a string when we pass the a suggested cue name
* @returns {object|null} - formatted object or null in case is invalid
*/
export const createEvent = (eventArgs: Partial<OntimeEvent>, eventIndex: number | string): OntimeEvent | null => {
if (Object.keys(eventArgs).length === 0) {
return null;
}
const cue = typeof eventIndex === 'number' ? String(eventIndex + 1) : eventIndex;
const baseEvent = {
id: eventArgs?.id ?? generateId(),
cue,
...eventDef,
};
const event = createPatch(baseEvent, eventArgs);
return event;
};
+4 -173
View File
@@ -1,178 +1,9 @@
import {
CustomField,
CustomFields,
DatabaseModel,
OntimeBlock,
OntimeDelay,
OntimeEvent,
ProjectData,
ProjectRundowns,
Rundown,
Settings,
URLPreset,
ViewSettings,
isOntimeBlock,
isOntimeDelay,
isOntimeEvent,
} from 'ontime-types';
import { customFieldLabelToKey, generateId, isAlphanumericWithSpace, isObjectEmpty } from 'ontime-utils';
import { CustomField, CustomFields, DatabaseModel, ProjectData, Settings, URLPreset, ViewSettings } from 'ontime-types';
import { customFieldLabelToKey, isAlphanumericWithSpace } from 'ontime-utils';
import { dbModel, defaultRundown } from '../models/dataModel.js';
import { block as blockDef, delay as delayDef } from '../models/eventsDefinition.js';
import { dbModel } from '../models/dataModel.js';
import { createEvent, type ErrorEmitter } from './parser.js';
/**
* Parse a rundowns object along with the project custom fields
* Returns a default rundown if none exists
*/
export function parseRundowns(
data: Partial<DatabaseModel>,
emitError?: ErrorEmitter,
): { customFields: CustomFields; rundowns: ProjectRundowns } {
// check custom fields first
const parsedCustomFields = parseCustomFields(data, emitError);
// ensure there is always a rundown to import
// this is important since the rest of the app assumes this exist
if (!data.rundowns || isObjectEmpty(data.rundowns)) {
emitError?.('No data found to import');
return {
customFields: parsedCustomFields,
rundowns: {
default: {
...defaultRundown,
},
},
};
}
const parsedRundowns: ProjectRundowns = {};
const iterableRundownsIds = Object.keys(data.rundowns);
// parse all the rundowns individually
for (const id of iterableRundownsIds) {
console.log('Found rundown, importing...');
const rundown = data.rundowns[id];
const parsedRundown = parseRundown(rundown, parsedCustomFields, emitError);
parsedRundowns[parsedRundown.id] = parsedRundown;
}
return { customFields: parsedCustomFields, rundowns: parsedRundowns };
}
/**
* Parses and validates a single project rundown along with given project custom fields
*/
export function parseRundown(
rundown: Rundown,
parsedCustomFields: Readonly<CustomFields>,
emitError?: ErrorEmitter,
): Rundown {
const parsedRundown: Rundown = {
id: rundown.id || generateId(),
title: rundown.title ?? '',
entries: {},
order: [],
flatOrder: [],
revision: rundown.revision ?? 1,
};
let eventIndex = 0;
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;
}
if (parsedRundown.order.includes(event.id)) {
emitError?.('ID collision on event import, skipping');
continue;
}
const id = entryId;
let newEvent: OntimeEvent | OntimeDelay | OntimeBlock | null;
const nestedEntryIds: string[] = [];
if (isOntimeEvent(event)) {
newEvent = createEvent(event, eventIndex);
// skip if event is invalid
if (newEvent == null) {
emitError?.('Skipping event without payload');
continue;
}
// for every field in custom, check that a key exists in customfields
for (const field in newEvent.custom) {
if (!Object.hasOwn(parsedCustomFields, field)) {
emitError?.(`Custom field ${field} not found`);
delete newEvent.custom[field];
}
}
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) {
nestedEntryIds.push(nestedEventId);
parsedRundown.entries[nestedEventId] = newNestedEvent;
}
}
}
newEvent = {
...blockDef,
title: event.title,
note: event.note,
events: event.events?.filter((eventId) => Object.hasOwn(rundown.entries, eventId)) ?? [],
skip: event.skip,
colour: event.colour,
custom: { ...event.custom },
id,
};
} else {
emitError?.('Unknown event type, skipping');
continue;
}
if (newEvent) {
parsedRundown.entries[id] = newEvent;
parsedRundown.order.push(id);
parsedRundown.flatOrder.push(id);
parsedRundown.flatOrder.push(...nestedEntryIds);
}
}
console.log(`Imported rundown ${parsedRundown.title} with ${parsedRundown.order.length} entries`);
return parsedRundown;
}
import { type ErrorEmitter } from './parser.js';
/**
* Parse event portion of an entry