refactor: restructure model to contain an object of rundowns

This commit is contained in:
Carlos Valente
2025-03-15 09:04:33 +01:00
committed by arc-alex
parent 0a80d6db31
commit abd9b127db
111 changed files with 4357 additions and 4515 deletions
@@ -0,0 +1,59 @@
export const dataFromExcelTemplate = [
['Ontime ┬À Schedule Template'],
[],
[
'id',
'Time Start',
'Time End',
'Title',
'End Action',
'Timer type',
'Count to end',
'Public',
'Skip',
'Notes',
't0',
'Test1',
'test2',
'test3',
'Colour',
'cue',
],
[
'event-a', // <-- eventId
'07:00:00', // <-- timeStart
'08:00:10', // <-- timeEnd
'Guest Welcome', // <-- title
'', // <-- endAction
'', // <-- timerType
'x', // <-- count to end
'x', // <-- public
'', // <-- skip
'Ballyhoo', // <-- notes
'a0', // <-- t0
'a1', // <-- test1
'a2', // <-- test2
'a3', // <-- test3
'red', // <-- colour
101, // <-- cue
],
[
'event-b', // <-- eventId
'08:00:00', // <-- timeStart
'08:30:00', // <-- timeEnd
'A song from the hearth', // <-- title
'load-next', // <-- endAction
'clock', // timerType
'x', // <-- count to end
'', // <-- public
'x', // <-- skip
'Rainbow chase', // <-- notes
'b0', // <-- t0
'', // <-- test1
'', // <-- test2
'', // <-- test3
'#F00', // <-- colour
102, // <-- cue
],
[],
];
File diff suppressed because it is too large Load Diff
@@ -1,45 +1,171 @@
import {
CustomFields,
DatabaseModel,
OntimeEvent,
OntimeRundown,
Settings,
SupportedEvent,
URLPreset,
} from 'ontime-types';
import { CustomFields, OntimeBlock, OntimeEvent, Rundown, Settings, SupportedEvent, URLPreset } from 'ontime-types';
import { defaultRundown } from '../../models/dataModel.js';
import {
parseCustomFields,
parseProject,
parseRundown,
parseRundowns,
parseSettings,
parseUrlPresets,
parseViewSettings,
sanitiseCustomFields,
} from '../parserFunctions.js';
describe('parseRundown()', () => {
it('returns an empty array if no rundown is given', () => {
describe('parseRundowns()', () => {
it('returns a default project rundown if nothing is given', () => {
const errorEmitter = vi.fn();
const result = parseRundown({}, errorEmitter);
expect(result.rundown).toEqual([]);
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: '1', type: SupportedEvent.Event, title: 'test', skip: false }, // OK
{ id: '1', type: SupportedEvent.Block, title: 'test 2', skip: false }, // duplicate ID
{}, // no data
{ id: '2', title: 'test 2', skip: false }, // no type
] as OntimeRundown;
const { rundown: parsedRundown } = parseRundown({ rundown, customFields: {} }, errorEmitter);
expect(parsedRundown.length).toEqual(1);
expect(parsedRundown.at(0)).toMatchObject({ id: '1', type: SupportedEvent.Event, title: 'test', skip: false });
const rundown = {
id: '',
title: '',
order: ['1', '2', '3', '4'],
entries: {
'1': { id: '1', type: SupportedEvent.Event, title: 'test', skip: false } as OntimeEvent, // OK
'2': { id: '1', type: SupportedEvent.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: SupportedEvent.Event,
title: 'test',
skip: false,
},
});
expect(errorEmitter).toHaveBeenCalled();
});
it('stringifies necessary values', () => {
const rundown = {
id: '',
title: '',
order: ['1', '2'],
entries: {
// @ts-expect-error -- testing external data which could be incorrect
'1': { id: '1', type: SupportedEvent.Event, cue: 101 } as OntimeEvent,
// @ts-expect-error -- testing external data which could be incorrect
'2': { id: '2', type: SupportedEvent.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'],
entries: {
'1': { id: '1', type: SupportedEvent.Event } as OntimeEvent,
'2': { id: '2', type: SupportedEvent.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'],
entries: {
'1': { id: '1', type: SupportedEvent.Event } as OntimeEvent,
'2': { id: '2', type: SupportedEvent.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'],
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);
});
});
describe('parseProject()', () => {
@@ -77,7 +203,6 @@ describe('parseProject()', () => {
projectLogo: null,
custom: [],
});
expect(errorEmitter).not.toHaveBeenCalled();
});
});
@@ -87,9 +212,17 @@ describe('parseSettings()', () => {
});
it('returns an a base model as long as we have the app and version', () => {
const minimalSettings = { app: 'ontime', version: '1' } as Settings;
const result = parseSettings({ settings: minimalSettings });
const result = parseSettings({ settings: { app: 'ontime', version: '1' } as Settings });
expect(result).toBeTypeOf('object');
expect(result).toMatchObject({
app: 'ontime',
version: expect.any(String),
serverPort: 4001,
editorKey: null,
operatorKey: null,
timeFormat: '24',
language: 'en',
});
});
});
@@ -221,17 +354,6 @@ describe('sanitiseCustomFields()', () => {
expect(sanitationResult).toStrictEqual(expectedCustomFields);
});
it('allow old keys', () => {
const customFields: CustomFields = {
test: { label: 'Test', type: 'string', colour: 'red' },
};
const expectedCustomFields: CustomFields = {
test: { label: 'Test', type: 'string', colour: 'red' },
};
const sanitationResult = sanitiseCustomFields(customFields);
expect(sanitationResult).toStrictEqual(expectedCustomFields);
});
it('labels with space', () => {
const customFields: CustomFields = {
Test_with_Space: { label: 'Test with Space', type: 'string', colour: 'red' },
@@ -262,100 +384,120 @@ describe('sanitiseCustomFields()', () => {
describe('parseRundown() linking', () => {
it('returns linked events', () => {
const data: Partial<DatabaseModel> = {
rundown: [
{
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,
],
customFields: {},
},
};
const result = parseRundown(data);
expect(result.rundown[1]).toMatchObject({
id: '2',
linkStart: '1',
const result = parseRundown(rundown, {});
expect(result).toMatchObject({
order: ['1', '2'],
entries: {
'2': {
linkStart: '1',
},
},
});
});
it('returns unlinked if no previous', () => {
const data: Partial<DatabaseModel> = {
rundown: [
{
const rundown: Rundown = {
id: '',
title: '',
revision: 1,
order: ['1', '2'],
entries: {
'2': {
id: '2',
type: SupportedEvent.Event,
linkStart: 'true',
skip: false,
} as OntimeEvent,
],
customFields: {},
},
};
const result = parseRundown(data);
expect(result.rundown[0]).toMatchObject({
id: '2',
linkStart: null,
const result = parseRundown(rundown, {});
expect(result).toMatchObject({
order: ['2'],
entries: {
'2': {
linkStart: null,
},
},
});
});
it('returns linked events past blocks and delays', () => {
const data: Partial<DatabaseModel> = {
rundown: [
{
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,
],
customFields: {},
},
};
const result = parseRundown(data);
expect(result.rundown[0]).toMatchObject({
id: '1',
cue: '1',
});
// skip delay
expect(result.rundown[2]).toMatchObject({
id: '2',
cue: '2',
linkStart: '1',
});
// skip block
expect(result.rundown[4]).toMatchObject({
id: '3',
cue: '3',
linkStart: '2',
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',
},
},
});
});
});
+23 -53
View File
@@ -2,68 +2,38 @@ import { MILLIS_PER_MINUTE } from 'ontime-utils';
import { parseExcelDate } from '../time.js';
describe('parseExcelDate', () => {
// TODO: our parsing currently does not use UTC, so the tests can not be done in CI
describe.todo('parses a valid date string as expected from excel', () => {
const testCases = [
{
fromExcel: '1899-12-30T00:00:00.000Z',
expected: 3600000,
},
{
fromExcel: '1899-12-30T00:10:00.000Z',
expected: 4200000,
},
{
fromExcel: '1899-12-30T01:00:00.000Z',
expected: 7200000,
},
{
fromExcel: '1899-12-30T07:00:00.000Z',
expected: 28800000,
},
{
fromExcel: '1899-12-30T08:00:10.000Z',
expected: 32410000,
},
{
fromExcel: '1899-12-30T08:30:00.000Z',
expected: 34200000,
},
];
for (const scenario of testCases) {
it(`handles ${scenario.fromExcel}`, () => {
expect(parseExcelDate(scenario.fromExcel)).toBe(scenario.expected);
});
}
});
describe('parses a time string that passes validation', () => {
const validFields = ['10:00:00', '10:00', '10:00AM', '10:00am', '10:00PM', '10:00pm'];
validFields.forEach((field) => {
it(`handles ${field}`, () => {
const millis = parseExcelDate(field);
expect(millis).not.toBe(0);
});
test.each([
['1899-12-30T00:00:00.000Z', 3600000],
['1899-12-30T00:10:00.000Z', 4200000],
['1899-12-30T01:00:00.000Z', 7200000],
['1899-12-30T07:00:00.000Z', 28800000],
['1899-12-30T08:00:10.000Z', 32410000],
['1899-12-30T08:30:00.000Z', 34200000],
])(`handles %s`, (fromExcel, expected) => {
expect(parseExcelDate(fromExcel)).toBe(expected);
});
});
describe('parses a time string that passes validation', () => {
test.each([['10:00:00'], ['10:00'], ['10:00AM'], ['10:00am'], ['10:00PM'], ['10:00pm']])(
`handles %s`,
(fromExcel) => {
expect(parseExcelDate(fromExcel)).not.toBe(0);
},
);
});
describe('uses numeric fields as minutes', () => {
const invalidFields = [1, 10, 100];
invalidFields.forEach((field) => {
it(`handles ${field}`, () => {
const millis = parseExcelDate(field);
expect(millis).toBe(field * MILLIS_PER_MINUTE);
});
test.each([[1], [10], [100]])(`handles numeric fields %s`, (fromExcel) => {
expect(parseExcelDate(fromExcel)).toBe(fromExcel * MILLIS_PER_MINUTE);
});
});
describe('returns 0 on other strings', () => {
const invalidFields = ['test', ''];
invalidFields.forEach((field) => {
it(`handles ${field}`, () => {
const millis = parseExcelDate(field);
expect(millis).toBe(0);
});
test.each([['test'], [''], ['x']])(`handles invalid fields %s`, (fromExcel) => {
expect(parseExcelDate(fromExcel)).toBe(0);
});
});
});
+1 -1
View File
@@ -106,7 +106,7 @@ export async function copyDirectory(src: string, dest: string) {
}
}
/**
/**
* workaround avoids origin errors in docker deployments
* EXDEV cross-device link not permitted
*/
+70 -47
View File
@@ -13,11 +13,12 @@ import {
import {
CustomFields,
DatabaseModel,
EventCustomFields,
EntryCustomFields,
isOntimeBlock,
LogOrigin,
OntimeBlock,
OntimeEvent,
OntimeRundown,
Rundown,
SupportedEvent,
TimerType,
TimeStrategy,
@@ -28,17 +29,14 @@ import { logger } from '../classes/Logger.js';
import { event as eventDef } from '../models/eventsDefinition.js';
import { makeString } from './parserUtils.js';
import { parseProject, parseRundown, parseSettings, parseUrlPresets, parseViewSettings } from './parserFunctions.js';
import { parseProject, parseRundowns, parseSettings, parseUrlPresets, parseViewSettings } from './parserFunctions.js';
import { parseExcelDate } from './time.js';
import { Merge } from 'ts-essentials';
export type ErrorEmitter = (message: string) => void;
export const EXCEL_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
export const JSON_MIME = 'application/json';
type ExcelData = Pick<DatabaseModel, 'rundown' | 'customFields'> & {
rundownMetadata: Record<string, { row: number; col: number }>;
};
function parseBooleanString(value: unknown): boolean {
if (typeof value === 'boolean') {
return value;
@@ -83,9 +81,14 @@ export function getCustomFieldData(
export const parseExcel = (
excelData: unknown[][],
existingCustomFields: CustomFields,
sheetName: string = 'Rundown from excel',
options?: Partial<ImportMap>,
): ExcelData => {
const rundownMetadata = {};
): {
rundown: Rundown;
customFields: CustomFields;
rundownMetadata: Record<string, { row: number; col: number }>;
} => {
const rundownMetadata: Record<string, { row: number; col: number }> = {};
const importMap: ImportMap = { ...defaultImportMap, ...options };
for (const [key, value] of Object.entries(importMap)) {
@@ -95,7 +98,13 @@ export const parseExcel = (
}
const { customFields, customFieldImportKeys } = getCustomFieldData(importMap, existingCustomFields);
const rundown: OntimeRundown = [];
const rundown: Rundown = {
id: generateId(),
title: sheetName,
order: [],
entries: {},
revision: 0,
};
// title stuff: strings
let titleIndex: number | null = null;
@@ -205,8 +214,8 @@ export const parseExcel = (
},
} as const;
const event: any = {};
const eventCustomFields: EventCustomFields = {};
const entry: Partial<Merge<OntimeEvent, OntimeBlock>> = {};
const entryCustomFields: EntryCustomFields = {};
for (let j = 0; j < row.length; j++) {
const column = row[j];
@@ -214,48 +223,50 @@ export const parseExcel = (
if (j === timerTypeIndex) {
const maybeTimeType = makeString(column, '');
if (maybeTimeType === 'block') {
event.type = SupportedEvent.Block;
// we leave this as a clue for the object filtering later on
entry.type = SupportedEvent.Block;
} else if (maybeTimeType === '' || maybeTimeType === 'event' || isKnownTimerType(maybeTimeType)) {
event.type = SupportedEvent.Event;
event.timerType = validateTimerType(maybeTimeType);
// @ts-expect-error -- we leave this as a clue for the object filtering later on
entry.type = SupportedEvent.Event;
entry.timerType = validateTimerType(maybeTimeType);
} else {
// if it is not a block or a known type, we dont import it
return;
}
} else if (j === titleIndex) {
event.title = makeString(column, '');
entry.title = makeString(column, '');
} else if (j === timeStartIndex) {
event.timeStart = parseExcelDate(column);
entry.timeStart = parseExcelDate(column);
} else if (j === linkStartIndex) {
event.linkStart = parseBooleanString(column);
entry.linkStart = parseBooleanString(column) ? 'true' : null;
} else if (j === timeEndIndex) {
event.timeEnd = parseExcelDate(column);
entry.timeEnd = parseExcelDate(column);
} else if (j === durationIndex) {
event.duration = parseExcelDate(column);
entry.duration = parseExcelDate(column);
} else if (j === cueIndex) {
event.cue = makeString(column, '');
entry.cue = makeString(column, '');
} else if (j === countToEndIndex) {
event.countToEnd = parseBooleanString(column);
entry.countToEnd = parseBooleanString(column);
} else if (j === isPublicIndex) {
event.isPublic = parseBooleanString(column);
entry.isPublic = parseBooleanString(column);
} else if (j === skipIndex) {
event.skip = parseBooleanString(column);
entry.skip = parseBooleanString(column);
} else if (j === notesIndex) {
event.note = makeString(column, '');
entry.note = makeString(column, '');
} else if (j === endActionIndex) {
event.endAction = validateEndAction(column);
entry.endAction = validateEndAction(column);
} else if (j === timeWarningIndex) {
event.timeWarning = parseExcelDate(column);
entry.timeWarning = parseExcelDate(column);
} else if (j === timeDangerIndex) {
event.timeDanger = parseExcelDate(column);
entry.timeDanger = parseExcelDate(column);
} else if (j === colourIndex) {
event.colour = makeString(column, '');
entry.colour = makeString(column, '');
} else if (j === entryIdIndex) {
event.id = encodeURIComponent(makeString(column, undefined));
entry.id = encodeURIComponent(makeString(column, undefined));
} else if (j in customFieldIndexes) {
const importKey = customFieldIndexes[j];
const ontimeKey = customFieldImportKeys[importKey];
eventCustomFields[ontimeKey] = makeString(column, '');
entryCustomFields[ontimeKey] = makeString(column, '');
} else {
// 2. if there is no flag, lets see if we know the field type
if (typeof column === 'string') {
@@ -282,20 +293,32 @@ export const parseExcel = (
}
}
// if any data was found in row, push to array
const keysFound = Object.keys(event).length + Object.keys(eventCustomFields).length;
if (keysFound > 0) {
// if it is a Block type drop all other filed
if (isOntimeBlock(event)) {
rundown.push({ type: event.type, id: event.id, title: event.title });
} else {
if (timerTypeIndex === null) {
event.timerType = TimerType.CountDown;
event.type = SupportedEvent.Event;
}
rundown.push({ ...event, custom: { ...eventCustomFields } });
}
// if we didnt find any keys (empty row, or some other data), skip making an event
const keysFound = Object.keys(entry).length + Object.keys(entryCustomFields).length;
if (keysFound === 0) {
return;
}
const id = entry.id || generateId();
// from excel, we can only get blocks and events
if (isOntimeBlock(entry)) {
const block: OntimeBlock = { ...entry, custom: { ...entryCustomFields } };
rundown.order.push(id);
rundown.entries[id] = block;
return;
}
const event = {
...entry,
custom: { ...entryCustomFields },
type: SupportedEvent.Event,
} as OntimeEvent;
if (timerTypeIndex === null) {
event.timerType = TimerType.CountDown;
}
rundown.order.push(id);
rundown.entries[id] = event;
});
return {
@@ -327,11 +350,10 @@ export function parseDatabaseModel(jsonData: Partial<DatabaseModel>): { data: Da
};
// we need to parse the custom fields first so they can be used in validating events
// TODO: can we improve the readability of the error?
const { rundown, customFields } = parseRundown(jsonData, makeEmitError('Rundown'));
const { rundowns, customFields } = parseRundowns(jsonData, makeEmitError('Rundown'));
const data: DatabaseModel = {
rundown,
rundowns,
project: parseProject(jsonData, makeEmitError('Project')),
settings,
viewSettings: parseViewSettings(jsonData, makeEmitError('View Settings')),
@@ -394,6 +416,7 @@ export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial<Onti
gap: originalEvent.gap, // is regenerated if timer related data is changed
// short circuit empty string
cue: makeString(patchEvent.cue ?? null, originalEvent.cue),
currentBlock: originalEvent.currentBlock,
revision: originalEvent.revision,
timeWarning: patchEvent.timeWarning ?? originalEvent.timeWarning,
timeDanger: patchEvent.timeDanger ?? originalEvent.timeDanger,
+80 -24
View File
@@ -5,8 +5,9 @@ import {
OntimeBlock,
OntimeDelay,
OntimeEvent,
OntimeRundown,
ProjectData,
ProjectRundowns,
Rundown,
Settings,
URLPreset,
ViewSettings,
@@ -14,45 +15,86 @@ import {
isOntimeDelay,
isOntimeEvent,
} from 'ontime-types';
import { customFieldLabelToKey, generateId, isAlphanumericWithSpace } from 'ontime-utils';
import { customFieldLabelToKey, generateId, isAlphanumericWithSpace, isObjectEmpty } from 'ontime-utils';
import { dbModel } from '../models/dataModel.js';
import { dbModel, defaultRundown } from '../models/dataModel.js';
import { block as blockDef, delay as delayDef } from '../models/eventsDefinition.js';
import { createEvent, type ErrorEmitter } from './parser.js';
/**
* Parse rundown array of an entry
* Parse a rundowns object along with the project custom fields
*/
export function parseRundown(
export function parseRundowns(
data: Partial<DatabaseModel>,
emitError?: ErrorEmitter,
): { customFields: CustomFields; rundown: OntimeRundown } {
): { customFields: CustomFields; rundowns: ProjectRundowns } {
// check custom fields first
const parsedCustomFields = parseCustomFields(data, emitError);
if (!data.rundown) {
if (!data.rundowns || isObjectEmpty(data.rundowns)) {
emitError?.('No data found to import');
return { customFields: parsedCustomFields, rundown: [] };
return {
customFields: parsedCustomFields,
rundowns: {
default: {
...defaultRundown,
},
},
};
}
console.log('Found rundown, importing...');
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: [],
revision: rundown.revision ?? 1,
};
const rundown: OntimeRundown = [];
let eventIndex = 0;
let previousId: string | null = null;
const ids: string[] = [];
for (const event of data.rundown) {
if (ids.includes(event.id)) {
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 = event.id || generateId();
const id = entryId;
let newEvent: OntimeEvent | OntimeDelay | OntimeBlock | null;
if (isOntimeEvent(event)) {
const maybeEvent = { ...event, id };
const maybeEvent = { ...event };
if (event.linkStart) {
maybeEvent.linkStart = previousId;
@@ -78,20 +120,29 @@ export function parseRundown(
} else if (isOntimeDelay(event)) {
newEvent = { ...delayDef, duration: event.duration, id };
} else if (isOntimeBlock(event)) {
newEvent = { ...blockDef, title: event.title, id };
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) {
rundown.push(newEvent);
ids.push(id);
parsedRundown.entries[id] = newEvent;
parsedRundown.order.push(id);
}
}
console.log(`Uploaded rundown with ${rundown.length} entries`);
return { customFields: parsedCustomFields, rundown };
console.log(`Imported rundown ${parsedRundown.title} with ${parsedRundown.order.length} entries`);
return parsedRundown;
}
/**
@@ -216,10 +267,15 @@ export function sanitiseCustomFields(data: object): CustomFields {
continue;
}
const keyFromLabel = customFieldLabelToKey(field.label);
// Test label and key cohesion, but allow old lowercased keys to stay
// TODO: the `toLocaleLowerCase` part here is to conserve keys from old projects and could be removed at some point (okt. 2024)
const key = originalKey.toLocaleLowerCase() === keyFromLabel.toLocaleLowerCase() ? originalKey : keyFromLabel;
// Test label and key cohesion
const key = (() => {
const keyFromLabel = customFieldLabelToKey(field.label);
if (keyFromLabel === null) {
return originalKey;
}
return originalKey.toLowerCase() === keyFromLabel.toLowerCase() ? originalKey : keyFromLabel;
})();
if (key in newCustomFields) {
continue;
}