refactor: migrate custom fields to transactions

refactor: extract functions to api domain

refactor: strict custom field parsing

refactor: remove rundown cache utilities

refactor: directory restructure
This commit is contained in:
Carlos Valente
2025-06-06 21:08:30 +02:00
committed by Carlos Valente
parent b1d23467a2
commit 62c8319d70
75 changed files with 2060 additions and 2480 deletions
@@ -1,59 +0,0 @@
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
],
[],
];
@@ -1,709 +0,0 @@
/* eslint-disable no-console -- we are mocking the console */
import { vi } from 'vitest';
import { CustomFields, DatabaseModel, OntimeEvent, SupportedEntry, TimerType } from 'ontime-types';
import { ImportMap, MILLIS_PER_MINUTE } from 'ontime-utils';
import { dbModel } from '../../models/dataModel.js';
import { demoDb } from '../../models/demoProject.js';
import { getCustomFieldData, parseExcel, parseDatabaseModel } from '../parser.js';
import { makeString } from '../parserUtils.js';
import { parseUrlPresets, parseViewSettings } from '../parserFunctions.js';
import { dataFromExcelTemplate } from './parser.mock-data.js';
// mock data provider
beforeAll(() => {
vi.mock('../../classes/data-provider/DataProvider.js', () => {
return {
getDataProvider: vi.fn().mockImplementation(() => {
return {
setRundown: vi.fn().mockImplementation((newData) => newData),
setCustomFields: vi.fn().mockImplementation((newData) => newData),
};
}),
};
});
});
describe('test parseDatabaseModel() with demo project (valid)', () => {
const filteredDemoProject = structuredClone(demoDb);
const { data } = parseDatabaseModel(filteredDemoProject);
it('has 17 events with 12 top level events', () => {
expect(data.rundowns.default.order.length).toBe(12);
expect(Object.keys(data.rundowns.default.entries).length).toBe(17);
});
it('is the same as the demo project since all data is valid', () => {
// @ts-expect-error -- its ok
delete filteredDemoProject.settings.version;
// @ts-expect-error -- its ok
delete data.settings.version;
expect(data).toMatchObject(filteredDemoProject);
});
});
describe('test parseDatabaseModel() edge cases', () => {
it('skips unknown app and version settings', () => {
console.log = vi.fn();
const testData = {
settings: {
osc_port: 8888,
},
};
// @ts-expect-error -- we know this is wrong, testing imports outside domain
expect(() => parseDatabaseModel(testData)).toThrow();
});
it('fails with invalid JSON', () => {
// @ts-expect-error -- we know this is wrong, testing imports outside domain
expect(() => parseDatabaseModel('some random dataset')).toThrow();
});
});
describe('test aliases import', () => {
it('imports a well defined urlPreset', () => {
const testData = {
rundown: [],
settings: {
version: '2.0.0',
},
urlPresets: [
{
enabled: false,
alias: 'testalias',
pathAndParams: 'testpathAndParams',
},
],
} as unknown as DatabaseModel;
const parsed = parseUrlPresets(testData);
expect(parsed.length).toBe(1);
// generates missing id
expect(parsed[0].alias).toBeDefined();
});
});
describe('test views import', () => {
it('imports data from file', () => {
const testData = {
rundown: [],
settings: {
version: '2.0.0',
},
viewSettings: {
normalColor: '#ffffffcc',
warningColor: '#FFAB33',
dangerColor: '#ED3333',
endMessage: '',
overrideStyles: false,
// known error: properties do not exist
notAthing: true,
},
// known error: views does not exist
views: {
overrideStyles: true,
},
};
const expectedParsedViewSettings = {
normalColor: '#ffffffcc',
warningColor: '#FFAB33',
dangerColor: '#ED3333',
freezeEnd: false,
endMessage: '',
overrideStyles: false,
};
// @ts-expect-error -- we know the above is incorrect
const parsed = parseViewSettings(testData);
expect(parsed).toStrictEqual(expectedParsedViewSettings);
});
it('imports defaults to model', () => {
const testData = {
rundown: [],
settings: {
version: '2.0.0',
},
} as unknown as DatabaseModel;
const parsed = parseViewSettings(testData);
expect(parsed).toStrictEqual(dbModel.viewSettings);
});
});
describe('makeString()', () => {
it('converts variables to string', () => {
const cases = [
{
val: 2,
expected: '2',
},
{
val: 2.22222222,
expected: '2.22222222',
},
{
val: ['testing'],
expected: 'testing',
},
{
val: ' testing ',
expected: 'testing',
},
{
val: { doing: 'testing' },
expected: 'fallback',
},
{
val: undefined,
expected: 'fallback',
},
];
cases.forEach(({ val, expected }) => {
const converted = makeString(val, 'fallback');
expect(converted).toBe(expected);
});
});
});
describe('getCustomFieldData()', () => {
it('generates a list of keys from the given import map', () => {
const importMap = {
worksheet: 'event schedule',
timeStart: 'time start',
linkStart: 'link start',
timeEnd: 'time end',
duration: 'duration',
cue: 'cue',
title: 'title',
countToEnd: 'count to end',
isPublic: 'public',
skip: 'skip',
note: 'notes',
colour: 'colour',
endAction: 'end action',
timerType: 'timer type',
timeWarning: 'warning time',
timeDanger: 'danger time',
custom: {
lighting: 'lx',
sound: 'sound',
video: 'av',
},
entryId: 'id',
} as ImportMap;
const result = getCustomFieldData(importMap, {});
expect(result.customFields).toStrictEqual({
lighting: {
type: 'string',
colour: '',
label: 'lighting',
},
sound: {
type: 'string',
colour: '',
label: 'sound',
},
video: {
type: 'string',
colour: '',
label: 'video',
},
});
// it is an inverted record of <importKey, ontimeKey>
expect(result.customFieldImportKeys).toStrictEqual({
lx: 'lighting',
sound: 'sound',
av: 'video',
});
});
it('keeps colour information from existing fields', () => {
const importMap = {
worksheet: 'event schedule',
timeStart: 'time start',
linkStart: 'link start',
timeEnd: 'time end',
duration: 'duration',
cue: 'cue',
title: 'title',
countToEnd: 'count to end',
isPublic: 'public',
skip: 'skip',
note: 'notes',
colour: 'colour',
endAction: 'end action',
timerType: 'timer type',
timeWarning: 'warning time',
timeDanger: 'danger time',
custom: {
lighting: 'lx',
sound: 'sound',
video: 'av',
ontime_label: 'excel label',
},
entryId: 'id',
} as ImportMap;
const customFields: CustomFields = {
lighting: { label: 'lx', type: 'string', colour: 'red' },
sound: { label: 'sound', type: 'string', colour: 'green' },
ontime_key: { label: 'ontime_label', type: 'string', colour: 'blue' },
};
const result = getCustomFieldData(importMap, customFields);
expect(result.customFields).toStrictEqual({
lighting: {
type: 'string',
colour: 'red',
label: 'lighting',
},
sound: {
type: 'string',
colour: 'green',
label: 'sound',
},
video: {
type: 'string',
colour: '',
label: 'video',
},
ontime_key: {
type: 'string',
colour: 'blue',
label: 'ontime_label',
},
});
// it is an inverted record of <importKey, ontimeKey>
expect(result.customFieldImportKeys).toStrictEqual({
lx: 'lighting',
sound: 'sound',
av: 'video',
'excel label': 'ontime_key',
});
});
});
describe('parseExcel()', () => {
it('parses the example file', () => {
// partial import map with only custom fields
const importMap = {
custom: {
user0: 't0',
user1: 'Test1',
user2: 'test2',
user3: 'test3',
},
};
const existingCustomFields: CustomFields = {
user0: { type: 'string', colour: 'red', label: 'user0' },
user1: { type: 'string', colour: 'green', label: 'user1' },
user2: { type: 'string', colour: 'blue', label: 'user2' },
};
const parsedData = parseExcel(dataFromExcelTemplate, existingCustomFields, 'testSheet', importMap);
expect(parsedData.customFields).toStrictEqual({
user0: {
type: 'string',
colour: 'red',
label: 'user0',
},
user1: {
type: 'string',
colour: 'green',
label: 'user1',
},
user2: {
type: 'string',
colour: 'blue',
label: 'user2',
},
user3: {
type: 'string',
colour: '',
label: 'user3',
},
});
expect(parsedData.rundown.order.length).toBe(2);
// TODO: why dont we parse the date in UTC?
expect(parsedData.rundown.entries).toMatchObject({
'event-a': {
id: 'event-a',
//timeStart: 28800000,
//timeEnd: 32410000,
title: 'Guest Welcome',
timerType: 'count-down',
endAction: 'none',
isPublic: true,
skip: false,
note: 'Ballyhoo',
custom: {
user0: 'a0',
user1: 'a1',
user2: 'a2',
user3: 'a3',
},
colour: 'red',
type: 'event',
cue: '101',
},
'event-b': {
id: 'event-b',
//timeStart: 32400000,
//timeEnd: 34200000,
title: 'A song from the hearth',
timerType: 'clock',
endAction: 'load-next',
isPublic: false,
skip: true,
note: 'Rainbow chase',
custom: {},
colour: '#F00',
type: 'event',
cue: '102',
},
});
});
it('parses a file without custom fields', () => {
// partial import map with only custom fields
const importMap = {
custom: {
niu1: 'niu1',
niu2: 'niu2',
},
};
const parsedData = parseExcel(dataFromExcelTemplate, {}, 'testSheet', importMap);
expect(parsedData.customFields).toStrictEqual({
niu1: {
type: 'string',
colour: '',
label: 'niu1',
},
niu2: {
type: 'string',
colour: '',
label: 'niu2',
},
});
expect(parsedData.rundown.order).toMatchObject(['event-a', 'event-b']);
expect(parsedData.rundown.entries['event-a']).toMatchObject({
//timeStart: 28800000,
//timeEnd: 32410000,
id: 'event-a',
title: 'Guest Welcome',
timerType: 'count-down',
endAction: 'none',
isPublic: true,
skip: false,
note: 'Ballyhoo',
custom: {},
colour: 'red',
type: 'event',
cue: '101',
});
expect(parsedData.rundown.entries['event-b']).toMatchObject({
//timeStart: 32400000,
//timeEnd: 34200000,
id: 'event-b',
title: 'A song from the hearth',
timerType: 'clock',
endAction: 'load-next',
isPublic: false,
skip: true,
note: 'Rainbow chase',
custom: {},
colour: '#F00',
type: 'event',
cue: '102',
});
});
it('ignores unknown event types', () => {
const testdata = [
['Title', 'Timer type'],
['Guest Welcome', 'x'],
['A song from the hearth', 'clock'],
];
const importMap = {
title: 'title',
timerType: 'timer type',
};
const result = parseExcel(testdata, {}, 'testSheet', importMap);
const firstEvent = result.rundown.entries[result.rundown.order[0]];
expect(result.rundown.order.length).toBe(1);
expect((firstEvent as OntimeEvent).title).toBe('A song from the hearth');
});
it('imports blocks', () => {
const testdata = [
['Title', 'Timer type'],
['a block', 'block'],
['an event', 'clock'],
];
const importMap = {
title: 'title',
timerType: 'timer type',
};
const result = parseExcel(testdata, {}, 'testSheet', importMap);
const firstEvent = result.rundown.entries[result.rundown.order[0]];
expect(result.rundown.order.length).toBe(2);
expect((firstEvent as OntimeEvent).type).toBe(SupportedEntry.Block);
});
it('imports as events if there is no timer type column', () => {
const testdata = [['Title'], ['no timer type'], ['also no timer type']];
const importMap = {
title: 'title',
};
const result = parseExcel(testdata, {}, 'testSheet', importMap);
const firstEvent = result.rundown.entries[result.rundown.order[0]];
const secondEvent = result.rundown.entries[result.rundown.order[1]];
expect(result.rundown.order.length).toBe(2);
expect(firstEvent).toMatchObject({
type: SupportedEntry.Event,
timerType: TimerType.CountDown,
});
expect(secondEvent).toMatchObject({
type: SupportedEntry.Event,
timerType: TimerType.CountDown,
});
});
it('imports as events if timer type is empty or has whitespace', () => {
const testdata = [
['Title', 'Timer type'],
['first', ' '],
['second', undefined],
['third', ' count-up '],
];
const importMap = {
title: 'title',
timerType: 'timer type',
};
const result = parseExcel(testdata, {}, 'testSheet', importMap);
const firstEvent = result.rundown.entries[result.rundown.order[0]];
const secondEvent = result.rundown.entries[result.rundown.order[1]];
const thirdEvent = result.rundown.entries[result.rundown.order[2]];
expect(result.rundown.order.length).toBe(3);
expect(firstEvent).toMatchObject({ title: 'first', type: SupportedEntry.Event, timerType: TimerType.CountDown });
expect(secondEvent).toMatchObject({ title: 'second', type: SupportedEntry.Event, timerType: TimerType.CountDown });
expect(thirdEvent).toMatchObject({ title: 'third', type: SupportedEntry.Event, timerType: TimerType.CountUp });
});
it('am/pm conversion to 24h', () => {
const testData = [
['Time Start', 'Time End', 'ID'],
['4:30:00', '4:36:00', 'event-1'],
['9:45:00', '10:56:00', 'event-2'],
['16:30:00', '16:36:00', 'event-3'],
['21:45:00', '22:56:00', 'event-4'],
['4:30:00AM', '4:36:00AM', 'event-5'],
['9:45:00AM', '10:56:00AM', 'event-6'],
['4:30:00PM', '4:36:00PM', 'event-7'],
['9:45:00PM', '10:56:00PM', 'event-8'],
];
const importMap = {
timeStart: 'time start',
timeEnd: 'time end',
id: 'id',
};
const result = parseExcel(testData, {}, 'testSheet', importMap);
expect(result.rundown.order.length).toBe(8);
expect(result.rundown.entries['event-1']).toMatchObject({
timeStart: 16200000,
timeEnd: 16560000,
});
expect(result.rundown.entries['event-2']).toMatchObject({
timeStart: 35100000,
timeEnd: 39360000,
});
expect(result.rundown.entries['event-3']).toMatchObject({
timeStart: 59400000,
timeEnd: 59760000,
});
expect(result.rundown.entries['event-4']).toMatchObject({
timeStart: 78300000,
timeEnd: 82560000,
});
expect(result.rundown.entries['event-5']).toMatchObject({
timeStart: 16200000,
timeEnd: 16560000,
});
expect(result.rundown.entries['event-6']).toMatchObject({
timeStart: 35100000,
timeEnd: 39360000,
});
expect(result.rundown.entries['event-7']).toMatchObject({
timeStart: 59400000,
timeEnd: 59760000,
});
expect(result.rundown.entries['event-8']).toMatchObject({
timeStart: 78300000,
timeEnd: 82560000,
});
});
it('handle leading and trailing whitespace', () => {
const testData = [
[' ID', ' title ', 'Colour '], // <--- leading and trailing white space
['event-a', 'title', '#F00'],
];
const importMap = {
id: 'id',
title: ' title', // <--- leading white space
colour: 'colour ', // <--- trailing white space
};
const result = parseExcel(testData, {}, 'testSheet', importMap);
expect(result.rundown.order.length).toBe(1);
expect(result.rundown.entries['event-a']).toMatchObject({
colour: '#F00',
id: 'event-a',
title: 'title',
});
});
it('parses link start', () => {
const testData = [
['Time Start', 'Time End', 'ID', 'Link Start', 'Timer type'],
['4:30:00', '9:45:00', 'A', '', 'count-down'],
['9:45:00', '10:56:00', 'B', 'x', 'count-down'],
['10:00:00', '16:36:00', 'C', 'x', 'count-down'],
['21:45:00', '22:56:00', 'D', '', 'count-down'],
['', '', 'BLOCK', 'x', 'block'], // <-- block with link
['00:0:00', '23:56:00', 'E', 'x', 'count-down'], // <-- link past blocks
];
const importMap = {
timeStart: 'time start',
timeEnd: 'time end',
linkStart: 'link start',
id: 'id',
timerType: 'timer type',
};
const result = parseExcel(testData, {}, 'testSheet', importMap);
expect(result.rundown.order.length).toBe(6);
expect(result.rundown.order).toMatchObject(['A', 'B', 'C', 'D', 'BLOCK', 'E']);
expect(result.rundown.entries).toMatchObject({
A: {
linkStart: false,
},
B: {
linkStart: true,
},
C: {
linkStart: true,
},
D: {
linkStart: false,
},
BLOCK: {
type: SupportedEntry.Block,
},
E: {
linkStart: true,
},
});
});
it('#971 BUG: parses time fields and booleans', () => {
const testData = [
[
'ID',
'Time Start',
'Time End',
'Duration',
'Link Start',
'Timer Type',
'End Action',
'Warning time',
'Danger time',
],
[
'SETUP',
'1899-12-30T07:15:00.000Z',
'1899-12-30T08:30:00.000Z',
'',
'false',
'count-down',
'none',
'15',
'00:05:00',
],
[
'MEET1',
'1899-12-30T08:30:00.000Z',
'1899-12-30T10:00:00.000Z',
'',
'false',
'count-down',
'none',
15,
'00:05:00',
],
['MEET2', '1899-12-30T10:00:00.000Z', '', '60', 'false', 'count-down', 'none', '13', '5'],
['lunch', '', '1899-12-30T11:30:00.000Z', '', 'true', 'count-down', 'none', 13, 5],
['MEET3', '1899-12-30T11:30:00.000Z', '', 90, false, 'count-up', 'none', '11', 5],
['MEET4', '', '', 30, true, 'count-up', 'none', 11, '00:05:00'],
];
const parsedData = parseExcel(testData, {}, 'bug-report');
// '15' as a string is parsed by smart time entry as minutes
expect(parsedData.rundown.entries['SETUP']).toMatchObject({
timeWarning: 15 * MILLIS_PER_MINUTE,
});
// elements in bug report
// 15 is a number, in which case we parse it as a minutes value
expect(parsedData.rundown.entries['MEET1']).toMatchObject({
timeWarning: 15 * MILLIS_PER_MINUTE,
});
// in the case where a string is passed, we need to check whether it is an ISO 8601 date
expect(parsedData.rundown.entries['MEET2']).toMatchObject({
duration: 60 * MILLIS_PER_MINUTE,
timeDanger: 5 * MILLIS_PER_MINUTE,
});
expect(parsedData.rundown.entries['lunch']).toMatchObject({
timeWarning: 13 * MILLIS_PER_MINUTE,
timeDanger: 5 * MILLIS_PER_MINUTE,
});
expect(parsedData.rundown.entries['MEET3']).toMatchObject({
duration: 90 * MILLIS_PER_MINUTE,
linkStart: false,
timeWarning: 11 * MILLIS_PER_MINUTE,
timeDanger: 5 * MILLIS_PER_MINUTE,
});
expect(parsedData.rundown.entries['MEET4']).toMatchObject({
duration: 30 * MILLIS_PER_MINUTE,
timeWarning: 11 * MILLIS_PER_MINUTE,
linkStart: true,
});
});
});
@@ -1,223 +0,0 @@
import { CustomFields, Settings, URLPreset } from 'ontime-types';
import {
parseCustomFields,
parseProject,
parseSettings,
parseUrlPresets,
parseViewSettings,
sanitiseCustomFields,
} from '../parserFunctions.js';
describe('parseProject()', () => {
it('returns an a base model if nothing is given', () => {
const errorEmitter = vi.fn();
const result = parseProject({}, errorEmitter);
expect(result).toBeTypeOf('object');
expect(errorEmitter).toHaveBeenCalledOnce();
});
it('test migration with adding the logo field v3.8.0', () => {
const errorEmitter = vi.fn();
const result = parseProject(
{
//@ts-expect-error -- checking migration when the logo field is added
project: {
title: 'title',
description: 'description',
publicUrl: 'publicUrl',
publicInfo: 'publicInfo',
backstageUrl: 'backstageUrl',
backstageInfo: 'backstageInfo',
custom: [],
},
},
errorEmitter,
);
expect(result).toStrictEqual({
title: 'title',
description: 'description',
publicUrl: 'publicUrl',
publicInfo: 'publicInfo',
backstageUrl: 'backstageUrl',
backstageInfo: 'backstageInfo',
projectLogo: null,
custom: [],
});
});
});
describe('parseSettings()', () => {
it('throws if settings object does not exist', () => {
expect(() => parseSettings({})).toThrow();
});
it('returns an a base model as long as we have the app version', () => {
const result = parseSettings({ settings: { version: '1' } as Settings });
expect(result).toBeTypeOf('object');
expect(result).toMatchObject({
version: expect.any(String),
serverPort: 4001,
editorKey: null,
operatorKey: null,
timeFormat: '24',
language: 'en',
});
});
});
describe('parseViewSettings()', () => {
it('returns an a base model if nothing is given', () => {
const errorEmitter = vi.fn();
const result = parseViewSettings({}, errorEmitter);
expect(result).toBeTypeOf('object');
expect(errorEmitter).toHaveBeenCalledOnce();
});
});
describe('parseUrlPresets()', () => {
it('returns an a base model if nothing is given', () => {
const errorEmitter = vi.fn();
const result = parseUrlPresets({}, errorEmitter);
expect(result).toBeTypeOf('object');
expect(errorEmitter).toHaveBeenCalledOnce();
});
it('parses data, skipping invalid results', () => {
const errorEmitter = vi.fn();
const urlPresets = [{ enabled: true, alias: 'alias', pathAndParams: 'ss' }] as URLPreset[];
const result = parseUrlPresets({ urlPresets }, errorEmitter);
expect(result.length).toEqual(1);
expect(result.at(0)).toMatchObject({
enabled: true,
alias: 'alias',
pathAndParams: 'ss',
});
expect(errorEmitter).not.toHaveBeenCalled();
});
});
describe('parseCustomFields()', () => {
it('returns an a base model if nothing is given', () => {
const errorEmitter = vi.fn();
const result = parseCustomFields({}, errorEmitter);
expect(result).toBeTypeOf('object');
expect(errorEmitter).toHaveBeenCalledOnce();
});
it('parses data, skipping invalid results', () => {
const errorEmitter = vi.fn();
// @ts-expect-error -- data is external, we check bad types
const customFields = {
1: { label: 'test', type: 'string', colour: 'red' }, // ok
2: { label: 'test', type: 'string' }, // duplicate label
3: { label: '', type: 'string' }, // missing colour
4: { type: 'string', colour: '' }, // missing label
} as CustomFields;
const result = parseCustomFields({ customFields }, errorEmitter);
expect(result).toMatchObject({
test: {
label: 'test',
type: 'string',
colour: 'red',
},
});
expect(errorEmitter).toHaveBeenCalled();
});
});
describe('sanitiseCustomFields()', () => {
it('returns an empty object the type is incorrect', () => {
expect(sanitiseCustomFields({})).toEqual({});
});
it('returns an object of valid entries', () => {
const customFields: CustomFields = {
test: { label: 'test', type: 'string', colour: 'red' },
test2: { label: 'test2', type: 'string', colour: 'green' },
Test3: { label: 'Test3', type: 'string', colour: '' },
};
const sanitationResult = sanitiseCustomFields(customFields);
expect(sanitationResult).toStrictEqual(customFields);
});
it('type should be one of (image | string)', () => {
const testTypes = sanitiseCustomFields({
test1: { label: 'test1', type: 'another', colour: 'red' },
test2: { label: 'test2', type: 'image', colour: 'red' },
test3: { label: 'test3', type: 'string', colour: 'red' },
});
expect(testTypes).toMatchObject({
test2: { label: 'test2', type: 'image', colour: 'red' },
test3: { label: 'test3', type: 'string', colour: 'red' },
});
});
it('colour must be a string', () => {
const customFields: CustomFields = {
// @ts-expect-error intentional bad data
test: { label: 'test', type: 'string', colour: 5 },
};
const sanitationResult = sanitiseCustomFields(customFields);
expect(sanitationResult).toStrictEqual({});
});
it('label can not be empty', () => {
const customFields: CustomFields = {
'': { label: '', type: 'string', colour: 'red' },
};
const sanitationResult = sanitiseCustomFields(customFields);
expect(sanitationResult).toStrictEqual({});
});
it('remove extra stuff', () => {
const customFields: CustomFields = {
// @ts-expect-error intentional bad data
test: { label: 'test', type: 'string', colour: 'red', extra: 'should be removed' },
};
const expectedCustomFields: CustomFields = {
test: { label: 'test', type: 'string', colour: 'red' },
};
const sanitationResult = sanitiseCustomFields(customFields);
expect(sanitationResult).toStrictEqual(expectedCustomFields);
});
it('enforce name cohesion', () => {
const customFields: CustomFields = {
test: { label: 'NewName', type: 'string', colour: 'red' },
};
const expectedCustomFields: CustomFields = {
NewName: { label: 'NewName', 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' },
};
const expectedCustomFields: CustomFields = {
Test_with_Space: { label: 'Test with Space', type: 'string', colour: 'red' },
};
const sanitationResult = sanitiseCustomFields(customFields);
expect(sanitationResult).toStrictEqual(expectedCustomFields);
});
it('filters invalid entries', () => {
const customFields: CustomFields = {
test: { label: 'test', type: 'string', colour: 'red' },
test2: { label: 'test2', type: 'string', colour: 'green' },
bad: { label: '', type: 'string', colour: '' },
Test3: { label: 'Test3', type: 'string', colour: '' },
};
const expectedCustomFields: CustomFields = {
test: { label: 'test', type: 'string', colour: 'red' },
test2: { label: 'test2', type: 'string', colour: 'green' },
Test3: { label: 'Test3', type: 'string', colour: '' },
};
const sanitationResult = sanitiseCustomFields(customFields);
expect(sanitationResult).toStrictEqual(expectedCustomFields);
});
});
@@ -1,4 +1,4 @@
import { isEmptyObject, removeUndefined } from '../parserUtils.js';
import { isEmptyObject, makeString, removeUndefined } from '../parserUtils.js';
describe('isEmptyObject()', () => {
test('finds an empty object', () => {
@@ -32,3 +32,39 @@ describe('removeUndefined()', () => {
expect(removeUndefined(obj)).toStrictEqual(obj);
});
});
describe('makeString()', () => {
it('converts variables to string', () => {
const cases = [
{
val: 2,
expected: '2',
},
{
val: 2.22222222,
expected: '2.22222222',
},
{
val: ['testing'],
expected: 'testing',
},
{
val: ' testing ',
expected: 'testing',
},
{
val: { doing: 'testing' },
expected: 'fallback',
},
{
val: undefined,
expected: 'fallback',
},
];
cases.forEach(({ val, expected }) => {
const converted = makeString(val, 'fallback');
expect(converted).toBe(expected);
});
});
});
@@ -1,11 +0,0 @@
import { isObject } from '../varUtils.js';
describe('isObject', () => {
const testCases = [1, 0, false, undefined, 'test', null, () => undefined, []];
testCases.forEach((test) => {
it(`recognises normal primitives ${test}`, () => {
const result = isObject(test);
expect(result).toBe(false);
});
});
});
+10 -1
View File
@@ -114,7 +114,7 @@ export async function dockerSafeRename(oldPath: PathLike, newPath: PathLike) {
* finds potential file index number in our (*) format and increments
* the number section (*) must be separated from the name by a space
* @example incrementProjectNumber('test(1).json') -> 'test(1).json'
* @example incrementProjectNumber('test (1).json') -> 'test(2).json'
* @example incrementProjectNumber('test (1).json') -> 'test(2).json'
*/
export function incrementProjectNumber(path: string): string {
const { dir, name, ext } = parse(path);
@@ -129,3 +129,12 @@ export function incrementProjectNumber(path: string): string {
return join(dir, `${name.slice(0, openingParenIndex)} (${maybeNumber + 1})${ext}`);
}
/**
* @description Delete file from system
*/
export const deleteFile = async (filePath: string) => {
return await unlink(filePath).catch((error) => {
console.error('Could not delete file:', error);
});
};
+3 -3
View File
@@ -2,11 +2,11 @@ import { writeFileSync } from 'fs';
import { join } from 'path';
import { ONTIME_VERSION } from '../ONTIME_VERSION.js';
import { get } from '../services/rundown-service/rundownCache.js';
import { getState } from '../stores/runtimeState.js';
import { publicDir } from '../setup/index.js';
import { ensureDirectory } from './fileManagement.js';
import { getCurrentRundown } from '../api-data/rundown/rundown.dao.js';
/**
* Writes a file to the crash report location
* @param fileName
@@ -31,7 +31,7 @@ function writeToFile(fileName: string, content: object) {
export function generateCrashReport(maybeError: unknown) {
const timeNow = new Date().toISOString();
const runtimeState = getState();
const rundownState = get();
const currentRundown = getCurrentRundown();
const error =
maybeError instanceof Error
? {
@@ -45,7 +45,7 @@ export function generateCrashReport(maybeError: unknown) {
version: ONTIME_VERSION,
error,
runtimeState,
rundownState,
currentRundown,
};
writeToFile(`crash-log-${timeNow}.log`, crashReport);
-374
View File
@@ -1,374 +0,0 @@
import {
customFieldLabelToKey,
customKeyFromLabel,
defaultImportMap,
generateId,
type ImportMap,
isKnownTimerType,
validateEndAction,
validateTimerType,
} from 'ontime-utils';
import {
CustomFields,
DatabaseModel,
EntryCustomFields,
isOntimeBlock,
LogOrigin,
OntimeBlock,
OntimeEvent,
Rundown,
SupportedEntry,
TimerType,
} 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 { makeString } from './parserUtils.js';
import { parseProject, parseSettings, parseUrlPresets, parseViewSettings } from './parserFunctions.js';
import { parseExcelDate } from './time.js';
import { is } from './is.js';
export type ErrorEmitter = (message: string) => void;
export const EXCEL_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
export const JSON_MIME = 'application/json';
function parseBooleanString(value: unknown): boolean {
if (typeof value === 'boolean') {
return value;
}
// falsy values would be nullish or empty string
if (!value || typeof value !== 'string') {
return false;
}
return value.toLowerCase() !== 'false';
}
export function getCustomFieldData(
importMap: ImportMap,
existingCustomFields: CustomFields,
): {
customFields: CustomFields;
customFieldImportKeys: Record<keyof CustomFields, string>;
} {
const customFields = {};
const customFieldImportKeys: Record<string, string> = {};
for (const ontimeLabel in importMap.custom) {
const ontimeKey = customKeyFromLabel(ontimeLabel, existingCustomFields) ?? customFieldLabelToKey(ontimeLabel);
if (!ontimeKey) {
continue;
}
const importLabel = importMap.custom[ontimeLabel].toLowerCase();
// @ts-expect-error -- we are sure that the key exists
customFields[ontimeKey] = {
type: 'string',
colour: ontimeKey in existingCustomFields ? existingCustomFields[ontimeKey].colour : '',
label: ontimeLabel,
};
customFieldImportKeys[importLabel] = ontimeKey;
}
return { customFields, customFieldImportKeys };
}
/**
* @description Excel array parser
* @param {array} excelData - array with excel sheet
* @param {ImportOptions} options - an object that contains the import map
* @returns {object} - parsed object
*/
export const parseExcel = (
excelData: unknown[][],
existingCustomFields: CustomFields,
sheetName: string = 'Rundown from excel',
options?: Partial<ImportMap>,
): {
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)) {
if (is.string(value)) {
// @ts-expect-error -- we are sure that the key exists
importMap[key] = value.toLowerCase().trim();
}
}
const { customFields, customFieldImportKeys } = getCustomFieldData(importMap, existingCustomFields);
const rundown: Rundown = {
id: generateId(),
title: sheetName,
order: [],
flatOrder: [],
entries: {},
revision: 0,
};
// title stuff: strings
let titleIndex: number | null = null;
let cueIndex: number | null = null;
let notesIndex: number | null = null;
let colourIndex: number | null = null;
// options: booleans
let isPublicIndex: number | null = null;
let skipIndex: number | null = null;
let countToEndIndex: number | null = null;
let linkStartIndex: number | null = null;
// times: numbers
let timeStartIndex: number | null = null;
let timeEndIndex: number | null = null;
let durationIndex: number | null = null;
let timeWarningIndex: number | null = null;
let timeDangerIndex: number | null = null;
// options: enum properties
let endActionIndex: number | null = null;
let timerTypeIndex: number | null = null;
//ID
let entryIdIndex: number | null = null;
// record of column index and the name of the field
const customFieldIndexes: Record<number, string> = {};
excelData.forEach((row, rowIndex) => {
if (row.length === 0) {
return;
}
// TODO: extract generating handlers from importMap
const handlers = {
[importMap.timeStart]: (row: number, col: number) => {
timeStartIndex = col;
rundownMetadata['timeStart'] = { row, col };
},
[importMap.linkStart]: (row: number, col: number) => {
linkStartIndex = col;
rundownMetadata['linkStart'] = { row, col };
},
[importMap.timeEnd]: (row: number, col: number) => {
timeEndIndex = col;
rundownMetadata['timeEnd'] = { row, col };
},
[importMap.duration]: (row: number, col: number) => {
durationIndex = col;
rundownMetadata['duration'] = { row, col };
},
[importMap.cue]: (row: number, col: number) => {
cueIndex = col;
rundownMetadata['cue'] = { row, col };
},
[importMap.title]: (row: number, col: number) => {
titleIndex = col;
rundownMetadata['title'] = { row, col };
},
[importMap.countToEnd]: (row: number, col: number) => {
countToEndIndex = col;
rundownMetadata['countToEnd'] = { row, col };
},
[importMap.isPublic]: (row: number, col: number) => {
isPublicIndex = col;
rundownMetadata['isPublic'] = { row, col };
},
[importMap.skip]: (row: number, col: number) => {
skipIndex = col;
rundownMetadata['skip'] = { row, col };
},
[importMap.note]: (row: number, col: number) => {
notesIndex = col;
rundownMetadata['note'] = { row, col };
},
[importMap.colour]: (row: number, col: number) => {
colourIndex = col;
rundownMetadata['colour'] = { row, col };
},
[importMap.endAction]: (row: number, col: number) => {
endActionIndex = col;
rundownMetadata['endAction'] = { row, col };
},
[importMap.timerType]: (row: number, col: number) => {
timerTypeIndex = col;
rundownMetadata['timerType'] = { row, col };
},
[importMap.timeWarning]: (row: number, col: number) => {
timeWarningIndex = col;
rundownMetadata['timeWarning'] = { row, col };
},
[importMap.timeDanger]: (row: number, col: number) => {
timeDangerIndex = col;
rundownMetadata['timeDanger'] = { row, col };
},
[importMap.entryId]: (row: number, col: number) => {
entryIdIndex = col;
rundownMetadata['id'] = { row, col };
},
custom: (row: number, col: number, columnText: string, ontimeKey: string) => {
customFieldIndexes[col] = columnText;
rundownMetadata[`custom:${ontimeKey}`] = { row, col };
},
} as const;
const entry: Partial<Merge<OntimeEvent, OntimeBlock>> = {};
const entryCustomFields: EntryCustomFields = {};
for (let j = 0; j < row.length; j++) {
const column = row[j];
// 1. we check if we have set a flag for a known field
if (j === timerTypeIndex) {
const maybeTimeType = makeString(column, '');
if (maybeTimeType === 'block') {
// we leave this as a clue for the object filtering later on
entry.type = SupportedEntry.Block;
} else if (maybeTimeType === '' || maybeTimeType === 'event' || isKnownTimerType(maybeTimeType)) {
// @ts-expect-error -- we leave this as a clue for the object filtering later on
entry.type = SupportedEntry.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) {
entry.title = makeString(column, '');
} else if (j === timeStartIndex) {
entry.timeStart = parseExcelDate(column);
} else if (j === linkStartIndex) {
entry.linkStart = parseBooleanString(column);
} else if (j === timeEndIndex) {
entry.timeEnd = parseExcelDate(column);
} else if (j === durationIndex) {
entry.duration = parseExcelDate(column);
} else if (j === cueIndex) {
entry.cue = makeString(column, '');
} else if (j === countToEndIndex) {
entry.countToEnd = parseBooleanString(column);
} else if (j === isPublicIndex) {
entry.isPublic = parseBooleanString(column);
} else if (j === skipIndex) {
entry.skip = parseBooleanString(column);
} else if (j === notesIndex) {
entry.note = makeString(column, '');
} else if (j === endActionIndex) {
entry.endAction = validateEndAction(column);
} else if (j === timeWarningIndex) {
entry.timeWarning = parseExcelDate(column);
} else if (j === timeDangerIndex) {
entry.timeDanger = parseExcelDate(column);
} else if (j === colourIndex) {
entry.colour = makeString(column, '');
} else if (j === entryIdIndex) {
entry.id = encodeURIComponent(makeString(column, undefined));
} else if (j in customFieldIndexes) {
const importKey = customFieldIndexes[j];
const ontimeKey = customFieldImportKeys[importKey];
entryCustomFields[ontimeKey] = makeString(column, '');
} else {
// 2. if there is no flag, lets see if we know the field type
if (typeof column === 'string') {
// we cant deal with empty content
if (column.length === 0) {
continue;
}
const columnText = column.toLowerCase().trim();
// check if it is an ontime column
if (handlers[columnText]) {
// @ts-expect-error -- its ok
handlers[columnText](rowIndex, j, undefined, undefined);
}
// check if it is a custom field
if (columnText in customFieldImportKeys) {
const ontimeKey = customFieldImportKeys[columnText];
handlers.custom(rowIndex, j, columnText, ontimeKey);
}
// else. we don't know how to handle this column
// just ignore it
}
}
}
// 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: SupportedEntry.Event,
} as OntimeEvent;
if (timerTypeIndex === null) {
event.timerType = TimerType.CountDown;
}
rundown.order.push(id);
rundown.flatOrder.push(id);
rundown.entries[id] = event;
});
return {
rundown,
customFields,
rundownMetadata,
};
};
type ParsingError = {
context: string;
message: string;
};
/**
* @description handles parsing of ontime project file
* @param {object} jsonData - project file to be parsed
* @returns {object} - parsed object
*/
export function parseDatabaseModel(jsonData: Partial<DatabaseModel>): { data: DatabaseModel; errors: ParsingError[] } {
// we need to parse settings first to make sure the data is ours
// this may throw
const settings = parseSettings(jsonData);
const errors: ParsingError[] = [];
const makeEmitError = (context: string) => (message: string) => {
logger.error(LogOrigin.Server, `Error parsing ${context}: ${message}`);
errors.push({ context, message });
};
// we need to parse the custom fields first so they can be used in validating events
const { rundowns, customFields } = parseRundowns(jsonData, makeEmitError('Rundown'));
const data: DatabaseModel = {
rundowns,
project: parseProject(jsonData, makeEmitError('Project')),
settings,
viewSettings: parseViewSettings(jsonData, makeEmitError('View Settings')),
urlPresets: parseUrlPresets(jsonData, makeEmitError('URL Presets')),
customFields,
automation: parseAutomationSettings(jsonData),
};
return { data, errors };
}
-164
View File
@@ -1,164 +0,0 @@
import { CustomField, CustomFields, DatabaseModel, ProjectData, Settings, URLPreset, ViewSettings } from 'ontime-types';
import { customFieldLabelToKey, isAlphanumericWithSpace } from 'ontime-utils';
import { dbModel } from '../models/dataModel.js';
import { type ErrorEmitter } from './parser.js';
/**
* Parse event portion of an entry
*/
export function parseProject(data: Partial<DatabaseModel>, emitError?: ErrorEmitter): ProjectData {
if (!data.project) {
emitError?.('No data found to import');
return { ...dbModel.project };
}
console.log('Found project data, importing...');
return {
title: data.project.title ?? dbModel.project.title,
description: data.project.description ?? dbModel.project.description,
publicUrl: data.project.publicUrl ?? dbModel.project.publicUrl,
publicInfo: data.project.publicInfo ?? dbModel.project.publicInfo,
backstageUrl: data.project.backstageUrl ?? dbModel.project.backstageUrl,
backstageInfo: data.project.backstageInfo ?? dbModel.project.backstageInfo,
projectLogo: data.project.projectLogo ?? dbModel.project.projectLogo,
custom: data.project.custom ?? dbModel.project.custom,
};
}
/**
* Parse settings portion of an entry
*/
export function parseSettings(data: Partial<DatabaseModel>): Settings {
// skip if file definition is missing
// TODO: skip parsing if the version is not correct
if (!data.settings || data.settings?.version == null) {
throw new Error('ERROR: unable to parse settings, missing or incorrect version');
}
console.log('Found settings, importing...');
return {
version: dbModel.settings.version,
serverPort: data.settings.serverPort ?? dbModel.settings.serverPort,
editorKey: data.settings.editorKey ?? null,
operatorKey: data.settings.operatorKey ?? null,
timeFormat: data.settings.timeFormat ?? '24',
language: data.settings.language ?? 'en',
};
}
/**
* Parse view settings portion of an entry
*/
export function parseViewSettings(data: Partial<DatabaseModel>, emitError?: ErrorEmitter): ViewSettings {
if (!data.viewSettings) {
emitError?.('No data found to import');
return { ...dbModel.viewSettings };
}
console.log('Found view settings, importing...');
return {
dangerColor: data.viewSettings.dangerColor ?? dbModel.viewSettings.dangerColor,
endMessage: data.viewSettings.endMessage ?? dbModel.viewSettings.endMessage,
freezeEnd: data.viewSettings.freezeEnd ?? dbModel.viewSettings.freezeEnd,
normalColor: data.viewSettings.normalColor ?? dbModel.viewSettings.normalColor,
overrideStyles: data.viewSettings.overrideStyles ?? dbModel.viewSettings.overrideStyles,
warningColor: data.viewSettings.warningColor ?? dbModel.viewSettings.warningColor,
};
}
/**
* Parse URL preset portion of an entry
*/
export function parseUrlPresets(data: Partial<DatabaseModel>, emitError?: ErrorEmitter): URLPreset[] {
if (!data.urlPresets) {
emitError?.('No data found to import');
return [];
}
console.log('Found URL presets, importing...');
const newPresets: URLPreset[] = [];
for (const preset of data.urlPresets) {
const newPreset = {
enabled: preset.enabled ?? false,
alias: preset.alias ?? '',
pathAndParams: preset.pathAndParams ?? '',
};
newPresets.push(newPreset);
}
console.log(`Uploaded ${newPresets.length} preset(s)`);
return newPresets;
}
/**
* Parse customFields entry
*/
export function parseCustomFields(data: Partial<DatabaseModel>, emitError?: ErrorEmitter): CustomFields {
if (typeof data.customFields !== 'object') {
emitError?.('No data found to import');
return {};
}
console.log('Found Custom Fields, importing...');
const customFields = sanitiseCustomFields(data.customFields);
if (Object.keys(customFields).length !== Object.keys(data.customFields).length) {
emitError?.('Skipped invalid custom fields');
}
return customFields;
}
export function sanitiseCustomFields(data: object): CustomFields {
const newCustomFields: CustomFields = {};
for (const [originalKey, field] of Object.entries(data)) {
if (!isValidField(field)) {
continue;
}
if (!isAlphanumericWithSpace(field.label)) {
continue;
}
// 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;
}
newCustomFields[key] = {
type: field.type,
colour: field.colour,
label: field.label,
};
}
function isValidField(data: unknown): data is CustomField {
return (
typeof data === 'object' &&
data !== null &&
'label' in data &&
data.label !== '' &&
'colour' in data &&
typeof data.colour === 'string' &&
'type' in data &&
(data.type === 'string' || data.type === 'image')
);
}
return newCustomFields;
}
+1 -12
View File
@@ -1,4 +1,4 @@
import { unlink } from 'fs';
export type ErrorEmitter = (message: string) => void;
/**
* @description Ensures variable is string, it skips object types
@@ -12,17 +12,6 @@ export const makeString = (val: unknown, fallback = ''): string => {
return val.toString().trim();
};
/**
* @description Delete file from system
*/
export const deleteFile = async (filePath: string) => {
unlink(filePath, (error) => {
if (error) {
console.error('Could not delete file:', error);
}
});
};
/**
* @description Verifies if object is empty
* @param {object} obj
-3
View File
@@ -1,3 +0,0 @@
export function isObject(variable: unknown): boolean {
return typeof variable === 'object' && variable !== null && !Array.isArray(variable);
}