mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-16 04:43:35 +00:00
v2 (#386)
* Refactor/ts sockets (#210) * refactor(ts): create socket subscription service * refactor(ts-sockets): useSubscription hook * refactor(ts-sockets): extract colour selection to utility * Refactor/socket controller (#212) * refactor(socket): dictionary cleanup * refactor(socket): detangle socket from EventTimer * refactor(socket): poll object * refactor(socket): wip, extract data responsibilities to provider * fix: issue with onAir * refactor: create data provider and validation utils * refactor: remove deprecated endpoint * refactor: detangle and validate ontime controller * refactor: detangle and validate events controller * refactor: validate routers * refactor: handle post failure in modals * chore: upgrade electron (#215) * Fix: issue with text colour (#214) * fix: adjust text colour from context * fix: issue with wrong proptype * chore: upgrade electron * chore: declare node version * chore: upgrade node actions * Feat/55 v2 (#225) * chore: upgrade relevant libraries * feat(skip): add skip styling to paginated items * chore: upgrade relevant libraries * Fix: issue with text colour (#214) * fix: adjust text colour from context * fix: issue with wrong proptype * fix issue with vite migration (#217) * hotfix: 1.8.2 issues vite migration * fix: file import options * feat(55): styling * refactor: remove onhover option for entry block * refactor: relocate logging provider * refactor: convert to typescript * feat(55): add event editor * refactor: extract event actions * fix: bad import * feat(55): redesign components * fix: issues with not awaiting async * refactor: replace socket with subscription * refactor: remove unused * style: small tweaks * refactor: extract event actions * refactor: cleanup debug * refactor: chakra imports * fix: optimistic mutations * refactor: handle promise rejections * refactor: validation * fix: rq optimistic mutations * feat: add playback feedback to block * refactor: no optimistic adding of events * refactor: simplify cursor state * styles: cleanup editor style * refactor: cleanup duration update * fix: revert package upgrade (issues with vitest) * fix: prevent cyclic imports * refactor: extract data fetcher * refactor: typescript migration * chore: update tests * fix: remove problematic (#228) * styles: v2 (#229) * style: revise selected style * style: revise editor styles * style: revise viewer styles * chore: update tests * v2:feat (#231) * style: element relationship * feat: utility to add first element * refactor: organise dir * ux: deleting element closes drawer * fix: issue with escaped characters * fix: issue with overflow in events list * fix: prevent event timer from receiving non event * style: prevent small shift on class change * style: visually detach event editor * fix: entry block knows last event * fix: skip event timer updates on non events * fix: prevent electron OPENGL error * style: cleanup text styles * style: unify input styles * refactor: simplify style and composition * refactor: tweaks on style + ts * refactor: add validation to events post * fix: handle non events in event timer * refactor: cleanup unused * V2 styles part2 (#233) * style: create tag to display network messages * style: create button style for playback * style: review style for QuickEntry * style: review style for Info panel * style: review style for Event blocks * style: review style for Message control panel * style: review style for Playback control * style: review application styles * style: review block styles * refactor: TapButton has active state * style: small style tweaks * refactor: type checks * V2 message manager (#234) * refactor: server run script * refactor: extract message control feature * refactor: socket usage for message control feature * refactor: migrate e2e testing to playwright * chore: add feature tests * refactor: add validation on socket controller * chore: jest tests server logic * chore: update tests * chore: run playwright tests on pull_requests * V2 rundown manager (#237) * refactor: migrate eventlist > rundown * hotfix: fix object naming error on excel import (#252) * feat: V2 ontime menu (#259) * V2 socket store (#254) * refactor: create socket utility * refactor: ensure a single subscription to topics * refactor: replace usages of socket object with subscription store handler * refactor: v2 event loader (#260) * refactor: extract jest config * refactor: rename params in reoder endpoint * refactor: avoid app exports * refactor: small code smells * refactor: run development server * refactor: extract rundown service and event loader logic * refactor: rename events > rundown * refactor: code style * fix: handle external change of title * refactor: cleanup dictionary * refactor: rename MessageService * refactor: use eventID for operations * refactor: migrate OSC controller to service * refactor: migrate Socket controller to service * refactor: migrate HTTP controller to service * refactor: extract logic into discrete services * refactor: remove rundown from event timer * refactor: remove duplicate * refactor: remove unused * chore: update tests * refactor: run test CI on master or manual run * lint: reorder imports * refactor: ensure consistent routing * V2 event block (#261) * feat: implement progress bar on running event * refactor: use event action hook * refactor: align backend data * v2 styling (#263) * feat: implement progress bar on running event * refactor: use event action hook * refactor: align backend data * style: v2 style tweaks * style: v2 style tweaks + ts <Info> * fix: fix delay increment calls * style: v2 style tweaks + ts <Playback> * style: v2 style tweaks + ts <EventEditor> * style: v2 style tweaks + ts <MessageControl> * fix: naming issue with message control socket endpoint * chore: upgrade style dependencies * style: v2 style tweaks + ts <MenuBar> * style: v2 style tweaks + ts <RundownMenu> * style: v2 style tweaks + ts <Playback> * fix: add selected and next information to EventTimer * style: v2 style tweaks + ts <EventBlock> * style: v2 style tweaks + ts <DelayBlock> * style: v2 style tweaks + ts <BlockBlock> * style: v2 style tweaks + ts <QuickEntry> * refactor: extract <TextInput> logic * chore: upgrade build dependencies * chore: folder structure refactor * fix: issue with dependency path in electron * chore: update version in demo db * v2: refactor (#264) * chore: upgrade dependencies * chore: remove unused * fix: issue with missing index data * fix: discrepancy on counting events * fix: swapped classes * fix: prevent attempting load in empty list * chore: cleanup completed * chore: remove unused * refactor: convert to typescript * refactor: migrate to new endpoints * refactor: convert to typescript * fix: prevent issue with undefined process * fix: small code smells * fix: issue with missing version variable on build * refactor: configure retries on data fetching * style: design review * fix: prevent cursor out of range * lint: react query linting * style: checkbox styles * v2 event editor (#265) * style: add cursor indicator in all blocks * style: prevent reflows on width * style: harmonise white range * ux: focus elements on cursor nav * ux: prevent focus on indicators * refactor: convert to typescript * fix: missing dependency * chore: add linter rules for dependency arrays * style: style editable component * style: tweak input styles * style: tweak switch styles * style: style tweaks * V2 sentry (#267) * chore: add sentry to frontend * chore: add sentry to server * chore: report only in production * chore: add sentry source maps * v2 alpha 3 (#272) * style: correct background colours * chore: add folder resolutions to vite * refactor: add auto refetch to HTTP APIs * fix: view settings override endpoint * refactor: memoize callbacks * fix: reorder reaching wrong data adapter * refactor: memoize callbacks * style: presentation cleanup * fix(delete): prevent flow with deleted event in playback * refactor: convert to typescript * refactor: small fixes and typescript conversion * ux: improve feedback on local changes * refactor: cleanup props * v2 event timer (#274) * lint: cleanup unused * refactor: rename component to avoid conflict * style: gap in element row * refactor: rename playstate -> playback * refactor: cleanup usages of timer and prepare integration manager * style: re-arrange button order * refactor: improve event loading * feat(timer-service): hot reload * fix: issue with duration input * chore: cleanup debug * refactor: resolve poll from runtime store * refactor: cleanup merge * refactor: small improvements in timer hot-reload * v2: views (#278) * config(sentry): instrumentation only in production * chore: upgrade deps * refactor: simplify and convert to ts * refactor: composition and styles * chore: remove deprecated deps * ux: close menu on click outside * style: small tweaks and clarifications * refactor: retire PiP view * ux: small tweaks * style: cleanup deprecated styles * chore: remove unused * refactor: remove usages of deprecated fields and improve null timer presentation * refactor: improve composition of countdown * style: cleanup overridable styles * ux: show event overtime status * style: correct logic in paused timers * v2: roll (#280) * ux: delete flow * style: small tweaks in interface * refactor: gracefully quit on error * fix: logic around updating events * chore: cleanup dictionary * refactor: improve DX on creating aux files * fix: safe destructure function return * refactor: safe handling of falsy timer values * fix: improve ux on stopping roll mode * chore: upgrade deps * feat: roll mode * refactor: remove unused * 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> * Fix: #279 End Message in Minimal Timer (#286) * fix: Minimal View #279 end message --------- Co-authored-by: Fabian Posenau <fabian@fphome.de> * hotfix: resolve data directory (#288) * Feature/#276/qrcodes (#289) * bugfix: wrong format when on 12h format * update: website link in placeholder --------- Co-authored-by: Fabian Posenau <fabian@fphome.de> Co-authored-by: cv <34649812+cpvalente@users.noreply.github.com> * V2 types (#291) * V2 integration (#293) * feat: integrations and lifecycles * refactor: prevent issues with start order * V2 fix offline (#294) * fix: resolve path to UI build in dev * fix: configure offline fetching * V2 fix socket (#296) * refactor: rename timer endpoint * refactor: typescript * refactor: rename eventData and viewSettings * refactor: message manager uses event store * style: alpha4 style tweaks (#299) * fix: backgroundcolor for .blockNotes (#300) Co-authored-by: Fabian Posenau <fabian@fphome.de> * Feat/countdown style (#290) * feat: Countdown Style --------- Co-authored-by: Fabian Posenau <fabian@fphome.de> Co-authored-by: cv <34649812+cpvalente@users.noreply.github.com> Co-authored-by: Fabian Posenau <19673098+kellhogs@users.noreply.github.com> * fix: wrong parameter (#302) Co-authored-by: Fabian Posenau <fabian@fphome.de> * V2 fix timer (#304) * Feat/update check (#303) * feat: show latest version in settings modal --------- Co-authored-by: Fabian Posenau <fabian@fphome.de> * improve time input (#307) * add the ability to parse time like 2h20m --------- Co-authored-by: Fabian Posenau <fabian@fphome.de> * V2 ws store (#310) * Update TimerService.ts * refactor: message service publishes to store * refactor: several type improvements * V2 ws store wss (#309) * refactor: shared logging types * refactor: simplify message service consumption * refactor: create discrete logging system * refactor: move socket.io > websocket * Feat/end action (#308) * feat: end action --------- Co-authored-by: Fabian Posenau <fabian@fphome.de> Co-authored-by: cv <34649812+cpvalente@users.noreply.github.com> Co-authored-by: Fabian Posenau <19673098+kellhogs@users.noreply.github.com> * V2 osc (#313) * chore: upgrade relevant deps * fix: electron app to tray * chore: cleanup dictionary * feat: handle several messages in a event * ux: disable irrelevant buttons in browser * feat: parse and validate subscriptions * feat: create UI for OSC Integration * fix: cleanup logger behaviour * feat: allow OSC settings to be changed at runtime * v2 alpha5 (#315) * fix: fetch in offline environments (#295) * add arm platforms to docker build --------- Co-authored-by: Fabian Posenau <fabian@fphome.de> * fix: docker build (#298) Co-authored-by: Fabian Posenau <fabian@fphome.de> * Timer: fix too many renders error when using ?progress (#305) * ux: remove duplicate showing of selected event * several small fixes and UX improvements --------- Co-authored-by: Fabian Posenau <fabian.p99@gmx.de> Co-authored-by: Fabian Posenau <fabian@fphome.de> Co-authored-by: Marks Polakovs <github@markspolakovs.me> * V2 docker (#312) * Create docker-ci.yml * docker build * add build app * path changes * delete double workflow * update docker run template * cleanup * resolve wierd path issue * wrong path * again wrong path * rename docker releases * move styles population and change to function call * re-add old build pipeline * rename buld file * feractor init function * refactor: startDb to initAssets * refactor: startDB to initAssets * fix: docker.cjs building whole app * fix: docker environment * update docker paths * update: build command * cleanup * cleanup: commented code --------- Co-authored-by: Fabian Posenau <fabian@fphome.de> * v2 lazy rundown (#317) * chore: update related deps * refactor: memoise entrypoints * refactor: store selectors * refactor: improve component performance * chore: remove unused dependencies * refactor: improve component dnd * V2 build (#319) * chore: build pipeline for v2 * chore: run unit tests * chore: configure sentry build * Update test_v2.yml * Update test_v2.yml * V2 e2e (#320) * chore: cleanup handling of a bad read * chore: configure e2e testing * chore: version bump * V2 align (#321) * fix: fetch in offline environments (#295) * Add arm platforms to docker build (#297) * add arm platforms to docker build --------- Co-authored-by: Fabian Posenau <fabian@fphome.de> * fix: docker build (#298) Co-authored-by: Fabian Posenau <fabian@fphome.de> * Timer: fix too many renders error when using ?progress (#305) * Timer: fix too many renders error when using ?progress * version bump (#314) * style: small tweaks * fix: prevent actions in roll mode * chore: update external assets * style: show shortcuts in quick blocks * refactor: revert optimisations on callbacks * fun: industry dictionary * style: indicators for event features --------- Co-authored-by: Fabian Posenau <fabian.p99@gmx.de> Co-authored-by: Fabian Posenau <fabian@fphome.de> Co-authored-by: Marks Polakovs <github@markspolakovs.me> * refactor: ensure start order (#322) * Update build_docker_v2.yml * V2 fix roll (#323) * chore: profile node * fix: always roll * Add translations for views (#325) (#328) * Add translations for views (#325) --------- Co-authored-by: Kevin <60135953+kev-ac@users.noreply.github.com> * feat: notify ws clients on rundown changes (#327) * V2 beta1 (#326) * style: labels on added time * style: remove mentions of PiP * refactor: unify usage of ms for timers * refactor: create events with 0 duration * style: several small tweaks * refactor: keep block when applying delays * feat: blocks have titles * style: improvements in time entry warnings * style: override progress bar styles * style: prevent overflow * feat: show character count in editor * refactor: provide initial payload * refactor: lower test boundary * refactor: get colour from swatches * style: rename title block * chore: version bump * style: fix info scroll * refactor: performance tweaks to event editor * chore: upgrade deps * refactor: prevent iterating on undefined * style: small style tweaks * fix: close editor on event delete * refactor: improve performance on EventBlock * style: small tweaks on blocks * chore: upgrade deps * chore: upgrade deps * style: ensure events are always visible * Fix/events enpoint (#329) * fix: sentry hangs node process * add onClick handler (#335) Co-authored-by: Fabian Posenau <fabian@fphome.de> * fix docker on different port (#337) Co-authored-by: Fabian Posenau <fabian@fphome.de> * V2 beta3 (#338) * chore: upgrade local build documentation * style: keep scrolling event in screen * feat: delay is time entry * refactor: remove unused * refactor: remove unused * refactor: batch store updates * refactor: virtually remove cap on events * refactor: style and behaviour tweaks to event block * style: tweaks on schedules * chore: remove sentry from server * style: reorder menu * chore: update docs * fix: resolve to static port in dev mode * V2 fix delay (#340) * fix: avoid modifying original objects * refactor: serve react as compressed br (#339) * refactor: changes in time input * V2 e2e playwright (#343) * test: create utility for uploading test-db * chore: upgrade deps * refactor: remove unused functions * chore: create testing boilerplate * test: upload showfile * refactor: parse time input (#351) * chore: vite upgrade (#354) * chore: upgrade vite * feat: edit mode (#344) * style: add mode selection to menu * style: rename rundown functions * feat: edit mode * fix: osc modal (#352) * refactor: parse time input * fix: form dirty flag on dynamic elements * style: cuesheet block (#355) * style: cuesheet show block title * style: cuesheet show full delay string * v2 about (#362) * chore: remove unused assets * feat: about ontime * refactor: cleanup unused * v2 beta 4 (#361) * ux: rename end action * chore: update demo db * style: tweaks on extracted rundown * chore: prevent console logs in production code * feat: go mode * style: remove window size limits * style: rename delete action * style: small tweaks on icons * fix: style override on params * chore: update test db * refactor: small code quality improvements * refactor: prevent circular imports * fix v2 roll timer (#363) * fix: roll update skips elapsed time * refactor: remove unused data * style: fix style in roll state * ux: content aware playback (#364) * v2 beta 4 bump (#367) * chore: update links to documentation * chore: version bump * Feature: quick start (#369) * feat: quick start modal * refactor: end message is part of view settings * refactor: small tweaks and type improvements * Feat/timer/stylechange (#341) * feat: multi color progress bar * add color support to minimal timer Co-Authored-By: Fabian Posenau <19673098+kellhogs@users.noreply.github.com> --------- Co-authored-by: Fabian Posenau <fabian@fphome.de> Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com> Co-authored-by: Fabian Posenau <19673098+kellhogs@users.noreply.github.com> * v2 beta5 modals (#377) * refactor: folder structure * style: modal wrapper * feat: add nordic translations * v2 beta 5 (#375) * chore: update documentation links * fix: sentry has no access to error context * style: clarify event history * fix: issue with clipboard write in safari * style: clarify event history * refactor: improvements to follow logic in rundown * refactor: improvements in go mode * several style tweaks and small improvements * Ux language (#378) * ux: language from menu * feat: add extra support for languages german portuguese spanish * refactor: type improvements * v2 beta6 (#379) * feat: operator key * chore: update demo * chore: version bump * fix: prevent stale keys * refactor: add new fields to validation * style: tweaks to modal arrangement * refactor: prevent parsing http * ux: click anywhere in event to edit * refactor: performance improvements to time input * refactor: performance improvements menu * V2 beta 6 (#381) * fix: fetch in offline environments (#295) * Add arm platforms to docker build (#297) * add arm platforms to docker build --------- Co-authored-by: Fabian Posenau <fabian@fphome.de> * fix: docker build (#298) Co-authored-by: Fabian Posenau <fabian@fphome.de> * Timer: fix too many renders error when using ?progress (#305) * Timer: fix too many renders error when using ?progress * version bump (#314) * chore: remove unused assets * refactor: improve rundown loading time * style: rename project file * chore: update demo files * refactor: improve loading time * chore: upgrade upload packages * refactor: modal redesign and ux improvements * refactor: type and ux improvements on translations * refactor: prevent flash on suspense * refactor: remove unnecessary import * chore: update tests * refactor: cleanup imports * fix: prevent flash on suspense --------- Co-authored-by: Fabian Posenau <fabian.p99@gmx.de> Co-authored-by: Fabian Posenau <fabian@fphome.de> Co-authored-by: Marks Polakovs <github@markspolakovs.me> * style views (#390) * style: animate transition from left side * refactor: improve schedule relationship * refactor: prevent passing unhandled value * style: remove inner radius in borders --------- Co-authored-by: Fabian Posenau <fabian.p99@gmx.de> Co-authored-by: Fabian Posenau <fabian@fphome.de> Co-authored-by: Fabian Posenau <19673098+kellhogs@users.noreply.github.com> Co-authored-by: Marks Polakovs <github@markspolakovs.me> Co-authored-by: Kevin <60135953+kev-ac@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
export interface IAdapter {
|
||||
shutdown: () => void;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Server } from 'node-osc';
|
||||
import { OSCSettings } from 'ontime-types';
|
||||
|
||||
import { IAdapter } from './IAdapter.js';
|
||||
import { dispatchFromAdapter } from '../controllers/integrationController.js';
|
||||
import { logger } from '../classes/Logger.js';
|
||||
|
||||
export class OscServer implements IAdapter {
|
||||
private osc: Server;
|
||||
|
||||
constructor(config: OSCSettings) {
|
||||
this.osc = new Server(config.portIn, '0.0.0.0');
|
||||
|
||||
this.osc.on('error', console.error);
|
||||
|
||||
this.osc.on('message', (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') {
|
||||
logger.error('RX', `OSC IN: OSC messages to ontime must start with /ontime/, received: ${msg}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// get second part (command)
|
||||
if (!path) {
|
||||
logger.error('RX', 'OSC IN: No path found');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const reply = dispatchFromAdapter(path, args, 'osc');
|
||||
if (reply) {
|
||||
const { topic, payload } = reply;
|
||||
this.osc.emit(topic, payload);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('RX', `OSC IN: ${error}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
console.log('Shutting down OSC Server');
|
||||
this.osc?.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* DESIGN BY CONTRACT
|
||||
* ===================
|
||||
* All websocket calls are expected to follow the defined format,
|
||||
* otherwise they will be ignored by Ontime server
|
||||
*
|
||||
* Messages should be in JSON format with two top level objects
|
||||
* {
|
||||
* type: ...
|
||||
* payload: ...
|
||||
* }
|
||||
*
|
||||
* Type: describes the action to be performed as enumerated in the API design
|
||||
* Payload: adds necessary payload for the request to be completed
|
||||
*/
|
||||
|
||||
import { WebSocket, WebSocketServer } from 'ws';
|
||||
|
||||
import getRandomName from '../utils/getRandomName.js';
|
||||
import { IAdapter } from './IAdapter.js';
|
||||
import { eventStore } from '../stores/EventStore.js';
|
||||
import { dispatchFromAdapter } from '../controllers/integrationController.js';
|
||||
import { logger } from '../classes/Logger.js';
|
||||
|
||||
let instance;
|
||||
|
||||
export class SocketServer implements IAdapter {
|
||||
private readonly MAX_PAYLOAD = 1024 * 256; // 256Kb
|
||||
|
||||
private wss: WebSocketServer | null;
|
||||
private clientIds: Set<string>;
|
||||
|
||||
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.clientIds = new Set<string>();
|
||||
this.wss = null;
|
||||
}
|
||||
|
||||
init(server) {
|
||||
this.wss = new WebSocketServer({ path: '/ws', server });
|
||||
|
||||
this.wss.on('connection', (ws) => {
|
||||
const clientId = getRandomName();
|
||||
this.clientIds.add(clientId);
|
||||
logger.info('RX', `${this.wss.clients.size} Connections with new: ${clientId}`);
|
||||
|
||||
// send store payload on connect
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'ontime',
|
||||
payload: eventStore.poll(),
|
||||
}),
|
||||
);
|
||||
|
||||
ws.on('error', console.error);
|
||||
|
||||
ws.on('close', () => {
|
||||
logger.info('RX', `${this.wss.clients.size} Connections with disconnected: ${clientId}`);
|
||||
this.clientIds.delete(clientId);
|
||||
});
|
||||
|
||||
ws.on('message', (data) => {
|
||||
if (data.length > this.MAX_PAYLOAD) {
|
||||
ws.close();
|
||||
}
|
||||
|
||||
// TODO: protocol specific stuff should be handled here
|
||||
// eg: rename-client
|
||||
// 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}`);
|
||||
// }
|
||||
// });
|
||||
|
||||
try {
|
||||
const message = JSON.parse(data);
|
||||
const { type, payload } = message;
|
||||
|
||||
if (type === 'hello') {
|
||||
ws.send('hi');
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'ontime-log') {
|
||||
if (payload.level && payload.origin && payload.text) {
|
||||
logger.emit(payload.level, payload.origin, payload.text);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const reply = dispatchFromAdapter(type, payload, 'ws');
|
||||
if (reply) {
|
||||
const { topic, payload } = reply;
|
||||
ws.send(topic, payload);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('RX', `WS IN: ${error}`);
|
||||
}
|
||||
} catch (_) {
|
||||
// we ignore unknown
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// message is any serializable value
|
||||
sendAsJson(message: unknown) {
|
||||
this.wss?.clients.forEach((client) => {
|
||||
if (client !== this.wss && client.readyState === WebSocket.OPEN) {
|
||||
client.send(JSON.stringify(message));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
this.wss?.close();
|
||||
}
|
||||
}
|
||||
|
||||
export const socket = new SocketServer();
|
||||
@@ -0,0 +1,12 @@
|
||||
import { socket } from './WebsocketAdapter.js';
|
||||
|
||||
/**
|
||||
* Utility function to notify clients that the REST data is stale
|
||||
* @param payload -- possible patch payload
|
||||
*/
|
||||
export function sendRefetch(payload: any | null = null) {
|
||||
socket.sendAsJson({
|
||||
type: 'ontime-refetch',
|
||||
payload,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import 'dotenv/config';
|
||||
import express from 'express';
|
||||
import expressStaticGzip from 'express-static-gzip';
|
||||
import http from 'http';
|
||||
import cors from 'cors';
|
||||
|
||||
// import utils
|
||||
import { join, resolve } from 'path';
|
||||
|
||||
import { currentDirectory, environment, externalsStartDirectory, isProduction, resolvedPath } from './setup.js';
|
||||
import { ONTIME_VERSION } from './ONTIME_VERSION.js';
|
||||
import { OSCSettings } from 'ontime-types';
|
||||
|
||||
// Import Routes
|
||||
import { router as rundownRouter } from './routes/rundownRouter.js';
|
||||
import { router as eventDataRouter } from './routes/eventDataRouter.js';
|
||||
import { router as ontimeRouter } from './routes/ontimeRouter.js';
|
||||
import { router as playbackRouter } from './routes/playbackRouter.js';
|
||||
|
||||
// Import adapters
|
||||
import { OscServer } from './adapters/OscAdapter.js';
|
||||
import { socket } from './adapters/WebsocketAdapter.js';
|
||||
import { DataProvider } from './classes/data-provider/DataProvider.js';
|
||||
import { dbLoadingProcess } from './modules/loadDb.js';
|
||||
|
||||
// Services
|
||||
import { eventTimer } from './services/TimerService.js';
|
||||
import { eventLoader } from './classes/event-loader/EventLoader.js';
|
||||
import { integrationService } from './services/integration-service/IntegrationService.js';
|
||||
import { logger } from './classes/Logger.js';
|
||||
import { oscIntegration } from './services/integration-service/OscIntegration.js';
|
||||
import { populateStyles } from './modules/loadStyles.js';
|
||||
import { eventStore, getInitialPayload } from './stores/EventStore.js';
|
||||
|
||||
console.log(`Starting Ontime version ${ONTIME_VERSION}`);
|
||||
|
||||
if (!isProduction) {
|
||||
console.log(`Ontime running in ${environment} environment`);
|
||||
console.log(`Ontime directory at ${currentDirectory} `);
|
||||
}
|
||||
|
||||
// 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('/events', rundownRouter);
|
||||
app.use('/eventdata', eventDataRouter);
|
||||
app.use('/ontime', ontimeRouter);
|
||||
app.use('/playback', playbackRouter);
|
||||
|
||||
// serve static - css
|
||||
app.use('/external', express.static(externalsStartDirectory));
|
||||
|
||||
// serve static - react, in dev/test mode we fetch the React app from module
|
||||
const reactAppPath = join(currentDirectory, resolvedPath());
|
||||
app.use(
|
||||
expressStaticGzip(reactAppPath, {
|
||||
enableBrotli: true,
|
||||
orderPreference: ['br'],
|
||||
}),
|
||||
);
|
||||
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(resolve(currentDirectory, resolvedPath(), '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
|
||||
*
|
||||
* Start order
|
||||
* ----------------
|
||||
*
|
||||
* The services need to be started in a certain order,
|
||||
* the enum below enforces that
|
||||
*/
|
||||
|
||||
enum OntimeStartOrder {
|
||||
Error,
|
||||
InitAssets,
|
||||
InitServer,
|
||||
InitIO,
|
||||
}
|
||||
|
||||
let step = OntimeStartOrder.InitAssets;
|
||||
let expressServer = null;
|
||||
let oscServer = null;
|
||||
|
||||
const checkStart = (currentState: OntimeStartOrder) => {
|
||||
if (step !== currentState) {
|
||||
step = OntimeStartOrder.Error;
|
||||
throw new Error('Init order error: initAssets > startServer > startOsc > startIntegrations');
|
||||
} else {
|
||||
if (step === 1 || step === 2) {
|
||||
step = step + 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const initAssets = async () => {
|
||||
checkStart(OntimeStartOrder.InitAssets);
|
||||
await dbLoadingProcess;
|
||||
populateStyles();
|
||||
};
|
||||
|
||||
/**
|
||||
* Starts servers
|
||||
* @return {Promise<string>}
|
||||
*/
|
||||
export const startServer = async () => {
|
||||
checkStart(OntimeStartOrder.InitServer);
|
||||
|
||||
const serverPort = 4001; // hardcoded for now
|
||||
const returnMessage = `Ontime is listening on port ${serverPort}`;
|
||||
|
||||
expressServer = http.createServer(app);
|
||||
|
||||
socket.init(expressServer);
|
||||
|
||||
// provide initial payload to event store
|
||||
eventLoader.init();
|
||||
eventStore.init(getInitialPayload());
|
||||
|
||||
expressServer.listen(serverPort, '0.0.0.0');
|
||||
|
||||
return returnMessage;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description starts OSC server
|
||||
* @description starts OSC server
|
||||
* @param overrideConfig
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
export const startOSCServer = async (overrideConfig = null) => {
|
||||
checkStart(OntimeStartOrder.InitIO);
|
||||
|
||||
const { osc } = DataProvider.getData();
|
||||
|
||||
if (!osc.enabledIn) {
|
||||
logger.info('RX', 'OSC Input Disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
// Setup default port
|
||||
const oscSettings = {
|
||||
...osc,
|
||||
portIn: overrideConfig?.port || osc.portIn,
|
||||
};
|
||||
|
||||
// Start OSC Server
|
||||
logger.info('RX', `Starting OSC Server on port: ${oscSettings.portIn}`);
|
||||
oscServer = new OscServer(oscSettings);
|
||||
};
|
||||
|
||||
/**
|
||||
* starts integrations
|
||||
*/
|
||||
export const startIntegrations = async (config?: { osc: OSCSettings }) => {
|
||||
checkStart(OntimeStartOrder.InitIO);
|
||||
|
||||
const { osc } = config ?? DataProvider.getData();
|
||||
|
||||
if (!osc) {
|
||||
return 'OSC Invalid configuration';
|
||||
}
|
||||
|
||||
const { success, message } = oscIntegration.init(osc);
|
||||
logger.info('RX', message);
|
||||
|
||||
if (success) {
|
||||
integrationService.register(oscIntegration);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @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();
|
||||
oscServer?.shutdown();
|
||||
eventTimer.shutdown();
|
||||
integrationService.shutdown();
|
||||
logger.shutdown();
|
||||
socket.shutdown();
|
||||
process.exit(exitCode);
|
||||
};
|
||||
|
||||
process.on('exit', (code) => console.log(`Ontime exited with code: ${code}`));
|
||||
|
||||
process.on('unhandledRejection', async (error) => {
|
||||
logger.error('SERVER', `Error: unhandled rejection ${error}`);
|
||||
await shutdown(1);
|
||||
});
|
||||
|
||||
process.on('uncaughtException', async (error) => {
|
||||
logger.error('SERVER', `Error: uncaught exception ${error}`);
|
||||
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,104 @@
|
||||
import { Log, LogLevel } from 'ontime-types';
|
||||
import { generateId, millisToString } from 'ontime-utils';
|
||||
|
||||
import { clock } from '../services/Clock.js';
|
||||
import { isProduction } from '../setup.js';
|
||||
import { socket } from '../adapters/WebsocketAdapter.js';
|
||||
|
||||
class Logger {
|
||||
private queue: Log[];
|
||||
|
||||
constructor() {
|
||||
this.queue = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Enabling setup logger after init
|
||||
*/
|
||||
init() {
|
||||
this.queue.forEach((log) => {
|
||||
this._push(log);
|
||||
});
|
||||
this.queue = [];
|
||||
}
|
||||
|
||||
private addToQueue(log: Log) {
|
||||
this.queue.push(log);
|
||||
if (this.queue.length > 100) {
|
||||
this.queue.pop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal safe push method, adds log to queue if callback not available
|
||||
* @param log
|
||||
*/
|
||||
private _push(log: Log) {
|
||||
if (!isProduction) {
|
||||
console.log(`[${log.level}] \t ${log.origin} \t ${log.text}`);
|
||||
}
|
||||
|
||||
try {
|
||||
socket.sendAsJson({
|
||||
type: 'ontime-log',
|
||||
payload: log,
|
||||
});
|
||||
} catch (_e) {
|
||||
this.addToQueue(log);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits logging message
|
||||
* @param level
|
||||
* @param origin
|
||||
* @param text
|
||||
*/
|
||||
emit(level, origin: string, text: string) {
|
||||
const log = {
|
||||
id: generateId(),
|
||||
level,
|
||||
origin,
|
||||
text,
|
||||
time: millisToString(clock.getSystemTime() || 0),
|
||||
};
|
||||
this._push(log);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to emit logging message of type INFO
|
||||
* @param origin
|
||||
* @param text
|
||||
*/
|
||||
info(origin: string, text: string) {
|
||||
this.emit(LogLevel.Info, origin, text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to emit logging message of type WARN
|
||||
* @param origin
|
||||
* @param text
|
||||
*/
|
||||
warning(origin: string, text: string) {
|
||||
this.emit(LogLevel.Warn, origin, text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to emit logging message of type ERROR
|
||||
* @param origin
|
||||
* @param text
|
||||
*/
|
||||
error(origin: string, text: string) {
|
||||
this.emit(LogLevel.Error, origin, text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown logger
|
||||
*/
|
||||
shutdown() {
|
||||
console.log('Shutting down logger');
|
||||
this.queue = [];
|
||||
}
|
||||
}
|
||||
|
||||
export const logger = new Logger();
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Class Event Provider is a mediator for handling the local db
|
||||
* and adds logic specific to ontime data
|
||||
*/
|
||||
import { EventData, SupportedEvent, ViewSettings } from 'ontime-types';
|
||||
|
||||
import { data, db } from '../../modules/loadDb.js';
|
||||
import { safeMerge } from './DataProvider.utils.js';
|
||||
|
||||
export class DataProvider {
|
||||
static getData() {
|
||||
return data;
|
||||
}
|
||||
|
||||
static async setEventData(newData: Partial<EventData>) {
|
||||
data.eventData = { ...data.eventData, ...newData };
|
||||
await this.persist();
|
||||
return data.eventData;
|
||||
}
|
||||
|
||||
static getEventData() {
|
||||
return data.eventData;
|
||||
}
|
||||
|
||||
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 };
|
||||
if (newEvent.type === SupportedEvent.Event) {
|
||||
newEvent.revision++;
|
||||
}
|
||||
data.rundown[eventIndex] = newEvent;
|
||||
await this.persist();
|
||||
return data.rundown[eventIndex];
|
||||
}
|
||||
|
||||
static async deleteEvent(eventId) {
|
||||
const eventIndex = data.rundown.findIndex((e) => e.id === eventId);
|
||||
|
||||
if (eventIndex !== -1) {
|
||||
data.rundown.splice(eventIndex, 1);
|
||||
await this.persist();
|
||||
}
|
||||
}
|
||||
|
||||
static getRundownLength() {
|
||||
return data.rundown.length;
|
||||
}
|
||||
|
||||
static async clearRundown() {
|
||||
data.rundown = [];
|
||||
// @ts-expect-error -- not sure how to type, this is library side
|
||||
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 getViewSettings() {
|
||||
return { ...data.viewSettings };
|
||||
}
|
||||
|
||||
static async setViewSettings(newData: ViewSettings) {
|
||||
data.viewSettings = { ...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() {
|
||||
// @ts-expect-error -- not sure how to type, this is library side
|
||||
await db.write();
|
||||
}
|
||||
|
||||
static async mergeIntoData(newData) {
|
||||
const mergedData = safeMerge(data, newData);
|
||||
data.eventData = mergedData.eventData;
|
||||
data.settings = mergedData.settings;
|
||||
data.viewSettings = mergedData.viewSettings;
|
||||
data.osc = mergedData.osc;
|
||||
data.aliases = mergedData.aliases;
|
||||
data.userFields = mergedData.userFields;
|
||||
data.rundown = mergedData.rundown;
|
||||
await this.persist();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Merges two data objects
|
||||
* @param {object} existing
|
||||
* @param {object} newData
|
||||
*/
|
||||
export function safeMerge(existing, newData) {
|
||||
const { rundown, eventData, settings, viewSettings, osc, http, aliases, userFields } = newData || {};
|
||||
return {
|
||||
...existing,
|
||||
rundown: rundown ?? existing.rundown,
|
||||
eventData: { ...existing.eventData, ...eventData },
|
||||
settings: { ...existing.settings, ...settings },
|
||||
viewSettings: { ...existing.viewSettings, ...viewSettings },
|
||||
aliases: aliases ?? existing.aliases,
|
||||
userFields: {
|
||||
...existing.userFields,
|
||||
...(userFields && Object.fromEntries(Object.entries(userFields).filter(([_, value]) => value !== null))),
|
||||
},
|
||||
osc: {
|
||||
...existing.osc,
|
||||
...osc,
|
||||
subscriptions: {
|
||||
...existing.osc?.subscriptions,
|
||||
...(newData?.osc?.subscriptions || {}),
|
||||
...(existing.osc?.subscriptions && newData?.osc?.subscriptions
|
||||
? Object.keys(existing.osc.subscriptions).reduce((acc, key) => {
|
||||
if (!(key in newData.osc.subscriptions)) {
|
||||
acc[key] = existing.osc.subscriptions[key];
|
||||
}
|
||||
return acc;
|
||||
}, {})
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
http: { ...existing.http, ...http },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { safeMerge } from '../DataProvider.utils.js';
|
||||
|
||||
describe('safeMerge', () => {
|
||||
const existing = {
|
||||
rundown: [],
|
||||
eventData: {
|
||||
title: 'existing title',
|
||||
publicUrl: 'existing public URL',
|
||||
backstageUrl: 'existing backstageUrl',
|
||||
backstageInfo: 'existing backstageInfo',
|
||||
},
|
||||
settings: {
|
||||
app: 'ontime',
|
||||
version: 2,
|
||||
serverPort: 4001,
|
||||
editorKey: null,
|
||||
operatorKey: null,
|
||||
timeFormat: '24',
|
||||
language: 'en',
|
||||
},
|
||||
viewSettings: {
|
||||
overrideStyles: false,
|
||||
endMessage: 'existing endMessage',
|
||||
},
|
||||
aliases: [],
|
||||
userFields: {
|
||||
user0: 'existing user0',
|
||||
user1: 'existing user1',
|
||||
},
|
||||
osc: {
|
||||
portIn: 8888,
|
||||
portOut: 9999,
|
||||
targetIP: '127.0.0.1',
|
||||
enabledIn: false,
|
||||
enabledOut: false,
|
||||
subscriptions: {
|
||||
onLoad: [],
|
||||
onStart: [],
|
||||
onPause: [],
|
||||
onStop: [],
|
||||
onUpdate: [],
|
||||
onFinish: [],
|
||||
},
|
||||
},
|
||||
http: {
|
||||
enabled: true,
|
||||
user: null,
|
||||
pwd: null,
|
||||
},
|
||||
};
|
||||
|
||||
it('returns existing data if new data is not provided', () => {
|
||||
const mergedData = safeMerge(existing, undefined);
|
||||
expect(mergedData).toEqual(existing);
|
||||
});
|
||||
|
||||
it('merges the rundown key', () => {
|
||||
const newData = {
|
||||
rundown: [{ name: 'item 1' }, { name: 'item 2' }],
|
||||
};
|
||||
const mergedData = safeMerge(existing, newData);
|
||||
expect(mergedData.rundown).toEqual(newData.rundown);
|
||||
});
|
||||
|
||||
it('merges the event key', () => {
|
||||
const newData = {
|
||||
eventData: {
|
||||
title: 'new title',
|
||||
publicInfo: 'new public info',
|
||||
},
|
||||
};
|
||||
const mergedData = safeMerge(existing, newData);
|
||||
expect(mergedData.eventData).toEqual({
|
||||
title: 'new title',
|
||||
publicUrl: 'existing public URL',
|
||||
publicInfo: 'new public info',
|
||||
backstageUrl: 'existing backstageUrl',
|
||||
backstageInfo: 'existing backstageInfo',
|
||||
});
|
||||
});
|
||||
|
||||
it('merges the settings key', () => {
|
||||
const newData = {
|
||||
settings: {
|
||||
serverPort: 3000,
|
||||
language: 'pt',
|
||||
},
|
||||
};
|
||||
const mergedData = safeMerge(existing, newData);
|
||||
expect(mergedData.settings).toEqual({
|
||||
app: 'ontime',
|
||||
version: 2,
|
||||
serverPort: 3000,
|
||||
operatorKey: null,
|
||||
editorKey: null,
|
||||
timeFormat: '24',
|
||||
language: 'pt',
|
||||
});
|
||||
});
|
||||
|
||||
it('merges the osc key', () => {
|
||||
const newData = {
|
||||
osc: {
|
||||
portIn: 7777,
|
||||
subscriptions: {
|
||||
onStart: [
|
||||
{
|
||||
id: 'unique',
|
||||
message: 'new message',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const mergedData = safeMerge(existing, newData);
|
||||
expect(mergedData.osc).toEqual({
|
||||
portIn: 7777,
|
||||
portOut: 9999,
|
||||
targetIP: '127.0.0.1',
|
||||
enabledIn: false,
|
||||
enabledOut: false,
|
||||
subscriptions: {
|
||||
onLoad: [],
|
||||
onStart: [
|
||||
{
|
||||
id: 'unique',
|
||||
message: 'new message',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
onPause: [],
|
||||
onStop: [],
|
||||
onUpdate: [],
|
||||
onFinish: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should merge the aliases key when present', () => {
|
||||
const existingData = {
|
||||
rundown: [],
|
||||
event: {
|
||||
title: '',
|
||||
publicUrl: '',
|
||||
publicInfo: '',
|
||||
backstageUrl: '',
|
||||
backstageInfo: '',
|
||||
},
|
||||
settings: {
|
||||
app: 'ontime',
|
||||
version: 2,
|
||||
serverPort: 4001,
|
||||
operatorKey: null,
|
||||
editorKey: null,
|
||||
timeFormat: '24',
|
||||
language: 'en',
|
||||
},
|
||||
viewSettings: {
|
||||
overrideStyles: false,
|
||||
endMessage: '',
|
||||
},
|
||||
aliases: [],
|
||||
userFields: {
|
||||
user0: 'user0',
|
||||
user1: 'user1',
|
||||
user2: 'user2',
|
||||
user3: 'user3',
|
||||
user4: 'user4',
|
||||
user5: 'user5',
|
||||
user6: 'user6',
|
||||
user7: 'user7',
|
||||
user8: 'user8',
|
||||
user9: 'user9',
|
||||
},
|
||||
osc: {
|
||||
portIn: 8888,
|
||||
portOut: 9999,
|
||||
targetIP: '127.0.0.1',
|
||||
enabledIn: false,
|
||||
enabledOut: false,
|
||||
subscriptions: {
|
||||
onLoad: [],
|
||||
onStart: [],
|
||||
onPause: [],
|
||||
onStop: [],
|
||||
onUpdate: [],
|
||||
onFinish: [],
|
||||
},
|
||||
},
|
||||
http: {
|
||||
user: null,
|
||||
pwd: null,
|
||||
messages: {
|
||||
onLoad: [],
|
||||
onStart: [],
|
||||
onUpdate: [],
|
||||
onPause: [],
|
||||
onStop: [],
|
||||
onFinish: [],
|
||||
},
|
||||
enabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
const newData = {
|
||||
aliases: ['alias1', 'alias2'],
|
||||
};
|
||||
|
||||
const mergedData = safeMerge(existingData, newData);
|
||||
|
||||
expect(mergedData.aliases).toEqual(newData.aliases);
|
||||
});
|
||||
|
||||
it('merges userFields into existing object', () => {
|
||||
const existing = {
|
||||
userFields: {
|
||||
user0: 'Alice',
|
||||
user1: 'Bob',
|
||||
},
|
||||
};
|
||||
|
||||
const newData = {
|
||||
userFields: {
|
||||
user2: 'Charlie',
|
||||
user3: 'David',
|
||||
user4: null,
|
||||
},
|
||||
};
|
||||
|
||||
const expected = {
|
||||
user0: 'Alice',
|
||||
user1: 'Bob',
|
||||
user2: 'Charlie',
|
||||
user3: 'David',
|
||||
};
|
||||
|
||||
const result = safeMerge(existing, newData);
|
||||
expect(result.userFields).toEqual(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,434 @@
|
||||
import { Loaded, OntimeEvent, TitleBlock } from 'ontime-types';
|
||||
|
||||
import { DataProvider } from '../data-provider/DataProvider.js';
|
||||
import { getRollTimers } from '../../services/rollUtils.js';
|
||||
import { eventStore } from '../../stores/EventStore.js';
|
||||
|
||||
let instance;
|
||||
|
||||
/**
|
||||
* Manages business logic around loading events
|
||||
*/
|
||||
export class EventLoader {
|
||||
loadedEvent: OntimeEvent | null;
|
||||
loaded: Loaded;
|
||||
titles: TitleBlock;
|
||||
titlesPublic: TitleBlock;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// we need to delay init until the store is ready
|
||||
init() {
|
||||
this.reset(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* returns all events that contain time data
|
||||
* @return {array}
|
||||
*/
|
||||
static getTimedEvents(): OntimeEvent[] {
|
||||
// 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(): OntimeEvent[] {
|
||||
// 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 {OntimeEvent | undefined}
|
||||
*/
|
||||
static getEventAtIndex(eventIndex: number): OntimeEvent | undefined {
|
||||
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.loaded.selectedEventIndex === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// if there is no event running, go to first
|
||||
if (this.loaded.selectedEventIndex === null) {
|
||||
return timedEvents[0];
|
||||
}
|
||||
|
||||
const newIndex = this.loaded.selectedEventIndex - 1;
|
||||
return timedEvents?.[newIndex];
|
||||
}
|
||||
|
||||
/**
|
||||
* finds the next event
|
||||
* @return {object | undefined}
|
||||
*/
|
||||
findNext() {
|
||||
const timedEvents = EventLoader.getPlayableEvents();
|
||||
if (timedEvents === null || !timedEvents.length || this.loaded.selectedEventIndex === this.loaded.numEvents - 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// if there is no event running, go to first
|
||||
if (this.loaded.selectedEventIndex === null) {
|
||||
return timedEvents[0];
|
||||
}
|
||||
const newIndex = this.loaded.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, timeToNext, nextEvent, nextPublicEvent, currentEvent, currentPublicEvent } = getRollTimers(
|
||||
timedEvents,
|
||||
timeNow,
|
||||
);
|
||||
|
||||
this.loadedEvent = currentEvent;
|
||||
this.loaded.selectedEventIndex = nowIndex;
|
||||
this.loaded.selectedEventId = currentEvent?.id || null;
|
||||
this.loaded.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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
loaded: this.loaded,
|
||||
titles: this.titles,
|
||||
titlesPublic: this.titlesPublic,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces event loader to update the event count
|
||||
*/
|
||||
updateNumEvents() {
|
||||
this.loaded.numEvents = EventLoader.getPlayableEvents().length;
|
||||
eventStore.set('loaded', this.loaded);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets instance state
|
||||
*/
|
||||
reset(emit = true) {
|
||||
this.loadedEvent = null;
|
||||
this.loaded = {
|
||||
selectedEventIndex: null,
|
||||
selectedEventId: null,
|
||||
selectedPublicEventId: null,
|
||||
nextEventId: null,
|
||||
nextPublicEventId: null,
|
||||
numEvents: EventLoader.getPlayableEvents().length,
|
||||
};
|
||||
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,
|
||||
noteNow: null,
|
||||
titleNext: null,
|
||||
subtitleNext: null,
|
||||
presenterNext: null,
|
||||
noteNext: null,
|
||||
};
|
||||
|
||||
// workaround for socket not being ready in constructor
|
||||
if (emit) {
|
||||
this._loadEvent();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.loaded.selectedEventIndex = eventIndex;
|
||||
this.loaded.selectedEventId = event.id;
|
||||
this.loaded.numEvents = timedEvents.length;
|
||||
// this.nextEventId = playableEvents[eventIndex + 1].id;
|
||||
this._loadTitlesNow(event, playableEvents);
|
||||
this._loadTitlesNext(playableEvents);
|
||||
|
||||
this._loadEvent();
|
||||
|
||||
return this.getLoaded();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle side effects from event loading
|
||||
*/
|
||||
private _loadEvent() {
|
||||
eventStore.batchSet({
|
||||
loaded: this.loaded,
|
||||
titles: this.titles,
|
||||
titlesPublic: this.titlesPublic,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @description loads given title (now)
|
||||
* @private
|
||||
* @param {object} event
|
||||
* @param {array} rundown
|
||||
*/
|
||||
private _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.loaded.selectedPublicEventId = null;
|
||||
|
||||
// if there is nothing before, return
|
||||
if (this.loaded.selectedEventIndex === 0) return;
|
||||
|
||||
// iterate backwards to find it
|
||||
for (let i = this.loaded.selectedEventIndex; i >= 0; i--) {
|
||||
if (rundown[i].isPublic) {
|
||||
this._loadThisTitles(rundown[i], 'now-public');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description look for next titles to load
|
||||
* @private
|
||||
*/
|
||||
private _loadTitlesNext(rundown) {
|
||||
// maybe there is nothing to load
|
||||
if (this.loaded.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.loaded.nextEventId = null;
|
||||
|
||||
this.titlesPublic.titleNext = null;
|
||||
this.titlesPublic.subtitleNext = null;
|
||||
this.titlesPublic.presenterNext = null;
|
||||
this.loaded.nextPublicEventId = null;
|
||||
|
||||
const numEvents = rundown.length;
|
||||
|
||||
if (this.loaded.selectedEventIndex < numEvents - 1) {
|
||||
let nextPublic = false;
|
||||
let nextPrivate = false;
|
||||
|
||||
for (let i = this.loaded.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
|
||||
*/
|
||||
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.titlesPublic.noteNow = event.note;
|
||||
this.loaded.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.loaded.selectedEventId = event.id;
|
||||
break;
|
||||
|
||||
case 'now-public':
|
||||
this.titlesPublic.titleNow = event.title;
|
||||
this.titlesPublic.subtitleNow = event.subtitle;
|
||||
this.titlesPublic.presenterNow = event.presenter;
|
||||
this.titlesPublic.noteNow = event.note;
|
||||
this.loaded.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.loaded.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.titlesPublic.noteNext = event.note;
|
||||
this.loaded.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.loaded.nextEventId = event.id;
|
||||
break;
|
||||
|
||||
case 'next-public':
|
||||
this.titlesPublic.titleNext = event.title;
|
||||
this.titlesPublic.subtitleNext = event.subtitle;
|
||||
this.titlesPublic.presenterNext = event.presenter;
|
||||
this.titlesPublic.noteNext = event.note;
|
||||
this.loaded.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.loaded.nextEventId = event.id;
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Unhandled title type: ${type}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const eventLoader = new EventLoader();
|
||||
@@ -0,0 +1,12 @@
|
||||
export const config = {
|
||||
database: {
|
||||
testdb: 'test-db',
|
||||
directory: 'db',
|
||||
filename: 'db.json',
|
||||
tablename: 'events',
|
||||
},
|
||||
styles: {
|
||||
directory: 'styles',
|
||||
filename: 'override.css',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
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 getEventData = async (req, res) => {
|
||||
res.json(DataProvider.getEventData());
|
||||
};
|
||||
|
||||
// Create controller for POST request to 'event'
|
||||
export const postEventData = async (req, res) => {
|
||||
if (failEmptyObjects(req.body, res)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const newEvent = removeUndefined({
|
||||
title: req.body?.title,
|
||||
publicUrl: req.body?.publicUrl,
|
||||
publicInfo: req.body?.publicInfo,
|
||||
backstageUrl: req.body?.backstageUrl,
|
||||
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,15 @@
|
||||
import { body, validationResult } from 'express-validator';
|
||||
|
||||
export const eventDataSanitizer = [
|
||||
body('title').optional().isString().trim(),
|
||||
body('publicUrl').optional().isString().trim(),
|
||||
body('publicInfo').optional().isString().trim(),
|
||||
body('backstageUrl').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,193 @@
|
||||
import { messageService } from '../services/message-service/MessageService.js';
|
||||
import { PlaybackService } from '../services/PlaybackService.js';
|
||||
import { eventStore } from '../stores/EventStore.js';
|
||||
|
||||
export function dispatchFromAdapter(type: string, payload: unknown, source?: 'osc' | 'ws') {
|
||||
switch (type.toLowerCase()) {
|
||||
case 'test-ontime': {
|
||||
return { topic: 'hello' };
|
||||
}
|
||||
|
||||
case 'ontime-poll': {
|
||||
return {
|
||||
topic: 'poll',
|
||||
payload: eventStore.poll(),
|
||||
};
|
||||
}
|
||||
|
||||
case 'set-onair': {
|
||||
if (typeof payload !== 'undefined') {
|
||||
messageService.setOnAir(Boolean(payload));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'onair': {
|
||||
messageService.setOnAir(true);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'offair': {
|
||||
messageService.setOnAir(false);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'set-timer-message-text': {
|
||||
if (typeof payload !== 'string') {
|
||||
throw new Error(`Unable to parse payload: ${payload}`);
|
||||
}
|
||||
messageService.setTimerText(payload);
|
||||
break;
|
||||
}
|
||||
case 'set-timer-message-visible': {
|
||||
if (typeof payload === 'undefined') {
|
||||
throw new Error(`Unable to parse payload: ${payload}`);
|
||||
}
|
||||
messageService.setTimerVisibility(Boolean(payload));
|
||||
break;
|
||||
}
|
||||
|
||||
case 'set-public-message-text': {
|
||||
if (typeof payload !== 'string') {
|
||||
throw new Error(`Unable to parse payload: ${payload}`);
|
||||
}
|
||||
messageService.setPublicText(payload);
|
||||
break;
|
||||
}
|
||||
case 'set-public-message-visible': {
|
||||
if (typeof payload === 'undefined') {
|
||||
throw new Error(`Unable to parse payload: ${payload}`);
|
||||
}
|
||||
messageService.setPublicVisibility(Boolean(payload));
|
||||
break;
|
||||
}
|
||||
|
||||
case 'set-lower-message-text': {
|
||||
if (typeof payload !== 'string') {
|
||||
throw new Error(`Unable to parse payload: ${payload}`);
|
||||
}
|
||||
messageService.setLowerText(payload);
|
||||
break;
|
||||
}
|
||||
case 'set-lower-message-visible': {
|
||||
if (typeof payload === 'undefined') {
|
||||
throw new Error(`Unable to parse payload: ${payload}`);
|
||||
}
|
||||
messageService.setLowerVisibility(Boolean(payload));
|
||||
break;
|
||||
}
|
||||
|
||||
case 'start': {
|
||||
PlaybackService.start();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'start-next': {
|
||||
PlaybackService.startNext();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'startindex': {
|
||||
const eventIndex = Number(payload);
|
||||
if (isNaN(eventIndex) || eventIndex <= 0) {
|
||||
throw new Error(`Event index not recognised or out of range ${eventIndex}`);
|
||||
}
|
||||
|
||||
try {
|
||||
// Indexes in frontend are 1 based
|
||||
PlaybackService.startByIndex(eventIndex - 1);
|
||||
} catch (error) {
|
||||
throw new Error(`Error loading event:: ${error}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'startid': {
|
||||
if (!payload || typeof payload !== 'string') {
|
||||
throw new Error(`Event ID not recognised: ${payload}`);
|
||||
}
|
||||
PlaybackService.startById(payload);
|
||||
break;
|
||||
}
|
||||
case 'pause': {
|
||||
PlaybackService.pause();
|
||||
break;
|
||||
}
|
||||
case 'previous': {
|
||||
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': {
|
||||
const delayTime = Number(payload);
|
||||
if (isNaN(delayTime)) {
|
||||
throw new Error(`Delay time not recognised ${payload}`);
|
||||
}
|
||||
|
||||
try {
|
||||
PlaybackService.setDelay(delayTime);
|
||||
} catch (error) {
|
||||
throw new Error(`Could not add delay: ${error}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'gotoindex':
|
||||
case 'loadindex': {
|
||||
const eventIndex = Number(payload);
|
||||
if (isNaN(eventIndex) || eventIndex <= 0) {
|
||||
throw new Error(`Event index not recognised or out of range ${eventIndex}`);
|
||||
}
|
||||
|
||||
try {
|
||||
// Indexes in frontend are 1 based
|
||||
PlaybackService.loadByIndex(eventIndex - 1);
|
||||
} catch (error) {
|
||||
throw new Error(`Event index not recognised or out of range ${error}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'gotoid':
|
||||
case 'loadid': {
|
||||
if (!payload) {
|
||||
throw new Error(`Event ID not recognised: ${payload}`);
|
||||
}
|
||||
|
||||
try {
|
||||
PlaybackService.loadById(payload.toString().toLowerCase());
|
||||
} catch (error) {
|
||||
throw new Error(`OSC IN: error calling goto ${error}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'get-playback': {
|
||||
const playback = eventStore.get('playback');
|
||||
return { topic: 'playback', payload: playback };
|
||||
}
|
||||
|
||||
case 'get-timer': {
|
||||
const timer = eventStore.get('timer');
|
||||
return { topic: 'timer', payload: timer };
|
||||
}
|
||||
|
||||
default: {
|
||||
throw new Error(`Unhandled message ${type}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import fs from 'fs';
|
||||
import type { Alias, EventData } from 'ontime-types';
|
||||
import { networkInterfaces } from 'os';
|
||||
import { fileHandler } from '../utils/parser.js';
|
||||
import { DataProvider } from '../classes/data-provider/DataProvider.js';
|
||||
import { failEmptyObjects, failIsNotArray } from '../utils/routerUtils.js';
|
||||
import { mergeObject } from '../utils/parserUtils.js';
|
||||
import { PlaybackService } from '../services/PlaybackService.js';
|
||||
import { eventStore } from '../stores/EventStore.js';
|
||||
import { resolveDbPath } from '../setup.js';
|
||||
import { oscIntegration } from '../services/integration-service/OscIntegration.js';
|
||||
import { logger } from '../classes/Logger.js';
|
||||
import { deleteAllEvents } from '../services/RundownService.js';
|
||||
|
||||
// Create controller for GET request to '/ontime/poll'
|
||||
// Returns data for current state
|
||||
export const poll = async (req, res) => {
|
||||
try {
|
||||
const s = eventStore.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';
|
||||
|
||||
res.download(resolveDbPath, `${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 ('error' in result && result.error) {
|
||||
res.status(400).send({ message: result.message });
|
||||
} else if ('data' in result && result.message === 'success') {
|
||||
PlaybackService.stop();
|
||||
// explicitly write objects
|
||||
if (typeof result !== 'undefined') {
|
||||
const newRundown = result.data.rundown || [];
|
||||
if (options?.onlyRundown === 'true') {
|
||||
await DataProvider.setRundown(newRundown);
|
||||
} else {
|
||||
await DataProvider.mergeIntoData(result.data);
|
||||
}
|
||||
}
|
||||
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: Alias[] = [];
|
||||
req.body.forEach((a) => {
|
||||
newAliases.push({
|
||||
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 settings = DataProvider.getSettings();
|
||||
res.status(200).send(settings);
|
||||
};
|
||||
|
||||
function extractPin(value: string | undefined | null, fallback: string | null): string | null {
|
||||
if (value === null) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'undefined') {
|
||||
return fallback;
|
||||
}
|
||||
if (value.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// 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();
|
||||
const editorKey = extractPin(req.body?.editorKey, settings.editorKey);
|
||||
const operatorKey = extractPin(req.body?.operatorKey, settings.operatorKey);
|
||||
|
||||
let timeFormat = settings.timeFormat;
|
||||
if (req.body?.timeFormat === '12' || req.body?.timeFormat === '24') {
|
||||
timeFormat = req.body.timeFormat;
|
||||
}
|
||||
|
||||
const language = req.body?.language || 'en';
|
||||
|
||||
const newData = {
|
||||
...settings,
|
||||
editorKey,
|
||||
operatorKey,
|
||||
timeFormat,
|
||||
language,
|
||||
};
|
||||
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.getViewSettings();
|
||||
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,
|
||||
endMessage: req.body?.endMessage || '',
|
||||
normalColor: req.body.normalColor,
|
||||
warningColor: req.body.warningColor,
|
||||
warningThreshold: req.body.warningThreshold,
|
||||
dangerColor: req.body.dangerColor,
|
||||
dangerThreshold: req.body.dangerThreshold,
|
||||
};
|
||||
await DataProvider.setViewSettings(newData);
|
||||
res.status(200).send(newData);
|
||||
} catch (error) {
|
||||
res.status(400).send(error);
|
||||
}
|
||||
};
|
||||
|
||||
// Create controller for GET 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 {
|
||||
const oscSettings = req.body;
|
||||
await DataProvider.setOsc(oscSettings);
|
||||
|
||||
// TODO: this update could be more granular, checking that relevant data was changed
|
||||
const { message } = oscIntegration.init(oscSettings);
|
||||
logger.info('RX', message);
|
||||
|
||||
res.send(oscSettings).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/new'
|
||||
export const postNew = async (req, res) => {
|
||||
try {
|
||||
const newEventData: Omit<EventData, 'endMessage'> = {
|
||||
title: req.body?.title ?? '',
|
||||
publicUrl: req.body?.publicUrl ?? '',
|
||||
publicInfo: req.body?.publicInfo ?? '',
|
||||
backstageUrl: req.body?.backstageUrl ?? '',
|
||||
backstageInfo: req.body?.backstageInfo ?? '',
|
||||
};
|
||||
const newData = await DataProvider.setEventData(newEventData);
|
||||
await deleteAllEvents();
|
||||
res.status(201).send(newData);
|
||||
} catch (error) {
|
||||
res.status(400).send(error);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import { body, check, validationResult } from 'express-validator';
|
||||
import { validateOscSubscription } from '../utils/parserFunctions.js';
|
||||
|
||||
/**
|
||||
* @description Validates object for POST /ontime/views
|
||||
*/
|
||||
export const viewValidator = [
|
||||
check('overrideStyles').isBoolean().withMessage('overrideStyles value must be boolean'),
|
||||
check('endMessage').isString().trim().withMessage('endMessage value must be string'),
|
||||
check('normalColor').isString().trim().withMessage('normalColor value must be string'),
|
||||
check('warningColor').isString().trim().withMessage('warningColor value must be string'),
|
||||
check('dangerColor').isString().trim().withMessage('dangerColor value must be string'),
|
||||
check('warningThreshold').isNumeric().withMessage('warningThreshold value must be a number'),
|
||||
check('dangerThreshold').isNumeric().withMessage('dangerThreshold value must a number'),
|
||||
(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('editorKey').isString().isLength({ min: 0, max: 4 }).optional({ nullable: true }),
|
||||
body('operatorKey').isString().isLength({ min: 0, max: 4 }).optional({ nullable: true }),
|
||||
body('timeFormat').isString().isIn(['12', '24']),
|
||||
body('language').isString(),
|
||||
(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('portIn').exists().isInt({ min: 1024, max: 65535 }),
|
||||
body('portOut').exists().isInt({ min: 1024, max: 65535 }),
|
||||
body('targetIP').exists().isIP(),
|
||||
body('enabledIn').exists().isBoolean(),
|
||||
body('enabledOut').exists().isBoolean(),
|
||||
body('subscriptions')
|
||||
.isObject()
|
||||
.custom((value) => validateOscSubscription(value)),
|
||||
(req, res, next) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
|
||||
next();
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,98 @@
|
||||
import { PlaybackService } from '../services/PlaybackService.js';
|
||||
import { eventStore } from '../stores/EventStore.js';
|
||||
|
||||
// Create controller for POST request to '/playback'
|
||||
// Returns playback state
|
||||
export const pbGet = async (req, res) => {
|
||||
res.send({ playback: eventStore.get('playback') });
|
||||
};
|
||||
|
||||
// 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.ts';
|
||||
import { failEmptyObjects } from '../utils/routerUtils.js';
|
||||
import {
|
||||
addEvent,
|
||||
applyDelay,
|
||||
deleteAllEvents,
|
||||
deleteEvent,
|
||||
editEvent,
|
||||
reorderEvent,
|
||||
} from '../services/RundownService.ts';
|
||||
|
||||
// Create controller for GET request to '/events'
|
||||
// Returns -
|
||||
export const rundownGetAll = async (req, res) => {
|
||||
res.json(DataProvider.getRundown());
|
||||
};
|
||||
|
||||
// Create controller for GET request to '/events/:eventId'
|
||||
// Returns -
|
||||
export const getEventById = async (req, res) => {
|
||||
res.json(DataProvider.getEventById(req.params?.eventId));
|
||||
};
|
||||
|
||||
// Create controller for POST request to '/events/'
|
||||
// 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 '/events/'
|
||||
// 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 '/events/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 '/events/: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 '/events/'
|
||||
// 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();
|
||||
},
|
||||
];
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
: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-progress-bg-override: #fff;
|
||||
--timer-progress-override: #202020;
|
||||
}
|
||||
|
||||
.timer {
|
||||
color: black !important;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { initAssets, startIntegrations, startOSCServer, startServer } from './app.js';
|
||||
|
||||
async function startOntime() {
|
||||
try {
|
||||
console.log('Starting Ontime');
|
||||
console.log('Loading Assets');
|
||||
await initAssets();
|
||||
console.log('Starting Server');
|
||||
await startServer();
|
||||
console.log('Starting OSC Server');
|
||||
await startOSCServer();
|
||||
console.log('Starting Integrations');
|
||||
await startIntegrations();
|
||||
} 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,58 @@
|
||||
import { DatabaseModel } from 'ontime-types';
|
||||
|
||||
export const dbModel: DatabaseModel = {
|
||||
rundown: [],
|
||||
eventData: {
|
||||
title: '',
|
||||
publicUrl: '',
|
||||
publicInfo: '',
|
||||
backstageUrl: '',
|
||||
backstageInfo: '',
|
||||
},
|
||||
settings: {
|
||||
app: 'ontime',
|
||||
version: 2,
|
||||
serverPort: 4001,
|
||||
editorKey: null,
|
||||
operatorKey: null,
|
||||
timeFormat: '24',
|
||||
language: 'en',
|
||||
},
|
||||
viewSettings: {
|
||||
overrideStyles: false,
|
||||
normalColor: '#ffffffcc',
|
||||
warningColor: '#FFAB33',
|
||||
warningThreshold: 120000,
|
||||
dangerColor: '#ED3333',
|
||||
dangerThreshold: 60000,
|
||||
endMessage: '',
|
||||
},
|
||||
aliases: [],
|
||||
userFields: {
|
||||
user0: 'user0',
|
||||
user1: 'user1',
|
||||
user2: 'user2',
|
||||
user3: 'user3',
|
||||
user4: 'user4',
|
||||
user5: 'user5',
|
||||
user6: 'user6',
|
||||
user7: 'user7',
|
||||
user8: 'user8',
|
||||
user9: 'user9',
|
||||
},
|
||||
osc: {
|
||||
portIn: 8888,
|
||||
portOut: 9999,
|
||||
targetIP: '127.0.0.1',
|
||||
enabledIn: false,
|
||||
enabledOut: false,
|
||||
subscriptions: {
|
||||
onLoad: [],
|
||||
onStart: [],
|
||||
onPause: [],
|
||||
onStop: [],
|
||||
onUpdate: [],
|
||||
onFinish: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { EndAction, OntimeBlock, OntimeDelay, OntimeEvent, SupportedEvent, TimerType } from 'ontime-types';
|
||||
|
||||
export const event: Omit<OntimeEvent, 'id'> = {
|
||||
title: '',
|
||||
subtitle: '',
|
||||
presenter: '',
|
||||
note: '',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
timeStart: 0,
|
||||
timeEnd: 0,
|
||||
duration: 0,
|
||||
isPublic: false,
|
||||
skip: false,
|
||||
colour: '',
|
||||
user0: '',
|
||||
user1: '',
|
||||
user2: '',
|
||||
user3: '',
|
||||
user4: '',
|
||||
user5: '',
|
||||
user6: '',
|
||||
user7: '',
|
||||
user8: '',
|
||||
user9: '',
|
||||
type: SupportedEvent.Event,
|
||||
revision: 0,
|
||||
};
|
||||
|
||||
export const delay: Omit<OntimeDelay, 'id'> = {
|
||||
duration: 0,
|
||||
type: SupportedEvent.Delay,
|
||||
revision: 0,
|
||||
};
|
||||
|
||||
export const block: Omit<OntimeBlock, 'id'> = {
|
||||
title: '',
|
||||
type: SupportedEvent.Block,
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Low } from 'lowdb';
|
||||
import { JSONFile } from 'lowdb/node';
|
||||
import { copyFileSync, existsSync } from 'fs';
|
||||
import { DatabaseModel } from 'ontime-types';
|
||||
|
||||
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 { 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 (_) {
|
||||
/* we do not handle this */
|
||||
}
|
||||
}
|
||||
|
||||
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<DatabaseModel>(dbInDisk);
|
||||
const db = new Low(adapter);
|
||||
|
||||
const data = await parseDb(dbInDisk, db);
|
||||
if (data === null) {
|
||||
console.log('ERROR: Invalid JSON format');
|
||||
return;
|
||||
}
|
||||
|
||||
db.data = data;
|
||||
await db.write();
|
||||
|
||||
return { db, data };
|
||||
}
|
||||
|
||||
export let db = {};
|
||||
export let data = {} as DatabaseModel;
|
||||
export const dbLoadingProcess = loadDb();
|
||||
|
||||
const init = async () => {
|
||||
const dbProvider = await dbLoadingProcess;
|
||||
db = dbProvider.db;
|
||||
data = dbProvider.data;
|
||||
};
|
||||
|
||||
init();
|
||||
@@ -0,0 +1,23 @@
|
||||
import { copyFileSync, existsSync } from 'fs';
|
||||
import { pathToStartStyles, resolveStylesDirectory, resolveStylesPath } from '../setup.js';
|
||||
import { ensureDirectory } from '../utils/fileManagement.js';
|
||||
|
||||
/**
|
||||
* @description ensures directories exist and populates stylesheet
|
||||
* @return {string} - path to stylesheet file
|
||||
*/
|
||||
export const populateStyles = () => {
|
||||
const stylesInDisk = resolveStylesPath;
|
||||
ensureDirectory(resolveStylesDirectory);
|
||||
|
||||
// if stylesInDisk doesn't exist we want to use startup stylesheet
|
||||
if (!existsSync(stylesInDisk)) {
|
||||
try {
|
||||
copyFileSync(pathToStartStyles, stylesInDisk);
|
||||
} catch (_) {
|
||||
/* we do not handle this */
|
||||
}
|
||||
}
|
||||
|
||||
return stylesInDisk;
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import express from 'express';
|
||||
// import event controller
|
||||
import { getEventData, postEventData } from '../controllers/eventDataController.ts';
|
||||
import { eventDataSanitizer } from '../controllers/eventDataController.validate.ts';
|
||||
|
||||
export const router = express.Router();
|
||||
|
||||
// create route between controller and 'GET /event' endpoint
|
||||
router.get('/', getEventData);
|
||||
|
||||
// create route between controller and 'POST /event' endpoint
|
||||
router.post('/', eventDataSanitizer, postEventData);
|
||||
@@ -0,0 +1,75 @@
|
||||
import express from 'express';
|
||||
import { uploadFile } from '../utils/upload.js';
|
||||
import {
|
||||
dbDownload,
|
||||
dbUpload,
|
||||
getAliases,
|
||||
getInfo,
|
||||
getOSC,
|
||||
getSettings,
|
||||
getUserFields,
|
||||
getViewSettings,
|
||||
poll,
|
||||
postAliases,
|
||||
postNew,
|
||||
postOSC,
|
||||
postSettings,
|
||||
postUserFields,
|
||||
postViewSettings,
|
||||
} from '../controllers/ontimeController.js';
|
||||
|
||||
import {
|
||||
validateAliases,
|
||||
validateOSC,
|
||||
validateSettings,
|
||||
validateUserFields,
|
||||
viewValidator,
|
||||
} from '../controllers/ontimeController.validate.js';
|
||||
import { eventDataSanitizer } from '../controllers/eventDataController.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/new' endpoint
|
||||
router.post('/new', eventDataSanitizer, postNew);
|
||||
@@ -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 '/events/' endpoint
|
||||
router.get('/', rundownGetAll);
|
||||
|
||||
// create route between controller and '/events/:eventId' endpoint
|
||||
router.get('/:eventId', paramsMustHaveEventId, getEventById);
|
||||
|
||||
// create route between controller and '/events/' endpoint
|
||||
router.post('/', rundownPostValidator, rundownPost);
|
||||
|
||||
// create route between controller and '/events/' endpoint
|
||||
router.put('/', rundownPutValidator, rundownPut);
|
||||
|
||||
// create route between controller and '/events/reorder' endpoint
|
||||
router.patch('/reorder/', rundownReorderValidator, rundownReorder);
|
||||
|
||||
// create route between controller and '/events/applydelay/:eventId' endpoint
|
||||
router.patch('/applydelay/:eventId', paramsMustHaveEventId, rundownApplyDelay);
|
||||
|
||||
// create route between controller and '/events/all' endpoint
|
||||
router.delete('/all', rundownDelete);
|
||||
|
||||
// create route between controller and '/events/:eventId' endpoint
|
||||
router.delete('/:eventId', paramsMustHaveEventId, deleteEventById);
|
||||
@@ -0,0 +1,57 @@
|
||||
enum Source {
|
||||
System = 'system',
|
||||
MIDI = 'MIDI',
|
||||
}
|
||||
|
||||
class Clock {
|
||||
private static instance: Clock;
|
||||
private readonly source: Source;
|
||||
|
||||
constructor(source?: Source) {
|
||||
if (Clock.instance) {
|
||||
return Clock.instance;
|
||||
}
|
||||
|
||||
Clock.instance = this;
|
||||
|
||||
this.source = source || Source.System;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current time from source
|
||||
*/
|
||||
timeNow(): number {
|
||||
switch (this.source) {
|
||||
case Source.System:
|
||||
return this.getSystemTime();
|
||||
case Source.MIDI:
|
||||
// @ts-expect-error -- not implemented
|
||||
return this.getMidiTime();
|
||||
default:
|
||||
throw new Error('Invalid time source');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current time from system
|
||||
*/
|
||||
getSystemTime() {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current time from MIDI
|
||||
*/
|
||||
getMidiTime() {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
}
|
||||
|
||||
export const clock = new Clock();
|
||||
@@ -0,0 +1,227 @@
|
||||
import { OntimeEvent, Playback } from 'ontime-types';
|
||||
|
||||
import { eventLoader, EventLoader } from '../classes/event-loader/EventLoader.js';
|
||||
import { eventStore } from '../stores/EventStore.js';
|
||||
import { eventTimer } from './TimerService.js';
|
||||
import { clock } from './Clock.js';
|
||||
import { logger } from '../classes/Logger.js';
|
||||
|
||||
/**
|
||||
* Service manages playback status of app
|
||||
* Coordinating with necessary services
|
||||
*/
|
||||
export class PlaybackService {
|
||||
/**
|
||||
* makes calls for loading and starting given event
|
||||
* @param {OntimeEvent} event
|
||||
* @return {boolean} success
|
||||
*/
|
||||
static loadEvent(event: OntimeEvent): boolean {
|
||||
let success = false;
|
||||
if (!event) {
|
||||
logger.error('PLAYBACK', 'No event found');
|
||||
} else if (event.skip) {
|
||||
logger.warning('PLAYBACK', `Refused playback of skipped event ID ${event.id}`);
|
||||
} else {
|
||||
eventLoader.loadEvent(event);
|
||||
eventTimer.load(event);
|
||||
success = true;
|
||||
}
|
||||
eventStore.broadcast();
|
||||
return success;
|
||||
}
|
||||
|
||||
/**
|
||||
* starts event matching given ID
|
||||
* @param {string} eventId
|
||||
* @return {boolean} success
|
||||
*/
|
||||
static startById(eventId: string): boolean {
|
||||
const event = EventLoader.getEventWithId(eventId);
|
||||
const success = PlaybackService.loadEvent(event);
|
||||
if (success) {
|
||||
logger.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: number): boolean {
|
||||
const event = EventLoader.getEventAtIndex(eventIndex);
|
||||
const success = PlaybackService.loadEvent(event);
|
||||
if (success) {
|
||||
logger.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: string): boolean {
|
||||
const event = EventLoader.getEventWithId(eventId);
|
||||
const success = PlaybackService.loadEvent(event);
|
||||
if (success) {
|
||||
logger.info('PLAYBACK', `Loaded event with ID ${event.id}`);
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
/**
|
||||
* loads event matching given ID
|
||||
* @param {number} eventIndex
|
||||
* @return {boolean} success
|
||||
*/
|
||||
static loadByIndex(eventIndex: number): boolean {
|
||||
const event = EventLoader.getEventAtIndex(eventIndex);
|
||||
const success = PlaybackService.loadEvent(event);
|
||||
if (success) {
|
||||
logger.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) {
|
||||
logger.info('PLAYBACK', `Loaded event with ID ${previousEvent.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads event after currently selected
|
||||
* @param {string} [fallbackAction] - 'stop', 'pause'
|
||||
* @return {boolean} success
|
||||
*/
|
||||
static loadNext(fallbackAction?: 'stop' | 'pause'): boolean {
|
||||
const nextEvent = eventLoader.findNext();
|
||||
if (nextEvent) {
|
||||
const success = PlaybackService.loadEvent(nextEvent);
|
||||
if (success) {
|
||||
logger.info('PLAYBACK', `Loaded event with ID ${nextEvent.id}`);
|
||||
return true;
|
||||
}
|
||||
} else if (fallbackAction === 'stop') {
|
||||
logger.info('PLAYBACK', 'No next event found! Stopping playback');
|
||||
PlaybackService.stop();
|
||||
return false;
|
||||
} else if (fallbackAction === 'pause') {
|
||||
logger.info('PLAYBACK', 'No next event found! Pausing playback');
|
||||
PlaybackService.pause();
|
||||
return false;
|
||||
} else {
|
||||
logger.info('PLAYBACK', 'No next event found! Continuing playback');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts playback on selected event
|
||||
*/
|
||||
static start() {
|
||||
if (eventTimer.playback === Playback.Armed || eventTimer.playback === Playback.Pause) {
|
||||
eventTimer.start();
|
||||
const newState = eventTimer.playback;
|
||||
logger.info('PLAYBACK', `Play Mode ${newState.toUpperCase()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts playback on next event
|
||||
* @param {string} [fallbackAction] - 'stop', 'pause'
|
||||
*/
|
||||
static startNext(fallbackAction?: 'stop' | 'pause') {
|
||||
const success = PlaybackService.loadNext(fallbackAction);
|
||||
if (success) {
|
||||
PlaybackService.start();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pauses playback on selected event
|
||||
*/
|
||||
static pause() {
|
||||
if (eventTimer.playback === Playback.Play) {
|
||||
eventTimer.pause();
|
||||
const newState = eventTimer.playback;
|
||||
logger.info('PLAYBACK', `Play Mode ${newState.toUpperCase()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops timer and unloads any events
|
||||
*/
|
||||
static stop() {
|
||||
if (eventTimer.playback !== Playback.Stop) {
|
||||
eventLoader.reset();
|
||||
eventTimer.stop();
|
||||
const newState = eventTimer.playback;
|
||||
logger.info('PLAYBACK', `Play Mode ${newState.toUpperCase()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reloads current event
|
||||
*/
|
||||
static reload() {
|
||||
if (eventTimer.loadedTimerId) {
|
||||
this.loadById(eventTimer.loadedTimerId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets playback to roll
|
||||
*/
|
||||
static roll() {
|
||||
if (EventLoader.getPlayableEvents()) {
|
||||
const rollTimers = eventLoader.findRoll(clock.timeNow());
|
||||
|
||||
// nothing to play
|
||||
if (rollTimers === null) {
|
||||
logger.warning('SERVER', 'Roll: no events found');
|
||||
PlaybackService.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
const { currentEvent, nextEvent } = rollTimers;
|
||||
if (!currentEvent && !nextEvent) {
|
||||
logger.warning('SERVER', 'Roll: no events found');
|
||||
PlaybackService.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
eventTimer.roll(currentEvent, nextEvent);
|
||||
|
||||
const newState = eventTimer.playback;
|
||||
logger.info('PLAYBACK', `Play Mode ${newState.toUpperCase()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds delay to current event
|
||||
* @param {number} delayTime time in minutes
|
||||
*/
|
||||
static setDelay(delayTime: number) {
|
||||
if (eventTimer.loadedTimerId) {
|
||||
const delayInMs = delayTime * 1000 * 60;
|
||||
eventTimer.delay(delayInMs);
|
||||
delayInMs > 0
|
||||
? logger.info('PLAYBACK', `Added ${delayTime} min delay`)
|
||||
: logger.info('PLAYBACK', `Removed ${delayTime} min delay`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import {
|
||||
OntimeBaseEvent,
|
||||
OntimeBlock,
|
||||
OntimeDelay,
|
||||
OntimeEvent,
|
||||
SupportedEvent,
|
||||
} from 'ontime-types';
|
||||
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 { sendRefetch } from '../adapters/websocketAux.js';
|
||||
|
||||
/**
|
||||
* Checks if a list of IDs is in the current selection
|
||||
*/
|
||||
const affectedLoaded = (affectedIds: string[]) => {
|
||||
const now = eventLoader.loaded.selectedEventId;
|
||||
const nowPublic = eventLoader.loaded.selectedPublicEventId;
|
||||
const next = eventLoader.loaded.nextEventId;
|
||||
const nextPublic = eventLoader.loaded.nextPublicEventId;
|
||||
return (
|
||||
affectedIds.includes(now) ||
|
||||
affectedIds.includes(nowPublic) ||
|
||||
affectedIds.includes(next) ||
|
||||
affectedIds.includes(nextPublic)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if timer replaces the loaded next
|
||||
*/
|
||||
const isNewNext = () => {
|
||||
const timedEvents = EventLoader.getTimedEvents();
|
||||
const now = eventLoader.loaded.selectedEventId;
|
||||
const next = eventLoader.loaded.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.loaded.selectedPublicEventId;
|
||||
const nextPublic = eventLoader.loaded.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
|
||||
*/
|
||||
export function updateTimer(affectedIds?: string[]) {
|
||||
const runningEventId = eventLoader.loaded.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: Partial<OntimeEvent> | Partial<OntimeDelay> | Partial<OntimeBlock>) {
|
||||
const numEvents = DataProvider.getRundownLength();
|
||||
if (numEvents > MAX_EVENTS) {
|
||||
throw new Error(`ERROR: Reached limit number of ${MAX_EVENTS} events`);
|
||||
}
|
||||
|
||||
let newEvent: Partial<OntimeBaseEvent> = {};
|
||||
const id = generateId();
|
||||
|
||||
switch (eventData.type) {
|
||||
case 'event':
|
||||
newEvent = { ...eventDef, ...eventData, id } as Partial<OntimeEvent>;
|
||||
break;
|
||||
case 'delay':
|
||||
newEvent = { ...delayDef, ...eventData, id } as Partial<OntimeDelay>;
|
||||
break;
|
||||
case 'block':
|
||||
newEvent = { ...blockDef, ...eventData, id } as Partial<OntimeBlock>;
|
||||
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]);
|
||||
updateChangeNumEvents();
|
||||
sendRefetch();
|
||||
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]);
|
||||
sendRefetch();
|
||||
return newEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* deletes event by its ID
|
||||
* @param eventId
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function deleteEvent(eventId) {
|
||||
await DataProvider.deleteEvent(eventId);
|
||||
updateTimer([eventId]);
|
||||
updateChangeNumEvents();
|
||||
sendRefetch();
|
||||
}
|
||||
|
||||
/**
|
||||
* deletes all events in database
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function deleteAllEvents() {
|
||||
await DataProvider.clearRundown();
|
||||
updateTimer();
|
||||
updateChangeNumEvents();
|
||||
sendRefetch();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
sendRefetch();
|
||||
return reorderedItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* applies delay value for given event
|
||||
* @param eventId
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function applyDelay(eventId: string) {
|
||||
const rundown = DataProvider.getRundown();
|
||||
let delayIndex = null;
|
||||
let delayValue = 0;
|
||||
|
||||
for (const [index, event] of rundown.entries()) {
|
||||
// look for delay
|
||||
if (delayIndex === null) {
|
||||
if (event.id === eventId && event.type === SupportedEvent.Delay) {
|
||||
delayValue = event.duration;
|
||||
delayIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
// apply delay value to all items until block or end
|
||||
else {
|
||||
if (event.type === SupportedEvent.Event) {
|
||||
event.timeStart = Math.max(0, event.timeStart + delayValue);
|
||||
event.timeEnd = Math.max(event.duration, event.timeStart + delayValue);
|
||||
event.revision += 1;
|
||||
} else if (event.type === SupportedEvent.Block) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (delayIndex === null) {
|
||||
throw new Error(`Delay event with ID ${eventId} not found`);
|
||||
}
|
||||
|
||||
// delete delay
|
||||
rundown.splice(delayIndex, 1);
|
||||
|
||||
// update rundown
|
||||
await DataProvider.setRundown(rundown);
|
||||
updateTimer();
|
||||
sendRefetch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces update in the store
|
||||
* Called when we make changes to the rundown object
|
||||
*/
|
||||
function updateChangeNumEvents() {
|
||||
eventLoader.updateNumEvents();
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
import { EndAction, OntimeEvent, Playback, TimerLifeCycle, TimerState } from 'ontime-types';
|
||||
|
||||
import { eventStore } from '../stores/EventStore.js';
|
||||
import { PlaybackService } from './PlaybackService.js';
|
||||
import { updateRoll } from './rollUtils.js';
|
||||
import { DAY_TO_MS } from '../utils/time.js';
|
||||
import { integrationService } from './integration-service/IntegrationService.js';
|
||||
import { getCurrent, getElapsed, getExpectedFinish } from './timerUtils.js';
|
||||
import { clock } from './Clock.js';
|
||||
|
||||
export class TimerService {
|
||||
private readonly _interval: NodeJS.Timer;
|
||||
|
||||
playback: Playback;
|
||||
timer: TimerState;
|
||||
|
||||
loadedTimerId: string | null;
|
||||
private pausedTime: number;
|
||||
private pausedAt: number | null;
|
||||
private secondaryTarget: number | null;
|
||||
|
||||
/**
|
||||
* @constructor
|
||||
* @param {object} [timerConfig]
|
||||
* @param {number} [timerConfig.refresh]
|
||||
*/
|
||||
constructor(timerConfig?) {
|
||||
this._clear();
|
||||
this._interval = setInterval(() => this.update(), timerConfig?.refresh || 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears internal state
|
||||
* @private
|
||||
*/
|
||||
_clear() {
|
||||
this.playback = Playback.Stop;
|
||||
this.timer = {
|
||||
clock: clock.timeNow(),
|
||||
current: null,
|
||||
elapsed: null,
|
||||
expectedFinish: null,
|
||||
addedTime: 0,
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
secondaryTimer: null,
|
||||
selectedEventId: null,
|
||||
duration: null,
|
||||
timerType: null,
|
||||
endAction: null,
|
||||
};
|
||||
this.loadedTimerId = null;
|
||||
this.pausedTime = 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.timer.timerType = timer.timerType;
|
||||
this.timer.endAction = timer.endAction;
|
||||
|
||||
// this might not be ideal
|
||||
this.timer.finishedAt = null;
|
||||
this.timer.expectedFinish = getExpectedFinish(
|
||||
this.timer.startedAt,
|
||||
this.timer.finishedAt,
|
||||
this.timer.duration,
|
||||
this.pausedTime,
|
||||
this.timer.addedTime,
|
||||
);
|
||||
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.timerBehaviour
|
||||
* @param {string} timer.timerType
|
||||
* @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 = Playback.Armed;
|
||||
this.timer.timerType = timer.timerType;
|
||||
this.timer.endAction = timer.endAction;
|
||||
this.pausedTime = 0;
|
||||
this.pausedAt = 0;
|
||||
|
||||
this._onLoad();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles side effects related to onLoad event
|
||||
* @private
|
||||
*/
|
||||
_onLoad() {
|
||||
eventStore.batchSet({
|
||||
playback: this.playback,
|
||||
timer: this.timer,
|
||||
});
|
||||
integrationService.dispatch(TimerLifeCycle.onLoad);
|
||||
}
|
||||
|
||||
start() {
|
||||
if (!this.loadedTimerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.playback === Playback.Play) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.timer.clock = clock.timeNow();
|
||||
|
||||
// add paused time
|
||||
if (this.pausedTime) {
|
||||
this.timer.addedTime += this.pausedTime;
|
||||
this.pausedAt = null;
|
||||
this.pausedTime = 0;
|
||||
} else {
|
||||
this.timer.startedAt = this.timer.clock;
|
||||
}
|
||||
|
||||
this.playback = Playback.Play;
|
||||
this.timer.expectedFinish = getExpectedFinish(
|
||||
this.timer.startedAt,
|
||||
this.timer.finishedAt,
|
||||
this.timer.duration,
|
||||
this.pausedTime,
|
||||
this.timer.addedTime,
|
||||
);
|
||||
this._onStart();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles side effects related to onStart event
|
||||
* @private
|
||||
*/
|
||||
_onStart() {
|
||||
eventStore.batchSet({
|
||||
playback: this.playback,
|
||||
timer: this.timer,
|
||||
});
|
||||
integrationService.dispatch(TimerLifeCycle.onStart);
|
||||
}
|
||||
|
||||
pause() {
|
||||
if (this.playback !== Playback.Play) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.playback = Playback.Pause;
|
||||
this.timer.clock = clock.timeNow();
|
||||
this.pausedAt = this.timer.clock;
|
||||
this._onPause();
|
||||
}
|
||||
|
||||
_onPause() {
|
||||
eventStore.batchSet({
|
||||
playback: this.playback,
|
||||
timer: this.timer,
|
||||
});
|
||||
integrationService.dispatch(TimerLifeCycle.onPause);
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.playback === Playback.Stop) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._clear();
|
||||
this._onStop();
|
||||
}
|
||||
|
||||
_onStop() {
|
||||
eventStore.batchSet({
|
||||
playback: this.playback,
|
||||
timer: this.timer,
|
||||
});
|
||||
integrationService.dispatch(TimerLifeCycle.onStop);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delays running timer by given amount
|
||||
* @param {number} amount
|
||||
*/
|
||||
delay(amount: number) {
|
||||
if (!this.loadedTimerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.timer.addedTime += 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 = clock.timeNow();
|
||||
}
|
||||
} 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 = clock.timeNow();
|
||||
|
||||
if (this.playback === 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;
|
||||
this.timer.elapsed = getElapsed(this.timer.startedAt, this.timer.clock);
|
||||
|
||||
if (isFinished) {
|
||||
this.timer.selectedEventId = null;
|
||||
this.loadedTimerId = null;
|
||||
this._onFinish();
|
||||
}
|
||||
|
||||
// to load the next event we have to escalate to parent service
|
||||
if (doRollLoad) {
|
||||
PlaybackService.roll();
|
||||
}
|
||||
} else {
|
||||
// we only update timer if a timer has been started
|
||||
if (this.timer.startedAt !== null) {
|
||||
if (this.playback === Playback.Pause) {
|
||||
this.pausedTime = this.timer.clock - this.pausedAt;
|
||||
}
|
||||
|
||||
if (this.playback === Playback.Play && this.timer.current <= 0 && this.timer.finishedAt === null) {
|
||||
this.timer.finishedAt = this.timer.clock;
|
||||
this._onFinish();
|
||||
} else {
|
||||
this.timer.expectedFinish = getExpectedFinish(
|
||||
this.timer.startedAt,
|
||||
this.timer.finishedAt,
|
||||
this.timer.duration,
|
||||
this.pausedTime,
|
||||
this.timer.addedTime,
|
||||
);
|
||||
}
|
||||
this.timer.current = getCurrent(
|
||||
this.timer.startedAt,
|
||||
this.timer.duration,
|
||||
this.timer.addedTime,
|
||||
this.pausedTime,
|
||||
this.timer.clock,
|
||||
);
|
||||
|
||||
this.timer.elapsed = getElapsed(this.timer.startedAt, this.timer.clock);
|
||||
}
|
||||
}
|
||||
this._onUpdate();
|
||||
}
|
||||
|
||||
_onUpdate() {
|
||||
eventStore.set('timer', this.timer);
|
||||
integrationService.dispatch(TimerLifeCycle.onUpdate);
|
||||
}
|
||||
|
||||
_onFinish() {
|
||||
eventStore.set('timer', this.timer);
|
||||
integrationService.dispatch(TimerLifeCycle.onFinish);
|
||||
if (this.playback === Playback.Play) {
|
||||
if (this.timer.endAction === EndAction.Stop) {
|
||||
PlaybackService.stop();
|
||||
} else if (this.timer.endAction === EndAction.LoadNext) {
|
||||
// we need to delay here to put this action in the queue stack. otherwise it won't be executed properly
|
||||
setTimeout(() => {
|
||||
PlaybackService.loadNext();
|
||||
}, 0);
|
||||
} else if (this.timer.endAction === EndAction.PlayNext) {
|
||||
PlaybackService.startNext();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads roll information into timer service
|
||||
* @param {OntimeEvent | null} currentEvent -- both current event and next event cant be null
|
||||
* @param {OntimeEvent | null} nextEvent -- both current event and next event cant be null
|
||||
*/
|
||||
roll(currentEvent: OntimeEvent | null, nextEvent: OntimeEvent | null) {
|
||||
this._clear();
|
||||
this.timer.clock = clock.timeNow();
|
||||
|
||||
if (currentEvent) {
|
||||
// there is something running, load
|
||||
this.timer.secondaryTimer = null;
|
||||
this.secondaryTarget = null;
|
||||
|
||||
// when we load a timer in roll, we do the same things as before
|
||||
// but also pre-populate some data as to the running state
|
||||
this.load(currentEvent);
|
||||
this.timer.startedAt = currentEvent.timeStart;
|
||||
this.timer.expectedFinish = currentEvent.timeEnd;
|
||||
} else if (nextEvent) {
|
||||
// account for day after
|
||||
const nextStart = nextEvent.timeStart < this.timer.clock ? nextEvent.timeStart + DAY_TO_MS : nextEvent.timeStart;
|
||||
// nothing now, but something coming up
|
||||
this.timer.secondaryTimer = nextStart - this.timer.clock;
|
||||
this.secondaryTarget = nextStart;
|
||||
}
|
||||
this.playback = Playback.Roll;
|
||||
this._onRoll();
|
||||
this.update();
|
||||
}
|
||||
|
||||
_onRoll() {
|
||||
eventStore.set('playback', this.playback);
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
clearInterval(this._interval);
|
||||
}
|
||||
}
|
||||
|
||||
export const eventTimer = new TimerService();
|
||||
@@ -0,0 +1,587 @@
|
||||
import {
|
||||
DAY_TO_MS,
|
||||
getRollTimers,
|
||||
normaliseEndTime,
|
||||
sortArrayByProperty,
|
||||
updateRoll,
|
||||
} from '../rollUtils.ts';
|
||||
|
||||
// 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,
|
||||
timeToNext: 5,
|
||||
nextEvent: eventlist[0],
|
||||
nextPublicEvent: eventlist[4],
|
||||
currentEvent: null,
|
||||
currentPublicEvent: null,
|
||||
};
|
||||
|
||||
const state = getRollTimers(eventlist, now);
|
||||
expect(state).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('if timer is at 5', () => {
|
||||
const now = 5;
|
||||
const expected = {
|
||||
nowIndex: 0,
|
||||
nowId: eventlist[0].id,
|
||||
publicIndex: null,
|
||||
nextIndex: 1,
|
||||
publicNextIndex: 4,
|
||||
timeToNext: 5,
|
||||
nextEvent: eventlist[1],
|
||||
nextPublicEvent: eventlist[4],
|
||||
currentEvent: eventlist[0],
|
||||
currentPublicEvent: null,
|
||||
};
|
||||
|
||||
const state = getRollTimers(eventlist, now);
|
||||
expect(state).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('if timer is at 15', () => {
|
||||
const now = 15;
|
||||
const expected = {
|
||||
nowIndex: 1,
|
||||
nowId: eventlist[1].id,
|
||||
publicIndex: null,
|
||||
nextIndex: 2,
|
||||
publicNextIndex: 4,
|
||||
timeToNext: 5,
|
||||
nextEvent: eventlist[2],
|
||||
nextPublicEvent: eventlist[4],
|
||||
currentEvent: eventlist[1],
|
||||
currentPublicEvent: null,
|
||||
};
|
||||
|
||||
const state = getRollTimers(eventlist, now);
|
||||
expect(state).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('if timer is at 20', () => {
|
||||
const now = 20;
|
||||
const expected = {
|
||||
nowIndex: 2,
|
||||
nowId: eventlist[2].id,
|
||||
publicIndex: null,
|
||||
nextIndex: 3,
|
||||
publicNextIndex: 4,
|
||||
timeToNext: 10,
|
||||
nextEvent: eventlist[3],
|
||||
nextPublicEvent: eventlist[4],
|
||||
currentEvent: eventlist[2],
|
||||
currentPublicEvent: null,
|
||||
};
|
||||
|
||||
const state = getRollTimers(eventlist, now);
|
||||
expect(state).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('if timer is at 49', () => {
|
||||
const now = 49;
|
||||
const expected = {
|
||||
nowIndex: 4,
|
||||
nowId: eventlist[4].id,
|
||||
publicIndex: 4,
|
||||
nextIndex: 5,
|
||||
publicNextIndex: 6,
|
||||
timeToNext: 1,
|
||||
nextEvent: eventlist[5],
|
||||
nextPublicEvent: eventlist[6],
|
||||
currentEvent: eventlist[4],
|
||||
currentPublicEvent: eventlist[4],
|
||||
};
|
||||
|
||||
const state = getRollTimers(eventlist, now);
|
||||
expect(state).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('if timer is at 63', () => {
|
||||
const now = 63;
|
||||
const expected = {
|
||||
nowIndex: 6,
|
||||
nowId: eventlist[6].id,
|
||||
publicIndex: 6,
|
||||
nextIndex: 7,
|
||||
publicNextIndex: null,
|
||||
timeToNext: 7,
|
||||
nextEvent: eventlist[7],
|
||||
nextPublicEvent: null,
|
||||
currentEvent: eventlist[6],
|
||||
currentPublicEvent: eventlist[6],
|
||||
};
|
||||
|
||||
const state = getRollTimers(eventlist, now);
|
||||
expect(state).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('if timer is at 75', () => {
|
||||
const now = 75;
|
||||
const expected = {
|
||||
nowIndex: 7,
|
||||
nowId: eventlist[7].id,
|
||||
publicIndex: 6,
|
||||
nextIndex: null,
|
||||
publicNextIndex: null,
|
||||
timeToNext: null,
|
||||
nextEvent: null,
|
||||
nextPublicEvent: null,
|
||||
currentEvent: eventlist[7],
|
||||
currentPublicEvent: eventlist[6],
|
||||
};
|
||||
|
||||
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,
|
||||
timeToNext: DAY_TO_MS - now + eventlist[0].timeStart,
|
||||
nextEvent: eventlist[0],
|
||||
nextPublicEvent: eventlist[4],
|
||||
currentEvent: null,
|
||||
currentPublicEvent: null,
|
||||
};
|
||||
|
||||
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,
|
||||
timeToNext: DAY_TO_MS - now + singleEventList[0].timeStart,
|
||||
nextEvent: singleEventList[0],
|
||||
nextPublicEvent: singleEventList[0],
|
||||
currentEvent: null,
|
||||
currentPublicEvent: null,
|
||||
};
|
||||
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,
|
||||
timeToNext: 10,
|
||||
nextEvent: eventlist[0],
|
||||
nextPublicEvent: eventlist[1],
|
||||
currentEvent: null,
|
||||
currentPublicEvent: null,
|
||||
};
|
||||
|
||||
const state = getRollTimers(eventlist, now);
|
||||
expect(state).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('if timer is at 10', () => {
|
||||
const now = 10;
|
||||
const expected = {
|
||||
nowIndex: 1,
|
||||
nowId: eventlist[1].id,
|
||||
publicIndex: 1,
|
||||
nextIndex: 2,
|
||||
publicNextIndex: null,
|
||||
timeToNext: 0,
|
||||
nextEvent: eventlist[2],
|
||||
nextPublicEvent: null,
|
||||
currentEvent: eventlist[1],
|
||||
currentPublicEvent: eventlist[1],
|
||||
};
|
||||
|
||||
const state = getRollTimers(eventlist, now);
|
||||
expect(state).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('if timer is at 15', () => {
|
||||
const now = 15;
|
||||
const expected = {
|
||||
nowIndex: 1,
|
||||
nowId: eventlist[1].id,
|
||||
publicIndex: 1,
|
||||
nextIndex: 2,
|
||||
publicNextIndex: null,
|
||||
timeToNext: -5,
|
||||
nextEvent: eventlist[2],
|
||||
nextPublicEvent: null,
|
||||
currentEvent: eventlist[1],
|
||||
currentPublicEvent: eventlist[1],
|
||||
};
|
||||
|
||||
const state = getRollTimers(eventlist, now);
|
||||
expect(state).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('if timer is at 20', () => {
|
||||
const now = 20;
|
||||
const expected = {
|
||||
nowIndex: 2,
|
||||
nowId: eventlist[2].id,
|
||||
publicIndex: 1,
|
||||
nextIndex: null,
|
||||
publicNextIndex: null,
|
||||
timeToNext: null,
|
||||
nextEvent: null,
|
||||
nextPublicEvent: null,
|
||||
currentEvent: eventlist[2],
|
||||
currentPublicEvent: eventlist[1],
|
||||
};
|
||||
|
||||
const state = getRollTimers(eventlist, now);
|
||||
expect(state).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('if timer is at 25', () => {
|
||||
const now = 25;
|
||||
const expected = {
|
||||
nowIndex: 2,
|
||||
nowId: eventlist[2].id,
|
||||
publicIndex: 1,
|
||||
nextIndex: null,
|
||||
publicNextIndex: null,
|
||||
timeToNext: null,
|
||||
nextEvent: null,
|
||||
nextPublicEvent: null,
|
||||
currentEvent: eventlist[2],
|
||||
currentPublicEvent: eventlist[1],
|
||||
};
|
||||
|
||||
const state = getRollTimers(eventlist, now);
|
||||
expect(state).toStrictEqual(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,
|
||||
timeToNext: null,
|
||||
nextEvent: null,
|
||||
nextPublicEvent: null,
|
||||
currentEvent: eventlist[0],
|
||||
currentPublicEvent: 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,
|
||||
timeToNext: eventlist[0].timeStart - now,
|
||||
nextEvent: eventlist[0],
|
||||
nextPublicEvent: null,
|
||||
currentEvent: null,
|
||||
currentPublicEvent: null,
|
||||
};
|
||||
|
||||
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: -1,
|
||||
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,143 @@
|
||||
import { getCurrent, getElapsed, getExpectedFinish } from '../timerUtils.js';
|
||||
|
||||
describe('getExpectedFinish()', () => {
|
||||
it('is null if we havent started', () => {
|
||||
const startedAt = null;
|
||||
const finishedAt = null;
|
||||
const duration = 10;
|
||||
const pausedTime = 0;
|
||||
const addedTime = 0;
|
||||
const calculatedFinish = getExpectedFinish(startedAt, finishedAt, duration, pausedTime, addedTime);
|
||||
expect(calculatedFinish).toBe(null);
|
||||
});
|
||||
it('is finishedAt if defined', () => {
|
||||
const startedAt = 10;
|
||||
const finishedAt = 20;
|
||||
const duration = 10;
|
||||
const pausedTime = 0;
|
||||
const addedTime = 0;
|
||||
const calculatedFinish = getExpectedFinish(startedAt, finishedAt, duration, pausedTime, addedTime);
|
||||
expect(calculatedFinish).toBe(finishedAt);
|
||||
});
|
||||
it('calculates the finish time', () => {
|
||||
const startedAt = 1;
|
||||
const finishedAt = null;
|
||||
const duration = 10;
|
||||
const pausedTime = 0;
|
||||
const addedTime = 0;
|
||||
const calculatedFinish = getExpectedFinish(startedAt, finishedAt, duration, pausedTime, addedTime);
|
||||
expect(calculatedFinish).toBe(11);
|
||||
});
|
||||
it('adds paused and added times', () => {
|
||||
const startedAt = 1;
|
||||
const finishedAt = null;
|
||||
const duration = 10;
|
||||
const pausedTime = 10;
|
||||
const addedTime = 10;
|
||||
const calculatedFinish = getExpectedFinish(startedAt, finishedAt, duration, pausedTime, addedTime);
|
||||
expect(calculatedFinish).toBe(31);
|
||||
});
|
||||
it('added time could be negative', () => {
|
||||
const startedAt = 1;
|
||||
const finishedAt = null;
|
||||
const duration = 10;
|
||||
const pausedTime = 10;
|
||||
const addedTime = -10;
|
||||
const calculatedFinish = getExpectedFinish(startedAt, finishedAt, duration, pausedTime, addedTime);
|
||||
expect(calculatedFinish).toBe(11);
|
||||
});
|
||||
it('user could add enough time for it to be negative', () => {
|
||||
const startedAt = 1;
|
||||
const finishedAt = null;
|
||||
const duration = 10;
|
||||
const pausedTime = 0;
|
||||
const addedTime = -100;
|
||||
const calculatedFinish = getExpectedFinish(startedAt, finishedAt, duration, pausedTime, addedTime);
|
||||
expect(calculatedFinish).toBe(1);
|
||||
});
|
||||
it('timer can have no duration', () => {
|
||||
const startedAt = 1;
|
||||
const finishedAt = null;
|
||||
const duration = 0;
|
||||
const pausedTime = 0;
|
||||
const addedTime = 0;
|
||||
const calculatedFinish = getExpectedFinish(startedAt, finishedAt, duration, pausedTime, addedTime);
|
||||
expect(calculatedFinish).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCurrent()', () => {
|
||||
it('is null if it hasnt started', () => {
|
||||
const startedAt = null;
|
||||
const duration = 0;
|
||||
const pausedTime = 0;
|
||||
const addedTime = 0;
|
||||
const clock = 0;
|
||||
const current = getCurrent(startedAt, duration, addedTime, pausedTime, clock);
|
||||
expect(current).toBe(null);
|
||||
});
|
||||
it('is the remaining time in clock', () => {
|
||||
const startedAt = 0;
|
||||
const duration = 10;
|
||||
const pausedTime = 0;
|
||||
const addedTime = 0;
|
||||
const clock = 1;
|
||||
const current = getCurrent(startedAt, duration, addedTime, pausedTime, clock);
|
||||
expect(current).toBe(9);
|
||||
});
|
||||
it('accounts for added times', () => {
|
||||
const startedAt = 0;
|
||||
const duration = 10;
|
||||
const pausedTime = 5;
|
||||
const addedTime = 5;
|
||||
const clock = 1;
|
||||
const current = getCurrent(startedAt, duration, addedTime, pausedTime, clock);
|
||||
expect(current).toBe(19);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getElapsedTime()', () => {
|
||||
it('time since we started', () => {
|
||||
const startedAt = 0;
|
||||
const clock = 5;
|
||||
const elapsed = getElapsed(startedAt, clock);
|
||||
expect(elapsed).toBe(5);
|
||||
});
|
||||
it('clock cannot be lower than started time', () => {
|
||||
const startedAt = 10;
|
||||
const clock = 5;
|
||||
expect(() => getElapsed(startedAt, clock)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExpectedFinish() getElapsedTime() and getCurrentTime() combined', () => {
|
||||
it('without added times, they combine to be duration', () => {
|
||||
const startedAt = 0;
|
||||
const duration = 10;
|
||||
const finishedAt = null;
|
||||
const pausedTime = 0;
|
||||
const addedTime = 0;
|
||||
const clock = 0;
|
||||
const expectedFinish = getExpectedFinish(startedAt, finishedAt, duration, pausedTime, addedTime);
|
||||
const elapsed = getElapsed(startedAt, clock);
|
||||
const current = getCurrent(startedAt, duration, addedTime, pausedTime, clock);
|
||||
expect(expectedFinish).toBe(10);
|
||||
expect(elapsed).toBe(0);
|
||||
expect(current).toBe(10);
|
||||
expect(elapsed + current).toBe(10);
|
||||
});
|
||||
it('added times influence expected finish', () => {
|
||||
const startedAt = 0;
|
||||
const duration = 10;
|
||||
const finishedAt = null;
|
||||
const pausedTime = 1;
|
||||
const addedTime = 2;
|
||||
const clock = 5;
|
||||
const expectedFinish = getExpectedFinish(startedAt, finishedAt, duration, pausedTime, addedTime);
|
||||
const elapsed = getElapsed(startedAt, clock);
|
||||
const current = getCurrent(startedAt, duration, addedTime, pausedTime, clock);
|
||||
expect(expectedFinish).toBe(13);
|
||||
expect(elapsed).toBe(5);
|
||||
expect(current).toBe(8);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { TimerLifeCycle, OscSubscription } from 'ontime-types';
|
||||
|
||||
export type TimerLifeCycleKey = keyof typeof TimerLifeCycle;
|
||||
|
||||
export default interface IIntegration {
|
||||
subscriptions: OscSubscription;
|
||||
init: (config: unknown) => OperationReturn;
|
||||
dispatch: (action: TimerLifeCycleKey, state?: object) => OperationReturn;
|
||||
emit: (...args: unknown[]) => unknown;
|
||||
shutdown: () => void;
|
||||
}
|
||||
|
||||
// either went well, or explain what failed
|
||||
type OperationReturn = ReturnOnSuccess | ReturnOnError;
|
||||
|
||||
type ReturnOnSuccess = {
|
||||
success: true;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
type ReturnOnError = {
|
||||
success: false;
|
||||
message: string;
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import IIntegration, { TimerLifeCycleKey } from './IIntegration.js';
|
||||
import { eventStore } from '../../stores/EventStore.js';
|
||||
|
||||
class IntegrationService {
|
||||
private integrations: IIntegration[];
|
||||
|
||||
constructor() {
|
||||
this.integrations = [];
|
||||
}
|
||||
|
||||
register(integrationService: IIntegration) {
|
||||
this.integrations.push(integrationService);
|
||||
}
|
||||
|
||||
unregister(integrationService: IIntegration) {
|
||||
this.integrations = this.integrations.filter((int) => int !== integrationService);
|
||||
}
|
||||
|
||||
dispatch(action: TimerLifeCycleKey) {
|
||||
const state = eventStore.poll();
|
||||
this.integrations.forEach((integration) => {
|
||||
integration.dispatch(action, state);
|
||||
});
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
console.log('Shutdown integrations');
|
||||
this.integrations.forEach((integration) => {
|
||||
integration.shutdown();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const integrationService = new IntegrationService();
|
||||
@@ -0,0 +1,130 @@
|
||||
import { ArgumentType, Client, Message } from 'node-osc';
|
||||
import { OSCSettings, OscSubscription } from 'ontime-types';
|
||||
|
||||
import IIntegration, { TimerLifeCycleKey } from './IIntegration.js';
|
||||
import { parseTemplateNested } from './integrationUtils.js';
|
||||
import { isObject } from '../../utils/varUtils.js';
|
||||
import { dbModel } from '../../models/dataModel.js';
|
||||
import { validateOscSubscription } from '../../utils/parserFunctions.js';
|
||||
|
||||
type Action = TimerLifeCycleKey | string;
|
||||
|
||||
/**
|
||||
* @description Class contains logic towards outgoing OSC communications
|
||||
* @class
|
||||
*/
|
||||
export class OscIntegration implements IIntegration {
|
||||
protected oscClient: null | Client;
|
||||
subscriptions: OscSubscription;
|
||||
|
||||
constructor() {
|
||||
this.oscClient = null;
|
||||
this.subscriptions = dbModel.osc.subscriptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes oscClient
|
||||
*/
|
||||
init(config: OSCSettings) {
|
||||
const { targetIP, portOut, subscriptions } = config;
|
||||
|
||||
this.initSubscriptions(subscriptions);
|
||||
|
||||
// runtime validation
|
||||
const validateType = typeof targetIP !== 'string' || typeof portOut !== 'number';
|
||||
const validateNull = !targetIP || !portOut;
|
||||
|
||||
if (validateType || validateNull) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Config options incorrect`,
|
||||
};
|
||||
}
|
||||
try {
|
||||
// this allows re-calling the init function during runtime
|
||||
this.oscClient?.close();
|
||||
this.oscClient = new Client(targetIP, portOut);
|
||||
return {
|
||||
success: true,
|
||||
message: `OSC integration client connected to ${targetIP}:${portOut}`,
|
||||
};
|
||||
} catch (error) {
|
||||
this.oscClient = null;
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed initialising OSC Client: ${error}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
initSubscriptions(subscriptionOptions: OscSubscription) {
|
||||
if (validateOscSubscription(subscriptionOptions)) {
|
||||
this.subscriptions = { ...subscriptionOptions };
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(action: Action, state?: object) {
|
||||
if (!this.oscClient) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Client not initialised',
|
||||
};
|
||||
}
|
||||
|
||||
if (!action) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'OSC called with no action',
|
||||
};
|
||||
}
|
||||
|
||||
// check subscriptions for action
|
||||
const eventSubscriptions = this.subscriptions?.[action] || [];
|
||||
|
||||
eventSubscriptions.forEach((sub) => {
|
||||
const { enabled, message } = sub;
|
||||
if (enabled && message) {
|
||||
const parsedMessage = parseTemplateNested(message, state || {});
|
||||
this.emit(parsedMessage);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
emit(path: string, payload?: ArgumentType) {
|
||||
const message = new Message(path);
|
||||
if (payload) {
|
||||
try {
|
||||
if (isObject(payload)) {
|
||||
message.append(JSON.stringify(payload));
|
||||
} else {
|
||||
message.append(payload);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('OSC ERROR', error, payload);
|
||||
}
|
||||
}
|
||||
|
||||
this.oscClient.send(message, (error) => {
|
||||
if (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Error sending message: ${JSON.stringify(error)}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
message: 'OSC Message sent',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
console.log('Shutting down OSC integration');
|
||||
if (this.oscClient) {
|
||||
this.oscClient?.close();
|
||||
this.oscClient = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const oscIntegration = new OscIntegration();
|
||||
@@ -0,0 +1,96 @@
|
||||
import { parseTemplate, parseTemplateNested } from './integrationUtils.js';
|
||||
|
||||
describe('parseTemplate()', () => {
|
||||
it('correctly parses a given string', () => {
|
||||
const mockState = { test: 'this' };
|
||||
const testString = 'That should replace {{test}}';
|
||||
const expected = `That should replace ${mockState.test}`;
|
||||
|
||||
const result = parseTemplate(testString, mockState);
|
||||
expect(result).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('parses string with multiple variables', () => {
|
||||
const mockState = { test1: 'that', test2: 'this' };
|
||||
const testString = '{{test1}} should replace {{test2}}';
|
||||
const expected = `${mockState.test1} should replace ${mockState.test2}`;
|
||||
|
||||
const result = parseTemplate(testString, mockState);
|
||||
expect(result).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('correctly parses a string without templates', () => {
|
||||
const testString = 'That should replace {test}';
|
||||
|
||||
const result = parseTemplate(testString, {});
|
||||
expect(result).toStrictEqual(testString);
|
||||
});
|
||||
|
||||
it('handles scenarios with missing variables', () => {
|
||||
// by failing to provide a value, we give visibility to
|
||||
// potential issues in the given string
|
||||
const mockState = { test1: 'that', test2: 'this' };
|
||||
const testString = '{{test1}} should replace {{test2}}, but not {{test3}}';
|
||||
const expected = `${mockState.test1} should replace ${mockState.test2}, but not {{test3}}`;
|
||||
|
||||
const result = parseTemplate(testString, mockState);
|
||||
expect(result).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('doesnt yet handle nested variables', () => {
|
||||
const mockState = {
|
||||
timer: {
|
||||
time: '10',
|
||||
},
|
||||
enabled: 'is',
|
||||
};
|
||||
const testString = 'Timer {{enabled}} enabled with {{timer.time}}ms interval';
|
||||
const expected = 'Timer is enabled with {{timer.time}}ms interval';
|
||||
|
||||
const result = parseTemplate(testString, mockState);
|
||||
expect(result).toStrictEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTemplateNested()', () => {
|
||||
it('parses string with a single-level variable name', () => {
|
||||
const store = { timer: 10 };
|
||||
const templateString = '/test/{{timer}}';
|
||||
const result = parseTemplateNested(templateString, store);
|
||||
expect(result).toEqual('/test/10');
|
||||
});
|
||||
|
||||
it('parses string with a nested variable name', () => {
|
||||
const store = { timer: { clock: 10 } };
|
||||
const templateString = '/timer/{{timer.clock}}';
|
||||
const result = parseTemplateNested(templateString, store);
|
||||
expect(result).toEqual('/timer/10');
|
||||
});
|
||||
|
||||
it('parses string with multiple variables', () => {
|
||||
const mockState = { test1: 'that', test2: 'this' };
|
||||
const testString = '{{test1}} should replace {{test2}}';
|
||||
const expected = `${mockState.test1} should replace ${mockState.test2}`;
|
||||
|
||||
const result = parseTemplateNested(testString, mockState);
|
||||
expect(result).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('correctly parses a string without templates', () => {
|
||||
const testString = 'That should replace {test}';
|
||||
|
||||
const result = parseTemplateNested(testString, {});
|
||||
expect(result).toStrictEqual(testString);
|
||||
});
|
||||
|
||||
it('handles scenarios with missing variables', () => {
|
||||
// by failing to provide a value, we give visibility to
|
||||
// potential issues in the given string
|
||||
const mockState = { test1: 'that', test2: 'this' };
|
||||
const testString = '{{test1}} should replace {{test2}}, but not {{test3}}';
|
||||
const expected = `${mockState.test1} should replace ${mockState.test2}, but not {{test3}}`;
|
||||
|
||||
const result = parseTemplateNested(testString, mockState);
|
||||
expect(result).toStrictEqual(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
// any value inside double curly braces {{val}}
|
||||
const placeholderRegex = /{{(.*?)}}/g;
|
||||
|
||||
/**
|
||||
* Parses a templated string
|
||||
*/
|
||||
export function parseTemplate(template: string, state: object): string {
|
||||
let parsedTemplate = template;
|
||||
let match;
|
||||
while ((match = placeholderRegex.exec(template)) !== null) {
|
||||
const variableName = match[1];
|
||||
if (Object.hasOwn(state, variableName)) {
|
||||
parsedTemplate = parsedTemplate.replace(match[0], state[variableName]);
|
||||
}
|
||||
}
|
||||
|
||||
return parsedTemplate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a templated string to values in a nested object
|
||||
*/
|
||||
export function parseTemplateNested(template: string, state: object): string {
|
||||
let parsedTemplate = template;
|
||||
let match;
|
||||
while ((match = placeholderRegex.exec(template)) !== null) {
|
||||
const variableName = match[1];
|
||||
const variableParts = variableName.split('.');
|
||||
// iterate through variable parts, and look for the property in the state object
|
||||
const value = variableParts.reduce((obj, key) => obj && obj[key], state);
|
||||
if (value !== undefined) {
|
||||
parsedTemplate = parsedTemplate.replace(match[0], value);
|
||||
}
|
||||
}
|
||||
|
||||
return parsedTemplate;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Message } from 'ontime-types';
|
||||
|
||||
import { eventStore } from '../../stores/EventStore.js';
|
||||
|
||||
let instance;
|
||||
|
||||
class MessageService {
|
||||
timerMessage: Message;
|
||||
publicMessage: Message;
|
||||
lowerMessage: Message;
|
||||
onAir: boolean;
|
||||
|
||||
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.timerMessage = {
|
||||
text: '',
|
||||
visible: false,
|
||||
};
|
||||
|
||||
this.publicMessage = {
|
||||
text: '',
|
||||
visible: false,
|
||||
};
|
||||
|
||||
this.lowerMessage = {
|
||||
text: '',
|
||||
visible: false,
|
||||
};
|
||||
|
||||
this.onAir = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description sets message on stage timer screen
|
||||
*/
|
||||
setTimerText(payload: string) {
|
||||
this.timerMessage.text = payload;
|
||||
eventStore.set('timerMessage', this.timerMessage);
|
||||
return this.getAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* @description sets message visibility on stage timer screen
|
||||
*/
|
||||
setTimerVisibility(status: boolean) {
|
||||
this.timerMessage.visible = status;
|
||||
eventStore.set('timerMessage', this.timerMessage);
|
||||
return this.getAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* @description sets message on public screen
|
||||
*/
|
||||
setPublicText(payload: string) {
|
||||
this.publicMessage.text = payload;
|
||||
eventStore.set('publicMessage', this.publicMessage);
|
||||
return this.getAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* @description sets message visibility on public screen
|
||||
*/
|
||||
setPublicVisibility(status: boolean) {
|
||||
this.publicMessage.visible = status;
|
||||
eventStore.set('publicMessage', this.publicMessage);
|
||||
return this.getAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* @description sets message on lower third screen
|
||||
*/
|
||||
setLowerText(payload: string) {
|
||||
this.lowerMessage.text = payload;
|
||||
eventStore.set('lowerMessage', this.lowerMessage);
|
||||
return this.getAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* @description sets message visibility on lower third screen
|
||||
*/
|
||||
setLowerVisibility(status: boolean) {
|
||||
this.lowerMessage.visible = status;
|
||||
eventStore.set('lowerMessage', this.lowerMessage);
|
||||
return this.getAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* @description set state of onAir, toggles if parameters are offered
|
||||
*/
|
||||
setOnAir(status?: boolean) {
|
||||
if (typeof status === 'undefined') {
|
||||
this.onAir = !this.onAir;
|
||||
} else {
|
||||
this.onAir = status;
|
||||
}
|
||||
eventStore.set('onAir', this.onAir);
|
||||
return this.getAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Returns feature data
|
||||
*/
|
||||
getAll() {
|
||||
return {
|
||||
timerMessage: this.timerMessage,
|
||||
publicMessage: this.publicMessage,
|
||||
lowerMessage: this.lowerMessage,
|
||||
onAir: this.onAir,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const messageService = new MessageService();
|
||||
@@ -0,0 +1,205 @@
|
||||
import { OntimeEvent } from 'ontime-types';
|
||||
|
||||
/**
|
||||
* Utility variable: 24 hour in milliseconds .
|
||||
*/
|
||||
export const DAY_TO_MS = 86400000;
|
||||
|
||||
/**
|
||||
* handle events that span over midnight
|
||||
*/
|
||||
export const normaliseEndTime = (start: number, end: number) => (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 = <T>(arr: T[], property: string): T[] => {
|
||||
return [...arr].sort((a, b) => {
|
||||
return a[property] - b[property];
|
||||
});
|
||||
};
|
||||
|
||||
type Timer = {
|
||||
_startedAt: number;
|
||||
_finishAt: number;
|
||||
duration: number;
|
||||
current: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Finds loading information given a current rundown and time
|
||||
* @param {OntimeEvent[]} rundown - List of playable events
|
||||
* @param {number} timeNow - time now in ms
|
||||
* @returns {{}}
|
||||
*/
|
||||
export const getRollTimers = (rundown: OntimeEvent[], timeNow: number) => {
|
||||
let nowIndex: number | null = null; // index of event now
|
||||
let nowId: string | null = null; // id of event now
|
||||
let publicIndex: number | null = null; // index of public event now
|
||||
let nextIndex: number | null = null; // index of next event
|
||||
let publicNextIndex: number | null = null; // index of next public event
|
||||
let timeToNext: number | null = null; // counter: time for next event
|
||||
let publicTimeToNext: number | null = null; // counter: time for next public event
|
||||
|
||||
const orderedEvents = sortArrayByProperty(rundown, 'timeStart');
|
||||
const lastEvent = orderedEvents[orderedEvents.length - 1];
|
||||
const lastNormalEnd = normaliseEndTime(lastEvent.timeStart, lastEvent.timeEnd);
|
||||
|
||||
let nextEvent: OntimeEvent | null = null;
|
||||
let nextPublicEvent: OntimeEvent | null = null;
|
||||
let currentEvent: OntimeEvent | null = null;
|
||||
let currentPublicEvent: OntimeEvent | null = null;
|
||||
|
||||
if (timeNow > lastNormalEnd) {
|
||||
// we are past last end
|
||||
// preload first and find next
|
||||
|
||||
const firstEvent = orderedEvents[0];
|
||||
nextIndex = 0;
|
||||
nextEvent = firstEvent;
|
||||
timeToNext = firstEvent.timeStart + DAY_TO_MS - timeNow;
|
||||
|
||||
if (firstEvent.isPublic) {
|
||||
nextPublicEvent = firstEvent;
|
||||
publicNextIndex = 0;
|
||||
} else {
|
||||
// look for next public
|
||||
// dev note: we feel that this is more efficient than filtering
|
||||
// since the next event will likely be close to the one playing
|
||||
for (const event of orderedEvents) {
|
||||
if (event.isPublic) {
|
||||
nextPublicEvent = event;
|
||||
// we need the index before this was sorted
|
||||
publicNextIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// flags: select first event if several overlapping
|
||||
let nowFound = false;
|
||||
// keep track of the end times when looking for public
|
||||
let publicTime = -1;
|
||||
|
||||
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
|
||||
|
||||
if (event.isPublic && normalEnd > publicTime) {
|
||||
// public event might not be the one running
|
||||
publicTime = normalEnd;
|
||||
currentPublicEvent = event;
|
||||
publicIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
|
||||
}
|
||||
} else if (normalEnd > timeNow && timeNow >= event.timeStart && !nowFound) {
|
||||
// event is running
|
||||
currentEvent = event;
|
||||
nowIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
|
||||
nowId = event.id;
|
||||
nowFound = true;
|
||||
|
||||
// it could also be public
|
||||
if (event.isPublic) {
|
||||
publicTime = normalEnd;
|
||||
currentPublicEvent = event;
|
||||
publicIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
|
||||
}
|
||||
} else if (normalEnd > timeNow) {
|
||||
// event will run
|
||||
|
||||
// we already know whats next and next-public
|
||||
if (nextIndex !== null && publicNextIndex !== null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// look for next events
|
||||
// check how far the start is from now
|
||||
const timeToEventStart = event.timeStart - timeNow;
|
||||
|
||||
// we don't have a next or this one starts sooner than current next
|
||||
if (nextIndex === null || timeToEventStart < timeToNext) {
|
||||
timeToNext = timeToEventStart;
|
||||
nextEvent = event;
|
||||
nextIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
|
||||
}
|
||||
|
||||
if (event.isPublic) {
|
||||
// if we don't have a public next or this one start sooner than assigned next
|
||||
if (publicNextIndex === null || timeToEventStart < publicTimeToNext) {
|
||||
publicTimeToNext = timeToEventStart;
|
||||
nextPublicEvent = event;
|
||||
publicNextIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nowIndex,
|
||||
nowId,
|
||||
publicIndex,
|
||||
nextIndex,
|
||||
publicNextIndex,
|
||||
timeToNext,
|
||||
nextEvent,
|
||||
nextPublicEvent,
|
||||
currentEvent,
|
||||
currentPublicEvent,
|
||||
};
|
||||
};
|
||||
|
||||
type CurrentTimers = {
|
||||
selectedEventId: string | null;
|
||||
current: number | null;
|
||||
_finishAt: number | null;
|
||||
clock: number | null;
|
||||
secondaryTimer: number | null;
|
||||
secondaryTarget: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Implements update functions for roll mode
|
||||
* @param {CurrentTimers} currentTimers
|
||||
* @returns {object} object with selection variables
|
||||
*/
|
||||
export const updateRoll = (currentTimers: CurrentTimers) => {
|
||||
const { selectedEventId, current, _finishAt, clock, secondaryTimer, secondaryTarget } = currentTimers;
|
||||
|
||||
// timers
|
||||
let updatedTimer = current;
|
||||
let updatedSecondaryTimer = secondaryTimer;
|
||||
// whether rollLoad should be called: force reload of events
|
||||
let doRollLoad = false;
|
||||
// whether finished event should trigger
|
||||
let isPrimaryFinished = false;
|
||||
|
||||
if (selectedEventId && current !== null) {
|
||||
// if we have something selected and a timer, we are running
|
||||
|
||||
updatedTimer = _finishAt - clock;
|
||||
if (updatedTimer < 0) {
|
||||
isPrimaryFinished = true;
|
||||
// we need a new event
|
||||
doRollLoad = true;
|
||||
}
|
||||
} else if (secondaryTimer >= 0) {
|
||||
// if secondaryTimer is running we are in waiting to roll
|
||||
|
||||
updatedSecondaryTimer = secondaryTarget - clock;
|
||||
|
||||
if (updatedSecondaryTimer <= 0) {
|
||||
// we need a new event
|
||||
doRollLoad = true;
|
||||
}
|
||||
}
|
||||
|
||||
return { updatedTimer, updatedSecondaryTimer, doRollLoad, isFinished: isPrimaryFinished };
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
type MaybeNumber = number | null;
|
||||
|
||||
/**
|
||||
* Calculates expected finish time of a running timer
|
||||
*/
|
||||
export function getExpectedFinish(
|
||||
startedAt: MaybeNumber,
|
||||
finishedAt: MaybeNumber,
|
||||
duration: number,
|
||||
pausedTime: number,
|
||||
addedTime: number,
|
||||
) {
|
||||
if (startedAt === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (finishedAt !== null) {
|
||||
return finishedAt;
|
||||
}
|
||||
|
||||
return Math.max(startedAt + duration + pausedTime + addedTime, startedAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates running countdown
|
||||
*/
|
||||
export function getCurrent(
|
||||
startedAt: MaybeNumber,
|
||||
duration: number,
|
||||
addedTime: number,
|
||||
pausedTime: number,
|
||||
clock: number,
|
||||
) {
|
||||
if (startedAt === null) {
|
||||
return null;
|
||||
}
|
||||
return startedAt + duration + addedTime + pausedTime - clock;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates elapsed time
|
||||
*/
|
||||
export function getElapsed(startedAt: number, clock: number) {
|
||||
if (startedAt > clock) {
|
||||
throw new Error('clock cannot be higher than startedAt');
|
||||
}
|
||||
return clock - startedAt;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const MAX_EVENTS = 32768;
|
||||
@@ -0,0 +1,87 @@
|
||||
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' || 'docker') && !isTest;
|
||||
export const isDocker = env === 'docker';
|
||||
|
||||
// =================================================
|
||||
// resolve path to external
|
||||
const productionPath = '../../Resources/extraResources/client';
|
||||
const devPath = '../../client/build/';
|
||||
const dockerPath = 'client/';
|
||||
|
||||
export const resolvedPath = (): string => {
|
||||
if (isTest) {
|
||||
return devPath;
|
||||
}
|
||||
if (isDocker) {
|
||||
return dockerPath;
|
||||
}
|
||||
if (isProduction) {
|
||||
return productionPath;
|
||||
}
|
||||
return 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 testDbStartDirectory = isTest ? '../' : getAppDataPath();
|
||||
export const externalsStartDirectory = isProduction ? getAppDataPath() : join(currentDirectory, 'external');
|
||||
|
||||
// path to public db
|
||||
export const resolveDbDirectory = join(
|
||||
testDbStartDirectory,
|
||||
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, '/preloaded-db/', config.database.filename);
|
||||
|
||||
// path to public styles
|
||||
export const resolveStylesDirectory = join(externalsStartDirectory, config.styles.directory);
|
||||
export const resolveStylesPath = join(resolveStylesDirectory, config.styles.filename);
|
||||
|
||||
export const pathToStartStyles = join(currentDirectory, '/external/styles/', config.styles.filename);
|
||||
@@ -0,0 +1,73 @@
|
||||
import { RuntimeStore } from 'ontime-types';
|
||||
import { socket } from '../adapters/WebsocketAdapter.js';
|
||||
import { eventTimer } from '../services/TimerService.js';
|
||||
import { messageService } from '../services/message-service/MessageService.js';
|
||||
import { eventLoader } from '../classes/event-loader/EventLoader.js';
|
||||
|
||||
let store: Partial<RuntimeStore> = {};
|
||||
|
||||
/**
|
||||
* A runtime store that broadcasts its payload
|
||||
* - init: allows for adding an initial payload to the store
|
||||
* - batchSet: allows setting several keys with a single broadcast
|
||||
* - poll: utility to return state
|
||||
* - broadcast: send its payload as json object
|
||||
*/
|
||||
export const eventStore = {
|
||||
init(payload: RuntimeStore) {
|
||||
store = payload;
|
||||
},
|
||||
get<T extends keyof RuntimeStore>(key: T) {
|
||||
return store[key];
|
||||
},
|
||||
set<T extends keyof RuntimeStore>(key: T, value: RuntimeStore[T]) {
|
||||
store[key] = value;
|
||||
// TODO: Partial updates seems to cause issues on the client
|
||||
// socket.send({
|
||||
// type: `ontime-${key}`,
|
||||
// payload: value,
|
||||
// });
|
||||
this.broadcast();
|
||||
},
|
||||
batchSet<K extends keyof RuntimeStore>(values: Record<K, RuntimeStore[K]>) {
|
||||
Object.entries(values).forEach(([key, value]) => {
|
||||
store[key] = value;
|
||||
});
|
||||
this.broadcast();
|
||||
},
|
||||
poll() {
|
||||
return store;
|
||||
},
|
||||
broadcast() {
|
||||
socket.sendAsJson({
|
||||
type: 'ontime',
|
||||
payload: store,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Module initialises the services and provides initial payload for the store
|
||||
* Currently registered objects in store
|
||||
* - Timer Service timer
|
||||
* - Timer Service playback
|
||||
* - Message Service timerMessage
|
||||
* - Message Service publicMessage
|
||||
* - Message Service lowerMessage
|
||||
* - Message Service onAir
|
||||
* - Event Loader loaded
|
||||
* - Event Loader titles
|
||||
* - Event Loader titlesPublic
|
||||
*/
|
||||
|
||||
export const getInitialPayload = () => ({
|
||||
timer: eventTimer.timer,
|
||||
playback: eventTimer.playback,
|
||||
timerMessage: messageService.timerMessage,
|
||||
publicMessage: messageService.publicMessage,
|
||||
lowerMessage: messageService.lowerMessage,
|
||||
onAir: messageService.onAir,
|
||||
loaded: eventLoader.loaded,
|
||||
titles: eventLoader.titles,
|
||||
titlesPublic: eventLoader.titlesPublic,
|
||||
});
|
||||
@@ -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 unique names', () => {
|
||||
const names = new Set();
|
||||
let attempts = 1;
|
||||
while (names.size < 10) {
|
||||
names.add(getRandomName());
|
||||
attempts++;
|
||||
}
|
||||
expect(attempts).toBeLessThan(50);
|
||||
});
|
||||
@@ -0,0 +1,878 @@
|
||||
import { vi } from 'vitest';
|
||||
import { dbModel } from '../../models/dataModel.js';
|
||||
import { parseExcel, parseJson, validateEvent } from '../parser.ts';
|
||||
import { makeString, validateDuration } from '../parserUtils.js';
|
||||
import { parseAliases, parseUserFields, parseViewSettings } 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 = parseViewSettings(testData);
|
||||
expect(parsed).toStrictEqual(testData.views);
|
||||
});
|
||||
|
||||
it('imports defaults to model', () => {
|
||||
const testData = {
|
||||
rundown: [],
|
||||
settings: {
|
||||
app: 'ontime',
|
||||
version: 2,
|
||||
},
|
||||
};
|
||||
const parsed = parseViewSettings(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,91 @@
|
||||
import { validateOscSubscription } from '../parserFunctions.ts';
|
||||
|
||||
test('validateOscSubscription()', () => {
|
||||
it('should return true when given a valid OscSubscription', () => {
|
||||
const validSubscription = {
|
||||
onLoad: [{ id: '1', message: 'test', enabled: true }],
|
||||
onStart: [{ id: '2', message: 'test', enabled: false }],
|
||||
onPause: [{ id: '3', message: 'test', enabled: true }],
|
||||
onStop: [{ id: '4', message: 'test', enabled: false }],
|
||||
onUpdate: [{ id: '5', message: 'test', enabled: true }],
|
||||
onFinish: [{ id: '6', message: 'test', enabled: false }],
|
||||
};
|
||||
|
||||
const result = validateOscSubscription(validSubscription);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when given undefined', () => {
|
||||
const result = validateOscSubscription(undefined);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when given null', () => {
|
||||
const result = validateOscSubscription(null);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when given an empty object', () => {
|
||||
const result = validateOscSubscription({});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when given an empty array', () => {
|
||||
const result = validateOscSubscription([]);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when given an object that is not an OscSubscription', () => {
|
||||
const invalidObject = { foo: 'bar' };
|
||||
|
||||
const result = validateOscSubscription(invalidObject);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when given an OscSubscription with a missing property', () => {
|
||||
const invalidSubscription = {
|
||||
onLoad: [{ id: '1', message: 'test', enabled: true }],
|
||||
onStart: [{ id: '2', message: 'test', enabled: false }],
|
||||
onPause: [{ id: '3', message: 'test', enabled: true }],
|
||||
// Missing onStop
|
||||
onUpdate: [{ id: '5', message: 'test', enabled: true }],
|
||||
onFinish: [{ id: '6', message: 'test', enabled: false }],
|
||||
};
|
||||
|
||||
const result = validateOscSubscription(invalidSubscription);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when given an OscSubscription with an invalid property value', () => {
|
||||
const invalidSubscription = {
|
||||
onLoad: [{ id: '1', message: 'test', enabled: true }],
|
||||
onStart: [{ id: '2', message: 'test', enabled: false }],
|
||||
onPause: [{ id: '3', message: 'test', enabled: true }],
|
||||
onStop: [{ id: '4', message: 'test', enabled: false }],
|
||||
onUpdate: [{ id: '5', message: 'test', enabled: true }],
|
||||
onFinish: [{ id: '6', message: 'test', enabled: 'not a boolean' }],
|
||||
};
|
||||
|
||||
const result = validateOscSubscription(invalidSubscription);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true if the message field is empty', () => {
|
||||
const invalidSubscription = {
|
||||
onLoad: [{ id: '1', message: 'test', enabled: true }],
|
||||
onStart: [{ id: '2', message: '', enabled: false }],
|
||||
onPause: [{ id: '3', message: '', enabled: true }],
|
||||
onStop: [{ id: '4', message: 'test', enabled: false }],
|
||||
onUpdate: [{ id: '5', message: 'test', enabled: true }],
|
||||
onFinish: [{ id: '6', message: 'test', enabled: 'not a boolean' }],
|
||||
};
|
||||
|
||||
const result = validateOscSubscription(invalidSubscription);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -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,11 @@
|
||||
import { isObject } from '../varUtils.js';
|
||||
|
||||
describe('isObject', () => {
|
||||
const testCases = [1, 0, false, undefined, 'test', null, () => undefined, []];
|
||||
testCases.forEach((test) => {
|
||||
it(`recognises normal primitives ${test}`, () => {
|
||||
const result = isObject(test);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
const adjective = [
|
||||
'ambient',
|
||||
'analog',
|
||||
'artistic',
|
||||
'binaural',
|
||||
'bright',
|
||||
'cinematic',
|
||||
'clean',
|
||||
'cool',
|
||||
'crisp',
|
||||
'dark',
|
||||
'deep',
|
||||
'distorted',
|
||||
'dreamy',
|
||||
'dynamic',
|
||||
'electric',
|
||||
'explosive',
|
||||
'flickering',
|
||||
'fluid',
|
||||
'glitchy',
|
||||
'glowing',
|
||||
'harmonious',
|
||||
'hazy',
|
||||
'intense',
|
||||
'low-fi',
|
||||
'mellow',
|
||||
'melodic',
|
||||
'metallic',
|
||||
'moody',
|
||||
'muted',
|
||||
'nostalgic',
|
||||
'noisy',
|
||||
'organic',
|
||||
'panoramic',
|
||||
'percussive',
|
||||
'powerful',
|
||||
'psychedelic',
|
||||
'quirky',
|
||||
'raw',
|
||||
'resonant',
|
||||
'retro',
|
||||
'sharp',
|
||||
'shimmering',
|
||||
'smooth',
|
||||
'soft',
|
||||
'soulful',
|
||||
'sparkling',
|
||||
'spacious',
|
||||
'subtle',
|
||||
'surround',
|
||||
'tilted',
|
||||
'uplifting',
|
||||
'vibrant',
|
||||
'warm',
|
||||
];
|
||||
|
||||
const object = [
|
||||
'acoustics',
|
||||
'animation',
|
||||
'audio',
|
||||
'barndoor',
|
||||
'beat',
|
||||
'brightness',
|
||||
'bulb',
|
||||
'channel',
|
||||
'chroma',
|
||||
'cinema',
|
||||
'clip',
|
||||
'color',
|
||||
'codec',
|
||||
'composition',
|
||||
'compression',
|
||||
'contrast',
|
||||
'darkness',
|
||||
'display',
|
||||
'distortion',
|
||||
'dolly',
|
||||
'downlight',
|
||||
'drum',
|
||||
'echo',
|
||||
'edit',
|
||||
'effect',
|
||||
'film',
|
||||
'fixture',
|
||||
'flash',
|
||||
'footage',
|
||||
'format',
|
||||
'floodlight',
|
||||
'flicker',
|
||||
'frame',
|
||||
'fresnel',
|
||||
'gel',
|
||||
'glow',
|
||||
'gobo',
|
||||
'groove',
|
||||
'guitar',
|
||||
'harmony',
|
||||
'highlight',
|
||||
'illumination',
|
||||
'instrument',
|
||||
'instrumental',
|
||||
'jib',
|
||||
'lamp',
|
||||
'led',
|
||||
'light',
|
||||
'luminance',
|
||||
'mastering',
|
||||
'melody',
|
||||
'microphone',
|
||||
'mix',
|
||||
'mixing',
|
||||
'moving light',
|
||||
'note',
|
||||
'pattern',
|
||||
'piano',
|
||||
'picture',
|
||||
'production',
|
||||
'record',
|
||||
'reflection',
|
||||
'reverb',
|
||||
'resolution',
|
||||
'sampler',
|
||||
'sample',
|
||||
'saxophone',
|
||||
'screen',
|
||||
'signal',
|
||||
'softlight',
|
||||
'song',
|
||||
'sound',
|
||||
'soundtrack',
|
||||
'speaker',
|
||||
'spot',
|
||||
'strobe',
|
||||
'streaming',
|
||||
'surround',
|
||||
'synth',
|
||||
'tune',
|
||||
'trumpet',
|
||||
'uplight',
|
||||
'video',
|
||||
'violin',
|
||||
'visual',
|
||||
'voice',
|
||||
'wave',
|
||||
'wash',
|
||||
];
|
||||
|
||||
/**
|
||||
* @description returns a random name from the dictionary
|
||||
* @return {`${string} ${string}`}
|
||||
*/
|
||||
export default function getRandomName() {
|
||||
return `${adjective[Math.floor(Math.random() * adjective.length)]} ${
|
||||
object[Math.floor(Math.random() * object.length)]
|
||||
}`;
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-nocheck -- not ready to fully type
|
||||
|
||||
import fs from 'fs';
|
||||
import xlsx from 'node-xlsx';
|
||||
import { generateId } from 'ontime-utils';
|
||||
import { DatabaseModel, EventData, OntimeEvent, OntimeRundown, UserFields } from 'ontime-types';
|
||||
import { event as eventDef } from '../models/eventsDefinition.js';
|
||||
import { dbModel } from '../models/dataModel.js';
|
||||
import { deleteFile, makeString, validateDuration } from './parserUtils.js';
|
||||
import {
|
||||
parseAliases,
|
||||
parseEventData,
|
||||
parseOsc,
|
||||
parseRundown,
|
||||
parseSettings,
|
||||
parseUserFields,
|
||||
parseViewSettings,
|
||||
} 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: Partial<EventData> = {
|
||||
title: '',
|
||||
publicUrl: '',
|
||||
backstageUrl: '',
|
||||
};
|
||||
const customUserFields: Partial<UserFields> = {};
|
||||
const rundown: OntimeRundown = [];
|
||||
let timeStartIndex: number | null = null;
|
||||
let timeEndIndex: number | null = null;
|
||||
let titleIndex: number | null = null;
|
||||
let presenterIndex: number | null = null;
|
||||
let subtitleIndex: number | null = null;
|
||||
let isPublicIndex: number | null = null;
|
||||
let skipIndex: number | null = null;
|
||||
let notesIndex: number | null = null;
|
||||
let colourIndex: number | null = null;
|
||||
let user0Index: number | null = null;
|
||||
let user1Index: number | null = null;
|
||||
let user2Index: number | null = null;
|
||||
let user3Index: number | null = null;
|
||||
let user4Index: number | null = null;
|
||||
let user5Index: number | null = null;
|
||||
let user6Index: number | null = null;
|
||||
let user7Index: number | null = null;
|
||||
let user8Index: number | null = null;
|
||||
let user9Index: number | null = null;
|
||||
|
||||
excelData
|
||||
.filter((e) => e.length > 0)
|
||||
.forEach((row) => {
|
||||
let eventTitleNext = false;
|
||||
let publicUrlNext = false;
|
||||
let publicInfoNext = false;
|
||||
let backstageUrlNext = false;
|
||||
let backstageInfoNext = false;
|
||||
let endMessageNext = false;
|
||||
|
||||
const event: Partial<OntimeEvent> = {};
|
||||
|
||||
row.forEach((column, j) => {
|
||||
// check flags
|
||||
if (eventTitleNext) {
|
||||
eventData.title = column;
|
||||
eventTitleNext = false;
|
||||
} else if (publicUrlNext) {
|
||||
eventData.publicUrl = column;
|
||||
publicUrlNext = false;
|
||||
} else if (publicInfoNext) {
|
||||
eventData.publicInfo = column;
|
||||
publicInfoNext = false;
|
||||
} else if (backstageUrlNext) {
|
||||
eventData.publicUrl = column;
|
||||
backstageUrlNext = 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 'public url':
|
||||
publicUrlNext = true;
|
||||
break;
|
||||
case 'public info':
|
||||
publicInfoNext = true;
|
||||
break;
|
||||
case 'backstage url':
|
||||
backstageUrlNext = 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,
|
||||
eventData: 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): Promise<DatabaseModel | null> => {
|
||||
if (!jsonData || typeof jsonData !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// object containing the parsed data
|
||||
const returnData: Partial<DatabaseModel> = {};
|
||||
|
||||
// parse Events
|
||||
returnData.rundown = parseRundown(jsonData);
|
||||
// parse Event
|
||||
returnData.eventData = parseEventData(jsonData, enforce);
|
||||
// Settings handled partially
|
||||
returnData.settings = parseSettings(jsonData, enforce);
|
||||
// View settings handled partially
|
||||
returnData.viewSettings = parseViewSettings(jsonData, enforce);
|
||||
// Import Aliases if any
|
||||
returnData.aliases = parseAliases(jsonData);
|
||||
// Import user fields if any
|
||||
returnData.userFields = parseUserFields(jsonData);
|
||||
// Import OSC settings if any
|
||||
// @ts-expect-error -- we are unable to type just yet
|
||||
returnData.osc = parseOsc(jsonData, enforce);
|
||||
// Import HTTP settings if any
|
||||
// returnData.http = parseHttp(jsonData, enforce);
|
||||
|
||||
return returnData as DatabaseModel;
|
||||
};
|
||||
|
||||
/**
|
||||
* @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,
|
||||
endAction: makeString(e.endAction, d.endAction),
|
||||
timerType: makeString(e.timerType, d.timerType),
|
||||
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;
|
||||
};
|
||||
|
||||
type ResponseOK = { data: Partial<DatabaseModel>; message: 'success' };
|
||||
type ResponseError = { error: true; message: string };
|
||||
|
||||
/**
|
||||
* @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): ResponseOK | ResponseError => {
|
||||
let res: Partial<ResponseOK | ResponseError> = {};
|
||||
|
||||
// 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.eventData = parseEventData(dataFromExcel, true);
|
||||
res.data.userFields = parseUserFields(dataFromExcel);
|
||||
res.message = 'success';
|
||||
} else {
|
||||
const errorMessage = 'No sheet found named ontime or event schedule';
|
||||
console.log(errorMessage);
|
||||
res = {
|
||||
error: true,
|
||||
message: errorMessage,
|
||||
};
|
||||
}
|
||||
} 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 === 2) {
|
||||
try {
|
||||
res.data = await parseJson(uploadedJson);
|
||||
res.message = 'success';
|
||||
} catch (error) {
|
||||
res = { error: true, message: `Error parsing file: ${error}` };
|
||||
}
|
||||
} else {
|
||||
res = { error: true, message: 'Error parsing file, version unknown' };
|
||||
}
|
||||
}
|
||||
|
||||
// delete file
|
||||
await deleteFile(file);
|
||||
return res;
|
||||
};
|
||||
@@ -0,0 +1,282 @@
|
||||
import { generateId } from 'ontime-utils';
|
||||
import {
|
||||
Alias,
|
||||
EventData,
|
||||
OntimeRundown,
|
||||
OSCSettings,
|
||||
OscSubscription,
|
||||
Settings,
|
||||
TimerLifeCycle,
|
||||
UserFields,
|
||||
ViewSettings,
|
||||
} from 'ontime-types';
|
||||
|
||||
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): OntimeRundown => {
|
||||
let newRundown: OntimeRundown = [];
|
||||
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, title: e.title, 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 parseEventData = (data, enforce): EventData => {
|
||||
let newEventData: Partial<EventData> = {};
|
||||
if ('eventData' in data) {
|
||||
console.log('Found event data, importing...');
|
||||
const e = data.eventData;
|
||||
// filter known properties and write to db
|
||||
newEventData = {
|
||||
...dbModel.eventData,
|
||||
title: e.title || dbModel.eventData.title,
|
||||
publicUrl: e.publicUrl || dbModel.eventData.publicUrl,
|
||||
publicInfo: e.publicInfo || dbModel.eventData.publicInfo,
|
||||
backstageUrl: e.backstageUrl || dbModel.eventData.backstageUrl,
|
||||
backstageInfo: e.backstageInfo || dbModel.eventData.backstageInfo,
|
||||
};
|
||||
} else if (enforce) {
|
||||
newEventData = { ...dbModel.eventData };
|
||||
console.log('Created event object in db');
|
||||
}
|
||||
return newEventData as EventData;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse settings portion of an entry
|
||||
* @param {object} data - data object
|
||||
* @param {boolean} enforce - whether to create a definition if one is missing
|
||||
* @returns {object} - event object data
|
||||
*/
|
||||
export const parseSettings = (data, enforce): Settings => {
|
||||
let newSettings: Partial<Settings> = {};
|
||||
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 = {
|
||||
editorKey: s.editorKey || null,
|
||||
operatorKey: s.operatorKey || null,
|
||||
timeFormat: s.timeFormat || '24',
|
||||
language: s.language || 'en',
|
||||
};
|
||||
|
||||
// write to db
|
||||
newSettings = {
|
||||
...dbModel.settings,
|
||||
...settings,
|
||||
};
|
||||
}
|
||||
} else if (enforce) {
|
||||
newSettings = dbModel.settings;
|
||||
console.log('Created settings object in db');
|
||||
}
|
||||
return newSettings as Settings;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse settings portion of an entry
|
||||
* @param {object} data - data object
|
||||
* @param {boolean} enforce - whether to create a definition if one is missing
|
||||
* @returns {object} - event object data
|
||||
*/
|
||||
export const parseViewSettings = (data, enforce): ViewSettings => {
|
||||
let newViews: Partial<ViewSettings> = {};
|
||||
if ('viewSettings' in data) {
|
||||
console.log('Found view definition, importing...');
|
||||
const v = data.viewSettings;
|
||||
|
||||
const viewSettings = {
|
||||
overrideStyles: v.overrideStyles ?? dbModel.viewSettings.overrideStyles,
|
||||
normalColor: v.normalColor ?? dbModel.viewSettings.normalColor,
|
||||
warningColor: v.warningColor ?? dbModel.viewSettings.warningColor,
|
||||
warningThreshold: v.warningThreshold ?? dbModel.viewSettings.warningThreshold,
|
||||
dangerColor: v.dangerColor ?? dbModel.viewSettings.dangerColor,
|
||||
dangerThreshold: v.dangerThreshold ?? dbModel.viewSettings.dangerThreshold,
|
||||
endMessage: v.endMessage ?? dbModel.viewSettings.endMessage,
|
||||
};
|
||||
|
||||
// write to db
|
||||
newViews = {
|
||||
...viewSettings,
|
||||
};
|
||||
} else if (enforce) {
|
||||
newViews = dbModel.viewSettings;
|
||||
console.log('Created viewSettings object in db');
|
||||
}
|
||||
return newViews as ViewSettings;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses and validates subscription object
|
||||
* @param data
|
||||
*/
|
||||
export const validateOscSubscription = (data: OscSubscription): boolean => {
|
||||
if (!data) {
|
||||
return false;
|
||||
}
|
||||
const timerKeys = Object.keys(TimerLifeCycle);
|
||||
for (const key of timerKeys) {
|
||||
if (!(key in data) || !Array.isArray(data[key])) {
|
||||
return false;
|
||||
}
|
||||
for (const subscription of data[key]) {
|
||||
if (!subscription.id || typeof subscription.message !== 'string' || typeof subscription.enabled !== 'boolean') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse osc portion of an entry
|
||||
*/
|
||||
export const parseOsc = (data: { osc?: Partial<OSCSettings> }, enforce: boolean): Partial<OSCSettings> => {
|
||||
if ('osc' in data) {
|
||||
console.log('Found OSC definition, importing...');
|
||||
|
||||
const loadedConfig = data?.osc || {};
|
||||
const validatedSubscriptions = validateOscSubscription(loadedConfig.subscriptions)
|
||||
? loadedConfig.subscriptions
|
||||
: dbModel.osc.subscriptions;
|
||||
|
||||
return {
|
||||
portIn: loadedConfig.portIn ?? dbModel.osc.portIn,
|
||||
portOut: loadedConfig.portOut ?? dbModel.osc.portOut,
|
||||
targetIP: loadedConfig.targetIP ?? dbModel.osc.targetIP,
|
||||
enabledIn: loadedConfig.enabledIn ?? dbModel.osc.enabledIn,
|
||||
enabledOut: loadedConfig.enabledOut ?? dbModel.osc.enabledOut,
|
||||
subscriptions: validatedSubscriptions,
|
||||
};
|
||||
} else if (enforce) {
|
||||
console.log('Created OSC object in db');
|
||||
return { ...dbModel.osc };
|
||||
} else return {};
|
||||
};
|
||||
|
||||
/**
|
||||
* 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...');
|
||||
} else if (enforce) {
|
||||
/* Not yet */
|
||||
}
|
||||
return newHttp;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse aliases portion of an entry
|
||||
* @param {object} data - data object
|
||||
* @returns {object} - event object data
|
||||
*/
|
||||
export const parseAliases = (data): Alias[] => {
|
||||
const newAliases: Alias[] = [];
|
||||
if ('aliases' in data) {
|
||||
console.log('Found Aliases definition, importing...');
|
||||
try {
|
||||
for (const a of data.aliases) {
|
||||
const newAlias = {
|
||||
enabled: a.enabled || false,
|
||||
alias: a.alias || '',
|
||||
pathAndParams: a.pathAndParams || '',
|
||||
};
|
||||
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): UserFields => {
|
||||
const newUserFields: UserFields = { ...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,118 @@
|
||||
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 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;
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export function isObject(variable: unknown): boolean {
|
||||
return typeof variable === 'object' && variable !== null && !Array.isArray(variable);
|
||||
}
|
||||
Reference in New Issue
Block a user