V2 monorepo (#285)

* refactor(project structure): UI

* refactor(project structure): extract utilities

* refactor(project structure): remove unused

* refactor(project structure): electron

* refactor(project structure): server

refactor: migrate to vitest

refactor: monorepo config

* refactor: extract application menu

* refactor: exit process

* refactor: extract tray menu

* chore: electron build

* Added Seconds in studio clock #282
---------

Co-authored-by: Fabian Posenau <fabian@fphome.de>

---------

Co-authored-by: Fabian Posenau <fabian.p99@gmx.de>
Co-authored-by: Fabian Posenau <fabian@fphome.de>
This commit is contained in:
Carlos Valente
2023-02-14 22:02:15 +01:00
committed by GitHub
parent 3918758d32
commit de9a7a87fd
439 changed files with 11381 additions and 14294 deletions
@@ -0,0 +1,164 @@
import { Server } from 'node-osc';
import { PlaybackService } from '../services/PlaybackService.js';
import { messageManager } from '../classes/message-manager/MessageManager.js';
import { socketProvider } from '../classes/socket/SocketController.js';
import { ADDRESS_MESSAGE_CONTROL } from '../classes/socket/socketConfig.js';
let oscServer = null;
/**
* @description utility function to shut down osc server
*/
export const shutdownOSCServer = () => {
if (oscServer != null) oscServer.close();
};
/**
* @description initialises OSC server
* @param {object} config
*/
export const initiateOSC = (config) => {
oscServer = new Server(config.port, '0.0.0.0');
oscServer.on('error', console.error);
oscServer.on('message', function (msg) {
// message should look like /ontime/{path} {args} where
// ontime: fixed message for app
// path: command to be called
// args: extra data, only used on some API entries (delay, goto)
// split message
const [, address, path] = msg[0].split('/');
const args = msg[1];
// get first part before (ontime)
if (address !== 'ontime') {
console.error('RX', `OSC IN: Message address ${address} not recognised`);
return;
}
// get second part (command)
if (!path) {
console.error('RX', 'OSC IN: No path found');
return;
}
switch (path.toLowerCase()) {
case 'onair': {
const featureData = messageManager.setOnAir(true);
socketProvider.send(ADDRESS_MESSAGE_CONTROL, featureData);
break;
}
case 'offair': {
const featureData = messageManager.setOnAir(false);
socketProvider.send(ADDRESS_MESSAGE_CONTROL, featureData);
break;
}
case 'play': {
PlaybackService.start();
break;
}
case 'start': {
try {
const eventIndex = Number(args);
if (isNaN(eventIndex)) {
socketProvider.error('RX', `OSC IN: event index not recognised ${args}`);
return;
}
PlaybackService.startByIndex(eventIndex);
} catch (error) {
console.log('Error loading event: ', error);
}
break;
}
case 'startid': {
if (!args) {
socketProvider.error('RX', `OSC IN: No ID in request`);
return;
}
PlaybackService.loadById(args);
break;
}
case 'pause': {
PlaybackService.pause();
break;
}
case 'prev': {
PlaybackService.loadPrevious();
break;
}
case 'next': {
PlaybackService.loadNext();
break;
}
case 'unload':
case 'stop': {
PlaybackService.stop();
break;
}
case 'reload': {
PlaybackService.reload();
break;
}
case 'roll': {
PlaybackService.roll();
break;
}
case 'delay': {
try {
const delayTime = Number(args);
if (isNaN(delayTime)) {
socketProvider.error('RX', `OSC IN: delay time not recognised ${args}`);
return;
}
PlaybackService.setDelay(delayTime);
} catch (error) {
console.log('Error adding delay: ', error);
}
break;
}
case 'goto':
case 'load': {
try {
const eventIndex = Number(args);
if (isNaN(eventIndex) || eventIndex <= 0) {
socketProvider.error(
'RX',
`OSC IN: event index not recognised or out of range ${eventIndex}`
);
} else {
PlaybackService.loadByIndex(eventIndex - 1);
}
} catch (error) {
socketProvider.error('RX', `OSC IN: error calling goto ${error}`);
}
break;
}
case 'gotoid':
case 'loadid': {
if (!args) {
socketProvider.error('RX', `OSC IN: event ID not recognised: ${args}}`);
return;
}
try {
PlaybackService.loadById(args.toString().toLowerCase());
} catch (error) {
socketProvider.error('RX', `OSC IN: error calling goto ${error}`);
}
break;
}
case 'get-playback': {
const playback = global.timer.state;
global.timer.sendOsc('playback', playback);
break;
}
default: {
socketProvider.warning('RX', `OSC IN: unhandled message ${path}`);
break;
}
}
});
};
@@ -0,0 +1,29 @@
import { removeUndefined } from '../utils/parserUtils.js';
import { failEmptyObjects } from '../utils/routerUtils.js';
import { DataProvider } from '../classes/data-provider/DataProvider.js';
// Create controller for GET request to 'event'
export const getEvent = async (req, res) => {
res.json(DataProvider.getEventData());
};
// Create controller for POST request to 'event'
export const postEvent = async (req, res) => {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const newEvent = removeUndefined({
title: req.body?.title,
url: req.body?.url,
publicInfo: req.body?.publicInfo,
backstageInfo: req.body?.backstageInfo,
endMessage: req.body?.endMessage,
});
const newData = await DataProvider.setEventData(newEvent);
res.status(200).send(newData);
} catch (error) {
res.status(400).send(error);
}
};
@@ -0,0 +1,14 @@
import { body, validationResult } from 'express-validator';
export const eventSanitizer = [
body('title').optional().isString().trim(),
body('url').optional().isString().trim(),
body('publicInfo').optional().isString().trim(),
body('backstageInfo').optional().isString().trim(),
body('endMessage').optional().isString().trim(),
(req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
@@ -0,0 +1,293 @@
import fs from 'fs';
import { networkInterfaces } from 'os';
import { generateId } from 'ontime-utils';
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 { runtimeState } from '../stores/EventStore.js';
import { resolveDbPath } from '../setup.js';
// Create controller for GET request to '/ontime/poll'
// Returns data for current state
export const poll = async (req, res) => {
try {
const s = runtimeState.poll();
res.status(200).send(s);
} catch (error) {
res.status(500).send({
message: `Could not get sync data: ${error}`,
});
}
};
// Create controller for GET request to '/ontime/db'
// Returns -
export const dbDownload = async (req, res) => {
const { title } = DataProvider.getEventData();
const fileTitle = title || 'ontime data';
const dbInDisk = resolveDbPath();
res.download(dbInDisk, `${fileTitle}.json`, (err) => {
if (err) {
res.status(500).send({
message: `Could not download the file: ${err}`,
});
}
});
};
/**
* handles file upload
* @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;
}
try {
const result = await fileHandler(file);
if (result?.error) {
res.status(400).send({ message: result.message });
} else if (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);
}
}
res.sendStatus(200);
} else {
res.status(400).send({ message: 'Failed parsing, no data' });
}
} catch (error) {
res.status(400).send({ message: `Failed parsing ${error}` });
}
};
/**
* @description Gets information on IPV4 non-internal interfaces
* @returns {array} - Array of objects {name: ip}
*/
const getNetworkInterfaces = () => {
const nets = networkInterfaces();
const results = [];
for (const name of Object.keys(nets)) {
for (const net of nets[name]) {
// Skip over non-IPv4 and internal (i.e. 127.0.0.1) addresses
if (net.family === 'IPv4' && !net.internal) {
results.push({
name: name,
address: net.address,
});
}
}
}
return results;
};
// Create controller for POST request to '/ontime/info'
// Returns -
export const getInfo = async (req, res) => {
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' });
// send object with network information
res.status(200).send({
networkInterfaces: ni,
version,
serverPort,
osc,
});
};
// Create controller for POST request to '/ontime/aliases'
// Returns -
export const getAliases = async (req, res) => {
const aliases = DataProvider.getAliases();
res.status(200).send(aliases);
};
// Create controller for POST request to '/ontime/aliases'
// Returns ACK message
export const postAliases = async (req, res) => {
if (failIsNotArray(req.body, res)) {
return;
}
try {
const newAliases = [];
req.body.forEach((a) => {
newAliases.push({
id: generateId(),
enabled: a.enabled,
alias: a.alias,
pathAndParams: a.pathAndParams,
});
});
await DataProvider.setAliases(newAliases);
res.status(200).send(newAliases);
} catch (error) {
res.status(400).send(error);
}
};
// Create controller for GET request to '/ontime/userfields'
// Returns -
export const getUserFields = async (req, res) => {
const userFields = DataProvider.getUserFields();
res.status(200).send(userFields);
};
// Create controller for POST request to '/ontime/userfields'
// Returns ACK message
export const postUserFields = async (req, res) => {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const persistedData = DataProvider.getUserFields();
const newData = mergeObject(persistedData, req.body);
await DataProvider.setUserFields(newData);
res.status(200).send(newData);
} catch (error) {
res.status(400).send(error);
}
};
// Create controller for POST request to '/ontime/settings'
// Returns -
export const getSettings = async (req, res) => {
const { version, serverPort, pinCode, timeFormat } = DataProvider.getSettings();
res.status(200).send({
version,
serverPort,
pinCode,
timeFormat,
});
};
// Create controller for POST request to '/ontime/settings'
// Returns ACK message
export const postSettings = async (req, res) => {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const settings = DataProvider.getSettings();
let pin = settings.pinCode;
if (typeof req.body?.pinCode === 'string') {
if (req.body?.pinCode.length === 0) {
pin = null;
} else if (req.body?.pinCode.length <= 4) {
pin = req.body?.pinCode;
}
}
let format = settings.timeFormat;
if (typeof req.body?.timeFormat === 'string') {
if (req.body?.timeFormat === '12' || req.body?.timeFormat === '24') {
format = req.body.timeFormat;
}
}
const newData = {
...settings,
pinCode: pin,
timeFormat: format,
};
await DataProvider.setSettings(newData);
res.status(200).send(newData);
} catch (error) {
res.status(400).send(error);
}
};
/**
* @description Get view Settings
* @method GET
*/
export const getViewSettings = async (req, res) => {
const views = DataProvider.getViews();
res.status(200).send(views);
};
/**
* @description Change view Settings
* @method POST
*/
export const postViewSettings = async (req, res) => {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const newData = { overrideStyles: req.body.overrideStyles };
await DataProvider.setViews(newData);
res.status(200).send(newData);
} catch (error) {
res.status(400).send(error);
}
};
// Create controller for POST request to '/ontime/osc'
// Returns -
export const getOSC = async (req, res) => {
const osc = DataProvider.getOsc();
res.status(200).send(osc);
};
// Create controller for POST request to '/ontime/osc'
// Returns ACK message
export const postOSC = async (req, res) => {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
await DataProvider.setOsc(req.body);
res.send(req.body).status(200);
} catch (error) {
res.status(400).send(error);
}
};
// Create controller for POST request to '/ontime/db'
// Returns -
export const dbUpload = async (req, res) => {
if (!req.file) {
res.status(400).send({ message: 'File not found' });
return;
}
const options = req.query;
const file = req.file.path;
await uploadAndParse(file, req, res, options);
};
// Create controller for POST request to '/ontime/dbpath'
// Returns -
export const dbPathToUpload = async (req, res) => {
if (!req.body.path) {
res.status(400).send({ message: 'Path to file not found' });
return;
}
await uploadAndParse(req.body.path, req, res);
};
@@ -0,0 +1,77 @@
import { body, check, validationResult } from 'express-validator';
/**
* @description Validates object for POST /ontime/views
*/
export const viewValidator = [
check('overrideStyles').isBoolean().withMessage('overrideStyles value must be boolean'),
(req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
/**
* @description Validates object for POST /ontime/aliases
*/
export const validateAliases = [
body().isArray(),
body('*.enabled').isBoolean(),
body('*.alias').isString().trim(),
body('*.pathAndParams').isString().trim(),
(req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
/**
* @description Validates object for POST /ontime/userfields
*/
export const validateUserFields = [
body('user0').exists().isString().trim(),
body('user1').exists().isString().trim(),
body('user2').exists().isString().trim(),
body('user3').exists().isString().trim(),
body('user4').exists().isString().trim(),
body('user5').exists().isString().trim(),
body('user6').exists().isString().trim(),
body('user7').exists().isString().trim(),
body('user8').exists().isString().trim(),
body('user9').exists().isString().trim(),
(req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
/**
* @description Validates object for POST /ontime/settings
*/
export const validateSettings = [
body('pinCode').isString().isLength({ min: 0, max: 4 }).optional({ nullable: true }),
body('timeFormat').isString().isIn(['12', '24']),
(req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
/**
* @description Validates object for POST /ontime/osc
*/
export const validateOSC = [
body('port').exists().isInt({ min: 0, max: 65353 }),
body('portOut').exists().isInt({ min: 0, max: 65353 }),
body('targetIP').exists().isIP(),
body('enabled').exists().isBoolean(),
(req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
@@ -0,0 +1,99 @@
// Create controller for GET request to '/playback'
// Returns ACK message
import { PlaybackService } from '../services/PlaybackService.js';
// Create controller for POST request to '/playback'
// Returns playback state
export const pbGet = async (req, res) => {
res.send({ playback: global.timer.state });
};
// Create controller for POST request to '/playback/start'
// Starts timer object
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');
} 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');
} else {
res.status(400).send('Invalid event index');
}
} else {
PlaybackService.start();
res.sendStatus(202);
}
};
// Create controller for POST request to '/playback/pause'
// Pauses timer object
export const pbPause = async (req, res) => {
PlaybackService.pause();
res.sendStatus(202);
};
// Create controller for POST request to '/playback/stop'
// Stops timer object
export const pbStop = async (req, res) => {
PlaybackService.stop();
res.sendStatus(202);
};
// Create controller for POST request to '/playback/roll'
// Sets timer object to roll mode
export const pbRoll = async (req, res) => {
PlaybackService.roll();
res.sendStatus(202);
};
// Create controller for POST request to '/playback/previous'
// Loads previous event
export const pbPrevious = async (req, res) => {
PlaybackService.loadPrevious();
res.sendStatus(202);
};
// Create controller for POST request to '/playback/next'
// Loads Next event
export const pbNext = async (req, res) => {
PlaybackService.loadNext();
res.sendStatus(202);
};
// Create controller for POST request to '/playback/load'
// Load requested event
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');
} 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');
} else {
res.status(400).send('Invalid event index');
}
} else {
res.status(400).send('No event given');
}
};
// Create controller for POST request to '/playback/unload'
// Unloads any events
export const pbUnload = async (req, res) => {
PlaybackService.stop();
res.sendStatus(202);
};
// Create controller for POST request to '/playback/reload'
// Reloads current event
export const pbReload = async (req, res) => {
PlaybackService.reload();
res.sendStatus(202);
};
@@ -0,0 +1,99 @@
import { DataProvider } from '../classes/data-provider/DataProvider.js';
import { failEmptyObjects } from '../utils/routerUtils.js';
import {
addEvent,
applyDelay,
deleteAllEvents,
deleteEvent,
editEvent,
reorderEvent,
} from '../services/RundownService.js';
// Create controller for GET request to '/eventlist'
// Returns -
export const rundownGetAll = async (req, res) => {
res.json(DataProvider.getRundown());
};
// Create controller for GET request to '/eventlist/:eventId'
// Returns -
export const getEventById = async (req, res) => {
res.json(DataProvider.getEventById(req.params?.eventId));
};
// Create controller for POST request to '/eventlist/'
// Returns -
export const rundownPost = async (req, res) => {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const newEvent = await addEvent(req.body);
res.status(201).send(newEvent);
} catch (error) {
res.status(400).send(error);
}
};
// Create controller for PUT request to '/eventlist/'
// Returns -
export const rundownPut = async (req, res) => {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const event = await editEvent(req.body);
res.status(200).send(event);
} catch (error) {
res.status(400).send(error);
}
};
export const rundownReorder = async (req, res) => {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const { eventId, from, to } = req.body;
const event = await reorderEvent(eventId, from, to);
res.status(200).send(event);
} catch (error) {
res.status(400).send(error);
}
};
// Create controller for PATCH request to '/eventlist/applydelay/:eventId'
// Returns -
export const rundownApplyDelay = async (req, res) => {
try {
await applyDelay(req.params.eventId);
res.sendStatus(200);
} catch (error) {
res.status(400).send(error);
}
};
// Create controller for DELETE request to '/eventlist/:eventId'
// Returns -
export const deleteEventById = async (req, res) => {
try {
await deleteEvent(req.params.eventId);
res.sendStatus(204);
} catch (error) {
res.status(400).send(error);
}
};
// Create controller for DELETE request to '/eventlist/'
// Returns -
export const rundownDelete = async (req, res) => {
try {
await deleteAllEvents();
res.sendStatus(204);
} catch (error) {
res.status(400).send(error);
}
};
@@ -0,0 +1,39 @@
import { body, param, validationResult } from 'express-validator';
export const rundownPostValidator = [
body('type').isString().exists().isIn(['event', 'delay', 'block']),
(req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
export const rundownPutValidator = [
body('id').isString().exists(),
(req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
export const rundownReorderValidator = [
body('eventId').isString().exists(),
body('from').isNumeric().exists(),
body('to').isNumeric().exists(),
(req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
export const paramsMustHaveEventId = [
param('eventId').exists(),
(req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];