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
+188
View File
@@ -0,0 +1,188 @@
import 'dotenv/config';
import express from 'express';
import http from 'http';
import cors from 'cors';
// import utils
import { join, resolve } from 'path';
import { config } from './config/config.js';
import { initiateOSC, shutdownOSCServer } from './controllers/OscController.js';
import { initSentry } from './modules/sentry.js';
import { currentDirectory, environment, isProduction, resolvedPath, uiPath } from './setup.js';
import { ONTIME_VERSION } from './ONTIME_VERSION.js';
// Import Routes
import { router as rundownRouter } from './routes/rundownRouter.js';
import { router as eventRouter } from './routes/eventRouter.js';
import { router as ontimeRouter } from './routes/ontimeRouter.js';
import { router as playbackRouter } from './routes/playbackRouter.js';
// Services
import { DataProvider } from './classes/data-provider/DataProvider.js';
import { socketProvider } from './classes/socket/SocketController.js';
import { eventTimer } from './services/TimerService.js';
import { promise } from './modules/loadDb.js';
console.log(`Starting Ontime version ${ONTIME_VERSION}`);
if (!isProduction) {
console.log(`Ontime running in ${environment} environment`);
console.log(`Ontime directory at ${currentDirectory} `);
}
initSentry(environment);
// import socket provider
const socketServer = socketProvider;
// Create express APP
const app = express();
app.disable('x-powered-by');
// setup cors for all routes
app.use(cors());
// enable pre-flight cors
app.options('*', cors());
// Implement middleware
app.use(express.urlencoded({ extended: true }));
app.use(express.json({ limit: '1mb' }));
// Implement route endpoints
app.use('/eventlist', rundownRouter);
app.use('/event', eventRouter);
app.use('/ontime', ontimeRouter);
app.use('/playback', playbackRouter);
// serve static - css
app.use('/external', express.static(join(currentDirectory, 'external')));
// serve static - react, in test mode we fetch the React app from module
app.use(express.static(join(currentDirectory, resolvedPath(), uiPath)));
app.get('*', (req, res) => {
res.sendFile(resolve(currentDirectory, resolvedPath(), uiPath, 'index.html'));
});
// Implement catch all
app.use((error, response) => {
response.status(400).send('Unhandled request');
});
/*************** START SERVICES ***************/
/* Override config
* ----------------
*
* Configuration of services comes from app general config
* It can be overridden here by the settings in the db
* It can also be overridden on call
*
*/
(async () => {
try {
await promise;
} catch (error) {
console.log(error);
}
})();
const { osc } = DataProvider.getData();
const oscIP = osc?.targetIP || config.osc.targetIP;
const oscOutPort = osc?.portOut || config.osc.portOut;
const oscInPort = osc?.port || config.osc.port;
const oscInEnabled = osc?.enabled !== undefined ? osc.enabled : config.osc.inputEnabled;
const serverPort = 4001; // hardcoded for now
/**
* @description starts OSC server
* @description starts OSC server
* @param overrideConfig
* @return {Promise<void>}
*/
export const startOSCServer = async (overrideConfig = null) => {
if (!oscInEnabled) {
socketServer.info('RX', 'OSC Input Disabled');
return;
}
// Setup default port
const oscSettings = {
port: overrideConfig?.port || oscInPort,
};
// Start OSC Server
socketServer.info('RX', `Starting OSC Server on port: ${oscInPort}`);
initiateOSC(oscSettings);
};
// create HTTP server
const expressServer = http.createServer(app);
/**
* Starts servers
* @return {Promise<string>}
*/
export const startServer = async () => {
// Start server
const returnMessage = `Ontime is listening on port ${serverPort}`;
expressServer.listen(serverPort, '0.0.0.0');
// init socket controller
await socketServer.initServer(expressServer);
socketServer.info('SERVER', 'Socket initialised');
socketServer.info('SERVER', returnMessage);
socketServer.startListener();
return returnMessage;
};
/**
* starts integrations
* @param overrideConfig
* @return {Promise<void>}
*/
export const startIntegrations = async (overrideConfig = null) => {
const { http } = DataProvider.getData();
// OSC Config
const oscConfig = {
ip: oscIP,
port: overrideConfig?.port || oscOutPort,
};
};
/**
* @description clean shutdown app services
* @param {number} exitCode
* @return {Promise<void>}
*/
export const shutdown = async (exitCode = 0) => {
console.log(`Ontime shutting down with code ${exitCode}`);
expressServer.close();
shutdownOSCServer();
eventTimer.shutdown();
socketServer.shutdown();
process.exit(exitCode);
};
process.on('exit', (code) => console.log(`Ontime exited with code: ${code}`));
process.on('unhandledRejection', async (error, promise) => {
console.error(error, 'Error: unhandled rejection', promise);
socketServer.error('SERVER', 'Error: unhandled rejection');
await shutdown(1);
});
process.on('uncaughtException', async (error, promise) => {
console.error(error, 'Error: uncaught exception', promise);
socketServer.error('SERVER', 'Error: uncaught exception');
await shutdown(1);
});
// register shutdown signals
process.once('SIGHUP', async () => shutdown(0));
process.once('SIGINT', async () => shutdown(0));
process.once('SIGTERM', async () => shutdown(0));
@@ -0,0 +1,198 @@
/**
* Class Event Provider is a mediator for handling the local db
* and adds logic specific to ontime data
*/
import { data, db } from '../../modules/loadDb.js';
export class DataProvider {
static getData() {
return data;
}
static async setEventData(newData) {
data.event = { ...data.event, ...newData };
await this.persist();
return data.event;
}
static getEventData() {
return data.event;
}
static async setRundown(newData) {
data.rundown = [...newData];
await this.persist();
}
static getEventById(eventId) {
return data.rundown.find((e) => e.id === eventId);
}
static async updateEventById(eventId, newData) {
const eventIndex = data.rundown.findIndex((e) => e.id === eventId);
const persistedEvent = data.rundown[eventIndex];
const newEvent = { ...persistedEvent, ...newData };
newEvent.revision++;
data.rundown[eventIndex] = newEvent;
await this.persist();
return data.rundown[eventIndex];
}
static async deleteEvent(eventId) {
data.rundown = Array.from(data.rundown).filter((e) => e.id !== eventId);
await this.persist();
}
static getRundownLength() {
return data.rundown.length;
}
static async clearRundown() {
data.rundown = [];
await db.write();
}
/**
* Insets an event after a given index
* @param entry
* @param index
* @return {Promise<void>}
*/
static async insertEventAt(entry, index) {
// get events
const events = DataProvider.getRundown();
const count = events.length;
const order = entry.order;
// Remove order field from object
delete entry.order;
// Insert at beginning
if (order === 0) {
events.unshift(entry);
}
// insert at end
else if (order >= count) {
events.push(entry);
}
// insert in the middle
else {
events.splice(index, 0, entry);
}
// save events
await DataProvider.setRundown(events);
}
/**
* @description Inserts an entry after an element with given ID
* @param entry
* @param id
* @return {Promise<void>}
*/
static async insertEventAfterId(entry, id) {
const index = [...data.rundown].findIndex((event) => event.id === id);
// eslint-disable-next-line no-unused-vars,@typescript-eslint/no-unused-vars -- we are just getting rid of after parameter
const { after, ...sanitisedEvent } = entry;
await DataProvider.insertEventAt(sanitisedEvent, index + 1);
}
static getSettings() {
return data.settings;
}
static async setSettings(newData) {
data.settings = { ...newData };
await this.persist();
}
static getOsc() {
return data.osc;
}
static getAliases() {
return data.aliases;
}
static async setAliases(newData) {
data.aliases = newData;
await this.persist();
}
static getUserFields() {
return { ...data.userFields };
}
static getViews() {
return { ...data.views };
}
static async setViews(newData) {
data.views = { ...newData };
await this.persist();
}
static async setUserFields(newData) {
data.userFields = { ...newData };
await this.persist();
}
static async setOsc(newData) {
data.osc = { ...newData };
await this.persist();
}
static getRundown() {
return [...data.rundown];
}
static async persist() {
await db.write();
}
static async mergeIntoData(newData) {
const mergedData = DataProvider.safeMerge(data, newData);
data.event = mergedData.event;
data.settings = mergedData.settings;
data.osc = mergedData.osc;
data.http = mergedData.http;
data.aliases = mergedData.aliases;
data.userFields = mergedData.userFields;
data.rundown = mergedData.rundown;
await this.persist();
}
/**
* Merges two data objects
* @param {object} existing
* @param {object} newData
*/
static safeMerge(existing, newData) {
const mergedData = { ...existing };
if (typeof newData?.rundown !== 'undefined') {
mergedData.rundown = newData.rundown;
}
if (typeof newData?.event !== 'undefined') {
mergedData.event = { ...newData.event };
}
if (typeof newData?.settings !== 'undefined') {
mergedData.settings = { ...newData.settings };
}
if (typeof newData?.osc !== 'undefined') {
mergedData.osc = { ...newData.osc };
}
if (typeof newData?.http !== 'undefined') {
mergedData.http = { ...newData.http };
}
if (typeof newData?.aliases !== 'undefined') {
mergedData.aliases = [...newData.aliases];
}
if (typeof newData?.userFields !== 'undefined') {
mergedData.userFields = { ...existing.userFields, ...newData.userFields };
}
return mergedData;
}
}
@@ -0,0 +1,401 @@
import { DataProvider } from '../data-provider/DataProvider.js';
import { getRollTimers } from '../../services/rollUtils.js';
let instance;
/**
* Manages business logic around loading events
*/
export class EventLoader {
constructor() {
if (instance) {
throw new Error('There can be only one');
}
// eslint-disable-next-line @typescript-eslint/no-this-alias -- this logic is used to ensure singleton
instance = this;
this.reset();
this.loadedEvent = null;
}
/**
* returns all events that contain time data
* @return {array}
*/
static getTimedEvents() {
// return mockLoaderData.filter((event) => event.type === 'event');
return DataProvider.getRundown().filter((event) => event.type === 'event');
}
/**
* returns all events that can be loaded
* @return {array}
*/
static getPlayableEvents() {
// return mockLoaderData.filter((event) => event.type === 'event' && !event.skip);
return DataProvider.getRundown().filter((event) => event.type === 'event' && !event.skip);
}
/**
* returns number of events
* @return {number}
*/
static getNumEvents() {
return EventLoader.getTimedEvents().length;
}
/**
* returns an event given its index
* @param {number} eventIndex
* @return {object | undefined}
*/
static getEventAtIndex(eventIndex) {
const timedEvents = EventLoader.getTimedEvents();
return timedEvents?.[eventIndex];
}
/**
* returns an event given its index
* @param {number} eventIndex
* @return {object | undefined}
*/
static getPlayableAtIndex(eventIndex) {
const timedEvents = EventLoader.getPlayableEvents();
return timedEvents?.[eventIndex];
}
/**
* returns an event given its id
* @param {string} eventId
* @return {object | undefined}
*/
static getEventWithId(eventId) {
const timedEvents = EventLoader.getTimedEvents();
return timedEvents.find((event) => event.id === eventId);
}
/**
* loads an event given its id
* @param {string} eventId
* @returns {{loadedEvent: null, selectedEventId: null, nextEventId: null, selectedPublicEventId: null, nextPublicEventId: null, numEvents: null, titles: {presenterNext: null, titleNow: null, subtitleNow: null, titleNext: null, subtitleNext: null, presenterNow: null, noteNow: null, noteNext: null}, titlesPublic: {presenterNext: null, titleNow: null, subtitleNow: null, titleNext: null, subtitleNext: null, presenterNow: null}, selectedEventIndex: null}}
*/
loadById(eventId) {
const event = EventLoader.getEventWithId(eventId);
return this.loadEvent(event);
}
/**
* loads an event given its index
* @param {number} eventIndex
* @returns {{loadedEvent: null, selectedEventId: null, nextEventId: null, selectedPublicEventId: null, nextPublicEventId: null, numEvents: null, titles: {presenterNext: null, titleNow: null, subtitleNow: null, titleNext: null, subtitleNext: null, presenterNow: null, noteNow: null, noteNext: null}, titlesPublic: {presenterNext: null, titleNow: null, subtitleNow: null, titleNext: null, subtitleNext: null, presenterNow: null}, selectedEventIndex: null}}
*/
loadByIndex(eventIndex) {
const event = EventLoader.getEventAtIndex(eventIndex);
return this.loadEvent(event);
}
/**
* finds the previous event
* @return {object | undefined}
*/
findPrevious() {
const timedEvents = EventLoader.getPlayableEvents();
if (timedEvents === null || !timedEvents.length || this.selectedEventIndex === 0) {
return null;
}
// if there is no event running, go to first
if (this.selectedEventIndex === null) {
return timedEvents[0];
}
const newIndex = this.selectedEventIndex - 1;
return timedEvents?.[newIndex];
}
/**
* finds the next event
* @return {object | undefined}
*/
findNext() {
const timedEvents = EventLoader.getPlayableEvents();
if (
timedEvents === null ||
!timedEvents.length ||
this.selectedEventIndex === this.numEvents - 1
) {
return null;
}
// if there is no event running, go to first
if (this.selectedEventIndex === null) {
return timedEvents[0];
}
const newIndex = this.selectedEventIndex + 1;
return timedEvents?.[newIndex];
}
/**
* finds next event within Roll context
* @param {number} timeNow - current time in ms
*/
findRoll(timeNow) {
const timedEvents = EventLoader.getPlayableEvents();
if (!timedEvents.length) {
return null;
}
const {
nowIndex,
timers,
timeToNext,
nextEvent,
nextPublicEvent,
currentEvent,
currentPublicEvent,
} = getRollTimers(timedEvents, timeNow);
this.loadedEvent = currentEvent;
this.selectedEventIndex = nowIndex;
this.selectedEventId = currentEvent?.id || null;
this.numEvents = timedEvents.length;
// titles
this._loadThisTitles(currentEvent, 'now-private');
this._loadThisTitles(currentPublicEvent, 'now-public');
this._loadThisTitles(nextEvent, 'next-private');
this._loadThisTitles(nextPublicEvent, 'next-public');
return { currentEvent, nextEvent, timeToNext, timers };
}
/**
* returns data for currently loaded event
* @returns {{loadedEvent: null, selectedEventId: (null|*), nextEventId: (null|*), selectedPublicEventId: (null|*), nextPublicEventId: (null|*), numEvents: (null|number|*), titles: (*|{presenterNext: null, titleNow: null, subtitleNow: null, titleNext: null, subtitleNext: null, presenterNow: null, noteNow: null, noteNext: null}), titlesPublic: (*|{presenterNext: null, titleNow: null, subtitleNow: null, titleNext: null, subtitleNext: null, presenterNow: null}), selectedEventIndex: (null|number|*)}}
*/
getLoaded() {
return {
loadedEvent: this.loadedEvent,
selectedEventIndex: this.selectedEventIndex,
selectedEventId: this.selectedEventId,
selectedPublicEventId: this.selectedPublicEventId,
nextEventId: this.nextEventId,
nextPublicEventId: this.nextPublicEventId,
numEvents: this.numEvents,
titles: this.titles,
titlesPublic: this.titlesPublic,
};
}
reset() {
this.loadedEvent = null;
this.selectedEventIndex = null;
this.selectedEventId = null;
this.selectedPublicEventId = null;
this.nextEventId = null;
this.nextPublicEventId = null;
this.numEvents = null;
this.titles = {
titleNow: null,
subtitleNow: null,
presenterNow: null,
noteNow: null,
titleNext: null,
subtitleNext: null,
presenterNext: null,
noteNext: null,
};
this.titlesPublic = {
titleNow: null,
subtitleNow: null,
presenterNow: null,
titleNext: null,
subtitleNext: null,
presenterNext: null,
};
}
/**
* loads an event given its id
* @param {object} event
*/
loadEvent(event) {
if (typeof event === 'undefined') {
return null;
}
const timedEvents = EventLoader.getPlayableEvents();
const eventIndex = timedEvents.findIndex((eventInMemory) => eventInMemory.id === event.id);
const playableEvents = EventLoader.getPlayableEvents();
// we know some stuff now
this.loadedEvent = event;
this.selectedEventIndex = eventIndex;
this.selectedEventId = event.id;
this.numEvents = timedEvents.length;
// this.nextEventId = playableEvents[eventIndex + 1].id;
this._loadTitlesNow(event, playableEvents);
this._loadTitlesNext(playableEvents);
return this.getLoaded();
}
/**
* @description loads given title (now)
* @private
* @param {object} event
* @param {array} rundown
*/
_loadTitlesNow(event, rundown) {
// private title is always current
// check if current is also public
if (event.isPublic) {
this._loadThisTitles(event, 'now');
} else {
this._loadThisTitles(event, 'now-private');
// assume there is no public event
this.titlesPublic.titleNow = null;
this.titlesPublic.subtitleNow = null;
this.titlesPublic.presenterNow = null;
this.selectedPublicEventId = null;
// if there is nothing before, return
if (this.selectedEventIndex === 0) return;
// iterate backwards to find it
for (let i = this.selectedEventIndex; i >= 0; i--) {
if (rundown[i].isPublic) {
this._loadThisTitles(rundown[i], 'now-public');
break;
}
}
}
}
/**
* @description look for next titles to load
* @private
*/
_loadTitlesNext(rundown) {
// Todo: is there a scenario where this gets called without an event?
// maybe there is nothing to load
if (this.selectedEventIndex === null) return;
// assume there is no next event
this.titles.titleNext = null;
this.titles.subtitleNext = null;
this.titles.presenterNext = null;
this.titles.noteNext = null;
this.nextEventId = null;
this.titlesPublic.titleNext = null;
this.titlesPublic.subtitleNext = null;
this.titlesPublic.presenterNext = null;
this.nextPublicEventId = null;
const numEvents = rundown.length;
if (this.selectedEventIndex < numEvents - 1) {
let nextPublic = false;
let nextPrivate = false;
for (let i = this.selectedEventIndex + 1; i < numEvents; i++) {
// if we have not set private
if (!nextPrivate) {
this._loadThisTitles(rundown[i], 'next-private');
nextPrivate = true;
}
// if event is public
if (rundown[i].isPublic) {
this._loadThisTitles(rundown[i], 'next-public');
nextPublic = true;
}
// Stop if both are set
if (nextPublic && nextPrivate) break;
}
}
}
/**
* @description loads given title
* @param event
* @param type
* @private
*/
_loadThisTitles(event, type) {
if (!event) {
return;
}
switch (type) {
// now, load to both public and private
case 'now':
// public
this.titlesPublic.titleNow = event.title;
this.titlesPublic.subtitleNow = event.subtitle;
this.titlesPublic.presenterNow = event.presenter;
this.selectedPublicEventId = event.id;
// private
this.titles.titleNow = event.title;
this.titles.subtitleNow = event.subtitle;
this.titles.presenterNow = event.presenter;
this.titles.noteNow = event.note;
this.selectedEventId = event.id;
break;
case 'now-public':
this.titlesPublic.titleNow = event.title;
this.titlesPublic.subtitleNow = event.subtitle;
this.titlesPublic.presenterNow = event.presenter;
this.selectedPublicEventId = event.id;
break;
case 'now-private':
this.titles.titleNow = event.title;
this.titles.subtitleNow = event.subtitle;
this.titles.presenterNow = event.presenter;
this.titles.noteNow = event.note;
this.selectedEventId = event.id;
break;
// next, load to both public and private
case 'next':
// public
this.titlesPublic.titleNext = event.title;
this.titlesPublic.subtitleNext = event.subtitle;
this.titlesPublic.presenterNext = event.presenter;
this.nextPublicEventId = event.id;
// private
this.titles.titleNext = event.title;
this.titles.subtitleNext = event.subtitle;
this.titles.presenterNext = event.presenter;
this.titles.noteNext = event.note;
this.nextEventId = event.id;
break;
case 'next-public':
this.titlesPublic.titleNext = event.title;
this.titlesPublic.subtitleNext = event.subtitle;
this.titlesPublic.presenterNext = event.presenter;
this.nextPublicEventId = event.id;
break;
case 'next-private':
this.titles.titleNext = event.title;
this.titles.subtitleNext = event.subtitle;
this.titles.presenterNext = event.presenter;
this.titles.noteNext = event.note;
this.nextEventId = event.id;
break;
default:
throw new Error(`Unhandled title type: ${type}`);
}
}
}
export const eventLoader = new EventLoader();
@@ -0,0 +1,53 @@
import * as http from 'http';
/**
* @description Class contains logic towards outgoing HTTP communications
* @class
*/
export class HTTPIntegration {
constructor() {
// nothing to do here
}
/**
* @description Initializes oscClient
* @param {object} httpConfig - Http configurations options
*/
init(httpConfig) {}
/**
* @description Sends http get request from predefined messages
* @param {string} path - complete http path
*/
async send(path) {
if (path == null) {
console.log('HTTP ERROR: Message undefined');
return;
}
const options = new URL(path);
let str = '';
const req = http.request(options, (res) => {
console.log(`statusCode: ${res.statusCode}`);
res.on('data', function (chunk) {
str += chunk;
});
res.on('end', function () {
console.log(str);
});
});
req.on('error', (error) => {
console.error(error);
});
req.end();
}
shutdown() {
/* Nothing to shutdown */
}
}
+169
View File
@@ -0,0 +1,169 @@
import { Client, Message } from 'node-osc';
/**
* @description Class contains logic towards outgoing OSC communications
* @class
*/
export class OSCIntegration {
constructor() {
// OSC Client
this.ADDRESS = '/ontime';
this.oscClient = null;
}
/**
* @description Returns list of implemented messages
* @returns {object} implemented messages
*/
get implemented() {
return {
play: 'play',
pause: 'pause',
stop: 'stop',
previous: 'prev',
next: 'next',
reload: 'reload',
finished: 'finished',
time: 'time',
overtime: 'overtime',
title: 'title',
eventNumber: 'eventNumber',
presenter: 'presenter',
};
}
/**
* @description Initializes oscClient
* @param {object} oscConfig - oscClient configuration options
* @param {string} oscConfig.ip - oscClient object
* @param {number} oscConfig.port - OSC Destination Port
*/
init(oscConfig) {
const { ip, port } = oscConfig;
const validateType = typeof ip !== 'string' || typeof port !== 'number';
const validateNull = ip == null || port == null;
if (validateType || validateNull) {
return {
success: false,
message: `Config options incorrect`,
};
}
try {
this.oscClient = new Client(ip, port);
return {
success: true,
message: `Initialised OSC Client at ${ip}:${port}`,
};
} catch (error) {
this.oscClient = null;
return {
success: false,
message: `Failed initialising OSC Client: ${error}`,
};
}
}
/**
* @description Sends osc from predefined messages
* @param {string} messageType - message to be sent
* @param {string} [payload] - optional payload required in some message types
*/
async send(messageType, payload) {
const reply = {
success: true,
message: 'OSC Message sent',
};
if (this.oscClient == null) {
reply.success = false;
reply.message = 'Client not initialised';
return reply;
}
if (messageType == null) {
reply.success = false;
reply.message = 'Message undefined';
return reply;
}
// only specify special cases
switch (payload) {
case 'overtime': {
// Whether timer is negative
this.oscClient.send(`${this.ADDRESS}/overtime`, payload, (err) => {
if (err) {
reply.success = false;
reply.message = err;
}
});
break;
}
case 'title': {
if (payload != null && payload !== '') {
// Send Title of current event
this.oscClient.send(`${this.ADDRESS}/title`, payload, (err) => {
if (err) {
reply.success = false;
reply.message = err;
}
});
} else {
reply.success = false;
reply.message = 'Missing message data';
}
break;
}
case 'eventNumber': {
if (payload != null && payload !== '') {
// Send event number of current event
this.oscClient.send(`${this.ADDRESS}/eventNumber`, payload, (err) => {
if (err) {
reply.success = false;
reply.message = err;
}
});
} else {
reply.success = false;
reply.message = 'Missing message data';
}
break;
}
case 'presenter': {
if (payload != null && payload !== '') {
// Send timer data on current event
this.oscClient.send(`${this.ADDRESS}/presenter`, payload, (err) => {
if (err) {
reply.success = false;
reply.message = err;
}
});
} else {
reply.success = false;
reply.message = 'Missing message data';
}
break;
}
default: {
// catch all for messages, allows to add new messages
// but should be used with the integrations definition
const message = new Message(`${this.ADDRESS}/${messageType}`);
if (payload != null) message.append(payload);
this.oscClient.send(message, (err) => {
if (err) {
reply.success = false;
reply.message = err;
}
});
break;
}
}
return reply;
}
shutdown() {
// Shutdown client object
this.oscClient.close();
this.oscClient = null;
}
}
@@ -0,0 +1,163 @@
import { OSCIntegration } from '../Osc';
import { Server } from 'node-osc';
test('Class initialises correctly', () => {
const osc = new OSCIntegration();
expect(osc.ADDRESS).toBe('/ontime');
expect(osc.oscClient).toBe(null);
// defined objects
expect(osc.implemented.play).toBeDefined();
expect(osc.implemented.pause).toBeDefined();
expect(osc.implemented.stop).toBeDefined();
expect(osc.implemented.previous).toBeDefined();
expect(osc.implemented.next).toBeDefined();
expect(osc.implemented.reload).toBeDefined();
expect(osc.implemented.finished).toBeDefined();
expect(osc.implemented.time).toBeDefined();
expect(osc.implemented.overtime).toBeDefined();
expect(osc.implemented.title).toBeDefined();
expect(osc.implemented.eventNumber).toBeDefined();
expect(osc.implemented.presenter).toBeDefined();
// initialise client succeeds
const { ip, port } = { ip: '127.0.0.1', port: 12345 };
const init = osc.init({ ip, port });
expect(init.message).toBe(`Initialised OSC Client at ${ip}:${port}`);
expect(init.success).toBe(true);
expect(osc.oscClient).not.toBe(null);
// object shutdown as expected
osc.shutdown();
expect(osc.oscClient).toBe(null);
});
describe('OSC fails to initialise when incorrect data is given', () => {
it('IP of wrong type', () => {
const osc = new OSCIntegration();
const init = osc.init({ ip: 123, port: 8888 });
expect(init.message).toBe('Config options incorrect');
expect(init.success).toBe(false);
expect(osc.oscClient).toBe(null);
});
it('IP is null', () => {
const osc = new OSCIntegration();
const init = osc.init({ ip: null, port: 8888 });
expect(init.message).toBe('Config options incorrect');
expect(init.success).toBe(false);
expect(osc.oscClient).toBe(null);
});
it('Port of wrong type', () => {
const osc = new OSCIntegration();
const init = osc.init({ ip: 'localhost', port: 'test' });
expect(init.message).toBe('Config options incorrect');
expect(init.success).toBe(false);
expect(osc.oscClient).toBe(null);
});
it('Port is null', () => {
const osc = new OSCIntegration();
const init = osc.init({ ip: 'localhost', port: null });
expect(init.message).toBe('Config options incorrect');
expect(init.success).toBe(false);
expect(osc.oscClient).toBe(null);
});
});
test('Test messages sending', async () => {
const testPort = 9999;
const testIP = 'localhost';
const testPayload = 'test';
const osc = new OSCIntegration();
const messages = [];
// prepare dummy server to receive messages
const oscServer = new Server(testPort, testIP);
oscServer.on('message', (m) => {
messages.push({ yay: m });
});
// try and send a message before initialising
const test = await osc.send('test');
expect(test.success).toBe(false);
expect(test.message).toBe('Client not initialised');
// initialise osc
osc.init({ ip: testIP, port: testPort });
// try and send unrecognised message
const test2 = await osc.send('test');
expect(test2.success).toBe(true);
// send play message
const playAddress = osc.implemented.play;
const playSent = await osc.send(playAddress);
expect(playSent.success).toBe(true);
// send pause message
const pauseAddress = osc.implemented.pause;
const pauseSent = await osc.send(pauseAddress);
expect(pauseSent.success).toBe(true);
// send stop message
const stopAddress = osc.implemented.stop;
const stopSent = await osc.send(stopAddress);
expect(stopSent.success).toBe(true);
// send previous message
const previousAddress = osc.implemented.previous;
const previousSent = await osc.send(previousAddress);
expect(previousSent.success).toBe(true);
// send next message
const nextAddress = osc.implemented.next;
const nextSent = await osc.send(nextAddress);
expect(nextSent.success).toBe(true);
// send reload message
const reloadAddress = osc.implemented.reload;
const reloadSent = await osc.send(reloadAddress);
expect(reloadSent.success).toBe(true);
// send finished message
const finishedAddress = osc.implemented.finished;
const finishedSent = await osc.send(finishedAddress);
expect(finishedSent.success).toBe(true);
// send time message
const timeAddress = osc.implemented.time;
const timeSent = await osc.send(timeAddress);
expect(timeSent.success).toBe(true);
// send overtime message
const overtimeAddress = osc.implemented.overtime;
const overtimeSent = await osc.send(overtimeAddress, testPayload);
expect(overtimeSent.success).toBe(true);
// send title message
const titleAddress = osc.implemented.title;
const titleSent = await osc.send(titleAddress, testPayload);
expect(titleSent.success).toBe(true);
// send eventNumber message
const eventNumberAddress = osc.implemented.eventNumber;
const eventNumberSent = await osc.send(eventNumberAddress, testPayload);
expect(eventNumberSent.success).toBe(true);
// send timer message
const presenterAddress = osc.implemented.presenter;
const presenterSent = await osc.send(presenterAddress, testPayload);
expect(presenterSent.success).toBe(true);
// cleanup
await osc.shutdown();
await oscServer.close();
// see messagesObject
// expect(messages.length).toBe(5);
});
@@ -0,0 +1,104 @@
let instance;
class MessageService {
constructor() {
if (instance) {
throw new Error('There can be only one');
}
// eslint-disable-next-line @typescript-eslint/no-this-alias -- this logic is used to ensure singleton
instance = this;
this.socket = null;
this.presenter = {
text: '',
visible: false,
};
this.public = {
text: '',
visible: false,
};
this.lower = {
text: '',
visible: false,
};
this.onAir = false;
}
/**
* @description sets message on stage timer screen
* @param payload {string}
*/
setTimerText(payload) {
this.presenter.text = payload;
return this.getAll();
}
/**
* @description sets message visibility on stage timer screen
* @param status {boolean}
*/
setTimerVisibility(status) {
this.presenter.visible = status;
return this.getAll();
}
/**
* @description sets message on public screen
* @param payload {string}
*/
setPublicText(payload) {
this.public.text = payload;
return this.getAll();
}
/**
* @description sets message visibility on public screen
* @param status {boolean}
*/
setPublicVisibility(status) {
this.public.visible = status;
return this.getAll();
}
/**
* @description sets message on lower third screen
* @param payload {string}
*/
setLowerText(payload) {
this.lower.text = payload;
return this.getAll();
}
/**
* @description sets message visibility on lower third screen
* @param status {boolean}
*/
setLowerVisibility(status) {
this.lower.visible = status;
return this.getAll();
}
/**
* @description set state of onAir
* @param status {boolean}
*/
setOnAir(status) {
this.onAir = status;
return this.getAll();
}
/**
* @description Returns feature data
*/
getAll() {
return {
presenter: this.presenter,
public: this.public,
lower: this.lower,
onAir: this.onAir,
};
}
}
export const messageManager = new MessageService();
@@ -0,0 +1,464 @@
import { Server } from 'socket.io';
import { generateId } from 'ontime-utils';
import getRandomName from '../../utils/getRandomName.js';
import { stringFromMillis } from '../../utils/time.js';
import { messageManager } from '../message-manager/MessageManager.js';
import { PlaybackService } from '../../services/PlaybackService.js';
import { ADDRESS_MESSAGE_CONTROL } from './socketConfig.js';
import { eventTimer, TimerService } from '../../services/TimerService.js';
import { EventLoader, eventLoader } from '../event-loader/EventLoader.js';
class SocketController {
constructor() {
this.numClients = 0;
this.messageStack = [];
this._MAX_MESSAGES = 100;
this._clientNames = {};
this.socket = null;
}
initServer(httpServer) {
this.socket = new Server(httpServer, {
cors: {
origin: '*',
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
preflightContinue: false,
optionsSuccessStatus: 204,
},
});
}
startListener() {
this._socketMessageHandler();
}
shutdown() {
this.info('SERVER', 'Shutting down ontime');
if (this.socket) {
this.info('TX', '... Closing socket server');
this.socket.close();
}
}
/**
* Handle socket io connections
* @private
*/
_socketMessageHandler() {
this.socket.on('connection', (socket) => {
/*******************************/
/*** HANDLE NEW CONNECTION ***/
/*** --------------------- ***/
/*******************************/
// keep track of connections
this.numClients++;
this._clientNames[socket.id] = getRandomName();
const message = `${this.numClients} Clients with new connection: ${
this._clientNames[socket.id]
}`;
this.info('CLIENT', message);
// Todo: review in favour of features
// send state
socket.emit('timer', eventTimer.timer);
socket.emit('playback', eventTimer.playback);
socket.emit('selected', {
id: eventLoader.selectedEventId,
index: eventLoader.selectedEventIndex,
total: eventLoader.numEvents,
});
socket.emit('next-id', eventLoader.nextEventId);
socket.emit('publicselected-id', eventLoader.selectedPublicEventId);
socket.emit('publicnext-id', eventLoader.nextPublicEventId);
/**
* @description handle disconnecting a user
*/
socket.on('disconnect', () => {
this.numClients--;
const message = `${this.numClients} Clients with disconnection: ${
this._clientNames[socket.id]
}`;
delete this._clientNames[socket.id];
this.info('CLIENT', message);
});
/**
* @description utility for renaming a user
*/
socket.on('rename-client', (newName) => {
if (newName) {
const previousName = this._clientNames[socket.id];
this._clientNames[socket.id] = newName;
this.info('CLIENT', `Client ${previousName} renamed to ${newName}`);
}
});
/***************************************/
/*** TIMER STATE GETTERS / SETTERS ***/
/*** ------- WEBSOCKET API ------- ***/
/*** ----------------------------- ***/
/***************************************/
/*******************************************/
socket.on('ontime-test', () => {
socket.emit('hello', socket.id);
});
socket.on('set-start', () => {
PlaybackService.start();
});
socket.on('set-startid', (data) => {
if (data) {
PlaybackService.startById(data);
}
});
socket.on('set-startindex', (data) => {
const eventIndex = Number(data);
if (!isNaN(eventIndex)) {
PlaybackService.startByIndex(eventIndex);
}
});
socket.on('set-loadid', (data) => {
if (data) {
PlaybackService.loadById(data);
}
});
socket.on('set-loadindex', (data) => {
const eventIndex = Number(data);
if (!isNaN(eventIndex)) {
PlaybackService.loadByIndex(eventIndex - 1);
}
});
socket.on('set-pause', () => {
PlaybackService.pause();
});
socket.on('set-stop', () => {
PlaybackService.stop();
});
socket.on('set-reload', () => {
PlaybackService.reload();
});
socket.on('set-previous', () => {
PlaybackService.loadPrevious();
});
socket.on('set-next', () => {
PlaybackService.loadNext();
});
socket.on('set-roll', () => {
PlaybackService.roll();
});
socket.on('set-delay', (data) => {
const delayTime = Number(data);
if (!isNaN(delayTime)) {
PlaybackService.setDelay(delayTime);
}
});
/*******************************************/
// general playback state, useful for external sync
// Todo: add delayed value (will come from rundownService)
socket.on('ontime-poll', () => {
const timerPoll = eventTimer.timer;
const isDelayed = false;
const colour = '';
socket.emit('ontime-poll', { isDelayed, colour, ...timerPoll });
});
/*******************************************/
socket.on('get-playback', () => {
socket.emit('playback', eventTimer.playback);
});
socket.on('get-onAir', () => {
socket.emit('onAir', messageManager.onAir);
});
/*******************************************/
socket.on('get-selected', () => {
socket.emit('selected', {
id: eventLoader.selectedEventId,
index: eventLoader.selectedEventIndex,
total: eventLoader.numEvents,
});
});
socket.on('get-titles', () => {
socket.emit('titles', eventLoader.titles);
});
socket.on('get-publictitles', () => {
socket.emit('publictitles', eventLoader.titlesPublic);
});
/***********************************/
/*** MESSAGE GETTERS / SETTERS ***/
/*** ------------------------- ***/
/***********************************/
// On Air
socket.on('set-onAir', (data) => {
if (typeof data === 'boolean') {
try {
const featureData = messageManager.setOnAir(data);
this.info('PLAYBACK', featureData.onAir ? 'Going On Air' : 'Going Off Air');
this.socket.emit(ADDRESS_MESSAGE_CONTROL, featureData);
} catch (error) {
this.error('RX', `Failed to parse message ${data} : ${error}`);
}
}
this.send('onAir', messageManager.onAir);
});
// Presenter message
socket.on('set-timer-message-text', (data) => {
if (typeof data !== 'string') {
return;
}
const featureData = messageManager.setTimerText(data);
this.socket.emit(ADDRESS_MESSAGE_CONTROL, featureData);
});
socket.on('set-timer-message-visible', (data) => {
if (typeof data !== 'boolean') {
return;
}
const featureData = messageManager.setTimerVisibility(data);
this.socket.emit(ADDRESS_MESSAGE_CONTROL, featureData);
});
/*******************************************/
// Public message
socket.on('set-public-message-text', (data) => {
if (typeof data !== 'string') {
return;
}
const featureData = messageManager.setPublicText(data);
this.socket.emit(ADDRESS_MESSAGE_CONTROL, featureData);
});
socket.on('set-public-message-visible', (data) => {
if (typeof data !== 'boolean') {
return;
}
const featureData = messageManager.setPublicVisibility(data);
this.socket.emit(ADDRESS_MESSAGE_CONTROL, featureData);
});
/*******************************************/
// Lower third message
socket.on('set-lower-message-text', (data) => {
if (typeof data !== 'string') {
return;
}
const featureData = messageManager.setLowerText(data);
this.socket.emit(ADDRESS_MESSAGE_CONTROL, featureData);
});
socket.on('set-lower-message-visible', (data) => {
if (typeof data !== 'boolean') {
return;
}
const featureData = messageManager.setLowerVisibility(data);
this.socket.emit(ADDRESS_MESSAGE_CONTROL, featureData);
});
/* MOLECULAR ENDPOINTS
* =====================
* 1. RUNDOWN
* 2. MESSAGE CONTROL
* 3. PLAYBACK CONTROL
* 4. INFO
* 5. CUE SHEET
* 6. TIMER OBJECT
* */
// 1. RUNDOWN
socket.on('get-feat-rundown', () => {
this.broadcastFeatureRundown();
});
// 2. MESSAGE CONTROL
socket.on('get-feat-messagecontrol', () => {
this.broadcastFeatureMessageControl();
});
// 3. PLAYBACK CONTROL
socket.on('get-feat-playbackcontrol', () => {
this.broadcastFeaturePlaybackControl();
});
// 4. INFO
socket.on('get-feat-info', () => {
this.broadcastFeatureInfo();
});
// 5. CUE SHEET
socket.on('get-feat-cuesheet', () => {
this.broadcastFeatureCuesheet();
});
// 6. TIMER
socket.on('get-ontime-timer', () => {
this.broadcastTimer();
});
});
}
send(topic, payload) {
this.socket?.emit(topic, payload);
}
/****************************************************************************/
/**
* Logger logic
* -------------
*/
/**
* Utility method, sends message and pushes into stack
* @param {string} level
* @param {string} origin
* @param {string} text
*/
_push(level, origin, text) {
const logMessage = {
id: generateId(),
level,
origin,
text,
time: stringFromMillis(TimerService.getCurrentTime() || 0),
};
this.messageStack.unshift(logMessage);
this.socket?.emit('logger', logMessage);
if (process.env.NODE_ENV !== 'production') {
console.log(`[${logMessage.level}] \t ${logMessage.origin} \t ${logMessage.text}`);
}
if (this.messageStack.length > this._MAX_MESSAGES) {
this.messageStack.pop();
}
}
/**
* Broadcast data for Event List feature
*/
broadcastFeatureRundown() {
const featureData = {
selectedEventId: eventLoader.selectedEventId,
nextEventId: eventLoader.nextEventId,
playback: eventTimer.playback,
};
this.send('feat-rundown', featureData);
}
/**
* Broadcast data for Message Control feature
*/
broadcastFeatureMessageControl() {
const featureData = messageManager.getAll();
this.send(ADDRESS_MESSAGE_CONTROL, featureData);
}
/**
* Broadcast data for Playback Control feature
*/
broadcastFeaturePlaybackControl() {
const featureData = {
playback: eventTimer.playback,
selectedEventId: eventLoader.selectedEventId,
numEvents: EventLoader.getNumEvents(),
};
this.send('feat-playbackcontrol', featureData);
}
/**
* Broadcast data for Info feature
*/
broadcastFeatureInfo() {
const featureData = {
titles: eventLoader.titles,
playback: eventTimer.playback,
selectedEventId: eventLoader.selectedEventId,
selectedEventIndex: eventLoader.selectedEventIndex,
numEvents: EventLoader.getNumEvents(),
};
this.send('feat-info', featureData);
}
/**
* Broadcast data for Cuesheet feature
*/
broadcastFeatureCuesheet() {
const featureData = {
playback: eventTimer.playback,
selectedEventId: eventLoader.selectedEventId,
selectedEventIndex: eventLoader.selectedEventIndex,
numEvents: EventLoader.getNumEvents(),
titleNow: eventLoader.titles.titleNow,
};
this.send('feat-cuesheet', featureData);
}
/**
* Broadcast Timer feature
*/
broadcastTimer() {
const featureData = eventTimer.timer;
this.send('ontime-timer', featureData);
}
broadcastState() {
this.broadcastFeatureRundown();
this.broadcastFeatureMessageControl();
this.broadcastFeaturePlaybackControl();
this.broadcastFeatureInfo();
this.broadcastFeatureCuesheet();
this.broadcastTimer();
}
/**
* Sends a message with level LOG
* @param {string} origin
* @param {string} text
*/
info(origin, text) {
this._push('INFO', origin, text);
}
/**
* Sends a message with level WARN
* @param {string} origin
* @param {string} text
*/
warning(origin, text) {
this._push('WARN', origin, text);
}
/**
* Sends a message with level ERROR
* @param {string} origin
* @param {string} text
*/
error(origin, text) {
this._push('ERROR', origin, text);
}
}
export const socketProvider = new SocketController();
@@ -0,0 +1 @@
export const ADDRESS_MESSAGE_CONTROL = 'feat-messagecontrol';
+25
View File
@@ -0,0 +1,25 @@
export const config = {
timer: {
refresh: 1000,
},
server: {
port: 4001,
},
database: {
testdb: 'test-db',
directory: 'preloaded-db',
filename: 'db.json',
tablename: 'events',
},
osc: {
port: 8888,
portOut: 9999,
targetIP: '127.0.0.1',
inputEnabled: true,
},
http: {
user: '',
pwd: '',
enabled: true,
},
};
@@ -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();
},
];
+15
View File
@@ -0,0 +1,15 @@
:root {
--background-color-override: #ececec;
--color-override: #101010;
--secondary-color-override: #404040;
--accent-color-override: #FA5656;
--label-color-override: #6c6c6c;
--timer-color-override: #202020;
--card-background-color-override: #FFF;
--font-family-override: "Open Sans";
--font-family-bold-override: "Arial Black";
}
.timer {
color: black !important;
}
+20
View File
@@ -0,0 +1,20 @@
// import { promise } from './modules/loadDb.js';
import { startOSCServer, startServer } from './app.js';
async function startOntime() {
try {
// await promise;
// Start express server
const loaded = await startServer();
console.log(loaded);
// Start OSC Server (API)
await startOSCServer();
} catch (error) {
console.log('Error starting Ontime');
console.log(error);
}
}
startOntime();
@@ -0,0 +1,5 @@
export const alias = {
enabled: false,
alias: '',
pathAndParams: '',
};
+71
View File
@@ -0,0 +1,71 @@
export const dbModel = {
rundown: [],
event: {
title: '',
url: '',
publicInfo: '',
backstageInfo: '',
endMessage: '',
},
settings: {
app: 'ontime',
version: 2,
serverPort: 4001,
lock: null,
pinCode: null,
timeFormat: '24',
},
views: {
overrideStyles: false,
},
aliases: [],
userFields: {
user0: 'user0',
user1: 'user1',
user2: 'user2',
user3: 'user3',
user4: 'user4',
user5: 'user5',
user6: 'user6',
user7: 'user7',
user8: 'user8',
user9: 'user9',
},
osc: {
port: 8888,
portOut: 9999,
targetIP: '127.0.0.1',
enabled: true,
},
http: {
user: null,
pwd: null,
messages: {
onLoad: {
url: '',
enabled: false,
},
onStart: {
url: '',
enabled: false,
},
onUpdate: {
url: '',
enabled: false,
},
onPause: {
url: '',
enabled: false,
},
onStop: {
url: '',
enabled: false,
},
onFinish: {
url: '',
enabled: false,
},
},
enabled: true,
},
};
@@ -0,0 +1,35 @@
export const event = {
title: '',
subtitle: '',
presenter: '',
note: '',
timeType: 'start-end',
timeStart: 0,
timeEnd: 0,
duration: 0,
isPublic: false,
skip: false,
colour: '',
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
type: 'event',
revision: 0,
};
export const delay = {
duration: 0,
type: 'delay',
revision: 0,
};
export const block = {
type: 'block',
};
+75
View File
@@ -0,0 +1,75 @@
import { Low } from 'lowdb';
import { JSONFile } from 'lowdb/node';
import { copyFileSync, existsSync } from 'fs';
import { ensureDirectory } from '../utils/fileManagement.js';
import { validateFile } from '../utils/parserUtils.js';
import { dbModel } from '../models/dataModel.js';
import { parseJson } from '../utils/parser.js';
import { reportSentryException } from './sentry.js';
import { pathToStartDb, resolveDbDirectory, resolveDbPath } from '../setup.js';
/**
* @description ensures directories exist and populates database
* @return {string} - path to db file
*/
const populateDb = () => {
const dbInDisk = resolveDbPath;
ensureDirectory(resolveDbDirectory);
// if dbInDisk doesn't exist we want to use startup db
if (!existsSync(dbInDisk)) {
try {
copyFileSync(pathToStartDb, dbInDisk);
} catch (error) {
reportSentryException(error);
}
}
return dbInDisk;
};
/**
* @description parses a json file to the adapter
* @param fileToRead
* @param adapterToUse
* @return {Promise<number|*>}
*/
const parseDb = async (fileToRead, adapterToUse) => {
if (validateFile(fileToRead)) {
await adapterToUse.read();
} else {
adapterToUse.data = dbModel;
}
return parseJson(adapterToUse.data, true);
};
/**
* @description loads ontime db
* @return {Promise<{data: (*), db: Low<unknown>}>}
*/
async function loadDb() {
const dbInDisk = populateDb();
const adapter = new JSONFile(dbInDisk);
const db = new Low(adapter);
const data = await parseDb(dbInDisk, db);
db.data = data;
await db.write();
return { db, data };
}
export let db = {};
export let data = {};
export const promise = loadDb();
const init = async () => {
const dbProvider = await promise;
db = dbProvider.db;
data = dbProvider.data;
};
init();
+19
View File
@@ -0,0 +1,19 @@
import * as Sentry from '@sentry/node';
let shouldReport;
export function initSentry(environment) {
shouldReport = environment === 'production';
Sentry.init({
dsn: 'https://ceb6abdce7374857bb50b65636cbaed1@o4504288369836032.ingest.sentry.io/4504288555565056',
tracesSampleRate: 1.0,
});
}
export function reportSentryException(e) {
if (shouldReport) {
Sentry.captureException(e);
} else {
console.error(e);
}
}
View File
+12
View File
@@ -0,0 +1,12 @@
import express from 'express';
export const router = express.Router();
// import event controller
import { getEvent, postEvent } from '../controllers/eventController.js';
import { eventSanitizer } from '../controllers/eventController.validate.js';
// create route between controller and 'GET /event' endpoint
router.get('/', getEvent);
// create route between controller and 'POST /event' endpoint
router.post('/', eventSanitizer, postEvent);
+74
View File
@@ -0,0 +1,74 @@
import express from 'express';
import { uploadFile } from '../utils/upload.js';
import {
dbDownload,
dbPathToUpload,
dbUpload,
getAliases,
getInfo,
getOSC,
getSettings,
getUserFields,
getViewSettings,
poll,
postAliases,
postOSC,
postSettings,
postUserFields,
postViewSettings,
} from '../controllers/ontimeController.js';
import {
viewValidator,
validateAliases,
validateUserFields,
validateSettings,
validateOSC,
} from '../controllers/ontimeController.validate.js';
export const router = express.Router();
// create route between controller and '/ontime/sync' endpoint
router.get('/poll', poll);
// create route between controller and '/ontime/db' endpoint
router.get('/db', dbDownload);
// create route between controller and '/ontime/db' endpoint
router.post('/db', uploadFile, dbUpload);
// create route between controller and '/ontime/settings' endpoint
router.get('/settings', getSettings);
// create route between controller and '/ontime/settings' endpoint
router.post('/settings', validateSettings, postSettings);
// create route between controller and '/ontime/views' endpoint
router.get('/views', getViewSettings);
// create route between controller and '/ontime/views' endpoint
router.post('/views', viewValidator, postViewSettings);
// create route between controller and '/ontime/aliases' endpoint
router.get('/aliases', getAliases);
// create route between controller and '/ontime/aliases' endpoint
router.post('/aliases', validateAliases, postAliases);
// create route between controller and '/ontime/aliases' endpoint
router.get('/userfields', getUserFields);
// create route between controller and '/ontime/aliases' endpoint
router.post('/userfields', validateUserFields, postUserFields);
// create route between controller and '/ontime/info' endpoint
router.get('/info', getInfo);
// create route between controller and '/ontime/osc' endpoint
router.get('/osc', getOSC);
// create route between controller and '/ontime/osc' endpoint
router.post('/osc', validateOSC, postOSC);
// create route between controller and '/ontime/dbpath' endpoint
router.post('/dbpath', dbPathToUpload);
+48
View File
@@ -0,0 +1,48 @@
import express from 'express';
// import playback controllers
import {
pbGet,
pbLoad,
pbNext,
pbPause,
pbPrevious,
pbReload,
pbRoll,
pbStart,
pbStop,
pbUnload,
} from '../controllers/playbackController.js';
export const router = express.Router();
// create route between controller and '/playback/' endpoint
router.get('/', pbGet);
// create route between controller and '/playback/start' endpoint
router.post('/start', pbStart);
// create route between controller and '/playback/pause' endpoint
router.post('/pause', pbPause);
// create route between controller and '/playback/stop' endpoint
router.post('/stop', pbStop);
// create route between controller and '/playback/roll' endpoint
router.post('/roll', pbRoll);
// create route between controller and '/playback/previous' endpoint
router.post('/previous', pbPrevious);
// create route between controller and '/playback/next' endpoint
router.post('/next', pbNext);
// create route between controller and '/playback/load' endpoint
router.post('/load', pbLoad);
// create route between controller and '/playback/unload' endpoint
router.post('/unload', pbUnload);
// create route between controller and '/playback/reload' endpoint
router.post('/reload', pbReload);
// router.post('*', (req, res) => res.return(404))
+43
View File
@@ -0,0 +1,43 @@
import express from 'express';
import {
deleteEventById,
getEventById,
rundownApplyDelay,
rundownDelete,
rundownGetAll,
rundownPost,
rundownPut,
rundownReorder,
} from '../controllers/rundownController.js';
import {
paramsMustHaveEventId,
rundownPostValidator,
rundownPutValidator,
rundownReorderValidator,
} from '../controllers/rundownController.validate.js';
export const router = express.Router();
// create route between controller and '/eventlist/' endpoint
router.get('/', rundownGetAll);
// create route between controller and '/eventlist/:eventId' endpoint
router.get('/:eventId', paramsMustHaveEventId, getEventById);
// create route between controller and '/eventlist/' endpoint
router.post('/', rundownPostValidator, rundownPost);
// create route between controller and '/eventlist/' endpoint
router.put('/', rundownPutValidator, rundownPut);
// create route between controller and '/eventlist/reorder' endpoint
router.patch('/reorder/', rundownReorderValidator, rundownReorder);
// create route between controller and '/eventlist/applydelay/:eventId' endpoint
router.patch('/applydelay/:eventId', paramsMustHaveEventId, rundownApplyDelay);
// create route between controller and '/eventlist/all' endpoint
router.delete('/all', rundownDelete);
// create route between controller and '/eventlist/:eventId' endpoint
router.delete('/:eventId', paramsMustHaveEventId, deleteEventById);
+205
View File
@@ -0,0 +1,205 @@
/**
* starts loaded timer
*/
import { socketProvider } from '../classes/socket/SocketController.js';
import { eventLoader, EventLoader } from '../classes/event-loader/EventLoader.js';
import { eventTimer, TimerService } from './TimerService.js';
/**
* Service manages playback status of app
* Coordinating with necessary services
*/
export class PlaybackService {
/**
* makes calls for loading and starting given event
* @param {object} event
* @return {boolean} success
*/
static loadEvent(event) {
let success = false;
if (!event) {
socketProvider.error('PLAYBACK', 'No event found');
} else if (event.skip) {
socketProvider.warning('PLAYBACK', `Refused playback of skipped event ID ${event.id}`);
} else {
eventLoader.loadEvent(event);
eventTimer.load(event);
success = true;
}
socketProvider.broadcastState();
return success;
}
/**
* starts event matching given ID
* @param {string} eventId
* @return {boolean} success
*/
static startById(eventId) {
const event = EventLoader.getEventWithId(eventId);
const success = PlaybackService.loadEvent(event);
if (success) {
socketProvider.info('PLAYBACK', `Loaded event with ID ${event.id}`);
PlaybackService.start();
}
return success;
}
/**
* starts an event at index
* @param {number} eventIndex
* @return {boolean} success
*/
static startByIndex(eventIndex) {
const event = EventLoader.getEventAtIndex(eventIndex);
const success = PlaybackService.loadEvent(event);
if (success) {
socketProvider.info('PLAYBACK', `Loaded event with ID ${event.id}`);
PlaybackService.start();
}
return success;
}
/**
* loads event matching given ID
* @param {string} eventId
* @return {boolean} success
*/
static loadById(eventId) {
const event = EventLoader.getEventWithId(eventId);
const success = PlaybackService.loadEvent(event);
if (success) {
socketProvider.info('PLAYBACK', `Loaded event with ID ${event.id}`);
}
return success;
}
/**
* loads event matching given ID
* @param {number} eventIndex
* @return {boolean} success
*/
static loadByIndex(eventIndex) {
const event = EventLoader.getEventAtIndex(eventIndex);
const success = PlaybackService.loadEvent(event);
if (success) {
socketProvider.info('PLAYBACK', `Loaded event with ID ${event.id}`);
}
return success;
}
/**
* Loads event before currently selected
*/
static loadPrevious() {
const previousEvent = eventLoader.findPrevious();
if (previousEvent) {
const success = PlaybackService.loadEvent(previousEvent);
if (success) {
socketProvider.info('PLAYBACK', `Loaded event with ID ${previousEvent.id}`);
}
}
}
/**
* Loads event after currently selected
*/
static loadNext() {
const nextEvent = eventLoader.findNext();
if (nextEvent) {
const success = PlaybackService.loadEvent(nextEvent);
if (success) {
socketProvider.info('PLAYBACK', `Loaded event with ID ${nextEvent.id}`);
}
}
}
/**
* Starts playback on selected event
*/
static start() {
if (eventLoader.selectedEventId) {
eventTimer.start();
const newState = eventTimer.playback;
socketProvider.info('PLAYBACK', `Play Mode ${newState.toUpperCase()}`);
}
socketProvider.broadcastState();
}
/**
* Pauses playback on selected event
*/
static pause() {
if (eventLoader.selectedEventId) {
eventTimer.pause();
const newState = eventTimer.playback;
socketProvider.info('PLAYBACK', `Play Mode ${newState.toUpperCase()}`);
}
socketProvider.broadcastState();
}
/**
* Stops timer and unloads any events
*/
static stop() {
if (eventLoader.selectedEventId || eventTimer.playback === 'roll') {
eventLoader.reset();
eventTimer.stop();
const newState = eventTimer.playback;
socketProvider.info('PLAYBACK', `Play Mode ${newState.toUpperCase()}`);
}
socketProvider.broadcastState();
}
/**
* Reloads current event
*/
static reload() {
if (eventLoader.selectedEventId) {
this.loadById(eventLoader.selectedEventId);
}
socketProvider.broadcastState();
}
/**
* Sets playback to roll
*/
static roll() {
if (EventLoader.getPlayableEvents()) {
const rollTimers = eventLoader.findRoll(TimerService.getCurrentTime());
// nothing to play
if (rollTimers === null) {
socketProvider.error('SERVER', 'Roll: no events found');
PlaybackService.stop();
return;
}
const { currentEvent, nextEvent, timers } = rollTimers;
if (!currentEvent && !nextEvent) {
socketProvider.error('SERVER', 'Roll: no events found');
PlaybackService.stop();
return;
}
eventTimer.roll(currentEvent, nextEvent, timers);
const newState = eventTimer.playback;
socketProvider.info('PLAYBACK', `Play Mode ${newState.toUpperCase()}`);
socketProvider.broadcastState();
}
}
/**
* Adds delay to current event
* @param {number} delayTime time in minutes
*/
static setDelay(delayTime) {
if (eventLoader.selectedEventId) {
const delayInMs = delayTime * 1000 * 60;
eventTimer.delay(delayInMs);
socketProvider.info('PLAYBACK', `Added ${delayTime} min delay`);
}
socketProvider.broadcastState();
}
}
+267
View File
@@ -0,0 +1,267 @@
import { generateId } from 'ontime-utils';
import { DataProvider } from '../classes/data-provider/DataProvider.js';
import {
block as blockDef,
delay as delayDef,
event as eventDef,
} from '../models/eventsDefinition.js';
import { MAX_EVENTS } from '../settings.js';
import { EventLoader, eventLoader } from '../classes/event-loader/EventLoader.js';
import { eventTimer } from './TimerService.js';
import { socketProvider } from '../classes/socket/SocketController.js';
/**
*
* @param affectedIds
* @returns boolean
*/
const affectedLoaded = (affectedIds) => {
const now = eventLoader.selectedEventId;
const nowPublic = eventLoader.selectedPublicEventId;
const next = eventLoader.nextEventId;
const nextPublic = eventLoader.nextPublicEventId;
return (
affectedIds.includes(now) ||
affectedIds.includes(nowPublic) ||
affectedIds.includes(next) ||
affectedIds.includes(nextPublic)
);
};
const isNewNext = () => {
const timedEvents = EventLoader.getTimedEvents();
const now = eventLoader.selectedEventId;
const next = eventLoader.nextEventId;
// check whether the index of now and next are consecutive
const indexNow = timedEvents.findIndex((event) => event.id === now);
const indexNext = timedEvents.findIndex((event) => event.id === next);
if (indexNext - indexNow !== 1) {
return true;
}
// iterate through timed events and see if there are public events between nowPublic and nextPublic
const nowPublic = eventLoader.selectedPublicEventId;
const nextPublic = eventLoader.nextPublicEventId;
let foundNew = false;
let isAfter = false;
for (const event of timedEvents) {
if (!isAfter) {
if (event.id === nowPublic) {
isAfter = true;
}
} else {
if (event.id === nextPublic) {
break;
}
if (event.isPublic) {
foundNew = true;
break;
}
}
}
return foundNew;
};
/**
* updates timer object
* @param {array} [affectedIds]
*/
export function updateTimer(affectedIds) {
const runningEventId = eventLoader.selectedEventId;
if (runningEventId === null) {
return false;
}
// we need to reload in a few scenarios:
// 1. we are not confident that changes do not affect running event
const safeOption = typeof affectedIds === 'undefined';
// 2. the edited event is in memory (now or next) running
const eventInMemory = safeOption ? false : affectedLoaded(affectedIds);
// 3. the edited event replaces next event
const isNext = isNewNext();
if (safeOption) {
eventLoader.reset();
const { loadedEvent } = eventLoader.loadById(runningEventId) || {};
eventTimer.hotReload(loadedEvent);
return true;
}
if (eventInMemory) {
eventLoader.reset();
const { loadedEvent } = eventLoader.loadById(runningEventId) || {};
if (!loadedEvent) {
eventTimer.stop();
} else {
eventTimer.hotReload(loadedEvent);
}
return true;
}
if (isNext) {
const { loadedEvent } = eventLoader.loadById(runningEventId) || {};
eventTimer.hotReload(loadedEvent);
return true;
}
return false;
}
/**
* @description creates a new event with given data
* @param {object} eventData
* @return {unknown[]}
*/
export async function addEvent(eventData) {
const numEvents = DataProvider.getRundownLength();
if (numEvents > MAX_EVENTS) {
throw new Error(`ERROR: Reached limit number of ${MAX_EVENTS} events`);
}
let newEvent = {};
const id = generateId();
switch (eventData.type) {
case 'event':
newEvent = { ...eventDef, ...eventData, id };
break;
case 'delay':
newEvent = { ...delayDef, ...eventData, id };
break;
case 'block':
newEvent = { ...blockDef, ...eventData, id };
break;
}
try {
const afterId = newEvent?.after;
if (typeof afterId === 'undefined') {
await DataProvider.insertEventAt(newEvent, 0);
} else {
delete newEvent.after;
await DataProvider.insertEventAfterId(newEvent, afterId);
}
} catch (error) {
throw new Error(error);
}
updateTimer([id]);
socketProvider.broadcastState();
return newEvent;
}
export async function editEvent(eventData) {
const eventId = eventData.id;
const eventInMemory = DataProvider.getEventById(eventId);
if (typeof eventInMemory === 'undefined') {
throw new Error('No event with ID found');
}
const newEvent = await DataProvider.updateEventById(eventId, eventData);
updateTimer([eventId]);
socketProvider.broadcastState();
return newEvent;
}
/**
* deletes event by its ID
* @param eventId
* @returns {Promise<void>}
*/
export async function deleteEvent(eventId) {
await DataProvider.deleteEvent(eventId);
updateTimer([eventId]);
socketProvider.broadcastState();
}
/**
* deletes all events in database
* @returns {Promise<void>}
*/
export async function deleteAllEvents() {
await DataProvider.clearRundown();
updateTimer();
socketProvider.broadcastState();
}
/**
* reorders a given event
* @param {string} eventId
* @param {number} from
* @param {number} to
* @returns {Promise<void>}
*/
export async function reorderEvent(eventId, from, to) {
const rundown = DataProvider.getRundown();
const index = rundown.findIndex((event) => event.id === eventId);
if (index !== from) {
throw new Error('ID not found at index');
}
const [reorderedItem] = rundown.splice(from, 1);
// reinsert item at to
rundown.splice(to, 0, reorderedItem);
// save rundown
await DataProvider.setRundown(rundown);
updateTimer();
return reorderedItem;
}
/**
* applies delay value for given event
* @param eventId
* @returns {Promise<void>}
*/
export async function applyDelay(eventId) {
const rundown = DataProvider.getRundown();
// AUX
let delayIndex = null;
let blockIndex = null;
let delayValue = 0;
for (const [index, e] of rundown.entries()) {
// look for delay
if (delayIndex === null) {
if (e.id === eventId && e.type === 'delay') {
delayValue = e.duration;
delayIndex = index;
}
}
// apply delay value to all items until block or end
else {
if (e.type === 'event') {
// update times
e.timeStart += delayValue;
e.timeEnd += delayValue;
// increment revision
e.revision += 1;
} else if (e.type === 'block') {
// save id and stop
blockIndex = index;
break;
}
}
}
if (delayIndex === null) {
throw new Error(`Delay event with ID ${eventId} not found`);
}
// delete delay
rundown.splice(delayIndex, 1);
// delete block
// index would have moved down since we deleted delay
if (blockIndex) rundown.splice(blockIndex - 1, 1);
// update rundown
await DataProvider.setRundown(rundown);
updateTimer();
socketProvider.broadcastState();
}
+338
View File
@@ -0,0 +1,338 @@
import { runtimeState } from '../stores/EventStore.js';
import { PlaybackService } from './PlaybackService.js';
import { updateRoll } from './rollUtils.js';
import { DAY_TO_MS } from '../utils/time.js';
export class TimerService {
/**
* @constructor
* @param {object} [timerConfig]
* @param {number} [timerConfig.refresh]
*/
constructor(timerConfig) {
this._clear();
this._interval = setInterval(() => this.update(), timerConfig?.refresh || 1000);
}
/**
* Get current time in ms from midnight
* @static
* @return {number}
*/
static getCurrentTime() {
const now = new Date();
// extract milliseconds since midnight
let elapsed = now.getHours() * 3600000;
elapsed += now.getMinutes() * 60000;
elapsed += now.getSeconds() * 1000;
elapsed += now.getMilliseconds();
return elapsed;
}
/**
* Returns expected time finish
* @private
*/
_getExpectedFinish() {
if (this.timer.startedAt === null) {
return null;
}
if (this.timer.finishedAt) {
return this.timer.finishedAt;
}
return Math.max(
this.timer.startedAt + this.timer.duration + this._pausedInterval + this.timer.addedTime,
this.timer.startedAt
);
}
/**
* Clears internal state
* @private
*/
_clear() {
this.playback = 'stop';
this.timer = {
clock: TimerService.getCurrentTime(),
current: null,
elapsed: null,
expectedFinish: null,
addedTime: 0,
startedAt: null,
finishedAt: null,
secondaryTimer: null,
};
this.loadedTimerId = null;
this._pausedInterval = 0;
this._pausedAt = null;
this._secondaryTarget = null;
}
/**
* Reloads information for currently running timer
* @param timer
*/
hotReload(timer) {
if (typeof timer === 'undefined') {
this.stop();
return;
}
if (timer?.id !== this.loadedTimerId) {
// event timer only concerns itself with current event
return;
}
if (timer?.skip) {
this.stop();
}
// TODO: check if any relevant information warrants update
// update relevant information and force update
this.timer.duration = timer.duration;
// this might not be ideal
this.timer.finishedAt = null;
this.timer.expectedFinish = this._getExpectedFinish();
if (this.timer.startedAt === null) {
this.timer.current = timer.duration;
}
this.update();
}
/**
* Loads given timer to object
* @param {object} timer
* @param {number} timer.id
* @param {number} timer.timeStart
* @param {number} timer.timeEnd
* @param {number} timer.duration
* @param {string} timer.timeType
* @param {boolean} timer.skip
*/
load(timer) {
if (timer.skip) {
throw new Error('Refuse load of skipped event');
}
this._clear();
this.loadedTimerId = timer.id;
this.timer.duration = timer.duration;
this.timer.current = timer.duration;
this.playback = 'armed';
this._pausedInterval = 0;
this._pausedAt = 0;
this._onLoad();
}
/**
* Handles side effects related to onLoad event
* @private
*/
_onLoad() {
runtimeState.set('playback', this.playback);
runtimeState.set('ontime-timer', this.timer);
}
start() {
if (!this.loadedTimerId) {
return;
}
if (this.playback === 'play') {
return;
}
this.timer.clock = TimerService.getCurrentTime();
// add paused time
if (this._pausedInterval) {
this.timer.addedTime += this._pausedInterval;
this._pausedAt = null;
this._pausedInterval = 0;
} else {
this.timer.startedAt = this.timer.clock;
}
this.playback = 'play';
this.timer.expectedFinish = this._getExpectedFinish();
this._onStart();
}
/**
* Handles side effects related to onStart event
* @private
*/
_onStart() {
runtimeState.set('playback', this.playback);
runtimeState.set('ontime-timer', this.timer);
}
pause() {
if (this.playback !== 'play') {
return;
}
this.playback = 'pause';
this.timer.clock = TimerService.getCurrentTime();
this._pausedAt = this.timer.clock;
this._onPause();
}
_onPause() {
runtimeState.set('playback', this.playback);
runtimeState.set('ontime-timer', this.timer);
}
stop() {
if (this.playback === 'stop') {
return;
}
this._clear();
this._onStop();
}
_onStop() {
runtimeState.set('playback', this.playback);
runtimeState.set('ontime-timer', this.timer);
}
/**
* Delays running timer by given amount
* @param {number} amount
*/
delay(amount) {
if (!this.loadedTimerId) {
return;
}
this.timer.addedTime += amount;
this.timer.current += amount;
this.timer.elapsed += amount;
// handle edge cases
if (amount < 0 && Math.abs(amount) > this.timer.current) {
if (this.timer.finishedAt === null) {
// if we will make the clock negative
this.timer.finishedAt = TimerService.getCurrentTime();
}
} else if (this.timer.current < 0 && this.timer.current + amount > 0) {
// clock will go from negative to positive
this.timer.finishedAt = null;
}
// force an update
this.update();
}
update() {
this.timer.clock = TimerService.getCurrentTime();
if (this.playback === 'roll') {
const tempCurrentTimer = {
selectedEventId: this.loadedTimerId,
current: this.timer.current,
// safeguard on midnight rollover
_finishAt:
this.timer.expectedFinish >= this.timer.startedAt
? this.timer.expectedFinish
: this.timer.expectedFinish + DAY_TO_MS,
clock: this.timer.clock,
secondaryTimer: this.timer.secondaryTimer,
_secondaryTarget: this._secondaryTarget,
};
const { updatedTimer, updatedSecondaryTimer, doRollLoad, isFinished } =
updateRoll(tempCurrentTimer);
this.timer.current = updatedTimer;
this.timer.secondaryTimer = updatedSecondaryTimer;
if (isFinished) {
this.timer.selectedEventId = null;
this.loadedTimerId = null;
this._onFinish();
}
if (doRollLoad) {
PlaybackService.roll();
}
} else {
// we only update timer if a timer has been started
if (this.timer.startedAt !== null) {
if (this.playback === 'pause') {
this._pausedInterval = this.timer.clock - this._pausedAt;
}
this.timer.current =
this.timer.startedAt +
this.timer.duration +
this.timer.addedTime +
this._pausedInterval -
this.timer.clock;
this.timer.elapsed = this.timer.duration - this.timer.current;
if (this.playback === 'play' && this.timer.current <= 0 && this.timer.finishedAt === null) {
this.timer.finishedAt = this.timer.clock;
this._onFinish();
} else {
this.timer.finishedAt = null;
}
this.timer.expectedFinish = this._getExpectedFinish();
}
}
this._onUpdate();
}
_onUpdate() {
runtimeState.set('playback', this.playback);
runtimeState.set('ontime-timer', this.timer);
}
_onFinish() {
runtimeState.set('playback', this.playback);
runtimeState.set('ontime-timer', this.timer);
}
roll(currentEvent, nextEvent, timers) {
this._clear();
this.timer.clock = TimerService.getCurrentTime();
if (currentEvent) {
// there is something running, load
this.timer.secondaryTimer = null;
this._secondaryTarget = null;
this.loadedTimerId = currentEvent.id;
this.timer.startedAt = currentEvent.timeStart;
this.timer.expectedFinish = currentEvent.timeEnd;
this.timer.duration = timers.duration;
this.timer.current = timers.current;
} else if (nextEvent) {
// nothing now, but something coming up
this.timer.secondaryTimer = nextEvent.timeStart - this.timer.clock;
this._secondaryTarget = nextEvent.timeStart;
}
this.playback = 'roll';
this._onRoll();
this.update();
}
_onRoll() {
this._onLoad();
}
shutdown() {
clearInterval(this._interval);
}
}
export const eventTimer = new TimerService();
@@ -0,0 +1,658 @@
import {
DAY_TO_MS,
getRollTimers,
normaliseEndTime,
replacePlaceholder,
sortArrayByProperty,
updateRoll,
} from '../rollUtils.js';
// test sortArrayByProperty()
describe('sort simple arrays of objects', () => {
it('sort array 1-5', () => {
const arr1 = [{ timeStart: 1 }, { timeStart: 5 }, { timeStart: 3 }, { timeStart: 2 }, { timeStart: 4 }];
const arr1Expected = [{ timeStart: 1 }, { timeStart: 2 }, { timeStart: 3 }, { timeStart: 4 }, { timeStart: 5 }];
const sorted = sortArrayByProperty(arr1, 'timeStart');
expect(sorted).toStrictEqual(arr1Expected);
});
it('sort array 1-5 with null', () => {
const arr1 = [
{ timeStart: 1 },
{ timeStart: 5 },
{ timeStart: 3 },
{ timeStart: 2 },
{ timeStart: 4 },
{ timeStart: null },
];
const arr1Expected = [
{ timeStart: null },
{ timeStart: 1 },
{ timeStart: 2 },
{ timeStart: 3 },
{ timeStart: 4 },
{ timeStart: 5 },
];
const sorted = sortArrayByProperty(arr1, 'timeStart');
expect(sorted).toStrictEqual(arr1Expected);
});
});
// test getRollTimers()
describe('test that roll loads selection in right order', () => {
const eventlist = [
{
id: 1,
timeStart: 5,
timeEnd: 10,
isPublic: false,
},
{
id: 2,
timeStart: 10,
timeEnd: 20,
isPublic: false,
},
{
id: 3,
timeStart: 20,
timeEnd: 30,
isPublic: false,
},
{
id: 4,
timeStart: 30,
timeEnd: 40,
isPublic: false,
},
{
id: 5,
timeStart: 40,
timeEnd: 50,
isPublic: true,
},
{
id: 6,
timeStart: 50,
timeEnd: 60,
isPublic: false,
},
{
id: 7,
timeStart: 60,
timeEnd: 70,
isPublic: true,
},
{
id: 8,
timeStart: 70,
timeEnd: 80,
isPublic: false,
},
];
it('if timer is at 0', () => {
const now = 0;
const expected = {
nowIndex: null,
nowId: null,
publicIndex: null,
nextIndex: 0,
publicNextIndex: 4,
timers: null,
timeToNext: 5,
};
const state = getRollTimers(eventlist, now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 5', () => {
const now = 5;
const expected = {
nowIndex: 0,
nowId: 1,
publicIndex: null,
nextIndex: 1,
publicNextIndex: 4,
timers: {
_finishAt: 10,
_startedAt: 5,
current: 5,
duration: 5,
},
timeToNext: 5,
};
const state = getRollTimers(eventlist, now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 15', () => {
const now = 15;
const expected = {
nowIndex: 1,
nowId: 2,
publicIndex: null,
nextIndex: 2,
publicNextIndex: 4,
timers: {
_finishAt: 20,
_startedAt: 10,
current: 5,
duration: 10,
},
timeToNext: 5,
};
const state = getRollTimers(eventlist, now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 20', () => {
const now = 20;
const expected = {
nowIndex: 2,
nowId: 3,
publicIndex: null,
nextIndex: 3,
publicNextIndex: 4,
timers: {
_startedAt: 20,
_finishAt: 30,
current: 10,
duration: 10,
},
timeToNext: 10,
};
const state = getRollTimers(eventlist, now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 49', () => {
const now = 49;
const expected = {
nowIndex: 4,
nowId: 5,
publicIndex: 4,
nextIndex: 5,
publicNextIndex: 6,
timers: {
_startedAt: 40,
_finishAt: 50,
current: 1,
duration: 10,
},
timeToNext: 1,
};
const state = getRollTimers(eventlist, now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 63', () => {
const now = 63;
const expected = {
nowIndex: 6,
nowId: 7,
publicIndex: 6,
nextIndex: 7,
publicNextIndex: null,
timers: {
_startedAt: 60,
_finishAt: 70,
current: 7,
duration: 10,
},
timeToNext: 7,
};
const state = getRollTimers(eventlist, now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 75', () => {
const now = 75;
const expected = {
nowIndex: 7,
nowId: 8,
publicIndex: 6,
nextIndex: null,
publicNextIndex: null,
timers: {
_startedAt: 70,
_finishAt: 80,
current: 5,
duration: 10,
},
timeToNext: null,
};
const state = getRollTimers(eventlist, now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 100', () => {
const now = 100;
const expected = {
nowIndex: null,
nowId: null,
publicIndex: null,
nextIndex: 0,
publicNextIndex: 4,
timers: null,
timeToNext: DAY_TO_MS - now + eventlist[0].timeStart,
};
const state = getRollTimers(eventlist, now);
expect(state).toStrictEqual(expected);
});
it('handles rolls to next day with real values', () => {
const singleEventList = [
{
id: 1,
timeStart: 36000000, // 10:00
timeEnd: 39600000, // 11:00
isPublic: true,
},
];
const now = 64800000; // 18:00
const expected = {
nowIndex: null,
nowId: null,
publicIndex: null,
nextIndex: 0,
publicNextIndex: 0,
timers: null,
timeToNext: DAY_TO_MS - now + singleEventList[0].timeStart,
};
const state = getRollTimers(singleEventList, now);
expect(state).toStrictEqual(expected);
});
});
// test getRollTimers()
describe('test that roll behaviour with overlapping times', () => {
const eventlist = [
{
id: 1,
timeStart: 10,
timeEnd: 10,
isPublic: false,
},
{
id: 2,
timeStart: 10,
timeEnd: 20,
isPublic: true,
},
{
id: 3,
timeStart: 10,
timeEnd: 30,
isPublic: false,
},
];
it('if timer is at 0', () => {
const now = 0;
const expected = {
nowIndex: null,
nowId: null,
publicIndex: null,
nextIndex: 0,
publicNextIndex: 1,
timers: null,
timeToNext: 10,
};
const state = getRollTimers(eventlist, now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 10', () => {
const now = 10;
const expected = {
nowIndex: 1,
nowId: 2,
publicIndex: 1,
nextIndex: 2,
publicNextIndex: null,
timers: {
_finishAt: 20,
_startedAt: 10,
current: 10,
duration: 10,
},
timeToNext: 0,
};
const state = getRollTimers(eventlist, now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 15', () => {
const now = 15;
const expected = {
nowIndex: 1,
nowId: 2,
publicIndex: 1,
nextIndex: 2,
publicNextIndex: null,
timers: {
_startedAt: 10,
_finishAt: 20,
current: 5,
duration: 10,
},
timeToNext: -5,
};
const state = getRollTimers(eventlist, now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 20', () => {
const now = 20;
const expected = {
nowIndex: 2,
nowId: 3,
publicIndex: 1,
nextIndex: null,
publicNextIndex: null,
timers: {
_startedAt: 10,
_finishAt: 30,
current: 10,
duration: 20,
},
timeToNext: null,
};
const state = getRollTimers(eventlist, now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 25', () => {
const now = 25;
const expected = {
nowIndex: 2,
nowId: 3,
publicIndex: 1,
nextIndex: null,
publicNextIndex: null,
timers: {
_startedAt: 10,
_finishAt: 30,
current: 5,
duration: 20,
},
timeToNext: null,
};
const state = getRollTimers(eventlist, now);
expect(state).toStrictEqual(expected);
});
});
// test replacePlaceholder()
describe('test that it replaces data correctly', () => {
const values = {
$timer: 'timer',
$title: 'title',
$presenter: 'presenter',
$subtitle: 'subtitle',
'$next-title': 'next title',
'$next-presenter': 'next presenter',
'$next-subtitle': 'next subtitle',
};
it('replaces timer', () => {
const test = '___1232132 $timer';
const expected = '___1232132 timer';
const s = replacePlaceholder(test, values);
expect(s).toBe(expected);
});
it('replaces title', () => {
const test = '___1232132 $title';
const expected = '___1232132 title';
const s = replacePlaceholder(test, values);
expect(s).toBe(expected);
});
it('replaces presenter', () => {
const test = '___1232132 $presenter';
const expected = '___1232132 presenter';
const s = replacePlaceholder(test, values);
expect(s).toBe(expected);
});
it('replaces subtitle', () => {
const test = '___1232132 $subtitle';
const expected = '___1232132 subtitle';
const s = replacePlaceholder(test, values);
expect(s).toBe(expected);
});
it('replaces next next title', () => {
const test = '___1232132 $next-title';
const expected = '___1232132 next title';
const s = replacePlaceholder(test, values);
expect(s).toBe(expected);
});
it('replaces next presenter', () => {
const test = '___1232132 $next-presenter';
const expected = '___1232132 next presenter';
const s = replacePlaceholder(test, values);
expect(s).toBe(expected);
});
it('replaces next subtitle', () => {
const test = '___1232132 $next-subtitle';
const expected = '___1232132 next subtitle';
const s = replacePlaceholder(test, values);
expect(s).toBe(expected);
});
});
// test getRollTimers() on issue #58
describe('test that roll behaviour multi day event edge cases', () => {
it('if the start time is the day after end time, and start time is earlier than now', () => {
const now = 66600000; // 19:30
const eventlist = [
{
id: 1,
timeStart: 66000000, // 19:20
timeEnd: 54600000, // 16:10
isPublic: false,
},
];
const expected = {
nowIndex: 0,
nowId: 1,
publicIndex: null,
nextIndex: null,
publicNextIndex: null,
timers: {
_startedAt: eventlist[0].timeStart,
_finishAt: eventlist[0].timeEnd,
current: eventlist[0].timeEnd + DAY_TO_MS - now,
duration: DAY_TO_MS - eventlist[0].timeStart + eventlist[0].timeEnd,
},
timeToNext: null,
};
const state = getRollTimers(eventlist, now);
expect(state).toStrictEqual(expected);
});
it('if the start time is the day after end time, and both are later than now', () => {
const now = 66840000; // 19:34
const eventlist = [
{
id: 1,
timeStart: 67200000, // 19:40
timeEnd: 66900000, // 19:35
isPublic: false,
},
];
const expected = {
nowIndex: null,
nowId: null,
publicIndex: null,
nextIndex: 0,
publicNextIndex: null,
timers: null,
timeToNext: eventlist[0].timeStart - now,
};
const state = getRollTimers(eventlist, now);
expect(state).toStrictEqual(expected);
});
});
// test normaliseEndTime() on issue #58
test('test typical scenarios', () => {
const t1 = {
start: 10,
end: 20,
};
const t1_expected = 20;
expect(normaliseEndTime(t1.start, t1.end)).toBe(t1_expected);
const t2 = {
start: 10 + DAY_TO_MS,
end: 20,
};
const t2_expected = 20 + DAY_TO_MS;
expect(normaliseEndTime(t2.start, t2.end)).toBe(t2_expected);
const t3 = {
start: 10,
end: 10,
};
const t3_expected = 10;
expect(normaliseEndTime(t3.start, t3.end)).toBe(t3_expected);
});
// test updateRoll()
describe('typical scenarios', () => {
it('it updates running events correctly', () => {
const timers = {
selectedEventId: 1,
current: 10,
_finishAt: 15,
clock: 11,
secondaryTimer: null,
_secondaryTarget: null,
};
const expected = {
updatedTimer: timers._finishAt - timers.clock,
updatedSecondaryTimer: null,
doRollLoad: false,
isFinished: false,
};
expect(updateRoll(timers)).toStrictEqual(expected);
// test that it can jump time
timers._finishAt = 1000;
timers.clock = 600;
expected.updatedTimer = timers._finishAt - timers.clock;
expect(updateRoll(timers)).toStrictEqual(expected);
});
it('it updates secondary timer', () => {
const timers = {
selectedEventId: null,
current: null,
_finishAt: null,
clock: 11,
secondaryTimer: 1,
_secondaryTarget: 15,
};
const expected = {
updatedTimer: null,
updatedSecondaryTimer: timers._secondaryTarget - timers.clock,
doRollLoad: false,
isFinished: false,
};
expect(updateRoll(timers)).toStrictEqual(expected);
});
it('flags an event end', () => {
const timers = {
selectedEventId: 1,
current: 10,
_finishAt: 11,
clock: 12,
secondaryTimer: null,
_secondaryTarget: null,
};
const expected = {
updatedTimer: timers._finishAt - timers.clock,
updatedSecondaryTimer: null,
doRollLoad: true,
isFinished: true,
};
expect(updateRoll(timers)).toStrictEqual(expected);
});
it('secondary events do not trigger event ends', () => {
const timers = {
selectedEventId: null,
current: null,
_finishAt: null,
clock: 16,
secondaryTimer: 1,
_secondaryTarget: 15,
};
const expected = {
updatedTimer: null,
updatedSecondaryTimer: timers._secondaryTarget - timers.clock,
doRollLoad: true,
isFinished: false,
};
expect(updateRoll(timers)).toStrictEqual(expected);
});
it('when a secondary timer is finished, it prompts for new event load', () => {
const timers = {
selectedEventId: null,
current: null,
_finishAt: null,
clock: 15,
secondaryTimer: 0,
_secondaryTarget: 15,
};
const expected = {
updatedTimer: null,
updatedSecondaryTimer: timers._secondaryTarget - timers.clock,
doRollLoad: true,
isFinished: false,
};
expect(updateRoll(timers)).toStrictEqual(expected);
});
});
+214
View File
@@ -0,0 +1,214 @@
/**
* Utility variable: 24 hour in milliseconds .
* @type {number}
*/
export const DAY_TO_MS = 86400000;
/**
* @description handle events that span over midnight
* @param {number} start - When does the event start
* @param {number} end - When does the event end
* @returns {number} normalised time
*/
export const normaliseEndTime = (start, end) => (end < start ? end + DAY_TO_MS : end);
/**
* @description Sorts an array of objects by given property
* @param {array} arr - array to be sorted
* @param {string} property - property to compare
* @returns {array} copy of array sorted in ascending order
*/
export const sortArrayByProperty = (arr, property) => {
return [...arr].sort((a, b) => {
return a[property] - b[property];
});
};
/**
* @description Replaces placeholder variables in string with given data
* @param {string} str - string to analyse
* @param {object} values - map of variables: values to use
* @returns {string} finished string
*/
export const replacePlaceholder = (str, values) => {
for (const [k, v] of Object.entries(values)) {
str = str.replace(k, v);
}
return str;
};
/**
*
* @param rundown
* @param timeNow
* @returns {{}}
*/
export const getRollTimers = (rundown, timeNow) => {
let nowIndex = null; // index of event now
let nowId = null; // id of event now
let publicIndex = null; // index of public event now
let publicTime = -1;
let nextIndex = null; // index of next event
let publicNextIndex = null; // index of next public event
let timeToNext = null; // counter: time for next event
let publicTimeToNext = null; // counter: time for next public event
let timers = null;
// Order events by startTime
const orderedEvents = sortArrayByProperty(rundown, 'timeStart');
// preload first if we are past events
const lastEvent = orderedEvents[orderedEvents.length - 1];
const lastNormalEnd = normaliseEndTime(lastEvent.timeStart, lastEvent.timeEnd);
let nextEvent = null;
let nextPublicEvent = null;
let currentEvent = null;
let currentPublicEvent = null;
if (timeNow > lastNormalEnd) {
nextIndex = 0;
timeToNext = orderedEvents[0].timeStart + DAY_TO_MS - timeNow;
// look for next public
for (const event of orderedEvents) {
if (event.isPublic) {
nextPublicEvent = event;
publicNextIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
break;
}
}
} else {
// flags: select first event if several overlapping
let nowFound = false;
// loop through events, look for where we should be
for (const event of orderedEvents) {
// When does the event end (handle midnight)
const normalEnd = normaliseEndTime(event.timeStart, event.timeEnd);
if (normalEnd <= timeNow) {
// event ran already
// public event might not be the one running
if (event.isPublic && normalEnd > publicTime) {
publicTime = normalEnd;
currentPublicEvent = event;
publicIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
}
} else if (normalEnd > timeNow && timeNow >= event.timeStart && !nowFound) {
// event is running
// it could also be public
if (event.isPublic) {
publicTime = normalEnd;
currentPublicEvent = event;
publicIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
}
currentEvent = event;
nowIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
nowId = event.id;
// set timers
timers = {
_startedAt: event.timeStart,
_finishAt: event.timeEnd,
duration: normalEnd - event.timeStart,
current: normalEnd - timeNow,
};
nowFound = true;
} else if (normalEnd > timeNow) {
// event will run
// no need to look after found first
if (nextIndex !== null && publicNextIndex !== null) continue;
// look for next events
// check how far the start is from now
const wait = event.timeStart - timeNow;
if (nextIndex === null || wait < timeToNext) {
timeToNext = wait;
nextEvent = event;
nextIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
}
if ((publicNextIndex === null || wait < publicTimeToNext) && event.isPublic) {
publicTimeToNext = wait;
nextPublicEvent = event;
publicNextIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
}
}
}
}
return {
nowIndex,
nowId,
publicIndex,
nextIndex,
publicNextIndex,
timers,
timeToNext,
nextEvent,
nextPublicEvent,
currentEvent,
currentPublicEvent,
};
};
/**
* @description Implements update functions for roll mode
* @param {object} currentTimers
* @param {object} currentTimers.selectedEventId - Id of currently selected event
* @param {object} currentTimers.current - Running timer
* @param {object} currentTimers._finishAt - Expected finish time
* @param {object} currentTimers.clock - time now
* @param {object} currentTimers.secondaryTimer - secondary timer
* @param {object} currentTimers._secondaryTarget - finish time of secondary timer
* @returns {object} object with selection variables
*/
export const updateRoll = (currentTimers) => {
const { selectedEventId, current, _finishAt, clock, secondaryTimer, _secondaryTarget } =
currentTimers;
// timers
let updatedTimer = current;
let updatedSecondaryTimer = secondaryTimer;
// whether rollLoad should be called
let doRollLoad = false;
// whether finished event should trigger
let isFinished = false;
if (selectedEventId && current >= 0) {
// if we have something selected and a timer, we are running
// this is true because roll never goes into negative times
// update timer
updatedTimer = _finishAt - clock;
if (updatedTimer < 0) {
isFinished = true;
updatedTimer = null;
}
} else if (secondaryTimer >= 0) {
// if secondaryTimer is running we are in waiting to roll
// update secondary
updatedSecondaryTimer = _secondaryTarget - clock;
}
// if nothing is running, we need to find out if
// a) we just finished an event (finished was set to true)
// b) we need to look for events
// this could be caused by a secondary timer or event finished
const secondaryRunning = updatedSecondaryTimer <= 0 && updatedSecondaryTimer != null;
if (isFinished || secondaryRunning) {
// look for events
doRollLoad = true;
}
return { updatedTimer, updatedSecondaryTimer, doRollLoad, isFinished };
};
+1
View File
@@ -0,0 +1 @@
export const MAX_EVENTS = 255;
+65
View File
@@ -0,0 +1,65 @@
import { fileURLToPath } from 'url';
import path, { dirname, join } from 'path';
import { config } from './config/config.js';
// =================================================
// resolve public path
/**
* @description Returns public path depending on OS
*/
export function getAppDataPath(): string {
// handle docker
if (process.env.ONTIME_DATA) {
return path.join(process.env.ONTIME_DATA);
}
switch (process.platform) {
case 'darwin': {
return path.join(process.env.HOME, 'Library', 'Application Support', 'Ontime');
}
case 'win32': {
return path.join(process.env.APPDATA, 'Ontime');
}
case 'linux': {
return path.join(process.env.HOME, '.Ontime');
}
default: {
throw new Error('Could not resolve public folder for platform');
}
}
}
// =================================================
// resolve running environment
const env = process.env.NODE_ENV || 'production';
export const isTest = Boolean(process.env.IS_TEST);
export const environment = isTest ? 'test' : env;
export const isProduction = env === 'production' && !isTest;
// =================================================
// resolve path to external
const productionPath = '../../Resources/extraResources/';
const devPath = '../../';
export const uiPath = 'client/';
export const resolvedPath = (): string => (isProduction ? productionPath : devPath);
// resolve file URL in both CJS and ESM (build and dev)
if (import.meta.url) {
globalThis.__dirname = fileURLToPath(import.meta.url);
}
// path to server src folder
export const currentDirectory = dirname(__dirname);
const appPath = isTest ? '../' : getAppDataPath();
// path to public db
export const resolveDbDirectory = join(appPath, isTest ? config.database.testdb : config.database.directory);
export const resolveDbPath = join(resolveDbDirectory, config.database.filename);
export const pathToStartDb = isTest
? join(currentDirectory, '../', config.database.testdb, config.database.filename)
: join(currentDirectory, config.database.directory, config.database.filename);
+19
View File
@@ -0,0 +1,19 @@
import { socketProvider } from '../classes/socket/SocketController.js';
const store = {};
/**
* A runtime store that broadcasts its payload
*/
export const runtimeState = {
get(key) {
return store[key];
},
set(key, value) {
store[key] = value;
socketProvider.send(key, value);
},
poll() {
return store;
},
};
@@ -0,0 +1,54 @@
import { getPreviousPlayable } from '../eventUtils.js';
describe('getPreviousPlayable()', () => {
describe('given a list of events', () => {
it('finds the previous playable event', () => {
const events = [
{ id: 100, type: 'delay' },
{ id: 101, type: 'event', skip: true },
{ id: 102, type: 'event', skip: true },
{ id: 103, type: 'event', skip: false },
{ id: 'not-this', type: 'block' },
{ id: 104, type: 'event' },
];
const { index, id } = getPreviousPlayable(events, events[4].id);
expect(index).toBe(3);
expect(id).toBe(103);
});
});
describe('handles common errors', () => {
it('returns null if id not found in list', () => {
const events = [
{ id: 0, type: 'delay' },
{ id: 1, type: 'event', skip: true },
{ id: 2, type: 'event', skip: true },
{ id: 3, type: 'event', skip: false },
{ id: 4, type: 'event' },
];
const { index, id } = getPreviousPlayable(events, 'no-valid-id');
expect(index).toBe(null);
expect(id).toBe(null);
});
it('returns null if there are no previous events to play', () => {
const events = [
{ id: 0, type: 'delay' },
{ id: 1, type: 'event', skip: true },
{ id: 2, type: 'event', skip: true },
{ id: 3, type: 'event', skip: true },
{ id: 4, type: 'event' },
];
const { index, id } = getPreviousPlayable(events, events[4].id);
expect(index).toBe(null);
expect(id).toBe(null);
});
it('returns null if list is empty', () => {
const events = [];
const { index, id } = getPreviousPlayable(events, 'made-up');
expect(index).toBe(null);
expect(id).toBe(null);
});
});
});
@@ -0,0 +1,11 @@
import getRandomName from '../getRandomName.js';
test('generates 100 unique names', () => {
const names = new Set();
let attempts = 1;
while (names.size < 100) {
names.add(getRandomName());
attempts++;
}
expect(attempts).toBeLessThan(105);
});
@@ -0,0 +1,878 @@
import { vi } from 'vitest';
import { dbModel } from '../../models/dataModel.js';
import { parseExcel, parseJson, validateEvent } from '../parser.js';
import { makeString, validateDuration } from '../parserUtils.js';
import { parseAliases, parseUserFields, parseViews } from '../parserFunctions.js';
describe('test json parser with valid def', () => {
const testData = {
rundown: [
{
title: 'Guest Welcoming',
subtitle: '',
presenter: '',
note: '',
timeStart: 31500000,
timeEnd: 32400000,
timeType: 'start-end',
duration: 32400000 - 31500000,
isPublic: false,
colour: '',
type: 'event',
revision: 0,
id: '4b31',
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
},
{
title: 'Good Morning',
subtitle: 'Days schedule',
presenter: 'Carlos Valente',
note: '',
timeStart: 32400000,
timeEnd: 36000000,
timeType: 'start-end',
duration: 36000000 - 32400000,
isPublic: true,
skip: true,
colour: 'red',
type: 'event',
revision: 0,
id: 'f24d',
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
},
{
title: 'Stage 2 setup',
subtitle: '',
presenter: '',
note: '',
timeStart: 32400000,
timeEnd: 37200000,
timeType: 'start-end',
duration: 37200000 - 32400000,
isPublic: false,
colour: '',
type: 'event',
revision: 0,
id: 'bbc5',
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
},
{
title: 'Working Procedures',
subtitle: '',
presenter: 'Filip Johansen',
note: '',
timeStart: 37200000,
timeEnd: 39000000,
timeType: 'start-end',
duration: 39000000 - 37200000,
isPublic: true,
skip: false,
colour: '',
type: 'event',
revision: 0,
id: '5b3e',
},
{
title: 'Lunch',
subtitle: '',
presenter: '',
note: '',
timeStart: 39600000,
timeEnd: 45000000,
timeType: 'start-end',
duration: 37200000 - 32400000,
isPublic: false,
colour: '',
type: 'event',
revision: 0,
id: '8e2c',
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
},
{
title: 'A day being carlos',
subtitle: 'My life in a song',
presenter: 'Carlos Valente',
note: '',
timeStart: 46800000,
timeEnd: 50400000,
timeType: 'start-end',
duration: 37200000 - 32400000,
isPublic: true,
colour: '',
type: 'event',
revision: 0,
id: '08e9',
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
},
{
title: 'Hamburgers and Cheese',
subtitle: '... and other life questions',
presenter: 'Filip Johansen',
note: '',
timeStart: 54000000,
timeEnd: 57600000,
timeType: 'start-end',
duration: 37200000 - 32400000,
isPublic: true,
colour: '',
type: 'event',
revision: 0,
id: 'e25a',
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
},
],
event: {
title: 'This is a test definition',
url: 'www.carlosvalente.com',
publicInfo: 'WiFi: demoproject \nPassword: ontimeproject',
backstageInfo: 'WiFi: demobackstage\nPassword: ontimeproject',
},
settings: {
app: 'ontime',
version: 2,
timeFormat: '24',
},
};
let parseResponse;
beforeEach(async () => {
parseResponse = await parseJson(testData);
});
it('has 7 events', () => {
const length = parseResponse?.rundown.length;
expect(length).toBe(7);
});
it('first event is as a match', () => {
const first = parseResponse?.rundown[0];
const expected = {
title: 'Guest Welcoming',
subtitle: '',
presenter: '',
note: '',
timeStart: 31500000,
timeEnd: 32400000,
timeType: 'start-end',
duration: 32400000 - 31500000,
isPublic: false,
skip: false,
colour: '',
type: 'event',
revision: 0,
id: '4b31',
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
};
expect(first).toStrictEqual(expected);
});
it('second event is as a match', () => {
const second = parseResponse?.rundown[1];
const expected = {
title: 'Good Morning',
subtitle: 'Days schedule',
presenter: 'Carlos Valente',
note: '',
timeStart: 32400000,
timeEnd: 36000000,
timeType: 'start-end',
duration: 36000000 - 32400000,
isPublic: true,
skip: true,
colour: 'red',
type: 'event',
revision: 0,
id: 'f24d',
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
};
expect(second).toStrictEqual(expected);
});
it('loaded event settings', () => {
const eventTitle = parseResponse?.event?.title;
expect(eventTitle).toBe('This is a test definition');
});
it('endMessage to exist but be empty', () => {
const endMessage = parseResponse?.event?.endMessage;
expect(endMessage).toBeDefined();
expect(endMessage).toBe('');
});
it('settings are for right app and version', () => {
const settings = parseResponse?.settings;
expect(settings.app).toBe('ontime');
expect(settings.version).toBe(2);
});
it('missing settings', () => {
const settings = parseResponse?.settings;
expect(settings.osc_port).toBeUndefined();
});
});
describe('test parser edge cases', () => {
it('generates missing ids', async () => {
const testData = {
rundown: [
{
title: 'Test Event',
type: 'event',
},
],
};
const parseResponse = await parseJson(testData);
expect(parseResponse.rundown[0].id).toBeDefined();
});
it('detects duplicate Ids', async () => {
console.log = vi.fn();
const testData = {
rundown: [
{
title: 'Test Event 1',
type: 'event',
id: '1',
},
{
title: 'Test Event 2',
type: 'event',
id: '1',
},
],
};
const parseResponse = await parseJson(testData);
expect(console.log).toHaveBeenCalledWith('ERROR: ID collision on import, skipping');
expect(parseResponse?.rundown.length).toBe(1);
});
it('handles incomplete datasets', async () => {
console.log = vi.fn();
const testData = {
rundown: [
{
title: 'Test Event 1',
id: '1',
},
{
title: 'Test Event 2',
id: '1',
},
],
};
const parseResponse = await parseJson(testData);
expect(console.log).toHaveBeenCalledWith('ERROR: undefined event type, skipping');
expect(parseResponse?.rundown.length).toBe(0);
});
it('skips unknown app and version settings', async () => {
console.log = vi.fn();
const testData = {
settings: {
osc_port: 8888,
},
};
await parseJson(testData);
expect(console.log).toHaveBeenCalledWith('ERROR: unknown app version, skipping');
});
});
describe('test corrupt data', () => {
it('handles some empty events', async () => {
const emptyEvents = {
rundown: [
{},
{},
{},
{
title: 'Test Event 1',
type: 'event',
id: '1',
},
{
title: 'Test Event 2',
type: 'event',
id: '2',
},
{},
{},
{},
],
event: {
title: 'All about Carlos demo event',
url: 'www.carlosvalente.com',
publicInfo: 'WiFi: demoproject \nPassword: ontimeproject',
backstageInfo: 'WiFi: demobackstage\nPassword: ontimeproject',
endMessage: '',
},
settings: {
app: 'ontime',
version: 2,
serverPort: 4001,
lock: null,
timeFormat: '24',
},
};
const parsedDef = await parseJson(emptyEvents);
expect(parsedDef.rundown.length).toBe(2);
});
it('handles all empty events', async () => {
const emptyEvents = {
rundown: [{}, {}, {}, {}, {}, {}, {}, {}],
event: {
title: 'All about Carlos demo event',
url: 'www.carlosvalente.com',
publicInfo: 'WiFi: demoproject \nPassword: ontimeproject',
backstageInfo: 'WiFi: demobackstage\nPassword: ontimeproject',
endMessage: '',
},
settings: {
app: 'ontime',
version: 2,
serverPort: 4001,
lock: null,
timeFormat: '24',
},
};
const parsedDef = await parseJson(emptyEvents);
expect(parsedDef.rundown.length).toBe(0);
});
it('handles missing event data', async () => {
const emptyEventData = {
rundown: [{}, {}, {}, {}, {}, {}, {}, {}],
event: {},
settings: {
app: 'ontime',
version: 2,
serverPort: 4001,
lock: null,
timeFormat: '24',
},
};
const parsedDef = await parseJson(emptyEventData);
expect(parsedDef.event).toStrictEqual(dbModel.event);
});
it('handles missing settings', async () => {
const missingSettings = {
rundown: [{}, {}, {}, {}, {}, {}, {}, {}],
event: {},
settings: {
app: 'ontime',
version: 2,
},
};
const parsedDef = await parseJson(missingSettings);
expect(parsedDef.settings).toStrictEqual(dbModel.settings);
});
it('fails with invalid JSON', async () => {
console.log = vi.fn();
const invalidJSON = 'some random dataset';
const parsedDef = await parseJson(invalidJSON);
expect(console.log).toHaveBeenCalledWith('ERROR: Invalid JSON format');
expect(parsedDef).toBe(-1);
});
});
describe('test event validator', () => {
it('validates a good object', () => {
const event = {
title: 'test',
};
const validated = validateEvent(event);
expect(validated).toEqual(
expect.objectContaining({
title: expect.any(String),
subtitle: expect.any(String),
presenter: expect.any(String),
note: expect.any(String),
timeStart: expect.any(Number),
timeEnd: expect.any(Number),
isPublic: expect.any(Boolean),
skip: expect.any(Boolean),
revision: expect.any(Number),
type: expect.any(String),
id: expect.any(String),
colour: expect.any(String),
user0: expect.any(String),
user1: expect.any(String),
user2: expect.any(String),
user3: expect.any(String),
user4: expect.any(String),
user5: expect.any(String),
user6: expect.any(String),
user7: expect.any(String),
user8: expect.any(String),
user9: expect.any(String),
}),
);
});
it('fails an empty object', () => {
const event = {};
const validated = validateEvent(event);
expect(validated).toEqual(null);
});
it('makes objects strings', () => {
const event = {
title: 2,
subtitle: true,
presenter: 3.2,
note: '1899-12-30T08:00:10.000Z',
};
const validated = validateEvent(event);
expect(typeof validated.title).toEqual('string');
expect(typeof validated.subtitle).toEqual('string');
expect(typeof validated.presenter).toEqual('string');
expect(typeof validated.note).toEqual('string');
});
it('enforces numbers on times', () => {
const event = {
timeStart: false,
timeEnd: '2',
};
const validated = validateEvent(event);
expect(typeof validated.timeStart).toEqual('number');
expect(validated.timeStart).toEqual(0);
expect(typeof validated.timeEnd).toEqual('number');
expect(validated.timeEnd).toEqual(0);
});
it('handles bad objects', () => {
const event = {
title: {},
};
const validated = validateEvent(event);
expect(typeof validated.title).toEqual('string');
});
});
describe('test makeString function', () => {
it('converts variables to string', () => {
let val = 2;
let expected = '2';
let converted = makeString(val);
expect(converted).toBe(expected);
val = 2.22222222;
expected = '2.22222222';
converted = makeString(val);
expect(converted).toBe(expected);
val = ['testing'];
expected = 'testing';
converted = makeString(val);
expect(converted).toBe(expected);
val = { doing: 'testing' };
converted = makeString(val, 'fallback');
expect(converted).toBe('fallback');
});
});
describe('test parseExcel function', () => {
it('parses the example file', async () => {
const testdata = [
['Ontime ┬À Schedule Template'],
[],
['Event Name', 'Test Event'],
['Event URL', 'www.carlosvalente.com'],
['Public Info', 'test public info'],
['Backstage Info', 'test backstage info'],
['End Message', 'test end message'],
[],
[],
[
'Time Start',
'Time End',
'Event Title',
'Presenter Name',
'Event Subtitle',
'Is Public? (x)',
'Skip? (x)',
'Notes',
'User0:test0',
'User1:test1',
'User2:test2',
'User3:test3',
'User4:test4',
'User5:test5',
'User6:test6',
'user7:test7',
'user8:test8',
'user9:test9',
'Colour',
],
[
'1899-12-30T07:00:00.000Z',
'1899-12-30T08:00:10.000Z',
'Guest Welcome',
'Carlos',
'Getting things started',
'x',
'',
'Ballyhoo',
'',
'',
'',
'',
'a0',
'a1',
'a2',
'a3',
'a4',
'a5',
'a6',
'a7',
'a8',
'a9',
'red',
],
[
'1899-12-30T08:00:00.000Z',
'1899-12-30T08:30:00.000Z',
'A song from the hearth',
'Still Carlos',
'Derailing early',
'',
'',
'Rainbow chase',
'',
'',
'',
'',
'b0',
'',
'',
'',
'',
'b5',
'',
'',
'',
'',
'#F00',
],
[],
];
const expectedParsedEvent = {
title: 'Test Event',
url: 'www.carlosvalente.com',
publicInfo: 'test public info',
backstageInfo: 'test backstage info',
endMessage: 'test end message',
};
const expectedParsedRundown = [
{
timeStart: 25200000,
timeEnd: 28810000,
title: 'Guest Welcome',
presenter: 'Carlos',
subtitle: 'Getting things started',
isPublic: true,
skip: false,
note: 'Ballyhoo',
user0: 'a0',
user1: 'a1',
user2: 'a2',
user3: 'a3',
user4: 'a4',
user5: 'a5',
user6: 'a6',
user7: 'a7',
user8: 'a8',
user9: 'a9',
colour: 'red',
type: 'event',
},
{
timeStart: 28800000,
timeEnd: 30600000,
title: 'A song from the hearth',
presenter: 'Still Carlos',
subtitle: 'Derailing early',
isPublic: false,
skip: true,
note: 'Rainbow chase',
user0: 'b0',
user5: 'b5',
colour: '#F00',
type: 'event',
},
];
const parsedData = await parseExcel(testdata);
expect(parsedData.event).toStrictEqual(expectedParsedEvent);
expect(parsedData.rundown).toBeDefined();
expect(parsedData.rundown.title).toBe(expectedParsedRundown.title);
expect(parsedData.rundown.presenter).toBe(expectedParsedRundown.presenter);
expect(parsedData.rundown.subtitle).toBe(expectedParsedRundown.subtitle);
expect(parsedData.rundown.isPublic).toBe(expectedParsedRundown.isPublic);
expect(parsedData.rundown.skip).toBe(expectedParsedRundown.skip);
expect(parsedData.rundown.note).toBe(expectedParsedRundown.note);
expect(parsedData.rundown.type).toBe(expectedParsedRundown.type);
});
});
describe('test aliases import', () => {
it('imports a well defined alias', () => {
const testData = {
rundown: [],
settings: {
app: 'ontime',
version: 2,
},
aliases: [
{
enabled: false,
alias: 'testalias',
pathAndParams: 'testpathAndParams',
},
],
};
const parsed = parseAliases(testData);
expect(parsed.length).toBe(1);
// generates missing id
expect(parsed[0].id).toBeDefined();
});
});
describe('test userFields import', () => {
const model = dbModel.userFields;
it('imports a fully defined user fields', () => {
const testUserFields = {
user0: 'test0',
user1: 'test1',
user2: 'test2',
user3: 'test3',
user4: 'test4',
user5: 'test5',
user6: 'test6',
user7: 'test7',
user8: 'test8',
user9: 'test9',
};
const testData = {
rundown: [],
settings: {
app: 'ontime',
version: 2,
},
userFields: testUserFields,
};
const parsed = parseUserFields(testData);
expect(parsed).toStrictEqual(testUserFields);
});
it('imports a partially defined user fields', () => {
const testUserFields = {
user0: 'test0',
user1: 'test1',
user7: 'test7',
user8: 'test8',
user9: 'test9',
};
const expected = {
...model,
...testUserFields,
};
const testData = {
rundown: [],
settings: {
app: 'ontime',
version: 2,
},
userFields: testUserFields,
};
const parsed = parseUserFields(testData);
expect(parsed).toStrictEqual(expected);
});
it('handles missing user fields', () => {
const testData = {
rundown: [],
settings: {
app: 'ontime',
version: 2,
},
};
const parsed = parseUserFields(testData);
expect(parsed).toStrictEqual(model);
expect(parsed).toStrictEqual(model);
});
it('ignores badly defined fields', () => {
const testData = {
rundown: [],
settings: {
app: 'ontime',
version: 2,
},
userFields: {
notThis: 'this shouldng be accepted',
orThis: 'this neither',
},
};
const parsed = parseUserFields(testData);
expect(parsed).toStrictEqual(model);
});
});
describe('test views import', () => {
it('imports data from file', () => {
const testData = {
rundown: [],
settings: {
app: 'ontime',
version: 2,
},
views: {
overrideStyles: true,
},
};
const parsed = parseViews(testData);
expect(parsed).toStrictEqual(testData.views);
});
it('imports defaults to model', () => {
const testData = {
rundown: [],
settings: {
app: 'ontime',
version: 2,
},
};
const parsed = parseViews(testData, true);
expect(parsed).toStrictEqual(dbModel.views);
});
});
describe('test validateDuration()', () => {
describe('handles valid inputs', () => {
const valid = [
{ test: 'zero values', timeStart: 0, timeEnd: 0 },
{ test: 'end after start', timeStart: 0, timeEnd: 1 },
];
valid.forEach((t) => {
it(t.test, () => {
const d = validateDuration(t.timeStart, t.timeEnd);
expect(d).toBe(t.timeEnd - t.timeStart);
});
});
});
describe('handles edge cases', () => {
// edge cases
const testData = [
{ test: 'negative 0', timeStart: -0, timeEnd: -0, expected: 0 },
{ test: 'end before start', timeStart: 2, timeEnd: 1, expected: 0 },
];
testData.forEach((t) => {
it(t.test, () => {
const d = validateDuration(t.timeStart, t.timeEnd);
expect(d).toBe(t.expected);
});
});
});
});
@@ -0,0 +1,92 @@
import { isEmptyObject, mergeObject, removeUndefined } from '../parserUtils.js';
describe('isEmptyObject()', () => {
test('finds an empty object', () => {
const isEmpty = isEmptyObject({});
expect(isEmpty).toBe(true);
});
test('throws on other types', () => {
expect(() => isEmptyObject(12)).toThrow();
});
test('resolves an object with methods', () => {
const isEmpty = isEmptyObject({ test: 'yes' });
expect(isEmpty).toBe(false);
});
});
describe('mergeObject()', () => {
test('it suppresses undefined keys', () => {
const a = {
first: 'yes',
second: 'yes',
};
const b = {
first: undefined,
second: 'no',
};
const merged = mergeObject(a, b);
expect(merged).toStrictEqual({
first: 'yes',
second: 'no',
});
});
test('it handles falsy values', () => {
const a = {
first: 'yes',
second: 'yes',
third: 'yes',
};
const b = {
first: 0,
second: null,
third: '',
};
const merged = mergeObject(a, b);
expect(merged).toStrictEqual({
first: 0,
second: null,
third: '',
});
});
test.skip('it only merges fields of the first object', () => {
const a = {
first: 'yes',
second: 'yes',
third: 'yes',
};
const b = {
first: 0,
second: null,
third: '',
forth: 'not-this',
};
const merged = mergeObject(a, b);
expect(merged).toStrictEqual({
first: 0,
second: null,
third: '',
});
});
});
describe('removeUndefined()', () => {
test('it removes undefined keys from object', () => {
const obj = {
first: 'yes',
second: undefined,
third: 'yes',
};
expect(removeUndefined(obj)).toStrictEqual({
first: 'yes',
third: 'yes',
});
});
test('it handles falsy values object', () => {
const obj = {
first: '',
second: 0,
third: 'null',
};
expect(removeUndefined(obj)).toStrictEqual(obj);
});
});
@@ -0,0 +1,28 @@
import { parseExcelDate } from '../time';
describe('parseExcelDate', () => {
it('parses a valid date string as expected from excel', () => {
const millis = parseExcelDate('1899-12-30T07:00:00.000Z');
expect(millis).not.toBe(0);
});
describe('parses a time string that passes validation', () => {
const validFields = ['10:00:00', '10:00'];
validFields.forEach((field) => {
it(`handles ${field}`, () => {
const millis = parseExcelDate(field);
expect(millis).not.toBe(0);
});
});
});
describe('returns 0 on other strings', () => {
const invalidFields = ['10', 'test', ''];
invalidFields.forEach((field) => {
it(`handles ${field}`, () => {
const millis = parseExcelDate(field);
expect(millis).toBe(0);
});
});
});
});
@@ -0,0 +1,33 @@
import { cleanURL } from '../url';
describe('url is correctly formatted', () => {
it('has no leading spaces', () => {
const test = ' http://testing';
const expected = 'http://testing';
expect(cleanURL(test)).toBe(expected);
});
it('has no trailing spaces', () => {
const test = 'http://testing ';
const expected = 'http://testing';
expect(cleanURL(test)).toBe(expected);
});
it('doesnt contain spaces', () => {
const test = 'http://t e s t i n g';
const expected = 'http://t%20e%20s%20t%20i%20n%20g';
expect(cleanURL(test)).toBe(expected);
});
it('only contains allowed characters', () => {
const test = 'http://<>[]{}|^';
const expected = 'http://';
expect(cleanURL(test)).toBe(expected);
});
it('begins with http://', () => {
const test = 'ontime.com';
const expected = 'http://ontime.com';
expect(cleanURL(test)).toBe(expected);
});
});
+25
View File
@@ -0,0 +1,25 @@
/**
* @description Returns id of previous played event
* @param {array} events
* @param {string} eventId
* @return {object}
*/
export function getPreviousPlayable(events, eventId) {
// find current index
const current = events.findIndex((event) => event.id === eventId);
if (current === -1) {
return { index: null, id: null };
}
let index = current - 1;
while (index >= 0) {
const event = events[index];
if (event.type === 'event' && !event.skip) {
return { index, id: event.id };
}
index--;
}
return { index: null, id: null };
}
+15
View File
@@ -0,0 +1,15 @@
import { existsSync, mkdirSync } from 'fs';
/**
* @description Creates a directory if it doesn't exist
* @param {string} directory - directory that should exist or will be created
*/
export function ensureDirectory(directory) {
if (!existsSync(directory)) {
try {
mkdirSync(directory, { recursive: true });
} catch (err) {
throw new Error(`Could not create directory: ${err}`);
}
}
}
File diff suppressed because it is too large Load Diff
+397
View File
@@ -0,0 +1,397 @@
import fs from 'fs';
import xlsx from 'node-xlsx';
import { generateId } from 'ontime-utils';
import { event as eventDef } from '../models/eventsDefinition.js';
import { dbModel } from '../models/dataModel.js';
import { deleteFile, makeString, validateDuration } from './parserUtils.js';
import {
parseAliases,
parseEvent,
parseHttp,
parseOsc,
parseRundown,
parseSettings,
parseUserFields,
parseViews,
} from './parserFunctions.js';
import { parseExcelDate } from './time.js';
export const EXCEL_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
export const JSON_MIME = 'application/json';
/**
* @description Excel array parser
* @param {array} excelData - array with excel sheet
* @returns {object} - parsed object
*/
export const parseExcel = async (excelData) => {
const eventData = {
title: '',
url: '',
};
const customUserFields = {};
const rundown = [];
let timeStartIndex = null;
let timeEndIndex = null;
let titleIndex = null;
let presenterIndex = null;
let subtitleIndex = null;
let isPublicIndex = null;
let skipIndex = null;
let notesIndex = null;
let colourIndex = null;
let user0Index = null;
let user1Index = null;
let user2Index = null;
let user3Index = null;
let user4Index = null;
let user5Index = null;
let user6Index = null;
let user7Index = null;
let user8Index = null;
let user9Index = null;
excelData
.filter((e) => e.length > 0)
.forEach((row) => {
let eventTitleNext = false;
let eventUrlNext = false;
let publicInfoNext = false;
let backstageInfoNext = false;
let endMessageNext = false;
const event = {};
row.forEach((column, j) => {
// check flags
if (eventTitleNext) {
eventData.title = column;
eventTitleNext = false;
} else if (eventUrlNext) {
eventData.url = column;
eventUrlNext = false;
} else if (publicInfoNext) {
eventData.publicInfo = column;
publicInfoNext = false;
} else if (backstageInfoNext) {
eventData.backstageInfo = column;
backstageInfoNext = false;
} else if (endMessageNext) {
eventData.endMessage = column;
endMessageNext = false;
} else if (j === timeStartIndex) {
event.timeStart = parseExcelDate(column);
} else if (j === timeEndIndex) {
event.timeEnd = parseExcelDate(column);
} else if (j === titleIndex) {
event.title = column;
} else if (j === presenterIndex) {
event.presenter = column;
} else if (j === subtitleIndex) {
event.subtitle = column;
} else if (j === isPublicIndex) {
event.isPublic = Boolean(column);
} else if (j === skipIndex) {
event.skip = Boolean(column);
} else if (j === notesIndex) {
event.note = column;
} else if (j === colourIndex) {
event.colour = column;
} else if (j === user0Index) {
event.user0 = column;
} else if (j === user1Index) {
event.user1 = column;
} else if (j === user2Index) {
event.user2 = column;
} else if (j === user3Index) {
event.user3 = column;
} else if (j === user4Index) {
event.user4 = column;
} else if (j === user5Index) {
event.user5 = column;
} else if (j === user6Index) {
event.user6 = column;
} else if (j === user7Index) {
event.user7 = column;
} else if (j === user8Index) {
event.user8 = column;
} else if (j === user9Index) {
event.user9 = column;
} else {
if (typeof column === 'string') {
const col = column.toLowerCase();
// look for keywords
// need to make sure it is a string first
switch (col) {
case 'event name':
eventTitleNext = true;
break;
case 'event url':
eventUrlNext = true;
break;
case 'public info':
publicInfoNext = true;
break;
case 'backstage info':
backstageInfoNext = true;
break;
case 'end message':
endMessageNext = true;
break;
case 'time start':
case 'start':
timeStartIndex = j;
break;
case 'time end':
case 'end':
case 'finish':
timeEndIndex = 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 'notes':
notesIndex = j;
break;
case 'colour':
case 'color':
colourIndex = 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 (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: 'event' });
}
});
return {
rundown,
event: eventData,
settings: {
app: 'ontime',
version: 2,
},
userFields: { ...dbModel.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
* @returns {object} - parsed object
*/
export const parseJson = async (jsonData, enforce = false) => {
if (!jsonData || typeof jsonData !== 'object') {
console.log('ERROR: Invalid JSON format');
return -1;
}
// object containing the parsed data
const returnData = {};
// parse Events
returnData.rundown = parseRundown(jsonData);
// parse Event
returnData.event = parseEvent(jsonData, enforce);
// Settings handled partially
returnData.settings = parseSettings(jsonData, enforce);
// View settings handled partially
returnData.views = parseViews(jsonData, enforce);
// Import OSC settings if any
returnData.osc = parseOsc(jsonData, enforce);
// Import HTTP settings if any
returnData.http = parseHttp(jsonData, enforce);
// Import Aliases if any
returnData.aliases = parseAliases(jsonData);
// Import user fields if any
returnData.userFields = parseUserFields(jsonData);
return returnData;
};
/**
* @description Enforces formatting for events
* @param {object} eventArgs - attributes of event
* @returns {object|null} - formatted object or null in case is invalid
*/
export const validateEvent = (eventArgs) => {
// ensure id is defined and unique
const id = eventArgs.id || generateId();
let event = null;
// return if object is empty
if (Object.keys(eventArgs).length > 0) {
// make sure all properties exits
// dont load any extra properties than the ones known
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;
event = {
...d,
title: makeString(e.title, d.title),
subtitle: makeString(e.subtitle, d.subtitle),
presenter: makeString(e.presenter, d.presenter),
timeStart: start,
timeEnd: end,
timeType: 'start-end',
duration: validateDuration(start, end),
isPublic: typeof e.isPublic === 'boolean' ? e.isPublic : d.isPublic,
skip: typeof e.skip === 'boolean' ? e.skip : d.skip,
note: makeString(e.note, d.note),
user0: makeString(e.user0, d.user0),
user1: makeString(e.user1, d.user1),
user2: makeString(e.user2, d.user2),
user3: makeString(e.user3, d.user3),
user4: makeString(e.user4, d.user4),
user5: makeString(e.user5, d.user5),
user6: makeString(e.user6, d.user6),
user7: makeString(e.user7, d.user7),
user8: makeString(e.user8, d.user8),
user9: makeString(e.user9, d.user9),
// deciding not to validate colour
// this adds flexibility to the user to write hex codes, rgb,
// but also colour names like blue and red
// CSS.supports is only available in frontend
colour: makeString(e.colour, d.colour),
id,
type: 'event',
};
}
return event;
};
/**
* @description Middleware function that checks file type and calls relevant parser
* @param {string} file - reference to file
* @return {object} - parse result message
*/
export const fileHandler = async (file) => {
let res = {};
// 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.event = parseEvent(dataFromExcel, true);
res.data.userFields = parseUserFields(dataFromExcel);
res.message = 'success';
} else {
console.log('Error: No sheets found named ontime or event schedule');
res = {
error: true,
message: `No sheets found named ontime or event schedule`,
};
}
} catch (error) {
res = { error: true, message: `Error parsing file: ${error}` };
}
}
if (file.endsWith('.json')) {
// if json check version
const rawdata = fs.readFileSync(file);
let uploadedJson = null;
try {
uploadedJson = JSON.parse(rawdata);
} catch (error) {
return { error: true, message: 'Error parsing JSON file' };
}
if (uploadedJson.settings.version === 1) {
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;
};
+265
View File
@@ -0,0 +1,265 @@
import { generateId } from 'ontime-utils';
import { block as blockDef, delay as delayDef } from '../models/eventsDefinition.js';
import { dbModel } from '../models/dataModel.js';
import { validateEvent } from './parser.js';
import { MAX_EVENTS } from '../settings.js';
/**
* Parse events array of an entry
* @param {object} data - data object
* @returns {object} - event object data
*/
export const parseRundown = (data) => {
let newRundown = [];
if ('rundown' in data) {
console.log('Found rundown definition, importing...');
const rundown = [];
try {
const ids = [];
for (const e of data.rundown) {
// cap number of events
if (rundown.length >= MAX_EVENTS) {
console.log(`ERROR: Reached limit number of ${MAX_EVENTS} events`);
break;
}
// double check unique ids
if (ids.indexOf(e?.id) !== -1) {
console.log('ERROR: ID collision on import, skipping');
continue;
}
if (e.type === 'event') {
const event = validateEvent(e);
if (event != null) {
rundown.push(event);
ids.push(event.id);
}
} else if (e.type === 'delay') {
rundown.push({
...delayDef,
duration: e.duration,
id: e.id || generateId(),
});
} else if (e.type === 'block') {
rundown.push({ ...blockDef, id: e.id || generateId() });
} else {
console.log('ERROR: undefined event type, skipping');
}
}
} catch (error) {
console.log(`Error ${error}`);
}
// write to db
newRundown = rundown;
console.log(`Uploaded file with ${newRundown.length} entries`);
}
return newRundown;
};
/**
* 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 parseEvent = (data, enforce) => {
let newEvent = {};
if ('event' in data) {
console.log('Found event data, importing...');
const e = data.event;
// filter known properties and write to db
newEvent = {
...dbModel.event,
title: e.title || dbModel.event.title,
url: e.url || dbModel.event.url,
publicInfo: e.publicInfo || dbModel.event.publicInfo,
backstageInfo: e.backstageInfo || dbModel.event.backstageInfo,
endMessage: e.endMessage || dbModel.event.endMessage,
};
} else if (enforce) {
newEvent = { ...dbModel.event };
console.log(`Created event object in db`);
}
return newEvent;
};
/**
* 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) => {
let newSettings = {};
if ('settings' in data) {
console.log('Found settings definition, importing...');
const s = data.settings;
// skip if file definition is missing
if (s.app == null || s.version == null) {
console.log('ERROR: unknown app version, skipping');
} else {
const settings = {
lock: s.lock || null,
pinCode: s.pinCode || null,
timeFormat: s.timeFormat || '24',
};
// write to db
newSettings = {
...dbModel.settings,
...settings,
};
}
} else if (enforce) {
newSettings = dbModel.settings;
console.log(`Created settings object in db`);
}
return newSettings;
};
/**
* 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 parseViews = (data, enforce) => {
let newViews = {};
if ('views' in data) {
console.log('Found view definition, importing...');
const v = data.views;
const viewSettings = {
overrideStyles: v.overrideStyles ?? dbModel.views.overrideStyles,
};
// write to db
newViews = {
...viewSettings,
};
} else if (enforce) {
newViews = dbModel.views;
console.log(`Created view object in db`);
}
return newViews;
};
/**
* Parse osc 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 parseOsc = (data, enforce) => {
let newOsc = {};
if ('osc' in data) {
console.log('Found OSC definition, importing...');
const s = data.osc;
const osc = {};
if (s.port) osc.port = s.port;
if (s.portOut) osc.portOut = s.portOut;
if (s.targetIP) osc.targetIP = s.targetIP;
if (typeof s.enabled !== 'undefined') osc.enabled = s.enabled;
// write to db
newOsc = {
...dbModel.osc,
...osc,
};
} else if (enforce) {
newOsc = { ...dbModel.osc };
console.log(`Created OSC object in db`);
}
return newOsc;
};
/**
* Parse Http 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 parseHttp = (data, enforce) => {
const newHttp = {};
if ('http' in data) {
console.log('Found HTTP definition, importing...');
const h = data.osc;
const http = {};
if (h.user) http.user = h.user;
if (h.pwd) http.pwd = h.pwd;
// write to db
newHttp.http = {
...dbModel.http,
...http,
};
} else if (enforce) {
newHttp.http = { ...dbModel.http };
console.log(`Created http object in db`);
}
return newHttp;
};
/**
* Parse aliases portion of an entry
* @param {object} data - data object
* @returns {object} - event object data
*/
export const parseAliases = (data) => {
const newAliases = [];
if ('aliases' in data) {
console.log('Found Aliases definition, importing...');
const ids = [];
try {
for (const a of data.aliases) {
// double check unique ids
if (ids.indexOf(a?.id) !== -1) {
console.log('ERROR: ID collision on import, skipping');
continue;
}
const newAlias = {
id: a.id || generateId(),
enabled: a.enabled || false,
alias: a.alias || '',
pathAndParams: a.pathAndParams || '',
};
ids.push(newAlias.id);
newAliases.push(newAlias);
}
console.log(`Uploaded ${newAliases?.length || 0} alias(es)`);
} catch (error) {
console.log(`Error: ${error}`);
}
}
return newAliases;
};
/**
* Parse userFields entry
* @param {object} data - data object
* @returns {object} - event object data
*/
export const parseUserFields = (data) => {
const newUserFields = { ...dbModel.userFields };
if ('userFields' in data) {
console.log('Found User Fields definition, importing...');
// we will only be importing the fields we know, so look for that
try {
let fieldsFound = 0;
for (const n in newUserFields) {
if (n in data.userFields) {
fieldsFound++;
newUserFields[n] = data.userFields[n];
}
}
console.log(`Uploaded ${fieldsFound} user fields`);
} catch (error) {
console.log(`Error: ${error}`);
}
}
return { ...newUserFields };
};
+87
View File
@@ -0,0 +1,87 @@
import fs from 'fs';
/**
* @description Ensures variable is string, it skips object types
* @param {any} val - variable to convert
* @param {string} [fallback=''] - fallback value
* @returns {string} - value as string or fallback if not possible
*/
export const makeString = (val, fallback = '') => {
if (typeof val === 'string') return val;
else if (val == null || val.constructor === Object) return fallback;
return val.toString();
};
/**
* @description validates a duration value against options
* @param {number} timeStart
* @param {number} timeEnd
* @returns {number}
*/
export const validateDuration = (timeStart, timeEnd) => {
// Durations must be positive
return Math.max(timeEnd - timeStart, 0);
};
/**
* @description Delete file from system
* @param {string} file - reference to file
*/
export const deleteFile = async (file) => {
// delete a file
fs.unlink(file, (err) => {
if (err) {
console.log(err);
}
});
};
/**
* @description Delete file from system
* @param {string} file - reference to file
* @returns {boolean} - whether file is valid JSON
*/
export const validateFile = (file) => {
try {
JSON.parse(fs.readFileSync(file, 'utf-8'));
return true;
} catch (err) {
return false;
}
};
/**
* @description Verifies if object is empty
* @param {object} obj
*/
export const isEmptyObject = (obj) => {
if (typeof obj === 'object' && obj !== null && !Array.isArray(obj)) {
return Object.keys(obj).length === 0;
}
throw new Error('Variable is not an object');
};
/**
* @description Merges two objects, suppressing undefined keys
* @param {object} a
* @param {object} b
*/
export const mergeObject = (a, b) => {
const merged = {};
Object.keys({ ...a, ...b }).map((key) => {
merged[key] = typeof b[key] === 'undefined' ? a[key] : b[key];
});
return merged;
};
/**
* @description Removes undefined
* @param {object} obj
*/
export const removeUndefined = (obj) => {
const patched = {};
Object.keys({ ...obj })
.filter((key) => typeof obj[key] !== 'undefined')
.map((key) => (patched[key] = obj[key]));
return patched;
};
+39
View File
@@ -0,0 +1,39 @@
import { isEmptyObject } from './parserUtils.js';
/**
* @description initial checks for an empty of malformed request object
* @param obj
* @param res
*/
export const failEmptyObjects = (obj, res) => {
let failed = false;
try {
if (isEmptyObject(obj)) {
res.status(400).send('No object found in request');
failed = true;
}
} catch (error) {
res.status(400).send(error);
failed = true;
}
return failed;
};
/**
* @description initial checks for an empty of malformed request object
* @param obj
* @param res
*/
export const failIsNotArray = (obj, res) => {
let failed = false;
try {
if (!Array.isArray(obj)) {
res.status(400).send('No array found in request');
failed = true;
}
} catch (error) {
res.status(400).send(error);
failed = true;
}
return failed;
};
+149
View File
@@ -0,0 +1,149 @@
const mts = 1000; // millis to seconds
const mtm = 1000 * 60; // millis to minutes
const mth = 1000 * 60 * 60; // millis to hours
export const timeFormat = 'HH:mm';
export const timeFormatSeconds = 'HH:mm:ss';
export const DAY_TO_MS = 86400000;
/**
* @description Validates a time string
* @param {string} string - time string "23:00:12"
* @returns {boolean} string represents time
*/
export const isTimeString = (string) => {
// ^ # Start of string
// (?: # Try to match...
// (?: # Try to match...
// ([01]?\d|2[0-3]): # HH:
// )? # (optionally).
// ([0-5]?\d): # MM: (required)
// )? # (entire group optional, so either HH:MM:, MM: or nothing)
// ([0-5]?\d) # SS (required)
// $ # End of string
const regex = /^(?:(?:([01]?\d|2[0-3])[:,.])?([0-5]?\d)[:,.])?([0-5]?\d)$/;
return regex.test(string);
};
/**
* @description Converts milliseconds to string representing time
* @param {number} ms - time in milliseconds
* @param {boolean} showSeconds - weather to show the seconds
* @param {string} delim - character between HH MM SS
* @param {string} ifNull - what to return if value is null
* @returns {string} String representing time 00:12:02
*/
export const stringFromMillis = (ms, showSeconds = true, delim = ':', ifNull = '...') => {
if (ms == null || isNaN(ms)) return ifNull;
const isNegative = ms < 0 ? '-' : '';
const millis = Math.abs(ms);
/**
* @description ensures value is double digit
* @param value
* @return {string|*}
*/
const showWith0 = (value) => (value < 10 ? `0${value}` : value);
const hours = showWith0(Math.floor(((millis / mth) % 60) % 24));
const minutes = showWith0(Math.floor((millis / mtm) % 60));
const seconds = showWith0(Math.floor((millis / mts) % 60));
return showSeconds
? `${isNegative}${
parseInt(hours, 10) ? `${hours}${delim}` : `00${delim}`
}${minutes}${delim}${seconds}`
: `${isNegative}${parseInt(hours, 10) ? `${hours}` : '00'}${delim}${minutes}`;
};
/**
* @description Converts an excel date to milliseconds
* @argument {string} date - excel string date
* @returns {number} - time in milliseconds
*/
export const dateToMillis = (date) => {
const h = date.getHours();
const m = date.getMinutes();
const s = date.getSeconds();
return h * mth + m * mtm + s * mts;
};
/**
* @description safe parse string to int, copied from client code
* @param valueAsString
* @return {number}
*/
const parse = (valueAsString) => {
const parsed = parseInt(valueAsString, 10);
if (isNaN(parsed)) {
return 0;
}
return Math.abs(parsed);
};
/**
* @description Parses a time string to millis, copied from client code
* @param {string} value - time string
* @param {boolean} fillLeft - autofill left = hours / right = seconds
* @returns {number} - time string in millis
*/
export const forgivingStringToMillis = (value, fillLeft = true) => {
let millis = 0;
// split string at known separators : , .
const separatorRegex = /[\s,:.]+/;
const [first, second, third] = value.split(separatorRegex);
if (first != null && second != null && third != null) {
// if string has three sections, treat as [hours] [minutes] [seconds]
millis = parse(first) * mth;
millis += parse(second) * mtm;
millis += parse(third) * mts;
} else if (first != null && second == null && third == null) {
// if string has one section,
// could be a complete string like 121010 - 12:10:10
if (first.length === 6) {
const hours = first.substring(0, 2);
const minutes = first.substring(2, 4);
const seconds = first.substring(4);
millis = parse(hours) * mth;
millis += parse(minutes) * mtm;
millis += parse(seconds) * mts;
} else {
// otherwise lets treat as [minutes]
millis = parse(first) * mtm;
}
}
if (first != null && second != null && third == null) {
// if string has two sections
if (fillLeft) {
// treat as [hours] [minutes]
millis = parse(first) * mth;
millis += parse(second) * mtm;
} else {
// treat as [minutes] [seconds]
millis = parse(first) * mtm;
millis += parse(second) * mts;
}
}
return millis;
};
/**
* @description Parses an excel date using the correct parser
* @param {string} excelDate
* @returns {number} - time in milliseconds
*/
export const parseExcelDate = (excelDate) => {
// attempt converting to date object
const date = new Date(excelDate);
if (date instanceof Date && !isNaN(date)) {
return dateToMillis(date);
} else if (isTimeString(excelDate)) {
return forgivingStringToMillis(excelDate);
}
return 0;
};
+46
View File
@@ -0,0 +1,46 @@
import multer from 'multer';
import * as path from 'path';
import { EXCEL_MIME, JSON_MIME } from './parser.js';
import { ensureDirectory } from './fileManagement.js';
import { getAppDataPath } from '../setup.js';
// Define multer storage object
const storage = multer.diskStorage({
destination: function (req, file, cb) {
// get platform path
const appDataPath = getAppDataPath();
if (appDataPath === '') {
throw new Error('Could not resolve public folder for platform');
}
// append uploads folder
const newDestination = path.join(appDataPath, 'uploads');
// Create directory if not exist
ensureDirectory(newDestination);
cb(null, newDestination);
},
filename: function (req, file, cb) {
cb(null, `${Date.now()}--${file.originalname}`);
},
});
/**
* @description Middleware function to filter allowed file types
* @argument file - reference to file
* @return {boolean} - file allowed
*/
const filterAllowed = (req, file, cb) => {
if (file.mimetype.includes(JSON_MIME) || file.mimetype.includes(EXCEL_MIME)) {
cb(null, true);
} else {
console.log('ERROR: Unrecognised file type');
cb(null, false);
}
};
// Build multer uploader for a single file
export const uploadFile = multer({
storage: storage,
fileFilter: filterAllowed,
}).single('userFile');
+19
View File
@@ -0,0 +1,19 @@
/**
* @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;
};