mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-10 18:03:47 +00:00
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:
committed by
Carlos Valente
parent
b1d23467a2
commit
62c8319d70
@@ -1,4 +1,6 @@
|
||||
import { TriggerDTO, TimerLifeCycle, AutomationDTO, Automation } from 'ontime-types';
|
||||
import { TriggerDTO, TimerLifeCycle, AutomationDTO, Automation, EntryId } from 'ontime-types';
|
||||
|
||||
import { makeRundown } from '../../rundown/__mocks__/rundown.mocks.js';
|
||||
|
||||
import {
|
||||
addTrigger,
|
||||
@@ -12,6 +14,7 @@ import {
|
||||
getAutomationTriggers,
|
||||
getAutomations,
|
||||
} from '../automation.dao.js';
|
||||
|
||||
import { makeOSCAction, makeHTTPAction } from './testUtils.js';
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -186,11 +189,9 @@ describe('editAutomation()', async () => {
|
||||
});
|
||||
|
||||
describe('deleteAutomation()', () => {
|
||||
// saving the ID of the added automation
|
||||
let firstAutomation: Automation;
|
||||
beforeEach(async () => {
|
||||
await deleteAll();
|
||||
firstAutomation = await addAutomation({
|
||||
await addAutomation({
|
||||
title: 'test-osc',
|
||||
filterRule: 'all',
|
||||
filters: [],
|
||||
@@ -198,35 +199,15 @@ describe('deleteAutomation()', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should remove m automation from the list', async () => {
|
||||
it('should remove an automation from the list', async () => {
|
||||
const automations = getAutomations();
|
||||
expect(Object.keys(automations).length).toEqual(1);
|
||||
|
||||
await deleteAutomation(Object.keys(automations)[0]);
|
||||
const rundown = makeRundown({});
|
||||
const timedEventOrder: EntryId[] = [];
|
||||
|
||||
await deleteAutomation(rundown, timedEventOrder, Object.keys(automations)[0]);
|
||||
const removed = getAutomations();
|
||||
expect(Object.keys(removed).length).toEqual(0);
|
||||
});
|
||||
|
||||
it('should not remove an automation which is in use', async () => {
|
||||
const automations = getAutomations();
|
||||
await addTrigger({
|
||||
title: 'test-automation',
|
||||
trigger: TimerLifeCycle.onLoad,
|
||||
automationId: firstAutomation.id,
|
||||
});
|
||||
|
||||
const automationKeys = Object.keys(automations);
|
||||
const automationId = automationKeys[0];
|
||||
expect(automationId).toEqual(firstAutomation.id);
|
||||
expect(automationKeys.length).toEqual(1);
|
||||
expect(automations[automationId]).toMatchObject({
|
||||
id: automationId,
|
||||
title: 'test-osc',
|
||||
filterRule: 'all',
|
||||
filters: expect.any(Array),
|
||||
outputs: expect.any(Array),
|
||||
});
|
||||
|
||||
await expect(deleteAutomation(automationId)).rejects.toThrowError();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { parseTemplateNested, stringToOSCArgs } from '../automation.utils.js';
|
||||
import { TimerLifeCycle } from 'ontime-types';
|
||||
import { makeOntimeEvent, makeRundown } from '../../rundown/__mocks__/rundown.mocks.js';
|
||||
import { isAutomationUsed, parseTemplateNested, stringToOSCArgs } from '../automation.utils.js';
|
||||
|
||||
describe('parseTemplateNested()', () => {
|
||||
it('parses string with a single-level variable name', () => {
|
||||
@@ -245,3 +247,53 @@ describe('test stringToOSCArgs()', () => {
|
||||
expect(stringToOSCArgs(test)).toStrictEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAutomationUsed()', () => {
|
||||
it('returns the first event which uses an automation', () => {
|
||||
const rundown = makeRundown({
|
||||
entries: {
|
||||
'1': makeOntimeEvent({
|
||||
id: '1',
|
||||
triggers: [
|
||||
{
|
||||
id: 'trigger-1',
|
||||
title: 'Trigger 1',
|
||||
trigger: TimerLifeCycle.onClock,
|
||||
automationId: 'test-automation',
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const timedEventOrder = ['1'];
|
||||
const automationId = 'test-automation';
|
||||
|
||||
const result = isAutomationUsed(rundown, timedEventOrder, automationId);
|
||||
expect(result).toBe('1');
|
||||
});
|
||||
|
||||
it('returns returns undefined if there are no matches', () => {
|
||||
const rundown = makeRundown({
|
||||
entries: {
|
||||
'1': makeOntimeEvent({
|
||||
id: '1',
|
||||
triggers: [
|
||||
{
|
||||
id: 'trigger-1',
|
||||
title: 'Trigger 1',
|
||||
trigger: TimerLifeCycle.onClock,
|
||||
automationId: 'test-automation',
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const timedEventOrder = ['1'];
|
||||
const automationId = 'does-not-exist';
|
||||
|
||||
const result = isAutomationUsed(rundown, timedEventOrder, automationId);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,8 @@ import type { Request, Response } from 'express';
|
||||
|
||||
import { oscServer } from '../../adapters/OscAdapter.js';
|
||||
|
||||
import { getCurrentRundown, getRundownMetadata } from '../rundown/rundown.dao.js';
|
||||
|
||||
import * as automationDao from './automation.dao.js';
|
||||
import * as automationService from './automation.service.js';
|
||||
import { parseOutput } from './automation.validation.js';
|
||||
@@ -106,7 +108,10 @@ export async function editAutomation(req: Request, res: Response<Automation | Er
|
||||
|
||||
export async function deleteAutomation(req: Request, res: Response<void | ErrorResponse>) {
|
||||
try {
|
||||
await automationDao.deleteAutomation(req.params.id);
|
||||
const rundown = getCurrentRundown();
|
||||
const { timedEventOrder } = getRundownMetadata();
|
||||
|
||||
await automationDao.deleteAutomation(rundown, timedEventOrder, req.params.id);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
|
||||
@@ -2,14 +2,17 @@ import type {
|
||||
Automation,
|
||||
AutomationDTO,
|
||||
AutomationSettings,
|
||||
EntryId,
|
||||
NormalisedAutomation,
|
||||
Rundown,
|
||||
Trigger,
|
||||
TriggerDTO,
|
||||
} from 'ontime-types';
|
||||
import { deleteAtIndex, generateId } from 'ontime-utils';
|
||||
|
||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
import { getTimedEvents } from '../../services/rundown-service/rundownUtils.js';
|
||||
|
||||
import { isAutomationUsed } from './automation.utils.js';
|
||||
|
||||
/**
|
||||
* Gets a copy of the stored automation settings
|
||||
@@ -133,7 +136,7 @@ export async function editAutomation(id: string, newAutomation: AutomationDTO):
|
||||
/**
|
||||
* Deletes a automation given its ID
|
||||
*/
|
||||
export async function deleteAutomation(id: string): Promise<void> {
|
||||
export async function deleteAutomation(rundown: Rundown, timedEventOrder: EntryId[], id: string): Promise<void> {
|
||||
const automations = getAutomations();
|
||||
// ignore request if automation does not exist
|
||||
if (!Object.hasOwn(automations, id)) {
|
||||
@@ -149,13 +152,9 @@ export async function deleteAutomation(id: string): Promise<void> {
|
||||
}
|
||||
|
||||
// prevent deleting a automation that is in use in events
|
||||
const events = getTimedEvents().filter(
|
||||
(event) => event.triggers && event.triggers.some((trigger) => trigger.automationId === id),
|
||||
);
|
||||
if (events.length) {
|
||||
throw new Error(
|
||||
`Unable to delete automation used in event: ${events[0].id}${events.length > 1 ? ` and ${events.length - 1} more` : ''}`,
|
||||
);
|
||||
const isInUse = isAutomationUsed(rundown, timedEventOrder, id);
|
||||
if (isInUse) {
|
||||
throw new Error(`Unable to delete automation used in event with ID ${isInUse}`);
|
||||
}
|
||||
|
||||
delete automations[id];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { DatabaseModel, AutomationSettings, NormalisedAutomation, Trigger } from 'ontime-types';
|
||||
|
||||
import { dbModel } from '../../models/dataModel.js';
|
||||
import type { ErrorEmitter } from '../../utils/parser.js';
|
||||
import type { ErrorEmitter } from '../../utils/parserUtils.js';
|
||||
|
||||
interface LegacyData extends Partial<DatabaseModel> {
|
||||
http?: unknown;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FilterRule, MaybeNumber, OntimeAction } from 'ontime-types';
|
||||
import { EntryId, FilterRule, isOntimeEvent, MaybeNumber, OntimeAction, Rundown } from 'ontime-types';
|
||||
import { millisToString, removeLeadingZero, splitWhitespace, getPropertyFromPath } from 'ontime-utils';
|
||||
import type { OscArgOrArrayInput, OscArgInput } from 'osc-min';
|
||||
|
||||
@@ -195,3 +195,25 @@ export function isBooleanEquals(a: boolean, b: string): boolean {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks is an automation is used in a rundown
|
||||
* TODO(v4): this currently only checks the current rundown, we will need to check all rundowns in the future
|
||||
*/
|
||||
export function isAutomationUsed(
|
||||
rundown: Rundown,
|
||||
timedEventOrder: EntryId[],
|
||||
automationId: string,
|
||||
): EntryId | undefined {
|
||||
for (let i = 0; i < timedEventOrder.length; i++) {
|
||||
const eventId = timedEventOrder[i];
|
||||
const event = rundown.entries[eventId];
|
||||
if (isOntimeEvent(event) && event.triggers) {
|
||||
for (const trigger of event.triggers) {
|
||||
if (trigger.automationId === automationId) {
|
||||
return eventId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { CustomFields } from 'ontime-types';
|
||||
|
||||
import { parseCustomFields, sanitiseCustomFields } from '../customFields.parser.js';
|
||||
|
||||
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,49 +0,0 @@
|
||||
import { CustomField, CustomFields, ErrorResponse } from 'ontime-types';
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import { getErrorMessage } from 'ontime-utils';
|
||||
|
||||
import { createCustomField, editCustomField, removeCustomField } from '../../services/rundown-service/rundownCache.js';
|
||||
|
||||
import { getProjectCustomFields } from '../rundown/rundown.dao.js';
|
||||
|
||||
export async function getCustomFields(_req: Request, res: Response<CustomFields>) {
|
||||
const customFields = getProjectCustomFields();
|
||||
res.json(customFields);
|
||||
}
|
||||
|
||||
export async function postCustomField(req: Request, res: Response<CustomFields | ErrorResponse>) {
|
||||
try {
|
||||
const newField = req.body as CustomField;
|
||||
const allFields = await createCustomField(newField);
|
||||
res.status(201).send(allFields);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function putCustomField(req: Request, res: Response<CustomFields | ErrorResponse>) {
|
||||
try {
|
||||
const oldLabel = req.params.label;
|
||||
const { colour, type, label } = req.body;
|
||||
const newFields = await editCustomField(oldLabel, { label, colour, type });
|
||||
res.status(200).send(newFields);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
}
|
||||
|
||||
// Expects { label: <label> }
|
||||
export async function deleteCustomField(req: Request, res: Response<CustomFields | ErrorResponse>) {
|
||||
try {
|
||||
const fieldToDelete = req.params.label;
|
||||
await removeCustomField(fieldToDelete);
|
||||
res.sendStatus(204);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { DatabaseModel, CustomFields, CustomField } from 'ontime-types';
|
||||
import { isAlphanumericWithSpace, customFieldLabelToKey } from 'ontime-utils';
|
||||
|
||||
import type { ErrorEmitter } from '../../utils/parserUtils.js';
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
// the key is always made from the label
|
||||
const key = customFieldLabelToKey(field.label);
|
||||
|
||||
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,14 +1,49 @@
|
||||
import express from 'express';
|
||||
import { CustomField, CustomFields, ErrorResponse } from 'ontime-types';
|
||||
import { getErrorMessage } from 'ontime-utils';
|
||||
|
||||
import express from 'express';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import { getProjectCustomFields } from '../rundown/rundown.dao.js';
|
||||
import { createCustomField, editCustomField, deleteCustomField } from '../rundown/rundown.service.js';
|
||||
|
||||
import { deleteCustomField, getCustomFields, postCustomField, putCustomField } from './customFields.controller.js';
|
||||
import { validateCustomField, validateDeleteCustomField, validateEditCustomField } from './customFields.validation.js';
|
||||
|
||||
export const router = express.Router();
|
||||
|
||||
router.get('/', getCustomFields);
|
||||
router.get('/', async (_req: Request, res: Response<CustomFields>) => {
|
||||
const customFields = getProjectCustomFields();
|
||||
res.json(customFields);
|
||||
});
|
||||
|
||||
router.post('/', validateCustomField, postCustomField);
|
||||
router.post('/', validateCustomField, async (req: Request, res: Response<CustomFields | ErrorResponse>) => {
|
||||
try {
|
||||
const newFields = await createCustomField(req.body as CustomField);
|
||||
res.status(201).send(newFields);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:label', validateEditCustomField, putCustomField);
|
||||
router.put('/:key', validateEditCustomField, async (req: Request, res: Response<CustomFields | ErrorResponse>) => {
|
||||
try {
|
||||
const currentKey = req.params.key;
|
||||
const { colour, type, label } = req.body;
|
||||
const newFields = await editCustomField(currentKey, { label, colour, type });
|
||||
res.status(200).send(newFields);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:label', validateDeleteCustomField, deleteCustomField);
|
||||
router.delete('/:key', validateDeleteCustomField, async (req: Request, res: Response<CustomFields | ErrorResponse>) => {
|
||||
try {
|
||||
const customFields = await deleteCustomField(req.params.key);
|
||||
res.status(200).send(customFields);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5,14 +5,14 @@ import { body, param, validationResult } from 'express-validator';
|
||||
|
||||
export const validateCustomField = [
|
||||
body('label')
|
||||
.exists()
|
||||
.isString()
|
||||
.trim()
|
||||
.notEmpty()
|
||||
.custom((value) => {
|
||||
return isAlphanumericWithSpace(value);
|
||||
}),
|
||||
body('type').exists().isIn(['string', 'image']),
|
||||
body('colour').exists().isString().trim(),
|
||||
body('type').isIn(['string', 'image']),
|
||||
body('colour').isString().trim(),
|
||||
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
const errors = validationResult(req);
|
||||
@@ -22,16 +22,16 @@ export const validateCustomField = [
|
||||
];
|
||||
|
||||
export const validateEditCustomField = [
|
||||
param('label').exists().isString().trim(),
|
||||
param('key').isString().trim().notEmpty(),
|
||||
body('label')
|
||||
.exists()
|
||||
.isString()
|
||||
.trim()
|
||||
.notEmpty()
|
||||
.custom((value) => {
|
||||
return isAlphanumericWithSpace(value);
|
||||
}),
|
||||
body('type').exists().isIn(['string', 'image']),
|
||||
body('colour').exists().isString().trim(),
|
||||
body('type').isIn(['string', 'image']),
|
||||
body('colour').isString().trim(),
|
||||
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
const errors = validationResult(req);
|
||||
@@ -41,7 +41,7 @@ export const validateEditCustomField = [
|
||||
];
|
||||
|
||||
export const validateDeleteCustomField = [
|
||||
param('label').exists().isString(),
|
||||
param('key').isString().notEmpty(),
|
||||
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
const errors = validationResult(req);
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/* eslint-disable no-console -- we are mocking the console */
|
||||
|
||||
import { demoDb } from '../../../models/demoProject.js';
|
||||
|
||||
import { parseDatabaseModel } from '../db.parser.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();
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { Request } from 'express';
|
||||
import multer, { type FileFilterCallback } from 'multer';
|
||||
|
||||
import { JSON_MIME } from '../../utils/parser.js';
|
||||
import { storage } from '../../utils/upload.js';
|
||||
|
||||
const filterProjectFile = (_req: Request, file: Express.Multer.File, cb: FileFilterCallback) => {
|
||||
if (file.mimetype.includes(JSON_MIME)) {
|
||||
if (file.mimetype.includes('application/json')) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(null, false);
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { DatabaseModel, LogOrigin } from 'ontime-types';
|
||||
|
||||
import { logger } from '../../classes/Logger.js';
|
||||
|
||||
import { parseAutomationSettings } from '../automation/automation.parser.js';
|
||||
import { parseProjectData } from '../project-data/projectData.parser.js';
|
||||
import { parseRundowns } from '../rundown/rundown.parser.js';
|
||||
import { parseSettings } from '../settings/settings.parser.js';
|
||||
import { parseUrlPresets } from '../url-presets/urlPresets.parser.js';
|
||||
import { parseViewSettings } from '../view-settings/viewSettings.parser.js';
|
||||
import { parseCustomFields } from '../custom-fields/customFields.parser.js';
|
||||
|
||||
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 customFields = parseCustomFields(jsonData, makeEmitError('Custom Fields'));
|
||||
const rundowns = parseRundowns(jsonData, customFields, makeEmitError('Rundowns'));
|
||||
|
||||
const data: DatabaseModel = {
|
||||
rundowns,
|
||||
project: parseProjectData(jsonData, makeEmitError('Project')),
|
||||
settings,
|
||||
viewSettings: parseViewSettings(jsonData, makeEmitError('View Settings')),
|
||||
urlPresets: parseUrlPresets(jsonData, makeEmitError('URL Presets')),
|
||||
customFields,
|
||||
automation: parseAutomationSettings(jsonData),
|
||||
};
|
||||
|
||||
return { data, errors };
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
import { CustomFields, OntimeEvent, SupportedEntry, TimerType } from 'ontime-types';
|
||||
import { defaultImportMap, ImportMap, MILLIS_PER_MINUTE } from 'ontime-utils';
|
||||
|
||||
import { getCustomFieldData, parseExcel } from '../excel.parser.js';
|
||||
|
||||
import { dataFromExcelTemplate } from './mockData.js';
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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.mergedCustomFields).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 key': 'excel label',
|
||||
},
|
||||
entryId: 'id',
|
||||
} as ImportMap;
|
||||
|
||||
const existingCustomFields: CustomFields = {
|
||||
lighting: { label: 'lighting', type: 'string', colour: 'red' },
|
||||
sound: { label: 'sound', type: 'string', colour: 'green' },
|
||||
ontime_key: { label: 'ontime key', type: 'string', colour: 'blue' },
|
||||
};
|
||||
|
||||
const result = getCustomFieldData(importMap, existingCustomFields);
|
||||
expect(result.mergedCustomFields).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 key',
|
||||
},
|
||||
});
|
||||
|
||||
// it is an inverted record of <importKey, ontimeKey>
|
||||
expect(result.customFieldImportKeys).toStrictEqual({
|
||||
lx: 'lighting',
|
||||
sound: 'sound',
|
||||
av: 'video',
|
||||
'excel label': 'ontime_key',
|
||||
});
|
||||
});
|
||||
|
||||
it('lowercases the keys in the import map', () => {
|
||||
const importMap: ImportMap = {
|
||||
...defaultImportMap,
|
||||
custom: {
|
||||
Lighting: 'Lx',
|
||||
Sound: 'sound',
|
||||
video: 'av',
|
||||
},
|
||||
};
|
||||
|
||||
const result = getCustomFieldData(importMap, {});
|
||||
expect(result.mergedCustomFields).toStrictEqual({
|
||||
Lighting: {
|
||||
type: 'string',
|
||||
colour: '',
|
||||
label: 'Lighting',
|
||||
},
|
||||
Sound: {
|
||||
type: 'string',
|
||||
colour: '',
|
||||
label: 'Sound',
|
||||
},
|
||||
video: {
|
||||
type: 'string',
|
||||
colour: '',
|
||||
label: 'video',
|
||||
},
|
||||
});
|
||||
|
||||
// notice that the keys excel keys are lowercased
|
||||
expect(result.customFieldImportKeys).toStrictEqual({
|
||||
lx: 'Lighting',
|
||||
sound: 'Sound',
|
||||
av: 'video',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
],
|
||||
[],
|
||||
];
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { Request } from 'express';
|
||||
import multer, { type FileFilterCallback } from 'multer';
|
||||
|
||||
import { EXCEL_MIME } from '../../utils/parser.js';
|
||||
import { storage } from '../../utils/upload.js';
|
||||
|
||||
export const EXCEL_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||
|
||||
const filterExcel = (_req: Request, file: Express.Multer.File, cb: FileFilterCallback) => {
|
||||
if (file.mimetype.includes(EXCEL_MIME)) {
|
||||
cb(null, true);
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
import {
|
||||
CustomFields,
|
||||
Rundown,
|
||||
OntimeEvent,
|
||||
OntimeBlock,
|
||||
EntryCustomFields,
|
||||
SupportedEntry,
|
||||
isOntimeBlock,
|
||||
TimerType,
|
||||
CustomFieldKey,
|
||||
} from 'ontime-types';
|
||||
import {
|
||||
ImportMap,
|
||||
defaultImportMap,
|
||||
generateId,
|
||||
isKnownTimerType,
|
||||
validateTimerType,
|
||||
validateEndAction,
|
||||
customFieldLabelToKey,
|
||||
isAlphanumericWithSpace,
|
||||
} from 'ontime-utils';
|
||||
|
||||
import { Merge } from 'ts-essentials';
|
||||
|
||||
import { is } from '../../utils/is.js';
|
||||
import { makeString } from '../../utils/parserUtils.js';
|
||||
import { parseExcelDate } from '../../utils/time.js';
|
||||
|
||||
/**
|
||||
* @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 { mergedCustomFields, 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: mergedCustomFields,
|
||||
rundownMetadata,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility function infers a boolean from a string value
|
||||
*/
|
||||
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';
|
||||
}
|
||||
|
||||
/**
|
||||
* Receives an import map which contains custom field labels and a custom fields object
|
||||
* the result importkeys is an inverted record of <importKey, ontimeKey>
|
||||
* We need this function since, when importing from sheets, the user gives us custom field labels, not keys
|
||||
* @returns the new custom fields, and a map of excel column names to ontime keys
|
||||
* @private exported for testing
|
||||
*/
|
||||
export function getCustomFieldData(
|
||||
importMap: ImportMap,
|
||||
existingCustomFields: CustomFields,
|
||||
): {
|
||||
mergedCustomFields: CustomFields;
|
||||
customFieldImportKeys: Record<keyof CustomFields, string>;
|
||||
} {
|
||||
const mergedCustomFields: CustomFields = {};
|
||||
/**
|
||||
* A map of import keys to ontime keys
|
||||
* Map<excel column name, ontime key>
|
||||
*/
|
||||
const customFieldImportKeys: Record<string, CustomFieldKey> = {};
|
||||
|
||||
for (const ontimeLabel in importMap.custom) {
|
||||
// if the label is not valid, we skip the import
|
||||
if (!isAlphanumericWithSpace(ontimeLabel)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// generate a key for the custom field
|
||||
const keyInCustomFields = customFieldLabelToKey(ontimeLabel);
|
||||
// we lower case the excel key to make it easier to match
|
||||
const columnNameInExcel = importMap.custom[ontimeLabel].toLowerCase();
|
||||
const maybeExistingColour = existingCustomFields[keyInCustomFields]?.colour ?? '';
|
||||
|
||||
// 1. add the custom field to the merged custom fields
|
||||
mergedCustomFields[keyInCustomFields] = {
|
||||
type: 'string', // we currently only support string custom fields
|
||||
colour: maybeExistingColour,
|
||||
label: ontimeLabel,
|
||||
};
|
||||
|
||||
// 2. add the column to the import keys
|
||||
customFieldImportKeys[columnNameInExcel] = keyInCustomFields;
|
||||
}
|
||||
return { mergedCustomFields, customFieldImportKeys };
|
||||
}
|
||||
@@ -11,12 +11,13 @@ import { existsSync } from 'fs';
|
||||
import xlsx from 'xlsx';
|
||||
import type { WorkBook } from 'xlsx';
|
||||
|
||||
import { parseExcel } from '../../utils/parser.js';
|
||||
import { parseCustomFields } from '../../utils/parserFunctions.js';
|
||||
import { deleteFile } from '../../utils/parserUtils.js';
|
||||
import { deleteFile } from '../../utils/fileManagement.js';
|
||||
|
||||
import { parseRundown } from '../rundown/rundown.parser.js';
|
||||
import { getProjectCustomFields } from '../rundown/rundown.dao.js';
|
||||
import { parseCustomFields } from '../custom-fields/customFields.parser.js';
|
||||
|
||||
import { parseExcel } from './excel.parser.js';
|
||||
|
||||
let excelData: WorkBook = xlsx.utils.book_new();
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { router as automationsRouter } from './automation/automation.router.js';
|
||||
import { router as urlPresetsRouter } from './url-presets/urlPresets.router.js';
|
||||
import { router as customFieldsRouter } from './custom-fields/customFields.router.js';
|
||||
import { router as dbRouter } from './db/db.router.js';
|
||||
import { router as projectRouter } from './project/project.router.js';
|
||||
import { router as projectRouter } from './project-data/projectData.router.js';
|
||||
import { router as rundownRouter } from './rundown/rundown.router.js';
|
||||
import { router as settingsRouter } from './settings/settings.router.js';
|
||||
import { router as sheetsRouter } from './sheets/sheets.router.js';
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { parseProjectData } from '../projectData.parser.js';
|
||||
|
||||
describe('parseProjectData()', () => {
|
||||
it('returns an a base model if nothing is given', () => {
|
||||
const errorEmitter = vi.fn();
|
||||
const result = parseProjectData({}, errorEmitter);
|
||||
expect(result).toBeTypeOf('object');
|
||||
expect(errorEmitter).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -6,7 +6,7 @@ import type { Request, Response } from 'express';
|
||||
import { removeUndefined } from '../../utils/parserUtils.js';
|
||||
import { failEmptyObjects } from '../../utils/routerUtils.js';
|
||||
import { editCurrentProjectData } from '../../services/project-service/ProjectService.js';
|
||||
import * as projectDao from './project.dao.js';
|
||||
import * as projectDao from './projectData.dao.js';
|
||||
|
||||
export function getProjectData(_req: Request, res: Response<ProjectData>) {
|
||||
res.json(projectDao.getProjectData());
|
||||
+1
@@ -1,4 +1,5 @@
|
||||
import { ProjectData } from 'ontime-types';
|
||||
|
||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
|
||||
/**
|
||||
@@ -0,0 +1,27 @@
|
||||
import { DatabaseModel, ProjectData } from 'ontime-types';
|
||||
|
||||
import { dbModel } from '../../models/dataModel.js';
|
||||
import { ErrorEmitter } from '../../utils/parserUtils.js';
|
||||
|
||||
/**
|
||||
* Parse event portion of an entry
|
||||
*/
|
||||
export function parseProjectData(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,
|
||||
};
|
||||
}
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import express from 'express';
|
||||
|
||||
import { getProjectData, postProjectData } from './project.controller.js';
|
||||
import { projectSanitiser } from './project.validation.js';
|
||||
import { getProjectData, postProjectData } from './projectData.controller.js';
|
||||
import { projectSanitiser } from './projectData.validation.js';
|
||||
import { uploadImageFile } from '../db/db.middleware.js';
|
||||
import { postProjectLogo } from '../db/db.controller.js';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { SupportedEntry, OntimeEvent, OntimeDelay, OntimeBlock, Rundown } from 'ontime-types';
|
||||
import { SupportedEntry, OntimeEvent, OntimeDelay, OntimeBlock, Rundown, CustomField } from 'ontime-types';
|
||||
|
||||
import { defaultRundown } from '../../../models/dataModel.js';
|
||||
|
||||
const baseEvent = {
|
||||
@@ -46,6 +47,15 @@ export function makeRundown(patch: Partial<Rundown>): Rundown {
|
||||
};
|
||||
}
|
||||
|
||||
export function makeCustomField(patch: Partial<CustomField>): CustomField {
|
||||
return {
|
||||
type: 'string',
|
||||
colour: '#000000',
|
||||
label: 'Custom Field',
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to generate a rundown of OntimeEvents form partial objects
|
||||
*/
|
||||
|
||||
@@ -1,18 +1,35 @@
|
||||
import { CustomFields, OntimeBlock, OntimeDelay, OntimeEvent, SupportedEntry, TimeStrategy } from 'ontime-types';
|
||||
import { dayInMs, MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils';
|
||||
|
||||
import { makeOntimeEvent, makeRundown, makeOntimeBlock, makeOntimeDelay } from '../__mocks__/rundown.mocks.js';
|
||||
import {
|
||||
makeOntimeEvent,
|
||||
makeRundown,
|
||||
makeOntimeBlock,
|
||||
makeOntimeDelay,
|
||||
makeCustomField,
|
||||
} from '../__mocks__/rundown.mocks.js';
|
||||
|
||||
import { createTransaction, processRundown, rundownCache, rundownMutation } from '../rundown.dao.js';
|
||||
import {
|
||||
createTransaction,
|
||||
customFieldMutation,
|
||||
processRundown,
|
||||
rundownCache,
|
||||
rundownMutation,
|
||||
} from '../rundown.dao.js';
|
||||
import { demoDb } from '../../../models/demoProject.js';
|
||||
import { ProcessedRundownMetadata } from '../../../services/rundown-service/rundownCache.utils.js';
|
||||
import type { AssignedMap } from '../rundown.types.js';
|
||||
import { type ProcessedRundownMetadata } from '../rundown.parser.js';
|
||||
|
||||
const setRundownMock = vi.fn();
|
||||
const setCustomFieldsMock = vi.fn();
|
||||
|
||||
beforeAll(() => {
|
||||
vi.mock('../../../classes/data-provider/DataProvider.js', () => {
|
||||
return {
|
||||
getDataProvider: vi.fn().mockImplementation(() => {
|
||||
return {
|
||||
setRundown: vi.fn().mockImplementation(() => undefined),
|
||||
setRundown: setRundownMock,
|
||||
setCustomFields: setCustomFieldsMock,
|
||||
};
|
||||
}),
|
||||
};
|
||||
@@ -24,17 +41,39 @@ afterAll(() => {
|
||||
});
|
||||
|
||||
describe('createTransaction', () => {
|
||||
it('should return a snapshot of the cached rundown and an commit function', () => {
|
||||
const { rundown, commit } = createTransaction();
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should return a snapshot of the cached data and an commit function', () => {
|
||||
const { rundown, customFields, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: true });
|
||||
expect(rundown).toBeDefined();
|
||||
expect(customFields).toBeDefined();
|
||||
expect(typeof commit).toBe('function');
|
||||
});
|
||||
|
||||
it('should return the updated rundown after commit is called and update the db', () => {
|
||||
const { rundown, commit } = createTransaction();
|
||||
it('should return the updated data after commit is called and writes are scheduled', () => {
|
||||
const { rundown, customFields, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: true });
|
||||
rundown.title = 'Another Title';
|
||||
customFields['newField'] = {
|
||||
label: 'New Field',
|
||||
type: 'string',
|
||||
colour: 'blue',
|
||||
};
|
||||
|
||||
const updated = commit();
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(updated.rundown.title).toBe('Another Title');
|
||||
expect(updated.customFields).toHaveProperty('newField');
|
||||
|
||||
expect(setRundownMock).toHaveBeenCalledOnce();
|
||||
expect(setCustomFieldsMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1568,3 +1607,119 @@ describe('rundownMutation.ungroup()', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('customFieldMutation.add()', () => {
|
||||
it('adds a custom field given object', () => {
|
||||
const customFields = {
|
||||
one: makeCustomField({ label: 'one' }),
|
||||
};
|
||||
|
||||
customFieldMutation.add(customFields, 'two', makeCustomField({ label: 'two' }));
|
||||
|
||||
expect(customFields).toMatchObject({
|
||||
one: { label: 'one' },
|
||||
two: { label: 'two' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('customFieldMutation.edit()', () => {
|
||||
it('changes properties of an existing custom field', () => {
|
||||
const customFields = {
|
||||
one: makeCustomField({ label: 'one', colour: 'blue' }),
|
||||
};
|
||||
|
||||
customFieldMutation.edit(customFields, 'one', customFields.one, { colour: 'red' });
|
||||
|
||||
expect(customFields).toMatchObject({
|
||||
one: { label: 'one', colour: 'red' },
|
||||
});
|
||||
});
|
||||
|
||||
it('changing the label makes a new key', () => {
|
||||
const customFields = {
|
||||
one: makeCustomField({ label: 'one', colour: 'blue' }),
|
||||
};
|
||||
|
||||
const { oldKey, newKey } = customFieldMutation.edit(customFields, 'one', customFields.one, {
|
||||
label: 'two',
|
||||
colour: 'red',
|
||||
});
|
||||
|
||||
expect(oldKey).toBe('one');
|
||||
expect(newKey).not.toEqual(oldKey);
|
||||
|
||||
expect(customFields).toMatchObject({
|
||||
[oldKey]: { label: 'one', colour: 'blue' },
|
||||
[newKey]: { label: 'two', colour: 'red' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('customFieldMutation.remove()', () => {
|
||||
it('deletes a custom field from the object', () => {
|
||||
const customFields = {
|
||||
one: makeCustomField({ label: 'one', colour: 'blue' }),
|
||||
};
|
||||
|
||||
customFieldMutation.remove(customFields, 'one');
|
||||
|
||||
expect(customFields).not.toHaveProperty('one');
|
||||
});
|
||||
});
|
||||
|
||||
describe('customFieldMutation.renameUsages()', () => {
|
||||
it('renames all custom field entries in a given rundown', () => {
|
||||
const rundown = makeRundown({
|
||||
order: ['1', '2', '3'],
|
||||
entries: {
|
||||
'1': makeOntimeEvent({ id: '1', custom: { one: 'value1' } }),
|
||||
'2': makeOntimeEvent({ id: '2', custom: { one: 'value2' } }),
|
||||
'3': makeOntimeEvent({ id: '3', custom: { two: 'value3' } }),
|
||||
},
|
||||
});
|
||||
|
||||
const assigned: AssignedMap = {
|
||||
one: ['1', '2'],
|
||||
two: ['3'],
|
||||
};
|
||||
|
||||
customFieldMutation.renameUsages(rundown, assigned, 'one', 'new-one');
|
||||
expect(rundown.entries).toMatchObject({
|
||||
'1': { id: '1', custom: { 'new-one': 'value1' } },
|
||||
'2': { id: '2', custom: { 'new-one': 'value2' } },
|
||||
'3': { id: '3', custom: { two: 'value3' } },
|
||||
});
|
||||
|
||||
expect(assigned).toStrictEqual({
|
||||
'new-one': ['1', '2'],
|
||||
two: ['3'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('customFieldMutation.removeUsages()', () => {
|
||||
it('deletes all custom field entries in a given rundown', () => {
|
||||
const rundown = makeRundown({
|
||||
order: ['1', '2', '3'],
|
||||
entries: {
|
||||
'1': makeOntimeEvent({ id: '1', custom: { one: 'value1' } }),
|
||||
'2': makeOntimeEvent({ id: '2', custom: { one: 'value2' } }),
|
||||
'3': makeOntimeEvent({ id: '3', custom: { two: 'value3' } }),
|
||||
},
|
||||
});
|
||||
|
||||
const assigned: AssignedMap = {
|
||||
one: ['1', '2'],
|
||||
two: ['3'],
|
||||
};
|
||||
|
||||
customFieldMutation.removeUsages(rundown, assigned, 'one');
|
||||
expect((rundown.entries['1'] as OntimeEvent).custom).not.toHaveProperty('one');
|
||||
expect((rundown.entries['2'] as OntimeEvent).custom).not.toHaveProperty('one');
|
||||
|
||||
expect(assigned).toStrictEqual({
|
||||
two: ['3'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { SupportedEntry, OntimeEvent, OntimeBlock, Rundown } from 'ontime-types';
|
||||
import { SupportedEntry, OntimeEvent, OntimeBlock, Rundown, CustomFields } from 'ontime-types';
|
||||
|
||||
import { defaultRundown } from '../../../models/dataModel.js';
|
||||
import { makeOntimeBlock, makeOntimeEvent } from '../__mocks__/rundown.mocks.js';
|
||||
|
||||
import { parseRundowns, parseRundown } from '../rundown.parser.js';
|
||||
import { parseRundowns, parseRundown, handleCustomField, addToCustomAssignment } from '../rundown.parser.js';
|
||||
|
||||
describe('parseRundowns()', () => {
|
||||
it('returns a default project rundown if nothing is given', () => {
|
||||
const errorEmitter = vi.fn();
|
||||
const result = parseRundowns({}, errorEmitter);
|
||||
expect(result.customFields).toEqual({});
|
||||
expect(result.rundowns).toStrictEqual({ default: defaultRundown });
|
||||
const result = parseRundowns({}, {}, errorEmitter);
|
||||
expect(result).toStrictEqual({ default: defaultRundown });
|
||||
// one for not having custom fields
|
||||
// one for not having a rundown
|
||||
expect(errorEmitter).toHaveBeenCalledTimes(2);
|
||||
expect(errorEmitter).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('ensures the rundown IDs are consistent', () => {
|
||||
@@ -27,14 +26,14 @@ describe('parseRundowns()', () => {
|
||||
'3': r2,
|
||||
},
|
||||
},
|
||||
{},
|
||||
errorEmitter,
|
||||
);
|
||||
expect(result.rundowns).toMatchObject({
|
||||
expect(result).toMatchObject({
|
||||
'1': r1,
|
||||
'2': r2,
|
||||
});
|
||||
// one for not having a rundown
|
||||
expect(errorEmitter).toHaveBeenCalledTimes(1);
|
||||
expect(errorEmitter).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -183,6 +182,37 @@ describe('parseRundown()', () => {
|
||||
expect(Object.keys(parsedRundown.entries).length).toEqual(2);
|
||||
});
|
||||
|
||||
it('parses customFields', () => {
|
||||
const rundown = {
|
||||
id: 'test',
|
||||
title: '',
|
||||
order: ['1', '2'],
|
||||
flatOrder: ['1', '2'],
|
||||
entries: {
|
||||
'1': makeOntimeEvent({ id: '1', custom: { lighting: 'on' } }),
|
||||
'2': makeOntimeEvent({ id: '2', custom: { sound: 'loud' } }),
|
||||
},
|
||||
revision: 1,
|
||||
} as Rundown;
|
||||
|
||||
const customFields: CustomFields = {
|
||||
lighting: {
|
||||
type: 'string',
|
||||
colour: 'red',
|
||||
label: 'lighting',
|
||||
},
|
||||
sound: {
|
||||
type: 'string',
|
||||
colour: 'red',
|
||||
label: 'sound',
|
||||
},
|
||||
};
|
||||
|
||||
const parsedRundown = parseRundown(rundown, customFields);
|
||||
expect((parsedRundown.entries['1'] as OntimeEvent).custom).toStrictEqual({ lighting: 'on' });
|
||||
expect((parsedRundown.entries['2'] as OntimeEvent).custom).toStrictEqual({ sound: 'loud' });
|
||||
});
|
||||
|
||||
it('parses events nested in blocks', () => {
|
||||
const rundown = {
|
||||
id: 'test',
|
||||
@@ -203,3 +233,50 @@ describe('parseRundown()', () => {
|
||||
expect(Object.keys(parsedRundown.entries).length).toEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addToCustomAssignment()', () => {
|
||||
it('adds given entry to assignedCustomFields', () => {
|
||||
const assignedCustomFields = {};
|
||||
|
||||
addToCustomAssignment('label1', 'eventId 1', assignedCustomFields);
|
||||
expect(assignedCustomFields).toStrictEqual({ label1: ['eventId 1'] });
|
||||
|
||||
addToCustomAssignment('label1', 'eventId 2', assignedCustomFields);
|
||||
expect(assignedCustomFields).toStrictEqual({ label1: ['eventId 1', 'eventId 2'] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleCustomField()', () => {
|
||||
it('creates a map of where custom fields are used', () => {
|
||||
const customFields = {
|
||||
lighting: {
|
||||
type: 'string',
|
||||
colour: 'red',
|
||||
label: 'lighting',
|
||||
},
|
||||
sound: {
|
||||
type: 'string',
|
||||
colour: 'red',
|
||||
label: 'sound',
|
||||
},
|
||||
} as CustomFields;
|
||||
|
||||
const event = makeOntimeEvent({
|
||||
type: SupportedEntry.Event,
|
||||
id: '2',
|
||||
timeStart: 0,
|
||||
linkStart: true,
|
||||
custom: {
|
||||
lighting: 'on',
|
||||
},
|
||||
});
|
||||
const assignedCustomFields = {};
|
||||
|
||||
const result = handleCustomField(customFields, event, assignedCustomFields);
|
||||
expect(result).toBeUndefined();
|
||||
expect(assignedCustomFields).toStrictEqual({ lighting: ['2'] });
|
||||
expect(event.custom).toStrictEqual({
|
||||
lighting: 'on',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { TimeStrategy, EndAction, TimerType, OntimeEvent } from 'ontime-types';
|
||||
import { MILLIS_PER_HOUR } from 'ontime-utils';
|
||||
|
||||
import { assertType } from 'vitest';
|
||||
|
||||
import { createEvent, deleteById, doesInvalidateMetadata, hasChanges } from '../rundown.utils.js';
|
||||
import { calculateDayOffset, createEvent, deleteById, doesInvalidateMetadata, getInsertAfterId, hasChanges } from '../rundown.utils.js';
|
||||
import { makeRundown } from '../__mocks__/rundown.mocks.js';
|
||||
|
||||
describe('test event validator', () => {
|
||||
it('validates a good object', () => {
|
||||
@@ -130,7 +132,7 @@ describe('hasChanges()', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteById', () => {
|
||||
describe('deleteById()', () => {
|
||||
it('should delete the first instance of the specified ID from the array', () => {
|
||||
const array = ['id1', 'id2', 'id3', 'id4'];
|
||||
const result = deleteById(array, 'id2');
|
||||
@@ -156,3 +158,83 @@ describe('deleteById', () => {
|
||||
expect(result).toStrictEqual(['id1', 'id2', 'id3']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateDayOffset()', () => {
|
||||
it('returns 0 if there is no previous event', () => {
|
||||
expect(calculateDayOffset({ timeStart: 0 }, null)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 if the previous event duration is 0', () => {
|
||||
expect(calculateDayOffset({ timeStart: 0 }, { timeStart: 0, duration: 0 })).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 if event starts after previous', () => {
|
||||
expect(calculateDayOffset({ timeStart: 11 }, { timeStart: 10, duration: 2 })).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 1 if event starts before previous', () => {
|
||||
expect(calculateDayOffset({ timeStart: 9 }, { timeStart: 10, duration: 2 })).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 1 if event starts at the same time as one before', () => {
|
||||
expect(calculateDayOffset({ timeStart: 10 }, { timeStart: 10, duration: 2 })).toBe(1);
|
||||
});
|
||||
|
||||
it('should account for an event that crossed midnight and there is a overlap', () => {
|
||||
expect(
|
||||
calculateDayOffset(
|
||||
{ timeStart: MILLIS_PER_HOUR }, // starts at 01:00:00
|
||||
{ timeStart: 20 * MILLIS_PER_HOUR, duration: 6 * MILLIS_PER_HOUR }, // ends at 02:00:00
|
||||
),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it('should account for an event that crossed midnight and there is a gap', () => {
|
||||
expect(
|
||||
calculateDayOffset(
|
||||
{ timeStart: 2 * MILLIS_PER_HOUR }, // starts at 02:00:00
|
||||
{ timeStart: 23 * MILLIS_PER_HOUR, duration: 2 * MILLIS_PER_HOUR }, // ends at 01:00:00
|
||||
),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it('should account for an event that crossed midnight with no overlaps or gaps', () => {
|
||||
expect(
|
||||
calculateDayOffset(
|
||||
{ timeStart: 2 * MILLIS_PER_HOUR }, // starts at 02:00:00
|
||||
{ timeStart: 20 * MILLIS_PER_HOUR, duration: 6 * MILLIS_PER_HOUR }, // ends at 02:00:00
|
||||
),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it('should account for an event that finishes exactly at midnight', () => {
|
||||
expect(
|
||||
calculateDayOffset(
|
||||
{ timeStart: 2 * MILLIS_PER_HOUR }, // starts at 02:00:00
|
||||
{ timeStart: 23 * MILLIS_PER_HOUR, duration: 6 * MILLIS_PER_HOUR }, // ends at 24:00:00
|
||||
),
|
||||
).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInsertAfterId()', () => {
|
||||
const rundown = makeRundown({
|
||||
flatOrder: ['a', 'b', 'c', 'd'],
|
||||
});
|
||||
|
||||
it('returns afterId if provided', () => {
|
||||
expect(getInsertAfterId(rundown, 'b')).toBe('b');
|
||||
});
|
||||
|
||||
it('returns the previous id before beforeId if provided', () => {
|
||||
expect(getInsertAfterId(rundown, undefined, 'c')).toBe('b');
|
||||
});
|
||||
|
||||
it('returns undefined if neither afterId nor beforeId is provided', () => {
|
||||
expect(getInsertAfterId(rundown)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns undefined if beforeId is not found', () => {
|
||||
expect(getInsertAfterId(rundown, undefined, 'z')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
CustomField,
|
||||
CustomFieldKey,
|
||||
CustomFields,
|
||||
EntryId,
|
||||
isOntimeBlock,
|
||||
@@ -23,13 +25,11 @@ import {
|
||||
PatchWithId,
|
||||
Rundown,
|
||||
} from 'ontime-types';
|
||||
import { insertAtIndex } from 'ontime-utils';
|
||||
import { customFieldLabelToKey, insertAtIndex } from 'ontime-utils';
|
||||
|
||||
import { makeRundownMetadata, ProcessedRundownMetadata } from '../../services/rundown-service/rundownCache.utils.js';
|
||||
import { customFieldChangelog } from '../../services/rundown-service/rundownCache.js';
|
||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
|
||||
import type { RundownMetadata } from './rundown.types.js';
|
||||
import type { AssignedMap, CustomFieldsMetadata, RundownMetadata } from './rundown.types.js';
|
||||
import {
|
||||
applyPatchToEntry,
|
||||
cloneBlock,
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
doesInvalidateMetadata,
|
||||
getUniqueId,
|
||||
} from './rundown.utils.js';
|
||||
import { makeRundownMetadata, ProcessedRundownMetadata } from './rundown.parser.js';
|
||||
|
||||
/**
|
||||
* The currently loaded rundown in cache
|
||||
@@ -62,8 +63,15 @@ let rundownMetadata: RundownMetadata = {
|
||||
playableEventOrder: [],
|
||||
timedEventOrder: [],
|
||||
flatEntryOrder: [],
|
||||
};
|
||||
|
||||
assignedCustomFields: {},
|
||||
const customFieldsMetadata: CustomFieldsMetadata = {
|
||||
/**
|
||||
* Keep track of which custom fields are used.
|
||||
* This will be handy for when we delete custom fields
|
||||
* since we can clear the custom fields from every event where they are used
|
||||
*/
|
||||
assigned: {},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -73,47 +81,103 @@ let rundownMetadata: RundownMetadata = {
|
||||
let projectCustomFields: CustomFields = {};
|
||||
|
||||
export const getCurrentRundown = (): Readonly<Rundown> => cachedRundown;
|
||||
export const getRundownMetadata = (): Readonly<RundownMetadata> => rundownMetadata;
|
||||
export const getProjectCustomFields = (): Readonly<CustomFields> => projectCustomFields;
|
||||
export const getEntryWithId = (entryId: EntryId): OntimeEntry | undefined => cachedRundown.entries[entryId];
|
||||
|
||||
export function createTransaction() {
|
||||
const rundown = structuredClone(cachedRundown);
|
||||
const customFields = projectCustomFields;
|
||||
type Transaction = {
|
||||
customFields: CustomFields;
|
||||
customFieldsMetadata: Readonly<CustomFieldsMetadata>;
|
||||
rundown: Rundown;
|
||||
rundownMetadata: Readonly<RundownMetadata>;
|
||||
|
||||
commit: (shouldProcess?: boolean) => {
|
||||
rundown: Readonly<Rundown>;
|
||||
rundownMetadata: Readonly<RundownMetadata>;
|
||||
customFields: Readonly<CustomFields>;
|
||||
revision: Readonly<number>;
|
||||
};
|
||||
};
|
||||
|
||||
type TransactionOptions = {
|
||||
mutableRundown?: boolean;
|
||||
mutableCustomFields?: boolean;
|
||||
};
|
||||
|
||||
export function createTransaction(options: TransactionOptions): Transaction {
|
||||
const rundown = options.mutableRundown ? structuredClone(cachedRundown) : cachedRundown;
|
||||
const customFields = options.mutableCustomFields ? structuredClone(projectCustomFields) : projectCustomFields;
|
||||
|
||||
/**
|
||||
* Applies a mutated rundown to the cache
|
||||
* @param shouldProcess - whether the rundown should be processed after the commit
|
||||
* Some edit mutations, and custom field changes do not require processing
|
||||
*/
|
||||
function commit(shouldProcess: boolean = true) {
|
||||
// schedule a database update
|
||||
setImmediate(async () => {
|
||||
await getDataProvider().setRundown(cachedRundown.id, cachedRundown);
|
||||
});
|
||||
// if the rundown is mutable we persist the changes
|
||||
if (options.mutableRundown) {
|
||||
// schedule a database update
|
||||
setImmediate(async () => {
|
||||
await getDataProvider().setRundown(cachedRundown.id, cachedRundown);
|
||||
});
|
||||
|
||||
const revision = rundown.revision + 1;
|
||||
cachedRundown.revision = revision;
|
||||
// increment the revision number
|
||||
cachedRundown.revision = cachedRundown.revision + 1;
|
||||
|
||||
/**
|
||||
* Some mutations do not require processing the rundown
|
||||
* We simply increment the revision and return the rundown
|
||||
*/
|
||||
if (!shouldProcess) {
|
||||
cachedRundown.entries = rundown.entries;
|
||||
cachedRundown.order = rundown.order;
|
||||
cachedRundown.flatOrder = rundown.flatOrder;
|
||||
return { rundown, rundownMetadata, customFields: projectCustomFields, revision: cachedRundown.revision };
|
||||
/**
|
||||
* Some mutations do not require processing the rundown
|
||||
* We simply increment the revision and return the rundown
|
||||
*/
|
||||
if (!shouldProcess) {
|
||||
cachedRundown.title = rundown.title;
|
||||
cachedRundown.entries = rundown.entries;
|
||||
cachedRundown.order = rundown.order;
|
||||
cachedRundown.flatOrder = rundown.flatOrder;
|
||||
return {
|
||||
rundown: cachedRundown,
|
||||
rundownMetadata, // metadata doesnt change as long as we dont process the rundown
|
||||
customFields: projectCustomFields,
|
||||
revision: cachedRundown.revision,
|
||||
};
|
||||
}
|
||||
|
||||
const processedData = processRundown(rundown, projectCustomFields);
|
||||
// update the cache values
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- we are not interested in the iteration data
|
||||
const { previousEvent, latestEvent, previousEntry, entries, order, assignedCustomFields, ...metadata } =
|
||||
processedData;
|
||||
|
||||
cachedRundown.title = rundown.title;
|
||||
cachedRundown.entries = entries;
|
||||
cachedRundown.order = order;
|
||||
cachedRundown.flatOrder = metadata.flatEntryOrder; // TODO: remove in favour of the metadata flatEntryOrder
|
||||
customFieldsMetadata.assigned = assignedCustomFields;
|
||||
rundownMetadata = metadata;
|
||||
}
|
||||
|
||||
const processedData = processRundown(rundown, projectCustomFields);
|
||||
// update the cache values
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- we are not interested in the iteration data
|
||||
const { previousEvent, latestEvent, previousEntry, entries, order, ...metadata } = processedData;
|
||||
cachedRundown.entries = entries;
|
||||
cachedRundown.order = order;
|
||||
cachedRundown.flatOrder = metadata.flatEntryOrder; // TODO: remove in favour of the metadata flatEntryOrder
|
||||
rundownMetadata = metadata;
|
||||
// if the customFields are mutable we persist the changes
|
||||
if (options.mutableCustomFields) {
|
||||
// schedule a database update
|
||||
setImmediate(async () => {
|
||||
await getDataProvider().setCustomFields(projectCustomFields);
|
||||
});
|
||||
|
||||
return { rundown, rundownMetadata, customFields: projectCustomFields, revision: cachedRundown.revision };
|
||||
projectCustomFields = customFields;
|
||||
}
|
||||
|
||||
return {
|
||||
rundown: cachedRundown,
|
||||
rundownMetadata,
|
||||
customFields: projectCustomFields,
|
||||
revision: cachedRundown.revision,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
customFields,
|
||||
customFieldsMetadata,
|
||||
rundown,
|
||||
rundownMetadata,
|
||||
commit,
|
||||
};
|
||||
}
|
||||
@@ -329,7 +393,7 @@ function applyDelay(rundown: Rundown, delay: OntimeDelay) {
|
||||
/**
|
||||
* Swaps the data between two events
|
||||
* The schedule and metadata are preserved
|
||||
* TODO: this logic is for now duplcate of Ontime-Utils.swapEventData
|
||||
* TODO: this logic is for now duplicate of Ontime-Utils.swapEventData
|
||||
*/
|
||||
function swap(rundown: Rundown, eventFrom: OntimeEvent, eventTo: OntimeEvent) {
|
||||
rundown.entries[eventFrom.id] = {
|
||||
@@ -484,6 +548,100 @@ export const rundownMutation = {
|
||||
ungroup,
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds a new custom field to the object and returns it
|
||||
*/
|
||||
function customFieldAdd(customFields: CustomFields, key: CustomFieldKey, newCustomField: CustomField): CustomFields {
|
||||
customFields[key] = {
|
||||
label: newCustomField.label,
|
||||
type: newCustomField.type,
|
||||
colour: newCustomField.colour,
|
||||
};
|
||||
|
||||
return { [key]: newCustomField };
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits an existing custom field
|
||||
*/
|
||||
function customFieldEdit(
|
||||
customFields: CustomFields,
|
||||
key: CustomFieldKey,
|
||||
existingField: CustomField,
|
||||
newField: Partial<CustomField>,
|
||||
): { oldKey: CustomFieldKey; newKey: CustomFieldKey } {
|
||||
// calculate the key in case it has changed
|
||||
const newKey = newField?.label ? customFieldLabelToKey(newField.label ?? key) : key;
|
||||
|
||||
// patch the new field and replace the reference in the object
|
||||
customFields[newKey] = { ...existingField, ...newField };
|
||||
|
||||
return { oldKey: key, newKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a custom field from the object
|
||||
*/
|
||||
function customFieldRemove(customFields: CustomFields, key: CustomFieldKey) {
|
||||
delete customFields[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames a custom field key in all the rundown entries that use it
|
||||
*/
|
||||
function customFieldRenameUsages(
|
||||
rundown: Rundown,
|
||||
assigned: AssignedMap,
|
||||
oldKey: CustomFieldKey,
|
||||
newKey: CustomFieldKey,
|
||||
) {
|
||||
const usages = assigned[oldKey];
|
||||
|
||||
// iterate through all the entries that use the custom field
|
||||
for (let i = 0; i < usages.length; i++) {
|
||||
const entryId = usages[i];
|
||||
const entry = rundown.entries[entryId] as OntimeEvent;
|
||||
|
||||
// copy the data a new key and delete the old key
|
||||
entry.custom[newKey] = entry.custom[oldKey];
|
||||
delete entry.custom[oldKey];
|
||||
}
|
||||
|
||||
// update assignment
|
||||
assigned[newKey] = [...assigned[oldKey]];
|
||||
delete assigned[oldKey];
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes data for a custom field from all the entries that use it
|
||||
*/
|
||||
function customFieldRemoveUsages(rundown: Rundown, assigned: AssignedMap, key: CustomFieldKey) {
|
||||
const usages = assigned[key];
|
||||
if (!usages) {
|
||||
return;
|
||||
}
|
||||
|
||||
// iterate through all the entries that use the custom field
|
||||
for (let i = 0; i < usages.length; i++) {
|
||||
const entryId = usages[i];
|
||||
const entry = rundown.entries[entryId] as OntimeEvent;
|
||||
|
||||
// delete the custom field entry
|
||||
delete entry.custom[key];
|
||||
}
|
||||
|
||||
// update assignment
|
||||
delete assigned[key];
|
||||
}
|
||||
|
||||
export const customFieldMutation = {
|
||||
add: customFieldAdd,
|
||||
edit: customFieldEdit,
|
||||
remove: customFieldRemove,
|
||||
renameUsages: customFieldRenameUsages,
|
||||
removeUsages: customFieldRemoveUsages,
|
||||
};
|
||||
|
||||
/**
|
||||
* Expose function to add an initial rundown to the system
|
||||
*/
|
||||
@@ -498,11 +656,13 @@ export function init(initialRundown: Readonly<Rundown>, initialCustomFields: Rea
|
||||
projectCustomFields = customFields;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- we are not interested in the iteration data
|
||||
const { previousEvent, latestEvent, previousEntry, entries, order, ...metadata } = processedData;
|
||||
const { previousEvent, latestEvent, previousEntry, entries, order, assignedCustomFields, ...metadata } =
|
||||
processedData;
|
||||
cachedRundown.entries = entries;
|
||||
cachedRundown.order = order;
|
||||
cachedRundown.flatOrder = metadata.flatEntryOrder; // TODO: remove in favour of the metadata flatEntryOrder
|
||||
cachedRundown.revision = rundown.revision;
|
||||
customFieldsMetadata.assigned = assignedCustomFields;
|
||||
rundownMetadata = metadata;
|
||||
|
||||
// defer writing to the database
|
||||
@@ -533,7 +693,7 @@ export function processRundown(
|
||||
initialRundown: Readonly<Rundown>,
|
||||
customFields: Readonly<CustomFields>,
|
||||
): ProcessedRundownMetadata {
|
||||
const { process, getMetadata } = makeRundownMetadata(customFields, customFieldChangelog);
|
||||
const { process, getMetadata } = makeRundownMetadata(customFields);
|
||||
|
||||
for (let i = 0; i < initialRundown.order.length; i++) {
|
||||
// we assign a reference to the current entry, this will be mutated in place
|
||||
|
||||
@@ -9,15 +9,21 @@ import {
|
||||
isOntimeEvent,
|
||||
isOntimeDelay,
|
||||
isOntimeBlock,
|
||||
CustomFieldKey,
|
||||
EntryId,
|
||||
OntimeEntry,
|
||||
PlayableEvent,
|
||||
RundownEntries,
|
||||
isPlayableEvent,
|
||||
} from 'ontime-types';
|
||||
import { isObjectEmpty, generateId } from 'ontime-utils';
|
||||
import { isObjectEmpty, generateId, getLinkedTimes, getTimeFrom, isNewLatest } from 'ontime-utils';
|
||||
|
||||
import { defaultRundown } from '../../models/dataModel.js';
|
||||
import { delay as delayDef, block as blockDef } from '../../models/eventsDefinition.js';
|
||||
import { ErrorEmitter } from '../../utils/parser.js';
|
||||
import { parseCustomFields } from '../../utils/parserFunctions.js';
|
||||
import type { ErrorEmitter } from '../../utils/parserUtils.js';
|
||||
|
||||
import { createEvent } from './rundown.utils.js';
|
||||
import { calculateDayOffset, createEvent } from './rundown.utils.js';
|
||||
import { RundownMetadata } from './rundown.types.js';
|
||||
|
||||
/**
|
||||
* Parse a rundowns object along with the project custom fields
|
||||
@@ -25,21 +31,16 @@ import { createEvent } from './rundown.utils.js';
|
||||
*/
|
||||
export function parseRundowns(
|
||||
data: Partial<DatabaseModel>,
|
||||
parsedCustomFields: Readonly<CustomFields>,
|
||||
emitError?: ErrorEmitter,
|
||||
): { customFields: CustomFields; rundowns: ProjectRundowns } {
|
||||
// check custom fields first
|
||||
const parsedCustomFields = parseCustomFields(data, emitError);
|
||||
|
||||
): ProjectRundowns {
|
||||
// ensure there is always a rundown to import
|
||||
// this is important since the rest of the app assumes this exist
|
||||
if (!data.rundowns || isObjectEmpty(data.rundowns)) {
|
||||
emitError?.('No data found to import');
|
||||
return {
|
||||
customFields: parsedCustomFields,
|
||||
rundowns: {
|
||||
default: {
|
||||
...defaultRundown,
|
||||
},
|
||||
[defaultRundown.id]: {
|
||||
...defaultRundown,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -55,7 +56,7 @@ export function parseRundowns(
|
||||
parsedRundowns[parsedRundown.id] = parsedRundown;
|
||||
}
|
||||
|
||||
return { customFields: parsedCustomFields, rundowns: parsedRundowns };
|
||||
return parsedRundowns;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,7 +82,7 @@ export function parseRundown(
|
||||
const entryId = rundown.order[i];
|
||||
const event = rundown.entries[entryId];
|
||||
|
||||
if (event === undefined) {
|
||||
if (!event) {
|
||||
emitError?.('Could not find referenced event, skipping');
|
||||
continue;
|
||||
}
|
||||
@@ -170,3 +171,195 @@ export function parseRundown(
|
||||
console.log(`Imported rundown ${parsedRundown.title} with ${parsedRundown.order.length} entries`);
|
||||
return parsedRundown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to add an entry, mutates given assignedCustomFields in place
|
||||
* @param label
|
||||
* @param eventId
|
||||
*/
|
||||
export function addToCustomAssignment(
|
||||
key: CustomFieldKey,
|
||||
eventId: EntryId,
|
||||
assignedCustomFields: Record<string, string[]>,
|
||||
) {
|
||||
if (!Array.isArray(assignedCustomFields[key])) {
|
||||
assignedCustomFields[key] = [];
|
||||
}
|
||||
assignedCustomFields[key].push(eventId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps track of which custom fields are assigned to which events
|
||||
* Mutates the given assignedCustomFields in place
|
||||
* If a field is referenced but is not in the customFields map, it is deleted
|
||||
*/
|
||||
export function handleCustomField(
|
||||
customFields: CustomFields,
|
||||
event: OntimeEvent,
|
||||
assignedCustomFields: Record<CustomFieldKey, EntryId[]>,
|
||||
) {
|
||||
for (const field in event.custom) {
|
||||
if (field in customFields) {
|
||||
// add field to assignment map
|
||||
addToCustomAssignment(field, event.id, assignedCustomFields);
|
||||
} else {
|
||||
// delete data if it is not declared in project level custom fields
|
||||
delete event.custom[field];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type ProcessedRundownMetadata = RundownMetadata & {
|
||||
entries: RundownEntries;
|
||||
order: EntryId[];
|
||||
previousEvent: PlayableEvent | null; // The playableEvent from the previous iteration
|
||||
latestEvent: PlayableEvent | null; // The playableEvent most forwards in time processed so far
|
||||
previousEntry: OntimeEntry | null; // The entry processed in the previous iteration
|
||||
assignedCustomFields: Record<CustomFieldKey, string[]>; // Custom fields assigned to events
|
||||
};
|
||||
|
||||
/**
|
||||
* Factory function to create a rundown metadata processor
|
||||
* @returns {process, getMetadata} process() - processes entries in order | getMetadata() -> returns the current metadata
|
||||
*/
|
||||
export function makeRundownMetadata(customFields: CustomFields) {
|
||||
let rundownMeta: ProcessedRundownMetadata = {
|
||||
totalDelay: 0,
|
||||
totalDuration: 0,
|
||||
totalDays: 0,
|
||||
firstStart: null,
|
||||
lastEnd: null,
|
||||
|
||||
assignedCustomFields: {},
|
||||
playableEventOrder: [],
|
||||
timedEventOrder: [],
|
||||
flatEntryOrder: [],
|
||||
|
||||
entries: {},
|
||||
order: [],
|
||||
previousEvent: null,
|
||||
latestEvent: null,
|
||||
previousEntry: null,
|
||||
};
|
||||
|
||||
function process<T extends OntimeEntry>(
|
||||
entry: T,
|
||||
childOfBlock: EntryId | null,
|
||||
): { processedData: ProcessedRundownMetadata; processedEntry: T } {
|
||||
const data = processEntry(rundownMeta, customFields, entry, childOfBlock);
|
||||
rundownMeta = data.processedData;
|
||||
return data;
|
||||
}
|
||||
|
||||
function getMetadata(): ProcessedRundownMetadata {
|
||||
return rundownMeta;
|
||||
}
|
||||
|
||||
return { process, getMetadata };
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a single entry and updates the rundown metadata
|
||||
*/
|
||||
function processEntry<T extends OntimeEntry>(
|
||||
rundownMetadata: ProcessedRundownMetadata,
|
||||
customFields: CustomFields,
|
||||
entry: T,
|
||||
childOfBlock: EntryId | null,
|
||||
): { processedData: ProcessedRundownMetadata; processedEntry: T } {
|
||||
const processedData = { ...rundownMetadata };
|
||||
const currentEntry = structuredClone(entry);
|
||||
processedData.flatEntryOrder.push(currentEntry.id);
|
||||
|
||||
if (isOntimeEvent(currentEntry)) {
|
||||
processedData.timedEventOrder.push(currentEntry.id);
|
||||
|
||||
/**
|
||||
* 1.Checks that link can be established (ie, events exist and are valid)
|
||||
* and populates the time data from link
|
||||
* The linked event is always the previous playable event
|
||||
* If no previous event exists, the link is removed
|
||||
*/
|
||||
if (currentEntry.linkStart) {
|
||||
if (processedData.previousEvent) {
|
||||
const timePatch = getLinkedTimes(currentEntry, processedData.previousEvent);
|
||||
currentEntry.timeStart = timePatch.timeStart;
|
||||
currentEntry.timeEnd = timePatch.timeEnd;
|
||||
currentEntry.duration = timePatch.duration;
|
||||
} else {
|
||||
currentEntry.linkStart = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. handle custom fields - mutates currentEntry
|
||||
handleCustomField(customFields, currentEntry, processedData.assignedCustomFields);
|
||||
|
||||
processedData.totalDays += calculateDayOffset(currentEntry, processedData.previousEvent);
|
||||
currentEntry.dayOffset = processedData.totalDays;
|
||||
currentEntry.delay = 0; // this means we dont calculate delays or gaps for skipped events
|
||||
currentEntry.gap = 0; // this means we dont calculate delays or gaps for skipped events
|
||||
currentEntry.parent = childOfBlock;
|
||||
|
||||
// update rundown metadata, it only concerns playable events
|
||||
if (isPlayableEvent(currentEntry)) {
|
||||
processedData.playableEventOrder.push(currentEntry.id);
|
||||
|
||||
// first start is always the first event
|
||||
if (processedData.firstStart === null) {
|
||||
processedData.firstStart = currentEntry.timeStart;
|
||||
}
|
||||
|
||||
currentEntry.gap = getTimeFrom(currentEntry, processedData.latestEvent);
|
||||
|
||||
if (currentEntry.gap === 0) {
|
||||
// event starts on previous finish, we add its duration
|
||||
processedData.totalDuration += currentEntry.duration;
|
||||
} else if (currentEntry.gap > 0) {
|
||||
// event has a gap, we add the gap and the duration
|
||||
processedData.totalDuration += currentEntry.gap + currentEntry.duration;
|
||||
} else if (currentEntry.gap < 0) {
|
||||
// there is an overlap, we remove the overlap from the duration
|
||||
// ensuring that the sum is not negative (ie: fully overlapped events)
|
||||
// NOTE: we add the gap since it is a negative number
|
||||
processedData.totalDuration += Math.max(currentEntry.duration + currentEntry.gap, 0);
|
||||
}
|
||||
|
||||
// remove eventual gaps from the accumulated delay
|
||||
// we only affect positive delays (time forwards)
|
||||
if (processedData.totalDelay > 0 && currentEntry.gap > 0) {
|
||||
let correctedDelay = 0;
|
||||
// we need to separate the delay that is accumulated from one that may exist after the gap
|
||||
if (isOntimeDelay(processedData.previousEntry)) {
|
||||
correctedDelay = processedData.previousEntry.duration;
|
||||
processedData.totalDelay -= correctedDelay;
|
||||
}
|
||||
processedData.totalDelay = Math.max(processedData.totalDelay - currentEntry.gap, 0);
|
||||
processedData.totalDelay += correctedDelay;
|
||||
}
|
||||
|
||||
// current event delay is the current accumulated delay
|
||||
currentEntry.delay = processedData.totalDelay;
|
||||
|
||||
// assign data for next iteration
|
||||
processedData.previousEvent = currentEntry;
|
||||
|
||||
// lastEntry is the event with the latest end time
|
||||
if (isNewLatest(currentEntry, processedData.latestEvent)) {
|
||||
processedData.latestEvent = currentEntry;
|
||||
processedData.lastEnd = currentEntry.timeEnd;
|
||||
}
|
||||
}
|
||||
} else if (isOntimeDelay(currentEntry)) {
|
||||
// !!! this must happen after handling the links
|
||||
processedData.totalDelay += currentEntry.duration;
|
||||
currentEntry.parent = childOfBlock;
|
||||
}
|
||||
|
||||
if (!childOfBlock) {
|
||||
processedData.order.push(currentEntry.id);
|
||||
}
|
||||
processedData.entries[currentEntry.id] = currentEntry;
|
||||
processedData.previousEntry = currentEntry;
|
||||
|
||||
return { processedData, processedEntry: currentEntry };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import {
|
||||
CustomField,
|
||||
CustomFieldKey,
|
||||
CustomFields,
|
||||
EntryId,
|
||||
EventPostPayload,
|
||||
@@ -9,21 +11,21 @@ import {
|
||||
PatchWithId,
|
||||
Rundown,
|
||||
} from 'ontime-types';
|
||||
import { customFieldLabelToKey } from 'ontime-utils';
|
||||
|
||||
import { getPreviousId } from '../../services/rundown-service/rundownUtils.js';
|
||||
import { updateRundownData } from '../../stores/runtimeState.js';
|
||||
import { sendRefetch } from '../../adapters/websocketAux.js';
|
||||
import { runtimeService } from '../../services/runtime-service/RuntimeService.js';
|
||||
|
||||
import { createTransaction, rundownCache, rundownMutation } from './rundown.dao.js';
|
||||
import { RundownMetadata } from './rundown.types.js';
|
||||
import { generateEvent, hasChanges } from './rundown.utils.js';
|
||||
import { createTransaction, customFieldMutation, rundownCache, rundownMutation } from './rundown.dao.js';
|
||||
import type { RundownMetadata } from './rundown.types.js';
|
||||
import { generateEvent, getInsertAfterId, hasChanges } from './rundown.utils.js';
|
||||
|
||||
/**
|
||||
* creates a new entry with given data
|
||||
*/
|
||||
export async function addEntry(eventData: EventPostPayload): Promise<OntimeEntry> {
|
||||
const { rundown, commit } = createTransaction();
|
||||
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
|
||||
|
||||
// we allow the user to provide an ID, but make sure it is unique
|
||||
if (eventData?.id && Object.hasOwn(rundown.entries, eventData.id)) {
|
||||
@@ -41,7 +43,7 @@ export async function addEntry(eventData: EventPostPayload): Promise<OntimeEntry
|
||||
}
|
||||
|
||||
// normalise the position of the event in the rundown order
|
||||
const afterId = getPreviousId(rundown, eventData?.after, eventData?.before);
|
||||
const afterId = getInsertAfterId(rundown, eventData?.after, eventData?.before);
|
||||
|
||||
// generate a fully formed entry from the patch
|
||||
const newEntry = generateEvent(rundown, eventData, afterId);
|
||||
@@ -66,7 +68,7 @@ export async function addEntry(eventData: EventPostPayload): Promise<OntimeEntry
|
||||
* Applies a patch to an entry in the rundown
|
||||
*/
|
||||
export async function editEntry(patch: PatchWithId): Promise<OntimeEntry> {
|
||||
const { rundown, commit } = createTransaction();
|
||||
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
|
||||
const currentEntry = rundown.entries[patch.id];
|
||||
|
||||
/**
|
||||
@@ -115,7 +117,7 @@ export async function editEntry(patch: PatchWithId): Promise<OntimeEntry> {
|
||||
* Applies a patch to several entries in the rundown
|
||||
*/
|
||||
export async function batchEditEntries(ids: EntryId[], patch: Partial<OntimeEntry>): Promise<Rundown> {
|
||||
const { rundown, commit } = createTransaction();
|
||||
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
|
||||
|
||||
/**
|
||||
* We can do some validation globally, but mostly we will validate each entry individually
|
||||
@@ -179,7 +181,7 @@ export async function batchEditEntries(ids: EntryId[], patch: Partial<OntimeEntr
|
||||
* Deletes a known entry from the current rundown
|
||||
*/
|
||||
export async function deleteEntries(entryIds: EntryId[]): Promise<Rundown> {
|
||||
const { rundown, commit } = createTransaction();
|
||||
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
|
||||
|
||||
for (let i = 0; i < entryIds.length; i++) {
|
||||
const entry = rundown.entries[entryIds[i]];
|
||||
@@ -207,7 +209,7 @@ export async function deleteEntries(entryIds: EntryId[]): Promise<Rundown> {
|
||||
* Deletes all entries from the current rundown
|
||||
*/
|
||||
export async function deleteAllEntries(): Promise<Rundown> {
|
||||
const { rundown, commit } = createTransaction();
|
||||
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
|
||||
|
||||
rundownMutation.removeAll(rundown);
|
||||
|
||||
@@ -231,7 +233,7 @@ export async function deleteAllEntries(): Promise<Rundown> {
|
||||
* @throws if entryId or destinationId not found
|
||||
*/
|
||||
export function reorderEntry(entryId: EntryId, destinationId: EntryId, order: 'before' | 'after' | 'insert') {
|
||||
const { rundown, commit } = createTransaction();
|
||||
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
|
||||
|
||||
// check that both entries exist
|
||||
const eventFrom = rundown.entries[entryId];
|
||||
@@ -262,7 +264,7 @@ export function reorderEntry(entryId: EntryId, destinationId: EntryId, order: 'b
|
||||
* The applied delay is deleted
|
||||
*/
|
||||
export async function applyDelay(delayId: EntryId): Promise<Rundown> {
|
||||
const { rundown, commit } = createTransaction();
|
||||
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
|
||||
|
||||
// check that delay exists
|
||||
const delay = rundown.entries[delayId];
|
||||
@@ -292,7 +294,7 @@ export async function applyDelay(delayId: EntryId): Promise<Rundown> {
|
||||
* Swaps the data between two events in the rundown
|
||||
*/
|
||||
export async function swapEvents(fromId: EntryId, toId: EntryId): Promise<Rundown> {
|
||||
const { rundown, commit } = createTransaction();
|
||||
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
|
||||
const eventFrom = rundown.entries[fromId];
|
||||
const eventTo = rundown.entries[toId];
|
||||
|
||||
@@ -326,7 +328,7 @@ export async function swapEvents(fromId: EntryId, toId: EntryId): Promise<Rundow
|
||||
* @throws if the entry to clone does not exist
|
||||
*/
|
||||
export async function cloneEntry(entryId: EntryId): Promise<Rundown> {
|
||||
const { rundown, commit } = createTransaction();
|
||||
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
|
||||
const originalEntry = rundown.entries[entryId];
|
||||
|
||||
if (!originalEntry) {
|
||||
@@ -359,7 +361,7 @@ export async function cloneEntry(entryId: EntryId): Promise<Rundown> {
|
||||
* Groups a list of entries into a new block
|
||||
*/
|
||||
export async function groupEntries(entryIds: EntryId[]): Promise<Rundown> {
|
||||
const { rundown, commit } = createTransaction();
|
||||
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
|
||||
|
||||
rundownMutation.group(rundown, entryIds);
|
||||
const { rundown: rundownResult, rundownMetadata, revision } = commit();
|
||||
@@ -380,7 +382,7 @@ export async function groupEntries(entryIds: EntryId[]): Promise<Rundown> {
|
||||
* Deletes a block and moves all its children to the top level
|
||||
*/
|
||||
export async function ungroupEntries(blockId: EntryId): Promise<Rundown> {
|
||||
const { rundown, commit } = createTransaction();
|
||||
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
|
||||
|
||||
const block = rundown.entries[blockId];
|
||||
if (!block || !isOntimeBlock(block)) {
|
||||
@@ -402,6 +404,106 @@ export async function ungroupEntries(blockId: EntryId): Promise<Rundown> {
|
||||
return rundownResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new custom field to the project
|
||||
* @throws if the label is missing or invalid
|
||||
*/
|
||||
export async function createCustomField(customField: CustomField): Promise<CustomFields> {
|
||||
const key = customFieldLabelToKey(customField.label);
|
||||
|
||||
if (!key) {
|
||||
throw new Error('Unable to convert label to a valid key');
|
||||
}
|
||||
|
||||
const { customFields, commit } = createTransaction({ mutableRundown: false, mutableCustomFields: true });
|
||||
|
||||
// check if label already exists
|
||||
if (Object.hasOwn(customFields, key)) {
|
||||
throw new Error('Label already exists');
|
||||
}
|
||||
|
||||
customFieldMutation.add(customFields, key, customField);
|
||||
|
||||
// Adding a custom field has no immediate implications on the rundown
|
||||
const { customFields: resultCustomFields } = commit(false);
|
||||
|
||||
// TODO: notify clients to refetch the custom fields
|
||||
|
||||
return resultCustomFields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits an existing custom field
|
||||
* In practice users can only change the label and the colour of the field
|
||||
* @throws if the field does not exist
|
||||
* @throws if the field type is changed
|
||||
* @throws if the label is missing or invalid
|
||||
* @throws if the new label already exists
|
||||
*/
|
||||
export async function editCustomField(key: CustomFieldKey, newField: Partial<CustomField>): Promise<CustomFields> {
|
||||
const { customFields, customFieldsMetadata, rundown, commit } = createTransaction({
|
||||
mutableRundown: true,
|
||||
mutableCustomFields: true,
|
||||
});
|
||||
|
||||
if (!(key in customFields)) {
|
||||
throw new Error('Could not find label');
|
||||
}
|
||||
|
||||
const existingField = customFields[key];
|
||||
// if user provides a type, it must be the same from before
|
||||
if (newField.type && existingField.type !== newField.type) {
|
||||
throw new Error('Change of field type is not allowed');
|
||||
}
|
||||
|
||||
const { oldKey, newKey } = customFieldMutation.edit(customFields, key, existingField, newField);
|
||||
|
||||
// if key has changed we remove the old reference
|
||||
if (oldKey !== newKey && oldKey in customFieldsMetadata.assigned) {
|
||||
customFieldMutation.renameUsages(rundown, customFieldsMetadata.assigned, oldKey, newKey);
|
||||
}
|
||||
|
||||
// the custom fields have been removed and there is no processing to be done
|
||||
const { rundownMetadata, revision, customFields: resultCustomFields } = commit(false);
|
||||
|
||||
// schedule the side effects
|
||||
setImmediate(() => {
|
||||
// TODO: notify clients to refetch the custom fields
|
||||
notifyChanges(rundownMetadata, revision, { timer: true, external: true });
|
||||
});
|
||||
|
||||
return resultCustomFields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an existing custom field
|
||||
*/
|
||||
export async function deleteCustomField(key: CustomFieldKey): Promise<CustomFields> {
|
||||
const { customFields, customFieldsMetadata, rundown, commit } = createTransaction({
|
||||
mutableRundown: true,
|
||||
mutableCustomFields: true,
|
||||
});
|
||||
if (!(key in customFields)) {
|
||||
return customFields;
|
||||
}
|
||||
|
||||
customFieldMutation.remove(customFields, key);
|
||||
if (key in customFieldsMetadata.assigned) {
|
||||
customFieldMutation.removeUsages(rundown, customFieldsMetadata.assigned, key);
|
||||
}
|
||||
|
||||
// the custom fields have been removed and there is no processing to be done
|
||||
const { rundownMetadata, revision, customFields: resultCustomFields } = commit(false);
|
||||
|
||||
// schedule the side effects
|
||||
setImmediate(() => {
|
||||
// TODO: notify clients to refetch the custom fields
|
||||
notifyChanges(rundownMetadata, revision, { timer: true, external: true });
|
||||
});
|
||||
|
||||
return resultCustomFields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces update in the store
|
||||
* Called when we make changes to the rundown object
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CustomFieldLabel, EntryId, MaybeNumber } from 'ontime-types';
|
||||
import { CustomFieldKey, EntryId, MaybeNumber } from 'ontime-types';
|
||||
|
||||
export type RundownMetadata = {
|
||||
totalDelay: number;
|
||||
@@ -10,11 +10,9 @@ export type RundownMetadata = {
|
||||
playableEventOrder: EntryId[]; // flat order of playable events
|
||||
timedEventOrder: EntryId[]; // flat order of timed events
|
||||
flatEntryOrder: EntryId[]; // flat order of entries
|
||||
|
||||
/**
|
||||
* Keep track of which custom fields are used.
|
||||
* This will be handy for when we delete custom fields
|
||||
* since we can clear the custom fields from every event where they are used
|
||||
*/
|
||||
assignedCustomFields: Record<CustomFieldLabel, string[]>;
|
||||
};
|
||||
|
||||
export type AssignedMap = Record<CustomFieldKey, EntryId[]>;
|
||||
export type CustomFieldsMetadata = {
|
||||
assigned: AssignedMap;
|
||||
};
|
||||
|
||||
@@ -12,7 +12,14 @@ import {
|
||||
SupportedEntry,
|
||||
TimeStrategy,
|
||||
} from 'ontime-types';
|
||||
import { generateId, getCueCandidate, validateEndAction, validateTimerType, validateTimes } from 'ontime-utils';
|
||||
import {
|
||||
dayInMs,
|
||||
generateId,
|
||||
getCueCandidate,
|
||||
validateEndAction,
|
||||
validateTimerType,
|
||||
validateTimes,
|
||||
} from 'ontime-utils';
|
||||
|
||||
import { event as eventDef, block as blockDef, delay as delayDef } from '../../models/eventsDefinition.js';
|
||||
import { makeString } from '../../utils/parserUtils.js';
|
||||
@@ -300,3 +307,54 @@ export function cloneEntry<T extends OntimeEntry>(entry: T, newId: EntryId): T {
|
||||
}
|
||||
throw new Error(`Unsupported entry type for cloning: ${entry}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility for calculating if the current events should have a day offset
|
||||
* @param current the current event under test
|
||||
* @param previous the previous event
|
||||
* @returns 0 or 1 for easy accumulation with the total days
|
||||
*/
|
||||
export function calculateDayOffset(
|
||||
current: Pick<OntimeEvent, 'timeStart'>,
|
||||
previous: Pick<OntimeEvent, 'timeStart' | 'duration'> | null,
|
||||
) {
|
||||
// if there is no previous there can't be a day offset
|
||||
if (!previous) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// if the previous events duration is zero it will push the current event to next day
|
||||
if (previous.duration === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// if the previous event crossed midnight then the current event is in the next day
|
||||
if (previous.timeStart + previous.duration >= dayInMs) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// if the current events starts at the same time or before the previous event then it is the next day
|
||||
if (current.timeStart <= previous.timeStart) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Receives an insertion order and returns the reference to an event ID
|
||||
* after which we will insert the new event
|
||||
*/
|
||||
export function getInsertAfterId(rundown: Rundown, afterId?: EntryId, beforeId?: EntryId): EntryId | null {
|
||||
if (afterId) {
|
||||
return afterId;
|
||||
}
|
||||
|
||||
if (beforeId) {
|
||||
const atIndex = rundown.flatOrder.findIndex((id) => id === beforeId);
|
||||
if (atIndex < 1) return null;
|
||||
return rundown.flatOrder[atIndex - 1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Settings } from 'ontime-types';
|
||||
|
||||
import { parseSettings } from '../settings.parser.js';
|
||||
|
||||
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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { DatabaseModel, Settings } from 'ontime-types';
|
||||
|
||||
import { dbModel } from '../../models/dataModel.js';
|
||||
|
||||
/**
|
||||
* Parse settings portion of a project file
|
||||
*/
|
||||
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',
|
||||
};
|
||||
}
|
||||
@@ -3,12 +3,12 @@
|
||||
* Google Sheets
|
||||
*/
|
||||
|
||||
import type { AuthenticationStatus, CustomFields, ErrorResponse, Rundown } from 'ontime-types';
|
||||
import { getErrorMessage } from 'ontime-utils';
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import { readFileSync } from 'fs';
|
||||
|
||||
import type { AuthenticationStatus, CustomFields, ErrorResponse, Rundown } from 'ontime-types';
|
||||
|
||||
import { deleteFile } from '../../utils/parserUtils.js';
|
||||
import {
|
||||
revoke,
|
||||
handleClientSecret,
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
upload,
|
||||
getWorksheetOptions,
|
||||
} from '../../services/sheet-service/SheetService.js';
|
||||
import { getErrorMessage } from 'ontime-utils';
|
||||
import { deleteFile } from '../../utils/fileManagement.js';
|
||||
|
||||
export async function requestConnection(
|
||||
req: Request,
|
||||
@@ -40,11 +40,7 @@ export async function requestConnection(
|
||||
}
|
||||
|
||||
// delete uploaded file after parsing
|
||||
try {
|
||||
await deleteFile(filePath);
|
||||
} catch (_error) {
|
||||
/** we dont handle failure here */
|
||||
}
|
||||
await deleteFile(filePath);
|
||||
}
|
||||
|
||||
export async function verifyAuthentication(
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Request } from 'express';
|
||||
import multer, { FileFilterCallback } from 'multer';
|
||||
|
||||
import { JSON_MIME } from '../../utils/parser.js';
|
||||
import { storage } from '../../utils/upload.js';
|
||||
|
||||
const filterClientSecret = (_req: Request, file: Express.Multer.File, cb: FileFilterCallback) => {
|
||||
if (file.mimetype.includes(JSON_MIME)) {
|
||||
if (file.mimetype.includes('application/json')) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(null, false);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { DatabaseModel, URLPreset } from 'ontime-types';
|
||||
|
||||
import { parseUrlPresets } from '../urlPresets.parser.js';
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { DatabaseModel, URLPreset } from 'ontime-types';
|
||||
|
||||
import { ErrorEmitter } from '../../utils/parserUtils.js';
|
||||
|
||||
/**
|
||||
* Parse URL preset portion of a project file
|
||||
*/
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { parseViewSettings } from '../viewSettings.parser.js';
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { DatabaseModel, ViewSettings } from 'ontime-types';
|
||||
|
||||
import { dbModel } from '../../models/dataModel.js';
|
||||
import { ErrorEmitter } from '../../utils/parserUtils.js';
|
||||
|
||||
/**
|
||||
* Parse viewSettings portion of a project file
|
||||
*/
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user