mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-16 12:53:32 +00:00
Merge branch 'master' into feat/http-integration
This commit is contained in:
@@ -12,7 +12,7 @@ export class OscServer implements IAdapter {
|
||||
constructor(config: OSCSettings) {
|
||||
this.osc = new Server(config.portIn, '0.0.0.0');
|
||||
|
||||
this.osc.on('error', console.error);
|
||||
this.osc.on('error', (error) => logger.error(LogOrigin.Rx, `OSC IN: ${error}`));
|
||||
|
||||
this.osc.on('message', (msg) => {
|
||||
// message should look like /ontime/{path}/{params?} {args} where
|
||||
|
||||
@@ -4,7 +4,7 @@ import { socket } from './WebsocketAdapter.js';
|
||||
* Utility function to notify clients that the REST data is stale
|
||||
* @param payload -- possible patch payload
|
||||
*/
|
||||
export function sendRefetch(payload: any | null = null) {
|
||||
export function sendRefetch(payload: unknown = null) {
|
||||
socket.sendAsJson({
|
||||
type: 'ontime-refetch',
|
||||
payload,
|
||||
|
||||
@@ -2,7 +2,16 @@
|
||||
* Class Event Provider is a mediator for handling the local db
|
||||
* and adds logic specific to ontime data
|
||||
*/
|
||||
import { ProjectData, OntimeRundown, ViewSettings } from 'ontime-types';
|
||||
import {
|
||||
ProjectData,
|
||||
OntimeRundown,
|
||||
ViewSettings,
|
||||
DatabaseModel,
|
||||
OSCSettings,
|
||||
UserFields,
|
||||
Alias,
|
||||
Settings,
|
||||
} from 'ontime-types';
|
||||
|
||||
import { data, db } from '../../modules/loadDb.js';
|
||||
import { safeMerge } from './DataProvider.utils.js';
|
||||
@@ -37,7 +46,6 @@ export class DataProvider {
|
||||
|
||||
static async clearRundown() {
|
||||
data.rundown = [];
|
||||
// @ts-expect-error -- not sure how to type, this is library side
|
||||
await db.write();
|
||||
}
|
||||
|
||||
@@ -45,7 +53,7 @@ export class DataProvider {
|
||||
return data.settings;
|
||||
}
|
||||
|
||||
static async setSettings(newData) {
|
||||
static async setSettings(newData: Settings) {
|
||||
data.settings = { ...newData };
|
||||
await this.persist();
|
||||
}
|
||||
@@ -62,7 +70,7 @@ export class DataProvider {
|
||||
return data.aliases;
|
||||
}
|
||||
|
||||
static async setAliases(newData) {
|
||||
static async setAliases(newData: Alias[]) {
|
||||
data.aliases = newData;
|
||||
await this.persist();
|
||||
}
|
||||
@@ -80,12 +88,12 @@ export class DataProvider {
|
||||
await this.persist();
|
||||
}
|
||||
|
||||
static async setUserFields(newData) {
|
||||
static async setUserFields(newData: UserFields) {
|
||||
data.userFields = { ...newData };
|
||||
await this.persist();
|
||||
}
|
||||
|
||||
static async setOsc(newData) {
|
||||
static async setOsc(newData: OSCSettings) {
|
||||
data.osc = { ...newData };
|
||||
await this.persist();
|
||||
}
|
||||
@@ -100,11 +108,10 @@ export class DataProvider {
|
||||
}
|
||||
|
||||
static async persist() {
|
||||
// @ts-expect-error -- not sure how to type, this is library side
|
||||
await db.write();
|
||||
}
|
||||
|
||||
static async mergeIntoData(newData) {
|
||||
static async mergeIntoData(newData: Partial<DatabaseModel>) {
|
||||
const mergedData = safeMerge(data, newData);
|
||||
data.project = mergedData.project;
|
||||
data.settings = mergedData.settings;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { DatabaseModel } from 'ontime-types';
|
||||
|
||||
/**
|
||||
* Merges two data objects
|
||||
* @param {object} existing
|
||||
* @param {object} newData
|
||||
*/
|
||||
export function safeMerge(existing, newData) {
|
||||
const { rundown, project, settings, viewSettings, osc, http, aliases, userFields } = newData || {};
|
||||
export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseModel>) {
|
||||
const { rundown, project, settings, viewSettings, osc, aliases, userFields } = newData || {};
|
||||
return {
|
||||
...existing,
|
||||
rundown: rundown ?? existing.rundown,
|
||||
@@ -32,6 +34,5 @@ export function safeMerge(existing, newData) {
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
http: { ...existing.http, ...http },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Alias, DatabaseModel, OntimeRundown, Settings } from 'ontime-types';
|
||||
import { safeMerge } from '../DataProvider.utils.js';
|
||||
|
||||
describe('safeMerge', () => {
|
||||
@@ -5,13 +6,15 @@ describe('safeMerge', () => {
|
||||
rundown: [],
|
||||
project: {
|
||||
title: 'existing title',
|
||||
description: 'existing description',
|
||||
publicUrl: 'existing public URL',
|
||||
backstageUrl: 'existing backstageUrl',
|
||||
publicInfo: 'existing backstageInfo',
|
||||
backstageInfo: 'existing backstageInfo',
|
||||
},
|
||||
settings: {
|
||||
app: 'ontime',
|
||||
version: 2,
|
||||
version: '2.0.0',
|
||||
serverPort: 4001,
|
||||
editorKey: null,
|
||||
operatorKey: null,
|
||||
@@ -42,12 +45,7 @@ describe('safeMerge', () => {
|
||||
onFinish: [],
|
||||
},
|
||||
},
|
||||
http: {
|
||||
enabled: true,
|
||||
user: null,
|
||||
pwd: null,
|
||||
},
|
||||
};
|
||||
} as DatabaseModel;
|
||||
|
||||
it('returns existing data if new data is not provided', () => {
|
||||
const mergedData = safeMerge(existing, undefined);
|
||||
@@ -56,7 +54,7 @@ describe('safeMerge', () => {
|
||||
|
||||
it('merges the rundown key', () => {
|
||||
const newData = {
|
||||
rundown: [{ name: 'item 1' }, { name: 'item 2' }],
|
||||
rundown: [{ title: 'item 1' }, { title: 'item 2' }] as OntimeRundown,
|
||||
};
|
||||
const mergedData = safeMerge(existing, newData);
|
||||
expect(mergedData.rundown).toEqual(newData.rundown);
|
||||
@@ -69,9 +67,11 @@ describe('safeMerge', () => {
|
||||
publicInfo: 'new public info',
|
||||
},
|
||||
};
|
||||
// @ts-expect-error -- just testing
|
||||
const mergedData = safeMerge(existing, newData);
|
||||
expect(mergedData.project).toEqual({
|
||||
title: 'new title',
|
||||
description: 'existing description',
|
||||
publicUrl: 'existing public URL',
|
||||
publicInfo: 'new public info',
|
||||
backstageUrl: 'existing backstageUrl',
|
||||
@@ -84,12 +84,12 @@ describe('safeMerge', () => {
|
||||
settings: {
|
||||
serverPort: 3000,
|
||||
language: 'pt',
|
||||
},
|
||||
} as Settings,
|
||||
};
|
||||
const mergedData = safeMerge(existing, newData);
|
||||
expect(mergedData.settings).toEqual({
|
||||
app: 'ontime',
|
||||
version: 2,
|
||||
version: '2.0.0',
|
||||
serverPort: 3000,
|
||||
operatorKey: null,
|
||||
editorKey: null,
|
||||
@@ -113,6 +113,7 @@ describe('safeMerge', () => {
|
||||
},
|
||||
},
|
||||
};
|
||||
//@ts-expect-error -- testing partial merge
|
||||
const mergedData = safeMerge(existing, newData);
|
||||
expect(mergedData.osc).toEqual({
|
||||
portIn: 7777,
|
||||
@@ -140,7 +141,7 @@ describe('safeMerge', () => {
|
||||
it('should merge the aliases key when present', () => {
|
||||
const existingData = {
|
||||
rundown: [],
|
||||
event: {
|
||||
project: {
|
||||
title: '',
|
||||
publicUrl: '',
|
||||
publicInfo: '',
|
||||
@@ -149,7 +150,7 @@ describe('safeMerge', () => {
|
||||
},
|
||||
settings: {
|
||||
app: 'ontime',
|
||||
version: 2,
|
||||
version: '2.0.0',
|
||||
serverPort: 4001,
|
||||
operatorKey: null,
|
||||
editorKey: null,
|
||||
@@ -188,23 +189,13 @@ describe('safeMerge', () => {
|
||||
onFinish: [],
|
||||
},
|
||||
},
|
||||
http: {
|
||||
user: null,
|
||||
pwd: null,
|
||||
messages: {
|
||||
onLoad: [],
|
||||
onStart: [],
|
||||
onUpdate: [],
|
||||
onPause: [],
|
||||
onStop: [],
|
||||
onFinish: [],
|
||||
},
|
||||
enabled: true,
|
||||
},
|
||||
};
|
||||
} as DatabaseModel;
|
||||
|
||||
const newData = {
|
||||
aliases: ['alias1', 'alias2'],
|
||||
aliases: [
|
||||
{ enabled: true, alias: 'alias1', pathAndParams: '' },
|
||||
{ enabled: true, alias: 'alias2', pathAndParams: '' },
|
||||
] as Alias[],
|
||||
};
|
||||
|
||||
const mergedData = safeMerge(existingData, newData);
|
||||
@@ -235,6 +226,7 @@ describe('safeMerge', () => {
|
||||
user3: 'David',
|
||||
};
|
||||
|
||||
//@ts-expect-error -- testing partial merge
|
||||
const result = safeMerge(existing, newData);
|
||||
expect(result.userFields).toEqual(expected);
|
||||
});
|
||||
|
||||
@@ -30,7 +30,7 @@ const whitelistedPayload = {
|
||||
};
|
||||
|
||||
export function parse(field: string, value: unknown) {
|
||||
if (!whitelistedPayload.hasOwnProperty(field)) {
|
||||
if (!Object.hasOwn(whitelistedPayload, field)) {
|
||||
throw new Error(`Field ${field} not permitted`);
|
||||
}
|
||||
const parserFn = whitelistedPayload[field];
|
||||
@@ -51,7 +51,7 @@ export function updateEvent(
|
||||
const event = EventLoader.getEventWithId(eventId);
|
||||
|
||||
if (event) {
|
||||
let propertiesToUpdate = { [propertyName]: newValue };
|
||||
const propertiesToUpdate = { [propertyName]: newValue };
|
||||
|
||||
// Handles the special case for duration
|
||||
// needs to be converted to milliseconds
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LogOrigin, OntimeEvent } from 'ontime-types';
|
||||
import { OntimeEvent } from 'ontime-types';
|
||||
|
||||
import { messageService } from '../services/message-service/MessageService.js';
|
||||
import { PlaybackService } from '../services/PlaybackService.js';
|
||||
@@ -7,13 +7,14 @@ import { parse, updateEvent } from './integrationController.config.js';
|
||||
import { isKeyOfType } from 'ontime-types/src/utils/guards.js';
|
||||
import { event } from '../models/eventsDefinition.js';
|
||||
|
||||
//TODO: re-throwing the error does not add any extra information or value
|
||||
export function dispatchFromAdapter(
|
||||
type: string,
|
||||
args: {
|
||||
payload: unknown;
|
||||
params?: Array<string>;
|
||||
},
|
||||
source?: 'osc' | 'ws',
|
||||
_source?: 'osc' | 'ws',
|
||||
) {
|
||||
const payload = args.payload;
|
||||
const typeComponents = type.toLowerCase().split('/');
|
||||
@@ -174,6 +175,19 @@ export function dispatchFromAdapter(
|
||||
PlaybackService.roll();
|
||||
break;
|
||||
}
|
||||
case 'addtime': {
|
||||
const time = Number(payload);
|
||||
if (isNaN(time)) {
|
||||
throw new Error(`Time not recognised ${payload}`);
|
||||
}
|
||||
try {
|
||||
PlaybackService.addTime(time);
|
||||
} catch (error) {
|
||||
throw new Error(`Could not add time: ${error}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
//deprecated
|
||||
case 'delay': {
|
||||
const delayTime = Number(payload);
|
||||
if (isNaN(delayTime)) {
|
||||
@@ -242,10 +256,10 @@ export function dispatchFromAdapter(
|
||||
// ontime/change/{eventID}/{propertyName}
|
||||
case 'change': {
|
||||
if (params.length < 2) {
|
||||
throw new Error(`To few parameters`);
|
||||
throw new Error('Too few parameters, 3 expected');
|
||||
}
|
||||
if (payload === undefined) {
|
||||
throw new Error(`Undefined payload`);
|
||||
throw new Error('No payload found');
|
||||
}
|
||||
const eventID = params[0];
|
||||
const propertyName = params[1] as keyof OntimeEvent;
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import { Alias, LogOrigin, ProjectData } from 'ontime-types';
|
||||
import { Alias, DatabaseModel, GetInfo, LogOrigin, ProjectData } from 'ontime-types';
|
||||
|
||||
import { RequestHandler } from 'express';
|
||||
import { RequestHandler, Request, Response } from 'express';
|
||||
import fs from 'fs';
|
||||
import { networkInterfaces } from 'os';
|
||||
|
||||
import { fileHandler } from '../utils/parser.js';
|
||||
import { DataProvider } from '../classes/data-provider/DataProvider.js';
|
||||
import { failEmptyObjects, failIsNotArray } from '../utils/routerUtils.js';
|
||||
import { mergeObject } from '../utils/parserUtils.js';
|
||||
import { PlaybackService } from '../services/PlaybackService.js';
|
||||
import { eventStore } from '../stores/EventStore.js';
|
||||
import { isDocker, resolveDbPath } from '../setup.js';
|
||||
import { isDocker, pathToStartStyles, resolveDbPath } from '../setup.js';
|
||||
import { oscIntegration } from '../services/integration-service/OscIntegration.js';
|
||||
import { httpIntegration } from '../services/integration-service/HttpIntegration.js';
|
||||
import { logger } from '../classes/Logger.js';
|
||||
import { deleteAllEvents, forceReset } from '../services/rundown-service/RundownService.js';
|
||||
import { deleteAllEvents, notifyChanges } from '../services/rundown-service/RundownService.js';
|
||||
import { deepmerge } from 'ontime-utils';
|
||||
import { runtimeCacheStore } from '../stores/cachingStore.js';
|
||||
import { delayedRundownCacheKey } from '../services/rundown-service/delayedRundown.utils.js';
|
||||
|
||||
// Create controller for GET request to '/ontime/poll'
|
||||
// Returns data for current state
|
||||
@@ -45,43 +47,40 @@ export const dbDownload = async (req, res) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* handles file upload
|
||||
* Parses a file and returns the result objects
|
||||
* @param file
|
||||
* @param _req
|
||||
* @param _res
|
||||
* @param options
|
||||
*/
|
||||
async function parseFile(file, _req, _res, options) {
|
||||
if (!fs.existsSync(file)) {
|
||||
throw new Error('Upload failed');
|
||||
}
|
||||
const result = await fileHandler(file, options);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* parse an uploaded file and apply its parsed objects
|
||||
* @param file
|
||||
* @param req
|
||||
* @param res
|
||||
* @param [options]
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const uploadAndParse = async (file, req, res, options) => {
|
||||
if (!fs.existsSync(file)) {
|
||||
res.status(500).send({ message: 'Upload failed' });
|
||||
return;
|
||||
}
|
||||
const parseAndApply = async (file, _req, res, options) => {
|
||||
const result = await parseFile(file, _req, res, options);
|
||||
|
||||
try {
|
||||
const result = await fileHandler(file);
|
||||
PlaybackService.stop();
|
||||
|
||||
if ('error' in result && result.error) {
|
||||
res.status(400).send({ message: result.message });
|
||||
} else if ('data' in result && result.message === 'success') {
|
||||
PlaybackService.stop();
|
||||
// explicitly write objects
|
||||
if (typeof result !== 'undefined') {
|
||||
const newRundown = result.data.rundown || [];
|
||||
if (options?.onlyRundown === 'true') {
|
||||
await DataProvider.setRundown(newRundown);
|
||||
} else {
|
||||
await DataProvider.mergeIntoData(result.data);
|
||||
}
|
||||
}
|
||||
forceReset();
|
||||
res.sendStatus(200);
|
||||
} else {
|
||||
res.status(400).send({ message: 'Failed parsing, no data' });
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(400).send({ message: `Failed parsing ${error}` });
|
||||
const newRundown = result.rundown || [];
|
||||
if (options?.onlyRundown === 'true') {
|
||||
await DataProvider.setRundown(newRundown);
|
||||
} else {
|
||||
await DataProvider.mergeIntoData(result);
|
||||
}
|
||||
notifyChanges({ timer: true, external: true, reset: true });
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -107,15 +106,16 @@ const getNetworkInterfaces = () => {
|
||||
return results;
|
||||
};
|
||||
|
||||
// Create controller for POST request to '/ontime/info'
|
||||
// Create controller for GET request to '/ontime/info'
|
||||
// Returns -
|
||||
export const getInfo = async (req, res) => {
|
||||
export const getInfo = async (req: Request, res: Response<GetInfo>) => {
|
||||
const { version, serverPort } = DataProvider.getSettings();
|
||||
const osc = DataProvider.getOsc();
|
||||
|
||||
// get nif and inject localhost
|
||||
const ni = getNetworkInterfaces();
|
||||
ni.unshift({ name: 'localhost', address: '127.0.0.1' });
|
||||
const cssOverride = pathToStartStyles;
|
||||
|
||||
// send object with network information
|
||||
res.status(200).send({
|
||||
@@ -123,6 +123,7 @@ export const getInfo = async (req, res) => {
|
||||
version,
|
||||
serverPort,
|
||||
osc,
|
||||
cssOverride,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -151,7 +152,7 @@ export const postAliases = async (req, res) => {
|
||||
await DataProvider.setAliases(newAliases);
|
||||
res.status(200).send(newAliases);
|
||||
} catch (error) {
|
||||
res.status(400).send(error);
|
||||
res.status(400).send({ message: error.toString() });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -170,11 +171,11 @@ export const postUserFields = async (req, res) => {
|
||||
}
|
||||
try {
|
||||
const persistedData = DataProvider.getUserFields();
|
||||
const newData = mergeObject(persistedData, req.body);
|
||||
const newData = deepmerge(persistedData, req.body);
|
||||
await DataProvider.setUserFields(newData);
|
||||
res.status(200).send(newData);
|
||||
} catch (error) {
|
||||
res.status(400).send(error);
|
||||
res.status(400).send({ message: error.toString() });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -237,7 +238,7 @@ export const postSettings = async (req, res) => {
|
||||
await DataProvider.setSettings(newData);
|
||||
res.status(200).send(newData);
|
||||
} catch (error) {
|
||||
res.status(400).send(error);
|
||||
res.status(400).send({ message: error.toString() });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -272,7 +273,7 @@ export const postViewSettings = async (req, res) => {
|
||||
await DataProvider.setViewSettings(newData);
|
||||
res.status(200).send(newData);
|
||||
} catch (error) {
|
||||
res.status(400).send(error);
|
||||
res.status(400).send({ message: error.toString() });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -307,7 +308,7 @@ export const postOscSubscriptions = async (req, res) => {
|
||||
|
||||
res.send(oscSettings).status(200);
|
||||
} catch (error) {
|
||||
res.status(400).send(error);
|
||||
res.status(400).send({ message: error.toString() });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -348,12 +349,42 @@ export const postOSC = async (req, res) => {
|
||||
|
||||
res.send(oscSettings).status(200);
|
||||
} catch (error) {
|
||||
res.status(400).send(error);
|
||||
res.status(400).send({ message: error.toString() });
|
||||
}
|
||||
};
|
||||
|
||||
// Create controller for POST request to '/ontime/db'
|
||||
// Returns -
|
||||
export async function patchPartialProjectFile(req, res) {
|
||||
if (failEmptyObjects(req.body, res)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const patchDb: Partial<DatabaseModel> = {
|
||||
project: req.body?.project,
|
||||
settings: req.body?.settings,
|
||||
viewSettings: req.body?.viewSettings,
|
||||
osc: req.body?.osc,
|
||||
aliases: req.body?.aliases,
|
||||
userFields: req.body?.userFields,
|
||||
rundown: req.body?.rundown,
|
||||
};
|
||||
|
||||
await DataProvider.mergeIntoData(patchDb);
|
||||
if (patchDb.rundown !== undefined) {
|
||||
// it is likely cheaper to invalidate cache than to calculate diff
|
||||
PlaybackService.stop();
|
||||
runtimeCacheStore.invalidate(delayedRundownCacheKey);
|
||||
notifyChanges({ external: true, reset: true });
|
||||
}
|
||||
res.status(200).send();
|
||||
} catch (error) {
|
||||
res.status(400).send({ message: error.toString() });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* uploads, parses and applies the data from a given file
|
||||
*/
|
||||
export const dbUpload = async (req, res) => {
|
||||
if (!req.file) {
|
||||
res.status(400).send({ message: 'File not found' });
|
||||
@@ -361,10 +392,39 @@ export const dbUpload = async (req, res) => {
|
||||
}
|
||||
const options = req.query;
|
||||
const file = req.file.path;
|
||||
await uploadAndParse(file, req, res, options);
|
||||
try {
|
||||
await parseAndApply(file, req, res, options);
|
||||
res.status(200).send();
|
||||
} catch (error) {
|
||||
res.status(400).send({ message: `Failed parsing ${error}` });
|
||||
}
|
||||
};
|
||||
|
||||
// Create controller for POST request to '/ontime/new'
|
||||
/**
|
||||
* uploads and parses an excel file
|
||||
* @returns parsed result
|
||||
*/
|
||||
export async function previewExcel(req, res) {
|
||||
if (!req.file) {
|
||||
res.status(400).send({ message: 'File not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const options = JSON.parse(req.body.options);
|
||||
const file = req.file.path;
|
||||
const data = await parseFile(file, req, res, options);
|
||||
res.status(200).send(data);
|
||||
} catch (error) {
|
||||
res.status(500).send({ message: error.toString() });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Meant to create a new project file, it will clear only fields which are specific to a project
|
||||
* @param req
|
||||
* @param res
|
||||
*/
|
||||
export const postNew: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const newProjectData: ProjectData = {
|
||||
@@ -379,6 +439,6 @@ export const postNew: RequestHandler = async (req, res) => {
|
||||
await deleteAllEvents();
|
||||
res.status(201).send(newData);
|
||||
} catch (error) {
|
||||
res.status(400).send(error);
|
||||
res.status(400).send({ message: error.toString() });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -118,3 +118,18 @@ export const validateSubscription = [
|
||||
next();
|
||||
},
|
||||
];
|
||||
|
||||
export const validatePatchProjectFile = [
|
||||
body('rundown').isArray().optional({ nullable: false }),
|
||||
body('project').isObject().optional({ nullable: false }),
|
||||
body('settings').isObject().optional({ nullable: false }),
|
||||
body('viewSettings').isObject().optional({ nullable: false }),
|
||||
body('aliases').isArray().optional({ nullable: false }),
|
||||
body('userFields').isObject().optional({ nullable: false }),
|
||||
body('osc').isObject().optional({ nullable: false }),
|
||||
(req, res, next) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
|
||||
next();
|
||||
},
|
||||
];
|
||||
|
||||
@@ -13,14 +13,14 @@ export const pbStart = async (req, res) => {
|
||||
const { eventId, eventIndex } = req.query;
|
||||
if (eventId) {
|
||||
const success = PlaybackService.startById(eventId);
|
||||
success ? res.sendStatus(202) : res.status(400).send('Invalid event ID');
|
||||
success ? res.sendStatus(202) : res.status(400).send({ message: 'Invalid event ID' });
|
||||
} else if (eventIndex) {
|
||||
const index = Number(eventIndex);
|
||||
if (!isNaN(index)) {
|
||||
const success = PlaybackService.startByIndex(eventIndex - 1);
|
||||
success ? res.sendStatus(202) : res.status(400).send('Invalid event index');
|
||||
success ? res.sendStatus(202) : res.status(400).send({ message: 'Invalid event index' });
|
||||
} else {
|
||||
res.status(400).send('Invalid event index');
|
||||
res.status(400).send({ message: 'Invalid event index' });
|
||||
}
|
||||
} else {
|
||||
PlaybackService.start();
|
||||
@@ -69,17 +69,17 @@ export const pbLoad = async (req, res) => {
|
||||
const { eventId, eventIndex } = req.query;
|
||||
if (eventId) {
|
||||
const success = PlaybackService.loadById(eventId);
|
||||
success ? res.sendStatus(202) : res.status(400).send('Invalid event ID');
|
||||
success ? res.sendStatus(202) : res.status(400).send({ message: 'Invalid event ID' });
|
||||
} else if (eventIndex) {
|
||||
const index = Number(eventIndex);
|
||||
if (!isNaN(index)) {
|
||||
const success = PlaybackService.loadByIndex(eventIndex - 1);
|
||||
success ? res.sendStatus(202) : res.status(400).send('Invalid event index');
|
||||
success ? res.sendStatus(202) : res.status(400).send({ message: 'Invalid event index' });
|
||||
} else {
|
||||
res.status(400).send('Invalid event index');
|
||||
res.status(400).send({ message: 'Invalid event index' });
|
||||
}
|
||||
} else {
|
||||
res.status(400).send('No event given');
|
||||
res.status(400).send({ message: 'No event given' });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -30,6 +30,6 @@ export const postProject: RequestHandler = async (req, res) => {
|
||||
const newData = await DataProvider.setProjectData(newEvent);
|
||||
res.status(200).send(newData);
|
||||
} catch (error) {
|
||||
res.status(400).send(error);
|
||||
res.status(400).send({ message: error.toString() });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { GetRundownCached } from 'ontime-types';
|
||||
|
||||
import { Request, Response, RequestHandler } from 'express';
|
||||
|
||||
import { failEmptyObjects } from '../utils/routerUtils.js';
|
||||
import {
|
||||
addEvent,
|
||||
@@ -8,8 +12,7 @@ import {
|
||||
reorderEvent,
|
||||
swapEvents,
|
||||
} from '../services/rundown-service/RundownService.js';
|
||||
import { getDelayedRundown } from '../services/rundown-service/delayedRundown.utils.js';
|
||||
import { RequestHandler } from 'express';
|
||||
import { getDelayedRundown, getRundownCache } from '../services/rundown-service/delayedRundown.utils.js';
|
||||
|
||||
// Create controller for GET request to '/events'
|
||||
// Returns -
|
||||
@@ -18,6 +21,13 @@ export const rundownGetAll: RequestHandler = async (_req, res) => {
|
||||
res.json(delayedRundown);
|
||||
};
|
||||
|
||||
// Create controller for GET request to '/events/cached'
|
||||
// Returns -
|
||||
export const rundownGetCached: RequestHandler = async (_req: Request, res: Response<GetRundownCached>) => {
|
||||
const cachedRundown = getRundownCache();
|
||||
res.json(cachedRundown);
|
||||
};
|
||||
|
||||
// Create controller for POST request to '/events/'
|
||||
// Returns -
|
||||
export const rundownPost: RequestHandler = async (req, res) => {
|
||||
@@ -29,7 +39,7 @@ export const rundownPost: RequestHandler = async (req, res) => {
|
||||
const newEvent = await addEvent(req.body);
|
||||
res.status(201).send(newEvent);
|
||||
} catch (error) {
|
||||
res.status(400).send(error);
|
||||
res.status(400).send({ message: error.toString() });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -44,7 +54,7 @@ export const rundownPut: RequestHandler = async (req, res) => {
|
||||
const event = await editEvent(req.body);
|
||||
res.status(200).send(event);
|
||||
} catch (error) {
|
||||
res.status(400).send(error);
|
||||
res.status(400).send({ message: error.toString() });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -58,7 +68,7 @@ export const rundownReorder: RequestHandler = async (req, res) => {
|
||||
const event = await reorderEvent(eventId, from, to);
|
||||
res.status(200).send(event);
|
||||
} catch (error) {
|
||||
res.status(400).send(error);
|
||||
res.status(400).send({ message: error.toString() });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -72,7 +82,7 @@ export const rundownSwap: RequestHandler = async (req, res) => {
|
||||
await swapEvents(from, to);
|
||||
res.sendStatus(200);
|
||||
} catch (error) {
|
||||
res.status(400).send(error);
|
||||
res.status(400).send({ message: error.toString() });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -83,7 +93,7 @@ export const rundownApplyDelay: RequestHandler = async (req, res) => {
|
||||
await applyDelay(req.params.eventId);
|
||||
res.sendStatus(200);
|
||||
} catch (error) {
|
||||
res.status(400).send(error);
|
||||
res.status(400).send({ message: error.toString() });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -94,7 +104,7 @@ export const deleteEventById: RequestHandler = async (req, res) => {
|
||||
await deleteEvent(req.params.eventId);
|
||||
res.sendStatus(204);
|
||||
} catch (error) {
|
||||
res.status(400).send(error);
|
||||
res.status(400).send({ message: error.toString() });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -105,6 +115,6 @@ export const rundownDelete: RequestHandler = async (req, res) => {
|
||||
await deleteAllEvents();
|
||||
res.sendStatus(204);
|
||||
} catch (error) {
|
||||
res.status(400).send(error);
|
||||
res.status(400).send({ message: error.toString() });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { DatabaseModel } from 'ontime-types';
|
||||
import { ONTIME_VERSION } from '../ONTIME_VERSION.js';
|
||||
|
||||
export const dbModel: DatabaseModel = {
|
||||
rundown: [],
|
||||
@@ -12,7 +13,7 @@ export const dbModel: DatabaseModel = {
|
||||
},
|
||||
settings: {
|
||||
app: 'ontime',
|
||||
version: 2,
|
||||
version: ONTIME_VERSION,
|
||||
serverPort: 4001,
|
||||
editorKey: null,
|
||||
operatorKey: null,
|
||||
|
||||
@@ -42,7 +42,7 @@ const parseDb = async (fileToRead, adapterToUse) => {
|
||||
adapterToUse.data = dbModel;
|
||||
}
|
||||
|
||||
return parseJson(adapterToUse.data, true);
|
||||
return parseJson(adapterToUse.data);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -67,7 +67,7 @@ async function loadDb() {
|
||||
return { db, data };
|
||||
}
|
||||
|
||||
export let db = {};
|
||||
export let db = {} as Low<DatabaseModel>;
|
||||
export let data = {} as DatabaseModel;
|
||||
export const dbLoadingProcess = loadDb();
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
getSettings,
|
||||
getUserFields,
|
||||
getViewSettings,
|
||||
patchPartialProjectFile,
|
||||
poll,
|
||||
postAliases,
|
||||
postNew,
|
||||
@@ -19,12 +20,14 @@ import {
|
||||
postSettings,
|
||||
postUserFields,
|
||||
postViewSettings,
|
||||
previewExcel,
|
||||
} from '../controllers/ontimeController.js';
|
||||
|
||||
import {
|
||||
validateAliases,
|
||||
validateOSC,
|
||||
validateSubscription,
|
||||
validatePatchProjectFile,
|
||||
validateSettings,
|
||||
validateUserFields,
|
||||
viewValidator,
|
||||
@@ -42,6 +45,12 @@ router.get('/db', dbDownload);
|
||||
// create route between controller and '/ontime/db' endpoint
|
||||
router.post('/db', uploadFile, dbUpload);
|
||||
|
||||
// create route between controller and '/ontime/excel' endpoint
|
||||
router.patch('/db', validatePatchProjectFile, patchPartialProjectFile);
|
||||
|
||||
// create route between controller and '/ontime/preview-spreadsheet' endpoint
|
||||
router.post('/preview-spreadsheet', uploadFile, previewExcel);
|
||||
|
||||
// create route between controller and '/ontime/settings' endpoint
|
||||
router.get('/settings', getSettings);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
rundownApplyDelay,
|
||||
rundownDelete,
|
||||
rundownGetAll,
|
||||
rundownGetCached,
|
||||
rundownPost,
|
||||
rundownPut,
|
||||
rundownReorder,
|
||||
@@ -19,6 +20,9 @@ import {
|
||||
|
||||
export const router = express.Router();
|
||||
|
||||
// create route between controller and '/events/cached' endpoint
|
||||
router.get('/cached', rundownGetCached);
|
||||
|
||||
// create route between controller and '/events/' endpoint
|
||||
router.get('/', rundownGetAll);
|
||||
|
||||
|
||||
@@ -269,17 +269,26 @@ export class PlaybackService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds time to current event
|
||||
* @param {number} time - time to add in seconds
|
||||
*/
|
||||
static addTime(time: number) {
|
||||
if (eventTimer.loadedTimerId) {
|
||||
const timeInMs = time * 1000;
|
||||
eventTimer.addTime(timeInMs);
|
||||
timeInMs > 0
|
||||
? logger.info(LogOrigin.Playback, `Added ${time} sec`)
|
||||
: logger.info(LogOrigin.Playback, `Removed ${time} sec`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds delay to current event
|
||||
* @deprecated Use addTime
|
||||
* @param {number} delayTime time in minutes
|
||||
*/
|
||||
static setDelay(delayTime: number) {
|
||||
if (eventTimer.loadedTimerId) {
|
||||
const delayInMs = delayTime * 1000 * 60;
|
||||
eventTimer.delay(delayInMs);
|
||||
delayInMs > 0
|
||||
? logger.info(LogOrigin.Playback, `Added ${delayTime} min delay`)
|
||||
: logger.info(LogOrigin.Playback, `Removed ${delayTime} min delay`);
|
||||
}
|
||||
this.addTime(delayTime * 60);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,10 +304,10 @@ export class TimerService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Delays running timer by given amount
|
||||
* Adds time to running timer by given amount
|
||||
* @param {number} amount
|
||||
*/
|
||||
delay(amount: number) {
|
||||
addTime(amount: number) {
|
||||
if (!this.loadedTimerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { Playback } from 'ontime-types';
|
||||
|
||||
@@ -35,7 +35,6 @@ import { clock } from '../Clock.js';
|
||||
*/
|
||||
export function forceReset() {
|
||||
eventLoader.reset();
|
||||
sendRefetch();
|
||||
runtimeCacheStore.invalidate(delayedRundownCacheKey);
|
||||
}
|
||||
|
||||
@@ -159,7 +158,7 @@ export function updateTimer(affectedIds?: string[]) {
|
||||
export async function addEvent(eventData: Partial<OntimeEvent> | Partial<OntimeDelay> | Partial<OntimeBlock>) {
|
||||
const numEvents = DataProvider.getRundownLength();
|
||||
if (numEvents > MAX_EVENTS) {
|
||||
throw new Error(`ERROR: Reached limit number of ${MAX_EVENTS} events`);
|
||||
throw new Error(`Reached limit number of ${MAX_EVENTS} events`);
|
||||
}
|
||||
|
||||
let newEvent: Partial<OntimeBaseEvent> = {};
|
||||
@@ -192,15 +191,11 @@ export async function addEvent(eventData: Partial<OntimeEvent> | Partial<OntimeD
|
||||
// modify rundown
|
||||
await cachedAdd(insertIndex, newEvent as OntimeEvent | OntimeDelay | OntimeBlock);
|
||||
|
||||
// notify timer service of changed events
|
||||
updateTimer([id]);
|
||||
notifyChanges({ timer: [id], external: true });
|
||||
|
||||
// notify event loader that rundown size has changed
|
||||
updateChangeNumEvents();
|
||||
|
||||
// advice socket subscribers of change
|
||||
sendRefetch();
|
||||
|
||||
return newEvent;
|
||||
}
|
||||
|
||||
@@ -211,11 +206,7 @@ export async function editEvent(eventData: Partial<OntimeEvent> | Partial<Ontime
|
||||
|
||||
const newEvent = await cachedEdit(eventData.id, eventData);
|
||||
|
||||
// notify timer service of changed events
|
||||
updateTimer([newEvent.id]);
|
||||
|
||||
// advice socket subscribers of change
|
||||
sendRefetch();
|
||||
notifyChanges({ timer: [newEvent.id], external: true });
|
||||
|
||||
return newEvent;
|
||||
}
|
||||
@@ -228,14 +219,9 @@ export async function editEvent(eventData: Partial<OntimeEvent> | Partial<Ontime
|
||||
export async function deleteEvent(eventId) {
|
||||
await cachedDelete(eventId);
|
||||
|
||||
// notify timer service of changed events
|
||||
updateTimer([eventId]);
|
||||
|
||||
notifyChanges({ timer: [eventId], external: true });
|
||||
// notify event loader that rundown size has changed
|
||||
updateChangeNumEvents();
|
||||
|
||||
// advice socket subscribers of change
|
||||
sendRefetch();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -245,9 +231,7 @@ export async function deleteEvent(eventId) {
|
||||
export async function deleteAllEvents() {
|
||||
await cachedClear();
|
||||
|
||||
// notify timer service of changed events
|
||||
updateTimer();
|
||||
forceReset();
|
||||
notifyChanges({ timer: true, external: true, reset: true });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -260,22 +244,15 @@ export async function deleteAllEvents() {
|
||||
export async function reorderEvent(eventId: string, from: number, to: number) {
|
||||
const reorderedItem = await cachedReorder(eventId, from, to);
|
||||
|
||||
// notify timer service of changed events
|
||||
updateTimer();
|
||||
notifyChanges({ timer: true, external: true });
|
||||
|
||||
// advice socket subscribers of change
|
||||
sendRefetch();
|
||||
return reorderedItem;
|
||||
}
|
||||
|
||||
export async function applyDelay(eventId: string) {
|
||||
await cachedApplyDelay(eventId);
|
||||
|
||||
// notify timer service of changed events
|
||||
updateTimer();
|
||||
|
||||
// advice socket subscribers of change
|
||||
sendRefetch();
|
||||
notifyChanges({ timer: true, external: true });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -287,11 +264,7 @@ export async function applyDelay(eventId: string) {
|
||||
export async function swapEvents(from: string, to: string) {
|
||||
await cachedSwap(from, to);
|
||||
|
||||
// notify timer service of changed events
|
||||
updateTimer();
|
||||
|
||||
// advice socket subscribers of change
|
||||
sendRefetch();
|
||||
notifyChanges({ timer: true, external: true });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -301,3 +274,26 @@ export async function swapEvents(from: string, to: string) {
|
||||
function updateChangeNumEvents() {
|
||||
eventLoader.updateNumEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify services of changes in the rundown
|
||||
*/
|
||||
export function notifyChanges(options: { timer?: boolean | string[]; external?: boolean; reset?: boolean }) {
|
||||
if (options.timer) {
|
||||
// notify timer service of changed events
|
||||
if (Array.isArray(options.timer)) {
|
||||
updateTimer(options.timer);
|
||||
}
|
||||
updateTimer();
|
||||
}
|
||||
|
||||
if (options.reset) {
|
||||
// force rundown to be recalculated
|
||||
forceReset();
|
||||
}
|
||||
|
||||
if (options.external) {
|
||||
// advice socket subscribers of change
|
||||
sendRefetch();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
GetRundownCached,
|
||||
isOntimeBlock,
|
||||
isOntimeDelay,
|
||||
isOntimeEvent,
|
||||
@@ -16,6 +17,11 @@ import { isProduction } from '../../setup.js';
|
||||
import { deleteAtIndex, insertAtIndex, reorderArray } from '../../utils/arrayUtils.js';
|
||||
import { _applyDelay } from '../delayUtils.js';
|
||||
|
||||
/**
|
||||
* Keep incremental revision number of rundown for runtime
|
||||
*/
|
||||
let rundownRevision = 0;
|
||||
|
||||
/**
|
||||
* Key of rundown in cache
|
||||
*/
|
||||
@@ -38,7 +44,25 @@ export function invalidateFromError(errorMessage = 'Found mismatch between store
|
||||
* Returns rundown with calculated delays
|
||||
* Ensures request goes through the caching layer
|
||||
*/
|
||||
export function getDelayedRundown(): OntimeRundown {
|
||||
export function getRundownCache(): GetRundownCached {
|
||||
function calculateRundown() {
|
||||
const rundown = DataProvider.getRundown();
|
||||
return calculateRuntimeDelays(rundown);
|
||||
}
|
||||
|
||||
const cached = getCached(delayedRundownCacheKey, calculateRundown);
|
||||
|
||||
return {
|
||||
rundown: cached,
|
||||
revision: rundownRevision,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns rundown with calculated delays
|
||||
* Ensures request goes through the caching layer
|
||||
*/
|
||||
export function getDelayedRundown() {
|
||||
function calculateRundown() {
|
||||
const rundown = DataProvider.getRundown();
|
||||
return calculateRuntimeDelays(rundown);
|
||||
@@ -72,6 +96,8 @@ export async function cachedAdd(eventIndex: number, event: OntimeEvent | OntimeD
|
||||
runtimeCacheStore.setCached(delayedRundownCacheKey, newDelayedRundown);
|
||||
// we need to delay updating this to ensure add operation happens on same dataset
|
||||
await DataProvider.setRundown(newRundown);
|
||||
|
||||
rundownRevision++;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,6 +139,8 @@ export async function cachedEdit(
|
||||
// we need to delay updating this to ensure edit operation happens on same dataset
|
||||
await DataProvider.setRundown(updatedRundown);
|
||||
|
||||
rundownRevision++;
|
||||
|
||||
return newEvent;
|
||||
}
|
||||
|
||||
@@ -147,6 +175,8 @@ export async function cachedDelete(eventId: string) {
|
||||
}
|
||||
// we need to delay updating this to ensure edit operation happens on same dataset
|
||||
await DataProvider.setRundown(updatedRundown);
|
||||
|
||||
rundownRevision++;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -178,12 +208,15 @@ export async function cachedReorder(eventId: string, from: number, to: number) {
|
||||
// we need to delay updating this to ensure edit operation happens on same dataset
|
||||
await DataProvider.setRundown(updatedRundown);
|
||||
|
||||
rundownRevision++;
|
||||
|
||||
return reorderedEvent;
|
||||
}
|
||||
|
||||
export async function cachedClear() {
|
||||
await DataProvider.clearRundown();
|
||||
runtimeCacheStore.setCached(delayedRundownCacheKey, []);
|
||||
rundownRevision++;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -211,6 +244,8 @@ export async function cachedSwap(fromEventId: string, toEventId: string) {
|
||||
}
|
||||
|
||||
await DataProvider.setRundown(rundownToUpdate);
|
||||
|
||||
rundownRevision++;
|
||||
}
|
||||
|
||||
export async function cachedApplyDelay(eventId: string) {
|
||||
@@ -224,6 +259,8 @@ export async function cachedApplyDelay(eventId: string) {
|
||||
// update
|
||||
runtimeCacheStore.setCached(delayedRundownCacheKey, cachedRundown);
|
||||
await DataProvider.setRundown(persistedRundown);
|
||||
|
||||
rundownRevision++;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable no-console -- we are mocking the console */
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { EndAction, TimerType } from 'ontime-types';
|
||||
import { EndAction, OntimeEvent, TimerType } from 'ontime-types';
|
||||
|
||||
import { dbModel } from '../../models/dataModel.js';
|
||||
import { parseExcel, parseJson, validateEvent } from '../parser.js';
|
||||
@@ -200,7 +201,7 @@ describe('test json parser with valid def', () => {
|
||||
},
|
||||
settings: {
|
||||
app: 'ontime',
|
||||
version: 2,
|
||||
version: '2.0.0',
|
||||
timeFormat: '24',
|
||||
},
|
||||
viewSettings: {},
|
||||
@@ -259,7 +260,7 @@ describe('test json parser with valid def', () => {
|
||||
it('settings are for right app and version', () => {
|
||||
const settings = parseResponse?.settings;
|
||||
expect(settings.app).toBe('ontime');
|
||||
expect(settings.version).toBe(2);
|
||||
expect(settings.version).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it('missing settings', () => {
|
||||
@@ -386,7 +387,7 @@ describe('test corrupt data', () => {
|
||||
},
|
||||
settings: {
|
||||
app: 'ontime',
|
||||
version: 2,
|
||||
version: '2.0.0',
|
||||
serverPort: 4001,
|
||||
lock: null,
|
||||
timeFormat: '24',
|
||||
@@ -409,7 +410,7 @@ describe('test corrupt data', () => {
|
||||
},
|
||||
settings: {
|
||||
app: 'ontime',
|
||||
version: 2,
|
||||
version: '2.0.0',
|
||||
serverPort: 4001,
|
||||
lock: null,
|
||||
timeFormat: '24',
|
||||
@@ -426,7 +427,7 @@ describe('test corrupt data', () => {
|
||||
project: {},
|
||||
settings: {
|
||||
app: 'ontime',
|
||||
version: 2,
|
||||
version: '2.0.0',
|
||||
serverPort: 4001,
|
||||
lock: null,
|
||||
timeFormat: '24',
|
||||
@@ -443,7 +444,7 @@ describe('test corrupt data', () => {
|
||||
event: {},
|
||||
settings: {
|
||||
app: 'ontime',
|
||||
version: 2,
|
||||
version: '2.0.0',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -525,7 +526,7 @@ describe('test event validator', () => {
|
||||
expect(typeof validated.timeStart).toEqual('number');
|
||||
expect(validated.timeStart).toEqual(0);
|
||||
expect(typeof validated.timeEnd).toEqual('number');
|
||||
expect(validated.timeEnd).toEqual(0);
|
||||
expect(validated.timeEnd).toEqual(2);
|
||||
});
|
||||
|
||||
it('handles bad objects', () => {
|
||||
@@ -579,24 +580,24 @@ describe('test parseExcel function', () => {
|
||||
[
|
||||
'Time Start',
|
||||
'Time End',
|
||||
'Event Title',
|
||||
'Presenter Name',
|
||||
'Event Subtitle',
|
||||
'Title',
|
||||
'Presenter',
|
||||
'Subtitle',
|
||||
'End Action',
|
||||
'Timer type',
|
||||
'Is Public? (x)',
|
||||
'Skip? (x)',
|
||||
'Public',
|
||||
'Skip',
|
||||
'Notes',
|
||||
'User0:test0',
|
||||
'User1:test1',
|
||||
'User2:test2',
|
||||
'User3:test3',
|
||||
'User4:test4',
|
||||
'User5:test5',
|
||||
'User6:test6',
|
||||
'user7:test7',
|
||||
'user8:test8',
|
||||
'user9:test9',
|
||||
'test0',
|
||||
'test1',
|
||||
'test2',
|
||||
'test3',
|
||||
'test4',
|
||||
'test5',
|
||||
'test6',
|
||||
'test7',
|
||||
'test8',
|
||||
'test9',
|
||||
'Colour',
|
||||
'cue',
|
||||
],
|
||||
@@ -651,6 +652,19 @@ describe('test parseExcel function', () => {
|
||||
[],
|
||||
];
|
||||
|
||||
const partialOptions = {
|
||||
user0: 'test0',
|
||||
user1: 'test1',
|
||||
user2: 'test2',
|
||||
user3: 'test3',
|
||||
user4: 'test4',
|
||||
user5: 'test5',
|
||||
user6: 'test6',
|
||||
user7: 'test7',
|
||||
user8: 'test8',
|
||||
user9: 'test9',
|
||||
};
|
||||
|
||||
const expectedParsedProjectData = {
|
||||
title: 'Test Event',
|
||||
description: 'test description',
|
||||
@@ -706,7 +720,7 @@ describe('test parseExcel function', () => {
|
||||
},
|
||||
];
|
||||
|
||||
const parsedData = await parseExcel(testdata);
|
||||
const parsedData = parseExcel(testdata, partialOptions);
|
||||
expect(parsedData.project).toStrictEqual(expectedParsedProjectData);
|
||||
expect(parsedData.rundown).toBeDefined();
|
||||
expect(parsedData.rundown[0]).toMatchObject(expectedParsedRundown[0]);
|
||||
@@ -720,7 +734,7 @@ describe('test aliases import', () => {
|
||||
rundown: [],
|
||||
settings: {
|
||||
app: 'ontime',
|
||||
version: 2,
|
||||
version: '2.0.0',
|
||||
},
|
||||
aliases: [
|
||||
{
|
||||
@@ -759,7 +773,7 @@ describe('test userFields import', () => {
|
||||
rundown: [],
|
||||
settings: {
|
||||
app: 'ontime',
|
||||
version: 2,
|
||||
version: '2.0.0',
|
||||
},
|
||||
userFields: testUserFields,
|
||||
};
|
||||
@@ -786,7 +800,7 @@ describe('test userFields import', () => {
|
||||
rundown: [],
|
||||
settings: {
|
||||
app: 'ontime',
|
||||
version: 2,
|
||||
version: '2.0.0',
|
||||
},
|
||||
userFields: testUserFields,
|
||||
};
|
||||
@@ -800,7 +814,7 @@ describe('test userFields import', () => {
|
||||
rundown: [],
|
||||
settings: {
|
||||
app: 'ontime',
|
||||
version: 2,
|
||||
version: '2.0.0',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -814,7 +828,7 @@ describe('test userFields import', () => {
|
||||
rundown: [],
|
||||
settings: {
|
||||
app: 'ontime',
|
||||
version: 2,
|
||||
version: '2.0.0',
|
||||
},
|
||||
userFields: {
|
||||
notThis: 'this shouldng be accepted',
|
||||
@@ -833,9 +847,18 @@ describe('test views import', () => {
|
||||
rundown: [],
|
||||
settings: {
|
||||
app: 'ontime',
|
||||
version: 2,
|
||||
version: '2.0.0',
|
||||
},
|
||||
viewSettings: {
|
||||
normalColor: '#ffffffcc',
|
||||
warningColor: '#FFAB33',
|
||||
warningThreshold: 120000,
|
||||
dangerColor: '#ED3333',
|
||||
dangerThreshold: 60000,
|
||||
endMessage: '',
|
||||
overrideStyles: false,
|
||||
notAthing: true,
|
||||
},
|
||||
viewSettings: {},
|
||||
views: {
|
||||
overrideStyles: true,
|
||||
},
|
||||
@@ -849,7 +872,7 @@ describe('test views import', () => {
|
||||
endMessage: '',
|
||||
overrideStyles: false,
|
||||
};
|
||||
const parsed = parseViewSettings(testData, false);
|
||||
const parsed = parseViewSettings(testData);
|
||||
expect(parsed).toStrictEqual(expectedParsedViewSettings);
|
||||
});
|
||||
|
||||
@@ -858,19 +881,10 @@ describe('test views import', () => {
|
||||
rundown: [],
|
||||
settings: {
|
||||
app: 'ontime',
|
||||
version: 2,
|
||||
version: '2.0.0',
|
||||
},
|
||||
};
|
||||
const expectedParsedViewSettings = {
|
||||
normalColor: '#ffffffcc',
|
||||
warningColor: '#FFAB33',
|
||||
warningThreshold: 120000,
|
||||
dangerColor: '#ED3333',
|
||||
dangerThreshold: 60000,
|
||||
endMessage: '',
|
||||
overrideStyles: false,
|
||||
};
|
||||
const parsed = parseViewSettings(testData, true);
|
||||
expect(parsed).toStrictEqual(expectedParsedViewSettings);
|
||||
const parsed = parseViewSettings(testData);
|
||||
expect(parsed).toStrictEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,18 +34,18 @@ describe('mergeObject()', () => {
|
||||
third: 'yes',
|
||||
};
|
||||
const b = {
|
||||
first: 0,
|
||||
first: 'no',
|
||||
second: null,
|
||||
third: '',
|
||||
};
|
||||
const merged = mergeObject(a, b);
|
||||
expect(merged).toStrictEqual({
|
||||
first: 0,
|
||||
first: 'no',
|
||||
second: null,
|
||||
third: '',
|
||||
});
|
||||
});
|
||||
test.skip('it only merges fields of the first object', () => {
|
||||
test('it only merges fields of the first object', () => {
|
||||
const a = {
|
||||
first: 'yes',
|
||||
second: 'yes',
|
||||
@@ -57,6 +57,7 @@ describe('mergeObject()', () => {
|
||||
third: '',
|
||||
forth: 'not-this',
|
||||
};
|
||||
// @ts-expect-error -- testing changing type
|
||||
const merged = mergeObject(a, b);
|
||||
expect(merged).toStrictEqual({
|
||||
first: 0,
|
||||
@@ -64,6 +65,36 @@ describe('mergeObject()', () => {
|
||||
third: '',
|
||||
});
|
||||
});
|
||||
test('merges nested objects', () => {
|
||||
// Define a sample object with nested properties
|
||||
const a = {
|
||||
name: 'John',
|
||||
address: {
|
||||
city: 'New York',
|
||||
postalCode: '10001',
|
||||
},
|
||||
};
|
||||
|
||||
// Define a partial object with nested properties for merging
|
||||
const b = {
|
||||
name: 'Doe',
|
||||
address: {
|
||||
city: 'San Francisco',
|
||||
state: 'CA',
|
||||
},
|
||||
};
|
||||
|
||||
// @ts-expect-error -- testing missing property
|
||||
const merged = mergeObject(a, b);
|
||||
|
||||
expect(merged.name).toBe('Doe');
|
||||
expect(merged.address.city).toBe('San Francisco');
|
||||
// @ts-expect-error -- its ok, just checking
|
||||
expect(merged.address.state).toBe('CA');
|
||||
expect(merged.address.postalCode).toBe('10001');
|
||||
expect(merged.address).not.toBe(a.address);
|
||||
expect(merged.address).not.toBe(b.address);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeUndefined()', () => {
|
||||
|
||||
+166
-222
@@ -1,18 +1,27 @@
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-nocheck -- not ready to fully type
|
||||
|
||||
import fs from 'fs';
|
||||
import xlsx from 'node-xlsx';
|
||||
import { generateId, calculateDuration } from 'ontime-utils';
|
||||
import {
|
||||
generateId,
|
||||
isExcelImportMap,
|
||||
type ExcelImportMap,
|
||||
defaultExcelImportMap,
|
||||
validateEndAction,
|
||||
validateTimerType,
|
||||
type ExcelImportOptions,
|
||||
validateTimes,
|
||||
} from 'ontime-utils';
|
||||
import {
|
||||
DatabaseModel,
|
||||
EndAction,
|
||||
OntimeEvent,
|
||||
OntimeRundown,
|
||||
SupportedEvent,
|
||||
TimerType,
|
||||
ProjectData,
|
||||
UserFields,
|
||||
EndAction,
|
||||
TimerType,
|
||||
} from 'ontime-types';
|
||||
|
||||
import fs from 'fs';
|
||||
import xlsx from 'node-xlsx';
|
||||
|
||||
import { event as eventDef } from '../models/eventsDefinition.js';
|
||||
import { dbModel } from '../models/dataModel.js';
|
||||
import { deleteFile, makeString } from './parserUtils.js';
|
||||
@@ -34,27 +43,55 @@ export const JSON_MIME = 'application/json';
|
||||
/**
|
||||
* @description Excel array parser
|
||||
* @param {array} excelData - array with excel sheet
|
||||
* @param {ExcelImportOptions} options - an object that contains the import map
|
||||
* @returns {object} - parsed object
|
||||
*/
|
||||
export const parseExcel = async (excelData) => {
|
||||
export const parseExcel = (excelData: unknown[][], options?: Partial<ExcelImportMap>) => {
|
||||
const importMap: ExcelImportMap = { ...defaultExcelImportMap, ...options };
|
||||
const projectData: Partial<ProjectData> = {
|
||||
title: '',
|
||||
description: '',
|
||||
publicUrl: '',
|
||||
publicInfo: '',
|
||||
backstageUrl: '',
|
||||
backstageInfo: '',
|
||||
};
|
||||
const customUserFields: Partial<UserFields> = {
|
||||
user0: importMap.user0,
|
||||
user1: importMap.user1,
|
||||
user2: importMap.user2,
|
||||
user3: importMap.user3,
|
||||
user4: importMap.user4,
|
||||
user5: importMap.user5,
|
||||
user6: importMap.user6,
|
||||
user7: importMap.user7,
|
||||
user8: importMap.user8,
|
||||
user9: importMap.user9,
|
||||
};
|
||||
const customUserFields: Partial<UserFields> = {};
|
||||
const rundown: OntimeRundown = [];
|
||||
let timeStartIndex: number | null = null;
|
||||
let timeEndIndex: number | null = null;
|
||||
|
||||
// title stuff: strings
|
||||
let titleIndex: number | null = null;
|
||||
let cueIndex: number | null = null;
|
||||
let presenterIndex: number | null = null;
|
||||
let subtitleIndex: number | null = null;
|
||||
let isPublicIndex: number | null = null;
|
||||
let skipIndex: 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;
|
||||
|
||||
// times: numbers
|
||||
let timeStartIndex: number | null = null;
|
||||
let timeEndIndex: number | null = null;
|
||||
let durationIndex: number | null = null;
|
||||
|
||||
// options: enum properties
|
||||
let endActionIndex: number | null = null;
|
||||
let timerTypeIndex: number | null = null;
|
||||
|
||||
// user fields: strings
|
||||
let user0Index: number | null = null;
|
||||
let user1Index: number | null = null;
|
||||
let user2Index: number | null = null;
|
||||
@@ -65,13 +102,11 @@ export const parseExcel = async (excelData) => {
|
||||
let user7Index: number | null = null;
|
||||
let user8Index: number | null = null;
|
||||
let user9Index: number | null = null;
|
||||
let endActionIndex: number | null = null;
|
||||
let timerTypeIndex: number | null = null;
|
||||
|
||||
excelData
|
||||
.filter((e) => e.length > 0)
|
||||
.forEach((row) => {
|
||||
// project data imports are on the column to the right
|
||||
// these fields contain the data to its right
|
||||
let projectTitleNext = false;
|
||||
let projectDescriptionNext = false;
|
||||
let publicUrlNext = false;
|
||||
@@ -80,31 +115,68 @@ export const parseExcel = async (excelData) => {
|
||||
let backstageInfoNext = false;
|
||||
|
||||
const event: Partial<OntimeEvent> = {};
|
||||
const handlers = {
|
||||
[importMap.projectName]: () => (projectTitleNext = true),
|
||||
[importMap.projectDescription]: () => (projectDescriptionNext = true),
|
||||
[importMap.publicUrl]: () => (publicUrlNext = true),
|
||||
[importMap.publicInfo]: () => (publicInfoNext = true),
|
||||
[importMap.backstageUrl]: () => (backstageUrlNext = true),
|
||||
[importMap.backstageInfo]: () => (backstageInfoNext = true),
|
||||
|
||||
[importMap.timeStart]: (index: number) => (timeStartIndex = index),
|
||||
[importMap.timeEnd]: (index: number) => (timeEndIndex = index),
|
||||
[importMap.duration]: (index: number) => (durationIndex = index),
|
||||
|
||||
[importMap.cue]: (index: number) => (cueIndex = index),
|
||||
[importMap.title]: (index: number) => (titleIndex = index),
|
||||
[importMap.presenter]: (index: number) => (presenterIndex = index),
|
||||
[importMap.subtitle]: (index: number) => (subtitleIndex = index),
|
||||
[importMap.isPublic]: (index: number) => (isPublicIndex = index),
|
||||
[importMap.skip]: (index: number) => (skipIndex = index),
|
||||
[importMap.note]: (index: number) => (notesIndex = index),
|
||||
[importMap.colour]: (index: number) => (colourIndex = index),
|
||||
|
||||
[importMap.endAction]: (index: number) => (endActionIndex = index),
|
||||
[importMap.timerType]: (index: number) => (timerTypeIndex = index),
|
||||
|
||||
[importMap.user0]: (index: number) => (user0Index = index),
|
||||
[importMap.user1]: (index: number) => (user1Index = index),
|
||||
[importMap.user2]: (index: number) => (user2Index = index),
|
||||
[importMap.user3]: (index: number) => (user3Index = index),
|
||||
[importMap.user4]: (index: number) => (user4Index = index),
|
||||
[importMap.user5]: (index: number) => (user5Index = index),
|
||||
[importMap.user6]: (index: number) => (user6Index = index),
|
||||
[importMap.user7]: (index: number) => (user7Index = index),
|
||||
[importMap.user8]: (index: number) => (user8Index = index),
|
||||
[importMap.user9]: (index: number) => (user9Index = index),
|
||||
} as const;
|
||||
|
||||
row.forEach((column, j) => {
|
||||
// check flags
|
||||
// 1. we check if we have set a flag for a known field
|
||||
if (projectTitleNext) {
|
||||
projectData.title = column;
|
||||
projectData.title = makeString(column, '');
|
||||
projectTitleNext = false;
|
||||
} else if (projectDescriptionNext) {
|
||||
projectData.description = column;
|
||||
projectData.description = makeString(column, '');
|
||||
projectDescriptionNext = false;
|
||||
} else if (publicUrlNext) {
|
||||
projectData.publicUrl = column;
|
||||
projectData.publicUrl = makeString(column, '');
|
||||
publicUrlNext = false;
|
||||
} else if (publicInfoNext) {
|
||||
projectData.publicInfo = column;
|
||||
projectData.publicInfo = makeString(column, '');
|
||||
publicInfoNext = false;
|
||||
} else if (backstageUrlNext) {
|
||||
projectData.backstageUrl = column;
|
||||
projectData.backstageUrl = makeString(column, '');
|
||||
backstageUrlNext = false;
|
||||
} else if (backstageInfoNext) {
|
||||
projectData.backstageInfo = column;
|
||||
projectData.backstageInfo = makeString(column, '');
|
||||
backstageInfoNext = false;
|
||||
} else if (j === timeStartIndex) {
|
||||
event.timeStart = parseExcelDate(column);
|
||||
} else if (j === timeEndIndex) {
|
||||
event.timeEnd = parseExcelDate(column);
|
||||
} else if (j === durationIndex) {
|
||||
event.duration = parseExcelDate(column);
|
||||
} else if (j === titleIndex) {
|
||||
event.title = makeString(column, '');
|
||||
} else if (j === cueIndex) {
|
||||
@@ -120,184 +192,68 @@ export const parseExcel = async (excelData) => {
|
||||
} else if (j === notesIndex) {
|
||||
event.note = makeString(column, '');
|
||||
} else if (j === endActionIndex) {
|
||||
if (column === '') {
|
||||
event.endAction = EndAction.None;
|
||||
} else {
|
||||
event.endAction = column;
|
||||
}
|
||||
event.endAction = validateEndAction(column);
|
||||
} else if (j === timerTypeIndex) {
|
||||
if (column === '') {
|
||||
event.timerType = TimerType.CountDown;
|
||||
} else {
|
||||
event.timerType = column;
|
||||
}
|
||||
event.timerType = validateTimerType(column);
|
||||
} else if (j === colourIndex) {
|
||||
event.colour = column;
|
||||
event.colour = makeString(column, '');
|
||||
} else if (j === user0Index) {
|
||||
event.user0 = column;
|
||||
event.user0 = makeString(column, '');
|
||||
} else if (j === user1Index) {
|
||||
event.user1 = column;
|
||||
event.user1 = makeString(column, '');
|
||||
} else if (j === user2Index) {
|
||||
event.user2 = column;
|
||||
event.user2 = makeString(column, '');
|
||||
} else if (j === user3Index) {
|
||||
event.user3 = column;
|
||||
event.user3 = makeString(column, '');
|
||||
} else if (j === user4Index) {
|
||||
event.user4 = column;
|
||||
event.user4 = makeString(column, '');
|
||||
} else if (j === user5Index) {
|
||||
event.user5 = column;
|
||||
event.user5 = makeString(column, '');
|
||||
} else if (j === user6Index) {
|
||||
event.user6 = column;
|
||||
event.user6 = makeString(column, '');
|
||||
} else if (j === user7Index) {
|
||||
event.user7 = column;
|
||||
event.user7 = makeString(column, '');
|
||||
} else if (j === user8Index) {
|
||||
event.user8 = column;
|
||||
event.user8 = makeString(column, '');
|
||||
} else if (j === user9Index) {
|
||||
event.user9 = column;
|
||||
event.user9 = makeString(column, '');
|
||||
} else {
|
||||
// 2. if there is no flag, lets see if we know the field type
|
||||
if (typeof column === 'string') {
|
||||
const col = column.toLowerCase();
|
||||
|
||||
// look for keywords
|
||||
// need to make sure it is a string first
|
||||
switch (col) {
|
||||
case 'project name':
|
||||
projectTitleNext = true;
|
||||
break;
|
||||
case 'project description':
|
||||
projectDescriptionNext = true;
|
||||
break;
|
||||
case 'public url':
|
||||
publicUrlNext = true;
|
||||
break;
|
||||
case 'public info':
|
||||
publicInfoNext = true;
|
||||
break;
|
||||
case 'backstage url':
|
||||
backstageUrlNext = true;
|
||||
break;
|
||||
case 'backstage info':
|
||||
backstageInfoNext = true;
|
||||
break;
|
||||
case 'time start':
|
||||
case 'start':
|
||||
timeStartIndex = j;
|
||||
break;
|
||||
case 'time end':
|
||||
case 'end':
|
||||
case 'finish':
|
||||
timeEndIndex = j;
|
||||
break;
|
||||
case 'cue':
|
||||
case 'page':
|
||||
cueIndex = j;
|
||||
break;
|
||||
case 'event title':
|
||||
case 'title':
|
||||
titleIndex = j;
|
||||
break;
|
||||
case 'presenter name':
|
||||
case 'speaker':
|
||||
case 'presenter':
|
||||
presenterIndex = j;
|
||||
break;
|
||||
case 'event subtitle':
|
||||
case 'subtitle':
|
||||
subtitleIndex = j;
|
||||
break;
|
||||
case 'is public? (x)':
|
||||
case 'is public':
|
||||
case 'public':
|
||||
isPublicIndex = j;
|
||||
break;
|
||||
case 'skip? (x)':
|
||||
case 'skip?':
|
||||
case 'skip':
|
||||
skipIndex = j;
|
||||
break;
|
||||
case 'note':
|
||||
case 'notes':
|
||||
notesIndex = j;
|
||||
break;
|
||||
case 'colour':
|
||||
case 'color':
|
||||
colourIndex = j;
|
||||
break;
|
||||
case 'end action':
|
||||
endActionIndex = j;
|
||||
break;
|
||||
case 'timer type':
|
||||
timerTypeIndex = j;
|
||||
break;
|
||||
default:
|
||||
// look for user defined
|
||||
if (col.startsWith('user')) {
|
||||
const index = column.charAt(4);
|
||||
// name is the bit after the :
|
||||
const [, name] = column.split(':');
|
||||
if (typeof name !== 'undefined') {
|
||||
if (index === '0') {
|
||||
customUserFields.user0 = name;
|
||||
user0Index = j;
|
||||
} else if (index === '1') {
|
||||
customUserFields.user1 = name;
|
||||
user1Index = j;
|
||||
} else if (index === '2') {
|
||||
customUserFields.user2 = name;
|
||||
user2Index = j;
|
||||
} else if (index === '3') {
|
||||
customUserFields.user3 = name;
|
||||
user3Index = j;
|
||||
} else if (index === '4') {
|
||||
customUserFields.user4 = name;
|
||||
user4Index = j;
|
||||
} else if (index === '5') {
|
||||
customUserFields.user5 = name;
|
||||
user5Index = j;
|
||||
} else if (index === '6') {
|
||||
customUserFields.user6 = name;
|
||||
user6Index = j;
|
||||
} else if (index === '7') {
|
||||
customUserFields.user7 = name;
|
||||
user7Index = j;
|
||||
} else if (index === '8') {
|
||||
customUserFields.user8 = name;
|
||||
user8Index = j;
|
||||
} else if (index === '9') {
|
||||
customUserFields.user9 = name;
|
||||
user9Index = j;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
if (handlers[col]) {
|
||||
handlers[col](j);
|
||||
}
|
||||
// else. we don't know how to handle this column
|
||||
// just ignore it
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(event).length > 0) {
|
||||
// if any data was found, push to array
|
||||
// take care of it in the next step
|
||||
rundown.push({ ...event, type: SupportedEvent.Event } as OntimeEvent);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
rundown,
|
||||
project: projectData,
|
||||
settings: {
|
||||
app: 'ontime',
|
||||
version: 2,
|
||||
version: '2.0.0',
|
||||
},
|
||||
userFields: { ...dbModel.userFields, ...customUserFields },
|
||||
userFields: customUserFields,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* @description JSON parser function for v1 of data system
|
||||
* @param {object} jsonData - json data JSON object to be parsed
|
||||
* @param {boolean} [enforce=false] - flag, tells to create an object anyway
|
||||
* @description JSON parser function for ontime project file
|
||||
* @param {object} jsonData - project file to be parsed
|
||||
* @returns {object} - parsed object
|
||||
*/
|
||||
export const parseJson = async (jsonData, enforce = false): Promise<DatabaseModel | null> => {
|
||||
export const parseJson = async (jsonData): Promise<DatabaseModel | null> => {
|
||||
if (!jsonData || typeof jsonData !== 'object') {
|
||||
return null;
|
||||
}
|
||||
@@ -308,17 +264,17 @@ export const parseJson = async (jsonData, enforce = false): Promise<DatabaseMode
|
||||
// parse Events
|
||||
returnData.rundown = parseRundown(jsonData);
|
||||
// parse Event
|
||||
returnData.project = parseProject(jsonData, enforce);
|
||||
returnData.project = parseProject(jsonData) ?? dbModel.project;
|
||||
// Settings handled partially
|
||||
returnData.settings = parseSettings(jsonData, enforce);
|
||||
returnData.settings = parseSettings(jsonData) ?? dbModel.settings;
|
||||
// View settings handled partially
|
||||
returnData.viewSettings = parseViewSettings(jsonData, enforce);
|
||||
returnData.viewSettings = parseViewSettings(jsonData) ?? dbModel.viewSettings;
|
||||
// Import Aliases if any
|
||||
returnData.aliases = parseAliases(jsonData);
|
||||
// Import user fields if any
|
||||
returnData.userFields = parseUserFields(jsonData);
|
||||
// Import OSC settings if any
|
||||
returnData.osc = parseOsc(jsonData, enforce);
|
||||
returnData.osc = parseOsc(jsonData) ?? dbModel.osc;
|
||||
// Import HTTP settings if any
|
||||
returnData.http = parseHttp(jsonData, enforce);
|
||||
|
||||
@@ -345,19 +301,19 @@ export const validateEvent = (eventArgs: Partial<OntimeEvent>, cueFallback: stri
|
||||
|
||||
const e = eventArgs;
|
||||
const d = eventDef;
|
||||
const start = e.timeStart != null && typeof e.timeStart === 'number' ? e.timeStart : d.timeStart;
|
||||
const end = e.timeEnd != null && typeof e.timeEnd === 'number' ? e.timeEnd : d.timeEnd;
|
||||
|
||||
const { timeStart, timeEnd, duration } = validateTimes(e.timeStart, e.timeEnd, e.duration);
|
||||
|
||||
event = {
|
||||
...d,
|
||||
title: makeString(e.title, d.title),
|
||||
subtitle: makeString(e.subtitle, d.subtitle),
|
||||
presenter: makeString(e.presenter, d.presenter),
|
||||
timeStart: start,
|
||||
timeEnd: end,
|
||||
endAction: makeString(e.endAction, d.endAction),
|
||||
timerType: makeString(e.timerType, d.timerType),
|
||||
duration: calculateDuration(start, end),
|
||||
timeStart,
|
||||
timeEnd,
|
||||
duration,
|
||||
endAction: validateEndAction(e.endAction, EndAction.None),
|
||||
timerType: validateTimerType(e.timerType, TimerType.CountDown),
|
||||
isPublic: typeof e.isPublic === 'boolean' ? e.isPublic : d.isPublic,
|
||||
skip: typeof e.skip === 'boolean' ? e.skip : d.skip,
|
||||
note: makeString(e.note, d.note),
|
||||
@@ -372,8 +328,8 @@ export const validateEvent = (eventArgs: Partial<OntimeEvent>, cueFallback: stri
|
||||
user8: makeString(e.user8, d.user8),
|
||||
user9: makeString(e.user9, d.user9),
|
||||
colour: makeString(e.colour, d.colour),
|
||||
id,
|
||||
cue: makeString(e.cue, cueFallback),
|
||||
id,
|
||||
type: 'event',
|
||||
};
|
||||
}
|
||||
@@ -381,68 +337,56 @@ export const validateEvent = (eventArgs: Partial<OntimeEvent>, cueFallback: stri
|
||||
return event;
|
||||
};
|
||||
|
||||
type ResponseOK = { data: Partial<DatabaseModel>; message: 'success' };
|
||||
type ResponseError = { error: true; message: string };
|
||||
type ResponseOK = {
|
||||
data: Partial<DatabaseModel>;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Middleware function that checks file type and calls relevant parser
|
||||
* @param {string} file - reference to file
|
||||
* @param options - import options
|
||||
* @return {object} - parse result message
|
||||
*/
|
||||
export const fileHandler = async (file): Promise<ResponseOK | ResponseError> => {
|
||||
let res: Partial<ResponseOK | ResponseError> = {};
|
||||
export const fileHandler = async (file: string, options: ExcelImportOptions): Promise<Partial<ResponseOK>> => {
|
||||
const res: Partial<ResponseOK> = {};
|
||||
|
||||
// check which file type are we dealing with
|
||||
if (file.endsWith('.xlsx')) {
|
||||
try {
|
||||
const excelData = xlsx
|
||||
.parse(file, { cellDates: true })
|
||||
.find(({ name }) => name.toLowerCase() === 'ontime' || name.toLowerCase() === 'event schedule');
|
||||
|
||||
// we only look at worksheets called ontime or event schedule
|
||||
if (excelData?.data) {
|
||||
const dataFromExcel = await parseExcel(excelData.data);
|
||||
res.data = {};
|
||||
res.data.rundown = parseRundown(dataFromExcel);
|
||||
res.data.project = parseProject(dataFromExcel, true);
|
||||
res.data.userFields = parseUserFields(dataFromExcel);
|
||||
res.message = 'success';
|
||||
} else {
|
||||
const errorMessage = 'No sheet found named "ontime" or "event schedule"';
|
||||
res = {
|
||||
error: true,
|
||||
message: errorMessage,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
res = { error: true, message: `Error parsing file: ${error}` };
|
||||
// we need to check that the options are applicable
|
||||
if (!isExcelImportMap(options)) {
|
||||
throw new Error('Got incorrect options to excel import', JSON.parse(options));
|
||||
}
|
||||
|
||||
const excelData = xlsx
|
||||
.parse(file, { cellDates: true })
|
||||
.find(({ name }) => name.toLowerCase() === options.worksheet.toLowerCase());
|
||||
|
||||
if (!excelData?.data) {
|
||||
throw new Error(`Could not find data to import, maybe the worksheet name is incorrect: ${options.worksheet}`);
|
||||
}
|
||||
|
||||
const dataFromExcel = parseExcel(excelData.data, options);
|
||||
// we run the parsed data through an extra step to ensure the objects shape
|
||||
res.data = {};
|
||||
res.data.rundown = parseRundown(dataFromExcel);
|
||||
if (res.data.rundown.length < 1) {
|
||||
throw new Error(`Could not find data to import in the worksheet ${options.worksheet}`);
|
||||
}
|
||||
res.data.project = parseProject(dataFromExcel);
|
||||
res.data.userFields = parseUserFields(dataFromExcel);
|
||||
return res;
|
||||
}
|
||||
|
||||
if (file.endsWith('.json')) {
|
||||
// if json check version
|
||||
const rawdata = fs.readFileSync(file);
|
||||
const rawdata = fs.readFileSync(file).toString();
|
||||
let uploadedJson = null;
|
||||
|
||||
try {
|
||||
uploadedJson = JSON.parse(rawdata);
|
||||
} catch (error) {
|
||||
return { error: true, message: 'Error parsing JSON file' };
|
||||
}
|
||||
uploadedJson = JSON.parse(rawdata);
|
||||
res.data = await parseJson(uploadedJson);
|
||||
|
||||
if (uploadedJson.settings.version === 2) {
|
||||
try {
|
||||
res.data = await parseJson(uploadedJson);
|
||||
res.message = 'success';
|
||||
} catch (error) {
|
||||
res = { error: true, message: `Error parsing file: ${error}` };
|
||||
}
|
||||
} else {
|
||||
res = { error: true, message: 'Error parsing file, version unknown' };
|
||||
}
|
||||
// delete file
|
||||
await deleteFile(file);
|
||||
return res;
|
||||
}
|
||||
|
||||
// delete file
|
||||
await deleteFile(file);
|
||||
return res;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { generateId } from 'ontime-utils';
|
||||
import {
|
||||
Alias,
|
||||
EndAction,
|
||||
OntimeRundown,
|
||||
HTTPSettings,
|
||||
OSCSettings,
|
||||
@@ -10,7 +9,6 @@ import {
|
||||
ProjectData,
|
||||
Settings,
|
||||
TimerLifeCycle,
|
||||
TimerType,
|
||||
UserFields,
|
||||
ViewSettings,
|
||||
} from 'ontime-types';
|
||||
@@ -46,18 +44,6 @@ export const parseRundown = (data): OntimeRundown => {
|
||||
continue;
|
||||
}
|
||||
|
||||
// validate the right endAction is used
|
||||
if (e.endAction && !Object.values(EndAction).includes(e.endAction)) {
|
||||
e.endAction = EndAction.None;
|
||||
console.log('WARNING: invalid End Action provided, using default');
|
||||
}
|
||||
|
||||
// validate the right timerType is used
|
||||
if (e.timerType && !Object.values(TimerType).includes(e.timerType)) {
|
||||
e.timerType = TimerType.CountDown;
|
||||
console.log('WARNING: invalid Timer Type provided, using default');
|
||||
}
|
||||
|
||||
if (e.type === 'event') {
|
||||
eventIndex += 1;
|
||||
const event = validateEvent(e, eventIndex.toString());
|
||||
@@ -89,10 +75,9 @@ export const parseRundown = (data): OntimeRundown => {
|
||||
/**
|
||||
* Parse event portion of an entry
|
||||
* @param {object} data - data object
|
||||
* @param {boolean} enforce - whether to create a definition if one is missing
|
||||
* @returns {object} - event object data
|
||||
*/
|
||||
export const parseProject = (data, enforce): ProjectData => {
|
||||
export const parseProject = (data): ProjectData => {
|
||||
let newProjectData: Partial<ProjectData> = {};
|
||||
// we are adding this here to aid transition, should be removed once enough time has past that users have fully migrated
|
||||
// TODO: Remove eventually
|
||||
@@ -110,9 +95,6 @@ export const parseProject = (data, enforce): ProjectData => {
|
||||
backstageUrl: project.backstageUrl || dbModel.project.backstageUrl,
|
||||
backstageInfo: project.backstageInfo || dbModel.project.backstageInfo,
|
||||
};
|
||||
} else if (enforce) {
|
||||
newProjectData = { ...dbModel.project };
|
||||
console.log('Created project object in db');
|
||||
}
|
||||
return newProjectData as ProjectData;
|
||||
};
|
||||
@@ -120,10 +102,9 @@ export const parseProject = (data, enforce): ProjectData => {
|
||||
/**
|
||||
* Parse settings portion of an entry
|
||||
* @param {object} data - data object
|
||||
* @param {boolean} enforce - whether to create a definition if one is missing
|
||||
* @returns {object} - event object data
|
||||
*/
|
||||
export const parseSettings = (data, enforce): Settings => {
|
||||
export const parseSettings = (data): Settings => {
|
||||
let newSettings: Partial<Settings> = {};
|
||||
if ('settings' in data) {
|
||||
console.log('Found settings definition, importing...');
|
||||
@@ -134,6 +115,7 @@ export const parseSettings = (data, enforce): Settings => {
|
||||
console.log('ERROR: unknown app version, skipping');
|
||||
} else {
|
||||
const settings = {
|
||||
version: dbModel.settings.version,
|
||||
serverPort: s.serverPort || dbModel.settings.serverPort,
|
||||
editorKey: s.editorKey || null,
|
||||
operatorKey: s.operatorKey || null,
|
||||
@@ -147,9 +129,6 @@ export const parseSettings = (data, enforce): Settings => {
|
||||
...settings,
|
||||
};
|
||||
}
|
||||
} else if (enforce) {
|
||||
newSettings = dbModel.settings;
|
||||
console.log('Created settings object in db');
|
||||
}
|
||||
return newSettings as Settings;
|
||||
};
|
||||
@@ -157,10 +136,9 @@ export const parseSettings = (data, enforce): Settings => {
|
||||
/**
|
||||
* Parse settings portion of an entry
|
||||
* @param {object} data - data object
|
||||
* @param {boolean} enforce - whether to create a definition if one is missing
|
||||
* @returns {object} - event object data
|
||||
*/
|
||||
export const parseViewSettings = (data, enforce): ViewSettings => {
|
||||
export const parseViewSettings = (data): ViewSettings => {
|
||||
let newViews: Partial<ViewSettings> = {};
|
||||
if ('viewSettings' in data) {
|
||||
console.log('Found view definition, importing...');
|
||||
@@ -176,13 +154,7 @@ export const parseViewSettings = (data, enforce): ViewSettings => {
|
||||
endMessage: v.endMessage ?? dbModel.viewSettings.endMessage,
|
||||
};
|
||||
|
||||
// write to db
|
||||
newViews = {
|
||||
...viewSettings,
|
||||
};
|
||||
} else if (enforce) {
|
||||
newViews = dbModel.viewSettings;
|
||||
console.log('Created viewSettings object in db');
|
||||
newViews = { ...viewSettings };
|
||||
}
|
||||
return newViews as ViewSettings;
|
||||
};
|
||||
@@ -225,16 +197,11 @@ export const validateOscObject = (data: Subscription): boolean => {
|
||||
/**
|
||||
* Parse osc portion of an entry
|
||||
*/
|
||||
export const parseOsc = (
|
||||
data: {
|
||||
osc?: Partial<OSCSettings>;
|
||||
},
|
||||
enforce: boolean,
|
||||
): OSCSettings | Record<string, never> => {
|
||||
export const parseOsc = (data: { osc?: Partial<OSCSettings> }): OSCSettings => {
|
||||
if ('osc' in data) {
|
||||
console.log('Found OSC definition, importing...');
|
||||
|
||||
const loadedConfig = data?.osc || {};
|
||||
const loadedConfig = data.osc || {};
|
||||
const validatedSubscriptions = validateOscObject(loadedConfig.subscriptions)
|
||||
? loadedConfig.subscriptions
|
||||
: dbModel.osc.subscriptions;
|
||||
@@ -247,10 +214,7 @@ export const parseOsc = (
|
||||
enabledOut: loadedConfig.enabledOut ?? dbModel.osc.enabledOut,
|
||||
subscriptions: validatedSubscriptions,
|
||||
};
|
||||
} else if (enforce) {
|
||||
console.log('Created OSC object in db');
|
||||
return { ...dbModel.osc };
|
||||
} else return {};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import fs from 'fs';
|
||||
import { deepmerge } from 'ontime-utils';
|
||||
|
||||
/**
|
||||
* @description Ensures variable is string, it skips object types
|
||||
* @param {any} val - variable to convert
|
||||
* @param val - variable to convert
|
||||
* @param {string} [fallback=''] - fallback value
|
||||
* @returns {string} - value as string or fallback if not possible
|
||||
*/
|
||||
export const makeString = (val: any, fallback = ''): string => {
|
||||
export const makeString = (val: unknown, fallback = ''): string => {
|
||||
if (typeof val === 'string') return val;
|
||||
else if (val == null || val.constructor === Object) return fallback;
|
||||
return val.toString();
|
||||
@@ -52,16 +53,30 @@ export const isEmptyObject = (obj: object) => {
|
||||
|
||||
/**
|
||||
* @description Merges two objects, suppressing undefined keys
|
||||
* @param {object} a
|
||||
* @param {object} b
|
||||
* @param {object} a - any object
|
||||
* @param {object} b - a potential partial object of same time as a
|
||||
*/
|
||||
export const mergeObject = (a, b) => {
|
||||
const merged = {};
|
||||
Object.keys({ ...a, ...b }).map((key) => {
|
||||
merged[key] = typeof b[key] === 'undefined' ? a[key] : b[key];
|
||||
});
|
||||
export function mergeObject<T extends object>(a: T, b: Partial<T>): T {
|
||||
const merged = { ...a };
|
||||
|
||||
for (const key in b) {
|
||||
const aValue = a[key];
|
||||
const bValue = b[key];
|
||||
|
||||
// ignore keys that do not exist in original object
|
||||
if (!Object.hasOwn(merged, key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof bValue === 'object' && bValue !== null && typeof aValue === 'object' && aValue !== null) {
|
||||
// @ts-expect-error -- library side, ignore for now
|
||||
merged[key] = deepmerge(aValue, bValue);
|
||||
} else if (bValue !== undefined) {
|
||||
merged[key] = bValue;
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Removes undefined
|
||||
|
||||
@@ -89,14 +89,18 @@ export const forgivingStringToMillis = (value: string, fillLeft = true): number
|
||||
* @returns {number} - time in milliseconds
|
||||
|
||||
*/
|
||||
export const parseExcelDate = (excelDate: string): number => {
|
||||
// attempt converting to date object
|
||||
const date = new Date(excelDate);
|
||||
if (date instanceof Date && !isNaN(date.getTime())) {
|
||||
return dateToMillis(date);
|
||||
} else if (isTimeString(excelDate)) {
|
||||
return forgivingStringToMillis(excelDate);
|
||||
export const parseExcelDate = (excelDate: unknown): number => {
|
||||
if (excelDate instanceof Date) {
|
||||
return dateToMillis(excelDate);
|
||||
} else if (typeof excelDate === 'string') {
|
||||
const date = new Date(excelDate);
|
||||
if (date instanceof Date && !isNaN(date.getTime())) {
|
||||
return dateToMillis(date);
|
||||
} else if (isTimeString(excelDate)) {
|
||||
return forgivingStringToMillis(excelDate);
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
/**
|
||||
* @description Cleans given url
|
||||
* @param {string} url - URL to be checked
|
||||
* @returns {string} Sanitized url
|
||||
*/
|
||||
export const cleanURL = (url) => {
|
||||
// trim whitespaces
|
||||
let r = url.trim();
|
||||
|
||||
// clear any whitespaces
|
||||
r = r.split(' ').join('%20');
|
||||
|
||||
// contain only allowed characters
|
||||
r = r.replace(/([@\s<>[\]{}|\\^])+/g, '');
|
||||
// starts with http://
|
||||
if (!r.startsWith('http://')) r = `http://${r}`;
|
||||
|
||||
return r;
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* @description Cleans given url
|
||||
* @param {string} url - URL to be checked
|
||||
* @returns {string} Sanitized url
|
||||
*/
|
||||
export const cleanURL = (url: string): string => {
|
||||
// trim whitespaces
|
||||
let sanitised = url.trim();
|
||||
|
||||
// clear any whitespaces
|
||||
sanitised = sanitised.split(' ').join('%20');
|
||||
|
||||
// contain only allowed characters
|
||||
sanitised = sanitised.replace(/([@\s<>[\]{}|\\^])+/g, '');
|
||||
|
||||
// starts with http://
|
||||
if (!sanitised.startsWith('http://')) sanitised = `http://${sanitised}`;
|
||||
|
||||
return sanitised;
|
||||
};
|
||||
Reference in New Issue
Block a user