mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-10 18:03:47 +00:00
refactor: restructure model to contain an object of rundowns
This commit is contained in:
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user