mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-14 03:43:50 +00:00
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:
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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: '',
|
||||
};
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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))
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export const MAX_EVENTS = 255;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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');
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user