mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-08 08:53:51 +00:00
refactor: extract rundown parsing
refactor: implement groups in editor
This commit is contained in:
committed by
Carlos Valente
parent
166be66ce3
commit
c616240db1
@@ -12,10 +12,12 @@ import xlsx from 'xlsx';
|
||||
import type { WorkBook } from 'xlsx';
|
||||
|
||||
import { parseExcel } from '../../utils/parser.js';
|
||||
import { parseCustomFields, parseRundown } from '../../utils/parserFunctions.js';
|
||||
import { parseCustomFields } from '../../utils/parserFunctions.js';
|
||||
import { deleteFile } from '../../utils/parserUtils.js';
|
||||
import { getCustomFields } from '../../services/rundown-service/rundownCache.js';
|
||||
|
||||
import { parseRundown } from '../rundown/rundown.parser.js';
|
||||
|
||||
let excelData: WorkBook = xlsx.utils.book_new();
|
||||
|
||||
export async function saveExcelFile(filePath: string) {
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { SupportedEntry, OntimeEvent, OntimeBlock, Rundown } from 'ontime-types';
|
||||
|
||||
import { defaultRundown } from '../../../models/dataModel.js';
|
||||
import { makeOntimeBlock, makeOntimeEvent } from '../../../services/rundown-service/__mocks__/rundown.mocks.js';
|
||||
|
||||
import { parseRundowns, parseRundown } from '../rundown.parser.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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { assertType } from 'vitest';
|
||||
|
||||
import { createEvent } from '../rundown.utils.js';
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import {
|
||||
DatabaseModel,
|
||||
CustomFields,
|
||||
ProjectRundowns,
|
||||
Rundown,
|
||||
OntimeEvent,
|
||||
OntimeDelay,
|
||||
OntimeBlock,
|
||||
isOntimeEvent,
|
||||
isOntimeDelay,
|
||||
isOntimeBlock,
|
||||
} from 'ontime-types';
|
||||
import { isObjectEmpty, generateId } from 'ontime-utils';
|
||||
|
||||
import { defaultRundown } from '../../models/dataModel.js';
|
||||
import { delay as delayDef, block as blockDef } from '../../models/eventsDefinition.js';
|
||||
import { ErrorEmitter } from '../../utils/parser.js';
|
||||
import { parseCustomFields } from '../../utils/parserFunctions.js';
|
||||
|
||||
import { createEvent } from './rundown.utils.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;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { OntimeEvent, SupportedEntry, TimeStrategy } from 'ontime-types';
|
||||
import { generateId, validateEndAction, validateTimerType, validateTimes } from 'ontime-utils';
|
||||
|
||||
import { event as eventDef } from '../../models/eventsDefinition.js';
|
||||
import { makeString } from '../../utils/parserUtils.js';
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user