refactor: initialise data

This commit is contained in:
Carlos Valente
2024-07-05 21:19:14 +02:00
committed by Carlos Valente
parent dae32e89a3
commit 84792bc00d
31 changed files with 1031 additions and 545 deletions
+1 -1
View File
@@ -63,7 +63,7 @@ export async function createProjectFile(req: Request, res: Response<{ filename:
*/
export async function projectDownload(req: Request, res: Response) {
const { filename } = req.body;
const pathToFile = await doesProjectExist(filename);
const pathToFile = doesProjectExist(filename);
if (!pathToFile) {
return res.status(404).send({ message: `Project ${filename} not found.` });
}
@@ -1,14 +1,14 @@
import type { ErrorResponse, HttpSettings } from 'ontime-types';
import { Request, Response } from 'express';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import { httpIntegration } from '../../services/integration-service/HttpIntegration.js';
import { getErrorMessage } from 'ontime-utils';
import type { Request, Response } from 'express';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import { httpIntegration } from '../../services/integration-service/HttpIntegration.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
export async function getHTTP(_req: Request, res: Response<HttpSettings>) {
const http = DataProvider.getHttp();
const http = getDataProvider().getHttp();
res.status(200).send(http);
}
@@ -22,7 +22,7 @@ export async function postHTTP(req: Request, res: Response<HttpSettings | ErrorR
httpIntegration.init(httpSettings);
// we persist the data after init to avoid persisting invalid data
const result = await DataProvider.setHttp(httpSettings);
const result = await getDataProvider().setHttp(httpSettings);
res.send(result).status(200);
} catch (error) {
const message = getErrorMessage(error);
@@ -1,14 +1,14 @@
import type { ErrorResponse, OSCSettings } from 'ontime-types';
import { Request, Response } from 'express';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import { oscIntegration } from '../../services/integration-service/OscIntegration.js';
import { getErrorMessage } from 'ontime-utils';
import type { Request, Response } from 'express';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import { oscIntegration } from '../../services/integration-service/OscIntegration.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
export async function getOSC(_req: Request, res: Response<OSCSettings>) {
const osc = DataProvider.getOsc();
const osc = getDataProvider().getOsc();
res.status(200).send(osc);
}
@@ -22,7 +22,7 @@ export async function postOSC(req: Request, res: Response<OSCSettings | ErrorRes
oscIntegration.init(oscSettings);
// we persist the data after init to avoid persisting invalid data
const result = await DataProvider.setOsc(oscSettings);
const result = await getDataProvider().setOsc(oscSettings);
res.send(result).status(200);
} catch (error) {
@@ -1,13 +1,14 @@
import { ErrorResponse, ProjectData } from 'ontime-types';
import type { Request, Response } from 'express';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { removeUndefined } from '../../utils/parserUtils.js';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import { getErrorMessage } from 'ontime-utils';
import type { Request, Response } from 'express';
import { removeUndefined } from '../../utils/parserUtils.js';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
export async function getProjectData(_req: Request, res: Response<ProjectData>) {
res.json(DataProvider.getProjectData());
res.json(getDataProvider().getProjectData());
}
export async function postProjectData(req: Request, res: Response<ProjectData | ErrorResponse>) {
@@ -25,7 +26,7 @@ export async function postProjectData(req: Request, res: Response<ProjectData |
backstageInfo: req.body?.backstageInfo,
endMessage: req.body?.endMessage,
});
const newData = await DataProvider.setProjectData(newEvent);
const newData = await getDataProvider().setProjectData(newEvent);
res.status(200).send(newData);
} catch (error) {
const message = getErrorMessage(error);
@@ -1,16 +1,16 @@
import { ErrorResponse, Settings } from 'ontime-types';
import { getErrorMessage, obfuscate } from 'ontime-utils';
import { Request, Response } from 'express';
import type { Request, Response } from 'express';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import { isDocker } from '../../setup/index.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import { extractPin } from './settings.utils.js';
export async function getSettings(_req: Request, res: Response<Settings>) {
const settings = DataProvider.getSettings();
const settings = getDataProvider().getSettings();
const obfuscatedSettings = { ...settings };
if (settings.editorKey) {
obfuscatedSettings.editorKey = obfuscate(settings.editorKey);
@@ -28,7 +28,7 @@ export async function postSettings(req: Request, res: Response<Settings | ErrorR
return;
}
try {
const settings = DataProvider.getSettings();
const settings = getDataProvider().getSettings();
const editorKey = extractPin(req.body?.editorKey, settings.editorKey);
const operatorKey = extractPin(req.body?.operatorKey, settings.operatorKey);
const serverPort = Number(req.body?.serverPort);
@@ -58,7 +58,7 @@ export async function postSettings(req: Request, res: Response<Settings | ErrorR
language,
serverPort,
};
await DataProvider.setSettings(newData);
await getDataProvider().setSettings(newData);
res.status(200).send(newData);
} catch (error) {
const message = getErrorMessage(error);
@@ -1,13 +1,13 @@
import type { ErrorResponse, URLPreset } from 'ontime-types';
import { Request, Response } from 'express';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { failIsNotArray } from '../../utils/routerUtils.js';
import { getErrorMessage } from 'ontime-utils';
import type { Request, Response } from 'express';
import { failIsNotArray } from '../../utils/routerUtils.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
export async function getUrlPresets(_req: Request, res: Response<URLPreset[]>) {
const presets = DataProvider.getUrlPresets();
const presets = getDataProvider().getUrlPresets();
res.status(200).send(presets as URLPreset[]);
}
@@ -21,7 +21,7 @@ export async function postUrlPresets(req: Request, res: Response<URLPreset[] | E
alias: preset.alias,
pathAndParams: preset.pathAndParams,
}));
await DataProvider.setUrlPresets(newPresets);
await getDataProvider().setUrlPresets(newPresets);
res.status(200).send(newPresets);
} catch (error) {
const message = getErrorMessage(error);
@@ -1,31 +1,26 @@
import type { ErrorResponse, ViewSettings } from 'ontime-types';
import { Request, Response } from 'express';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import { getErrorMessage } from 'ontime-utils';
import type { Request, Response } from 'express';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
export async function getViewSettings(_req: Request, res: Response<ViewSettings>) {
const views = DataProvider.getViewSettings();
const views = getDataProvider().getViewSettings();
res.status(200).send(views);
}
export async function postViewSettings(req: Request, res: Response<ViewSettings | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const newData = {
dangerColor: req.body.dangerColor,
endMessage: req.body?.endMessage ?? '',
endMessage: req.body.endMessage,
freezeEnd: req.body.freezeEnd,
normalColor: req.body.normalColor,
overrideStyles: req.body.overrideStyles,
warningColor: req.body.warningColor,
};
await DataProvider.setViewSettings(newData);
} as ViewSettings;
await getDataProvider().setViewSettings(newData);
res.status(200).send(newData);
} catch (error) {
const message = getErrorMessage(error);
@@ -5,11 +5,12 @@ import { Request, Response, NextFunction } from 'express';
* @description Validates object for POST /ontime/views
*/
export const validateViewSettings = [
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('dangerColor').exists().isString().trim().withMessage('dangerColor value must be string'),
check('endMessage').exists().isString().trim().withMessage('endMessage value must be string'),
check('freezeEnd').exists().isBoolean().withMessage('freezeEnd value must be boolean'),
check('normalColor').exists().isString().trim().withMessage('normalColor value must be string'),
check('overrideStyles').exists().isBoolean().withMessage('overrideStyles value must be boolean'),
check('warningColor').exists().isString().trim().withMessage('warningColor value must be string'),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
@@ -55,16 +55,17 @@ const actionHandlers: Record<string, ActionHandler> = {
throw new Error('Invalid property or value');
}
const newObjectProperty = parseProperty(property, value);
// parseProperty is async because of the data lock
parseProperty(property, value).then((newObjectProperty) => {
const key = Object.keys(newObjectProperty)[0] as keyof OntimeEvent;
shouldThrottle = willCauseRegeneration(key) || shouldThrottle;
const key = Object.keys(newObjectProperty)[0] as keyof OntimeEvent;
shouldThrottle = willCauseRegeneration(key) || shouldThrottle;
if (patchEvent.custom && newObjectProperty.custom) {
Object.assign(patchEvent.custom, newObjectProperty.custom);
} else {
Object.assign(patchEvent, newObjectProperty);
}
if (patchEvent.custom && newObjectProperty.custom) {
Object.assign(patchEvent.custom, newObjectProperty.custom);
} else {
Object.assign(patchEvent, newObjectProperty);
}
});
});
if (shouldThrottle) {
@@ -1,17 +1,17 @@
import { EndAction, OntimeEvent, TimerType, isKeyOfType, isOntimeEvent } from 'ontime-types';
import { MILLIS_PER_SECOND, maxDuration } from 'ontime-utils';
import { DataProvider } from '../classes/data-provider/DataProvider.js';
import { editEvent } from '../services/rundown-service/RundownService.js';
import { getEventWithId } from '../services/rundown-service/rundownUtils.js';
import { coerceBoolean, coerceColour, coerceEnum, coerceNumber, coerceString } from '../utils/coerceType.js';
import { getDataProvider } from '../classes/data-provider/DataProvider.js';
/**
*
* @param {number} value time amount in seconds
* @returns {number} time in milliseconds clamped to 0 and max duration
*/
function clampDuration(value: number) {
function clampDuration(value: number): number {
const valueInMillis = value * MILLIS_PER_SECOND;
if (valueInMillis > maxDuration || valueInMillis < 0) {
throw new Error('Times should be from 0 to 23:59:59');
@@ -42,10 +42,11 @@ const propertyConversion = {
timeEnd: (value: unknown) => clampDuration(coerceNumber(value)),
};
export function parseProperty(property: string, value: unknown) {
export async function parseProperty(property: string, value: unknown) {
if (property.startsWith('custom:')) {
const customKey = property.split(':')[1].toLocaleLowerCase(); // all custom fields keys are lowercase
if (!(customKey in DataProvider.getCustomFields())) {
const customFields = getDataProvider().getCustomFields();
if (!(customKey in customFields)) {
throw new Error(`Custom field ${customKey} not found`);
}
const parserFn = propertyConversion.custom;
+13 -13
View File
@@ -1,4 +1,4 @@
import { HttpSettings, LogOrigin, OSCSettings, Playback, SimpleDirection, SimplePlayback } from 'ontime-types';
import { LogOrigin, Playback, SimpleDirection, SimplePlayback } from 'ontime-types';
import 'dotenv/config';
import express from 'express';
@@ -13,10 +13,10 @@ import {
srcDirectory,
environment,
isProduction,
resolveDbPath,
resolveExternalsDirectory,
resolveStylesDirectory,
resolvedPath,
resolvePublicDirectoy,
} from './setup/index.js';
import { ONTIME_VERSION } from './ONTIME_VERSION.js';
import { consoleSuccess, consoleHighlight } from './utils/console.js';
@@ -27,8 +27,7 @@ import { integrationRouter } from './api-integration/integration.router.js';
// Import adapters
import { socket } from './adapters/WebsocketAdapter.js';
import { DataProvider } from './classes/data-provider/DataProvider.js';
import { dbLoadingProcess } from './setup/loadDb.js';
import { getDataProvider } from './classes/data-provider/DataProvider.js';
// Services
import { integrationService } from './services/integration-service/IntegrationService.js';
@@ -43,6 +42,7 @@ import { messageService } from './services/message-service/MessageService.js';
import { populateDemo } from './setup/loadDemo.js';
import { getState } from './stores/runtimeState.js';
import { initRundown } from './services/rundown-service/RundownService.js';
import { initialiseProject } from './services/project-service/ProjectService.js';
// Utilities
import { clearUploadfolder } from './utils/upload.js';
@@ -55,8 +55,8 @@ consoleHighlight(`Starting Ontime version ${ONTIME_VERSION}`);
const canLog = isProduction;
if (!canLog) {
console.log(`Ontime running in ${environment} environment`);
console.log(`Ontime directory at ${srcDirectory} `);
console.log(`Ontime database at ${resolveDbPath}`);
console.log(`Ontime source directory at ${srcDirectory} `);
console.log(`Ontime public directory at ${resolvePublicDirectoy} `);
}
// Create express APP
@@ -149,10 +149,11 @@ const checkStart = (currentState: OntimeStartOrder) => {
export const initAssets = async () => {
checkStart(OntimeStartOrder.InitAssets);
await dbLoadingProcess;
await clearUploadfolder();
populateStyles();
populateDemo();
const project = await initialiseProject();
logger.info(LogOrigin.Server, `Initialised Ontime with ${project}`);
};
/**
@@ -162,8 +163,7 @@ export const startServer = async (
escalateErrorFn?: (error: string) => void,
): Promise<{ message: string; serverPort: number }> => {
checkStart(OntimeStartOrder.InitServer);
const { serverPort } = DataProvider.getSettings();
const { serverPort } = getDataProvider().getSettings();
expressServer = http.createServer(app);
socket.init(expressServer);
@@ -194,8 +194,8 @@ export const startServer = async (
logger.init(escalateErrorFn);
// initialise rundown service
const persistedRundown = DataProvider.getRundown();
const persistedCustomFields = DataProvider.getCustomFields();
const persistedRundown = getDataProvider().getRundown();
const persistedCustomFields = getDataProvider().getCustomFields();
initRundown(persistedRundown, persistedCustomFields);
// load restore point if it exists
@@ -225,11 +225,11 @@ export const startServer = async (
/**
* starts integrations
*/
export const startIntegrations = async (config?: { osc: OSCSettings; http: HttpSettings }) => {
export const startIntegrations = async () => {
checkStart(OntimeStartOrder.InitIO);
// if a config is not provided, we use the persisted one
const { osc, http } = config ?? DataProvider.getData();
const { osc, http } = getDataProvider().getData();
if (osc) {
logger.info(LogOrigin.Tx, 'Initialising OSC Integration...');
@@ -1,7 +1,3 @@
/**
* Class Event Provider is a mediator for handling the local db
* and adds logic specific to ontime data
*/
import {
ProjectData,
OntimeRundown,
@@ -14,118 +10,165 @@ import {
URLPreset,
} from 'ontime-types';
import { data, db } from '../../setup/loadDb.js';
import type { Low } from 'lowdb';
import { JSONFilePreset } from 'lowdb/node';
import { isProduction, isTest } from '../../setup/index.js';
import { isPath } from '../../utils/fileManagement.js';
import { consoleError } from '../../utils/console.js';
import { safeMerge } from './DataProvider.utils.js';
import { isTest } from '../../setup/index.js';
type ReadonlyPromise<T> = Promise<Readonly<T>>;
export class DataProvider {
static getData() {
return data;
}
let db = {} as Low<DatabaseModel>;
static async setProjectData(newData: Partial<ProjectData>): ReadonlyPromise<ProjectData> {
data.project = { ...data.project, ...newData };
this.persist();
return data.project;
}
static getProjectData(): Readonly<ProjectData> {
return data.project;
}
static async setCustomFields(newData: CustomFields): ReadonlyPromise<CustomFields> {
data.customFields = { ...newData };
this.persist();
return data.customFields;
}
static getCustomFields(): Readonly<CustomFields> {
return data.customFields;
}
static async setRundown(newData: OntimeRundown): ReadonlyPromise<OntimeRundown> {
data.rundown = [...newData];
this.persist();
return data.rundown;
}
static getSettings(): Readonly<Settings> {
return data.settings;
}
static async setSettings(newData: Settings): ReadonlyPromise<Settings> {
data.settings = { ...newData };
this.persist();
return data.settings;
}
static getOsc(): Readonly<OSCSettings> {
return data.osc;
}
static getHttp(): Readonly<HttpSettings> {
return data.http;
}
static getUrlPresets(): Readonly<URLPreset[]> {
return data.urlPresets;
}
static async setUrlPresets(newData: URLPreset[]): ReadonlyPromise<URLPreset[]> {
data.urlPresets = newData;
this.persist();
return data.urlPresets;
}
static getViewSettings(): Readonly<ViewSettings> {
return data.viewSettings;
}
static async setViewSettings(newData: ViewSettings): ReadonlyPromise<ViewSettings> {
data.viewSettings = { ...newData };
this.persist();
return data.viewSettings;
}
static async setOsc(newData: OSCSettings): ReadonlyPromise<OSCSettings> {
data.osc = { ...newData };
this.persist();
return data.osc;
}
static async setHttp(newData: HttpSettings): ReadonlyPromise<HttpSettings> {
data.http = { ...newData };
this.persist();
return data.http;
}
static getRundown(): Readonly<OntimeRundown> {
return data.rundown;
}
private static async persist() {
// TODO: this is already handled by lowDb
if (isTest) {
return;
export async function initPersistence(filePath: string, fallbackData: DatabaseModel) {
if (!isProduction) {
if (!isPath(filePath)) {
consoleError(filePath);
consoleError(new Error('initPersistence should be called with a path').stack);
process.exit(0);
}
await db.write();
}
const newDb = await JSONFilePreset<DatabaseModel>(filePath, fallbackData);
static async mergeIntoData(newData: Partial<DatabaseModel>): ReadonlyPromise<DatabaseModel> {
const mergedData = safeMerge(data, newData);
data.project = mergedData.project;
data.settings = mergedData.settings;
data.viewSettings = mergedData.viewSettings;
data.osc = mergedData.osc;
data.http = mergedData.http;
data.urlPresets = mergedData.urlPresets;
data.customFields = mergedData.customFields;
data.rundown = mergedData.rundown;
// Read the database to initialize it
newDb.data = fallbackData;
await newDb.write();
await newDb.read();
this.persist();
return data;
}
db = newDb;
}
export function getDataProvider() {
if (db === null) throw new Error('Database not initialized');
return {
getData,
setProjectData,
getProjectData,
setCustomFields,
getCustomFields,
setRundown,
getSettings,
setSettings,
getOsc,
getHttp,
getUrlPresets,
setUrlPresets,
getViewSettings,
setViewSettings,
setOsc,
setHttp,
getRundown,
mergeIntoData,
};
}
function getData(): Readonly<DatabaseModel> {
return db.data;
}
async function setProjectData(newData: Partial<ProjectData>): ReadonlyPromise<ProjectData> {
db.data.project = { ...db.data.project, ...newData };
await persist();
return db.data.project;
}
function getProjectData(): Readonly<ProjectData> {
return db.data.project;
}
async function setCustomFields(newData: CustomFields): ReadonlyPromise<CustomFields> {
db.data.customFields = { ...newData };
await persist();
return db.data.customFields;
}
function getCustomFields(): Readonly<CustomFields> {
return db.data.customFields;
}
async function setRundown(newData: OntimeRundown): ReadonlyPromise<OntimeRundown> {
db.data.rundown = [...newData];
await persist();
return db.data.rundown;
}
function getSettings(): Readonly<Settings> {
return db.data.settings;
}
async function setSettings(newData: Settings): ReadonlyPromise<Settings> {
db.data.settings = { ...newData };
await persist();
return db.data.settings;
}
function getOsc(): Readonly<OSCSettings> {
return db.data.osc;
}
function getHttp(): Readonly<HttpSettings> {
return db.data.http;
}
function getUrlPresets(): Readonly<URLPreset[]> {
return db.data.urlPresets;
}
async function setUrlPresets(newData: URLPreset[]): ReadonlyPromise<URLPreset[]> {
db.data.urlPresets = newData;
await persist();
return db.data.urlPresets;
}
function getViewSettings(): Readonly<ViewSettings> {
return db.data.viewSettings;
}
async function setViewSettings(newData: ViewSettings): ReadonlyPromise<ViewSettings> {
db.data.viewSettings = { ...newData };
await persist();
return db.data.viewSettings;
}
async function setOsc(newData: OSCSettings): ReadonlyPromise<OSCSettings> {
db.data.osc = { ...newData };
await persist();
return db.data.osc;
}
async function setHttp(newData: HttpSettings): ReadonlyPromise<HttpSettings> {
db.data.http = { ...newData };
await persist();
return db.data.http;
}
function getRundown(): Readonly<OntimeRundown> {
return db.data.rundown;
}
async function mergeIntoData(newData: Partial<DatabaseModel>): ReadonlyPromise<DatabaseModel> {
const mergedData = safeMerge(db.data, newData);
db.data.project = mergedData.project;
db.data.settings = mergedData.settings;
db.data.viewSettings = mergedData.viewSettings;
db.data.osc = mergedData.osc;
db.data.http = mergedData.http;
db.data.urlPresets = mergedData.urlPresets;
db.data.customFields = mergedData.customFields;
db.data.rundown = mergedData.rundown;
await persist();
return db.data;
}
/**
* Handles persisting data to file
*/
async function persist() {
if (isTest) return;
await db.write();
}
@@ -1,11 +1,9 @@
import { DatabaseModel } from 'ontime-types';
/**
* Merges two data objects
* @param {object} existing
* @param {object} newData
* Merges a partial ontime project into a given ontime project
*/
export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseModel>) {
export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseModel>): DatabaseModel {
const { rundown, project, settings, viewSettings, urlPresets, customFields, osc, http } = newData || {};
return {
+409
View File
@@ -0,0 +1,409 @@
import { DatabaseModel, EndAction, SupportedEvent, TimeStrategy, TimerType } from 'ontime-types';
export const demoDb: DatabaseModel = {
rundown: [
{
type: SupportedEvent.Event,
id: '32d31',
cue: 'SF1.01',
title: 'Albania',
note: 'SF1.01',
endAction: EndAction.None,
timerType: TimerType.CountDown,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 36000000,
timeEnd: 37200000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'Sekret',
artist: 'Ronela Hajati',
},
},
{
type: SupportedEvent.Event,
id: '21cd2',
cue: 'SF1.02',
title: 'Latvia',
note: 'SF1.02',
endAction: EndAction.None,
timerType: TimerType.CountDown,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 37500000,
timeEnd: 38700000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'Eat Your Salad',
artist: 'Citi Zeni',
},
},
{
type: SupportedEvent.Event,
id: '0b371',
cue: 'SF1.03',
title: 'Lithuania',
note: 'SF1.03',
endAction: EndAction.None,
timerType: TimerType.CountDown,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 39000000,
timeEnd: 40200000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'Sentimentai',
artist: 'Monika Liu',
},
},
{
type: SupportedEvent.Event,
id: '3cd28',
cue: 'SF1.04',
title: 'Switzerland',
note: 'SF1.04',
endAction: EndAction.None,
timerType: TimerType.CountDown,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 40500000,
timeEnd: 41700000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'Boys Do Cry',
artist: 'Marius Bear',
},
},
{
type: SupportedEvent.Event,
id: 'e457f',
cue: 'SF1.05',
title: 'Slovenia',
note: 'SF1.05',
endAction: EndAction.None,
timerType: TimerType.CountDown,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 42000000,
timeEnd: 43200000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'Disko',
artist: 'LPS',
},
},
{
type: SupportedEvent.Block,
id: '01e85',
title: 'Lunch break',
},
{
type: SupportedEvent.Event,
id: '1c420',
cue: 'SF1.06',
title: 'Ukraine',
note: 'SF1.06',
endAction: EndAction.None,
timerType: TimerType.CountDown,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 47100000,
timeEnd: 48300000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'Stefania',
artist: 'Kalush Orchestra',
},
},
{
type: SupportedEvent.Event,
id: 'b7737',
cue: 'SF1.07',
title: 'Bulgaria',
note: 'SF1.07',
endAction: EndAction.None,
timerType: TimerType.CountDown,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 48600000,
timeEnd: 49800000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'Intention',
artist: 'Intelligent Music Project',
},
},
{
type: SupportedEvent.Event,
id: 'd3a80',
cue: 'SF1.08',
title: 'Netherlands',
note: 'SF1.08',
endAction: EndAction.None,
timerType: TimerType.CountDown,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 50100000,
timeEnd: 51300000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'De Diepte',
artist: 'S10',
},
},
{
type: SupportedEvent.Event,
id: '8276c',
cue: 'SF1.09',
title: 'Moldova',
note: 'SF1.09',
endAction: EndAction.None,
timerType: TimerType.CountDown,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 51600000,
timeEnd: 52800000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'Trenuletul',
artist: 'Zdob si Zdub',
},
},
{
type: SupportedEvent.Event,
id: '2340b',
cue: 'SF1.10',
title: 'Portugal',
note: 'SF1.10',
endAction: EndAction.None,
timerType: TimerType.CountDown,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 53100000,
timeEnd: 54300000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'Saudade Saudade',
artist: 'Maro',
},
},
{
type: SupportedEvent.Block,
id: 'cb90b',
title: 'Afternoon break',
},
{
type: SupportedEvent.Event,
id: '503c4',
cue: 'SF1.11',
title: 'Croatia',
note: 'SF1.11',
endAction: EndAction.None,
timerType: TimerType.CountDown,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 56100000,
timeEnd: 57300000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'Guilty Pleasure',
artist: 'Mia Dimsic',
},
},
{
type: SupportedEvent.Event,
id: '5e965',
cue: 'SF1.12',
title: 'Denmark',
note: 'SF1.12',
endAction: EndAction.None,
timerType: TimerType.CountDown,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 57600000,
timeEnd: 58800000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'The Show',
artist: 'Reddi',
},
},
{
type: SupportedEvent.Event,
id: 'bab4a',
cue: 'SF1.13',
title: 'Austria',
note: 'SF1.13',
endAction: EndAction.None,
timerType: TimerType.CountDown,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 59100000,
timeEnd: 60300000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'Halo',
artist: 'LUM!X & Pia Maria',
},
},
{
type: SupportedEvent.Event,
id: 'd3eb1',
cue: 'SF1.14',
title: 'Greece',
note: 'SF1.14',
endAction: EndAction.None,
timerType: TimerType.CountDown,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 60600000,
timeEnd: 61800000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'Die Together',
artist: 'Amanda Tenfjord',
},
},
],
project: {
title: 'Eurovision Song Contest',
description: 'Turin 2022',
publicUrl: 'www.getontime.no',
publicInfo: 'Rehearsal Schedule - Turin 2022',
backstageUrl: 'www.github.com/cpvalente/ontime',
backstageInfo: 'Rehearsal Schedule - Turin 2022\nAll performers to wear full costumes for 1st rehearsal',
},
settings: {
app: 'ontime',
version: '3.3.2',
serverPort: 4001,
editorKey: null,
operatorKey: null,
timeFormat: '24',
language: 'en',
},
viewSettings: {
dangerColor: '#ED3333',
endMessage: '',
freezeEnd: false,
normalColor: '#ffffffcc',
overrideStyles: false,
warningColor: '#FFAB33',
},
customFields: {
song: {
label: 'Song',
type: 'string',
colour: '#339E4E',
},
artist: {
label: 'Artist',
type: 'string',
colour: '#3E75E8',
},
},
urlPresets: [
{
enabled: true,
alias: 'test',
pathAndParams: 'lower?bg=ff2&text=f00&size=0.6&transition=5',
},
],
osc: {
portIn: 8888,
portOut: 9999,
targetIP: '127.0.0.1',
enabledIn: true,
enabledOut: false,
subscriptions: [],
},
http: {
enabledOut: false,
subscriptions: [],
},
};
@@ -1,61 +1,40 @@
import { Low } from 'lowdb';
import { JSONFile } from 'lowdb/node';
import { appStatePath, isTest } from '../../setup/index.js';
import { appStatePath, isProduction, isTest } from '../../setup/index.js';
import { isPath } from '../../utils/fileManagement.js';
import { consoleError } from '../../utils/console.js';
interface Config {
interface AppState {
lastLoadedProject?: string;
}
/**
* Manages Ontime's runtime memory between boots
*/
const adapter = new JSONFile<AppState>(appStatePath);
const config = new Low<AppState>(adapter, {});
class AppState {
private config: Low<Config>;
private pathToFile: string;
private didInit = false;
constructor(appStatePath: string) {
this.pathToFile = appStatePath;
const adapter = new JSONFile<Config>(this.pathToFile);
this.config = new Low<Config>(adapter, {});
}
private async init() {
await this.config.read();
await this.config.write();
this.didInit = true;
}
private async get(): Promise<Config> {
if (!this.didInit) {
await this.init();
}
await this.config.read();
return this.config.data;
}
async isLastLoadedProject(projectName: string): Promise<boolean> {
const lastLoaded = await this.getLastLoadedProject();
return lastLoaded === projectName;
}
async getLastLoadedProject(): Promise<string | undefined> {
const data = await this.get();
return data.lastLoadedProject;
}
async setLastLoadedProject(filename: string): Promise<void> {
if (isTest) return;
if (!this.didInit) {
await this.init();
}
this.config.data.lastLoadedProject = filename;
await this.config.write();
}
export async function isLastLoadedProject(projectName: string): Promise<boolean> {
const lastLoaded = await getLastLoadedProject();
return lastLoaded === projectName;
}
export const appStateProvider = new AppState(appStatePath);
export async function getLastLoadedProject(): Promise<string | undefined> {
// in test environment, we want to start the demo project
if (isTest) return;
await config.read();
return config.data.lastLoadedProject;
}
export async function setLastLoadedProject(filename: string): Promise<void> {
if (isTest) return;
if (!isProduction) {
if (isPath(filename)) {
consoleError(filename);
consoleError(new Error('setLastLoadedProject should not be called with a path').stack);
process.exit(0);
}
}
config.data.lastLoadedProject = filename;
await config.write();
}
@@ -1,28 +1,44 @@
import { DatabaseModel, GetInfo, LogOrigin, ProjectData, ProjectFileListResponse } from 'ontime-types';
import { getErrorMessage } from 'ontime-utils';
import { copyFile, rename } from 'fs/promises';
import { join } from 'path';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { logger } from '../../classes/Logger.js';
import { getNetworkInterfaces } from '../../utils/networkInterfaces.js';
import { resolveCorruptDirectory, resolveProjectsDirectory, resolveStylesPath } from '../../setup/index.js';
import { appendToName, ensureDirectory, removeFileExtension } from '../../utils/fileManagement.js';
import {
appendToName,
ensureDirectory,
generateUniqueFileName,
getFileNameFromPath,
removeFileExtension,
} from '../../utils/fileManagement.js';
import { dbModel } from '../../models/dataModel.js';
import { deleteFile } from '../../utils/parserUtils.js';
import { switchDb } from '../../setup/loadDb.js';
import { generateUniqueFileName } from '../../utils/generateUniqueFilename.js';
import { parseJson } from '../../utils/parser.js';
import { parseDatabaseModel } from '../../utils/parser.js';
import { parseRundown } from '../../utils/parserFunctions.js';
import { demoDb } from '../../models/demoProject.js';
import { config } from '../../setup/config.js';
import { getDataProvider, initPersistence } from '../../classes/data-provider/DataProvider.js';
import { initRundown } from '../rundown-service/RundownService.js';
import { appStateProvider } from '../app-state-service/AppStateService.js';
import {
getLastLoadedProject,
isLastLoadedProject,
setLastLoadedProject,
} from '../app-state-service/AppStateService.js';
import { runtimeService } from '../runtime-service/RuntimeService.js';
import { oscIntegration } from '../integration-service/OscIntegration.js';
import { httpIntegration } from '../integration-service/HttpIntegration.js';
import { parseProjectFile } from './projectFileUtils.js';
import { doesProjectExist, getPathToProject, getProjectFiles } from './projectServiceUtils.js';
import { parseRundown } from '../../utils/parserFunctions.js';
import {
copyCorruptFile,
doesProjectExist,
getPathToProject,
getProjectFiles,
moveCorruptFile,
parseJsonFile,
} from './projectServiceUtils.js';
// init dependencies
init();
@@ -32,39 +48,123 @@ init();
*/
function init() {
ensureDirectory(resolveProjectsDirectory);
ensureDirectory(resolveCorruptDirectory);
}
/**
* Private function loads a demo project
* to be composed in the loading functions
*/
async function loadDemoProject(): Promise<string> {
const pathToNewFile = generateUniqueFileName(resolveProjectsDirectory, config.demoProject);
await initPersistence(getPathToProject(pathToNewFile), demoDb);
const newName = getFileNameFromPath(pathToNewFile);
await setLastLoadedProject(newName);
return newName;
}
/**
* Private function loads a new, empty project
* to be composed in the loading functions
*/
async function loadNewProject(): Promise<string> {
const pathToNewFile = generateUniqueFileName(resolveProjectsDirectory, config.newProject);
await initPersistence(getPathToProject(pathToNewFile), dbModel);
const newName = getFileNameFromPath(pathToNewFile);
await setLastLoadedProject(newName);
return newName;
}
/**
* Private function handles side effects on currupted files
* Corrupted files in this context contain data that failed domain validation
*/
async function handleCorruptedFile(filePath: string, fileName: string): Promise<string> {
// copy file to corrupted folder
await copyCorruptFile(filePath, fileName).catch((_) => {
/* while we have to catch the error, we dont need to handle it */
});
// and make a new file with the recovered data
const newPath = appendToName(filePath, '(recovered)');
await rename(filePath, newPath);
return getFileNameFromPath(newPath);
}
/**
* Coordinates the initial load of a project on app startup
* This is different from the load project since we need to always load something
* @returns {Promise<string>} - name of the loaded file
*/
export async function initialiseProject(): Promise<string> {
// check what was loaded before
const previousProject = await getLastLoadedProject();
if (!previousProject) {
return loadDemoProject();
}
// try and load the previous project
const filePath = doesProjectExist(previousProject);
if (filePath === null) {
logger.warning(LogOrigin.Server, `Previous project file ${previousProject} not found`);
return loadNewProject();
}
try {
const fileData = await parseJsonFile(filePath);
const result = parseDatabaseModel(fileData);
let parsedFileName = previousProject;
let parsedFilePath = filePath;
if (result.errors.length > 0) {
logger.warning(LogOrigin.Server, 'Project loaded with errors');
parsedFileName = await handleCorruptedFile(filePath, previousProject);
parsedFilePath = getPathToProject(parsedFileName);
}
await initPersistence(parsedFilePath, result.data);
await setLastLoadedProject(parsedFileName);
return parsedFileName;
} catch (error) {
logger.warning(LogOrigin.Server, `Unable to load previous project ${previousProject}: ${getErrorMessage(error)}`);
await moveCorruptFile(filePath, previousProject).catch((_) => {
/* while we have to catch the error, we dont need to handle it */
});
return loadNewProject();
}
}
/**
* Loads a data from a file into the runtime
*/
export async function loadProjectFile(name: string) {
const filePath = await doesProjectExist(name);
const filePath = doesProjectExist(name);
if (filePath === null) {
throw new Error('Project file not found');
}
// when loading a project file, we allow parsing to fail and interrupt the process
const fileData = await parseProjectFile(filePath);
const result = parseJson(fileData);
const fileData = await parseJsonFile(filePath);
const result = parseDatabaseModel(fileData);
let parsedFileName = name;
let parsedFilePath = filePath;
if (result.errors.length > 0) {
logger.warning(LogOrigin.Server, 'Project loaded with errors');
// move original file to corrupted
ensureDirectory(resolveCorruptDirectory);
copyFile(filePath, join(resolveCorruptDirectory, name));
// rename file to indicate recovery
const newName = appendToName(filePath, '(recovered)');
await rename(filePath, newName);
parsedFileName = await handleCorruptedFile(filePath, name);
parsedFilePath = getPathToProject(parsedFileName);
}
// change LowDB to point to new file
await switchDb(filePath, result.data);
logger.info(LogOrigin.Server, `Loaded project ${name}`);
await initPersistence(parsedFilePath, result.data);
logger.info(LogOrigin.Server, `Loaded project ${parsedFileName}`);
// persist the project selection
await appStateProvider.setLastLoadedProject(name);
await setLastLoadedProject(parsedFileName);
// since load happens at runtime, we need to update the services that depend on the data
// apply data model
runtimeService.stop();
@@ -84,7 +184,7 @@ export async function loadProjectFile(name: string) {
*/
export async function getProjectList(): Promise<ProjectFileListResponse> {
const files = await getProjectFiles();
const lastLoadedProject = await appStateProvider.getLastLoadedProject();
const lastLoadedProject = await getLastLoadedProject();
return {
files,
@@ -96,12 +196,12 @@ export async function getProjectList(): Promise<ProjectFileListResponse> {
* Duplicates an existing project file
*/
export async function duplicateProjectFile(originalFile: string, newFilename: string) {
const projectFilePath = await doesProjectExist(originalFile);
const projectFilePath = doesProjectExist(originalFile);
if (projectFilePath === null) {
throw new Error('Project file not found');
}
const duplicateProjectFilePath = await doesProjectExist(newFilename);
const duplicateProjectFilePath = doesProjectExist(newFilename);
if (duplicateProjectFilePath !== null) {
throw new Error(`Project file with name ${newFilename} already exists`);
}
@@ -114,12 +214,12 @@ export async function duplicateProjectFile(originalFile: string, newFilename: st
* Renames an existing project file
*/
export async function renameProjectFile(originalFile: string, newFilename: string) {
const projectFilePath = await doesProjectExist(originalFile);
const projectFilePath = doesProjectExist(originalFile);
if (projectFilePath === null) {
throw new Error('Project file not found');
}
const newProjectFilePath = await doesProjectExist(newFilename);
const newProjectFilePath = doesProjectExist(newFilename);
if (newProjectFilePath !== null) {
throw new Error(`Project file with name ${newFilename} already exists`);
}
@@ -128,17 +228,17 @@ export async function renameProjectFile(originalFile: string, newFilename: strin
await rename(projectFilePath, pathToRenamed);
// Update the last loaded project config if current loaded project is the one being renamed
const isLoaded = await appStateProvider.isLastLoadedProject(originalFile);
const isLoaded = await isLastLoadedProject(originalFile);
if (isLoaded) {
const fileData = await parseProjectFile(pathToRenamed);
const result = parseJson(fileData);
const fileData = await parseJsonFile(pathToRenamed);
const result = parseDatabaseModel(fileData);
// change LowDB to point to new file
await switchDb(pathToRenamed, result.data);
await initPersistence(pathToRenamed, result.data);
logger.info(LogOrigin.Server, `Loaded project ${newFilename}`);
// persist the project selection
await appStateProvider.setLastLoadedProject(newFilename);
await setLastLoadedProject(newFilename);
// apply data model
runtimeService.stop();
@@ -170,14 +270,14 @@ export async function createProject(filename: string, projectData: ProjectData)
const newFile = getPathToProject(uniqueFileName);
// change LowDB to point to new file
await switchDb(newFile, data);
await initPersistence(newFile, data);
// apply data to running services
// we dont need to parse since we are creating a new file
await patchCurrentProject(data);
// update app state to point to new value
appStateProvider.setLastLoadedProject(uniqueFileName);
setLastLoadedProject(uniqueFileName);
return uniqueFileName;
}
@@ -186,12 +286,12 @@ export async function createProject(filename: string, projectData: ProjectData)
* Deletes a project file
*/
export async function deleteProjectFile(filename: string) {
const isLastLoadedProject = await appStateProvider.isLastLoadedProject(filename);
if (isLastLoadedProject) {
const isPreviousProject = await isLastLoadedProject(filename);
if (isPreviousProject) {
throw new Error('Cannot delete currently loaded project');
}
const projectFilePath = await doesProjectExist(filename);
const projectFilePath = doesProjectExist(filename);
if (projectFilePath === null) {
throw new Error('Project file not found');
}
@@ -203,8 +303,8 @@ export async function deleteProjectFile(filename: string) {
* Adds business logic to gathering data for the info endpoint
*/
export async function getInfo(): Promise<GetInfo> {
const { version, serverPort } = DataProvider.getSettings();
const osc = DataProvider.getOsc();
const { version, serverPort } = getDataProvider().getSettings();
const osc = getDataProvider().getOsc();
// get nif and inject localhost
const ni = getNetworkInterfaces();
@@ -226,10 +326,10 @@ export async function getInfo(): Promise<GetInfo> {
export async function patchCurrentProject(data: Partial<DatabaseModel>) {
runtimeService.stop();
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- we need to remove the fields before meging
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- we need to remove the fields before merging
const { rundown, customFields, ...rest } = data;
// we can pass some stuff straight to the data provider
const newData = await DataProvider.mergeIntoData(rest);
const newData = await getDataProvider().mergeIntoData(rest);
// ... but rundown and custom fields need to be checked
if (rundown != null) {
@@ -1,6 +1,6 @@
import { Mock } from 'vitest';
import { appStateProvider } from '../../app-state-service/AppStateService.js';
import { isLastLoadedProject } from '../../app-state-service/AppStateService.js';
import { deleteProjectFile, duplicateProjectFile, renameProjectFile } from '../ProjectService.js';
import { doesProjectExist } from '../projectServiceUtils.js';
@@ -13,9 +13,7 @@ vi.mock('../../../setup/loadDb.js', () => {
});
vi.mock('../../app-state-service/AppStateService.js', () => ({
appStateProvider: {
isLastLoadedProject: vi.fn(),
},
isLastLoadedProject: vi.fn(),
}));
vi.mock('../projectServiceUtils.js', () => ({
@@ -29,12 +27,12 @@ vi.mock('../projectServiceUtils.js', () => ({
*/
describe('deleteProjectFile', () => {
it('throws an error if trying to delete the currently loaded project', async () => {
(appStateProvider.isLastLoadedProject as Mock).mockResolvedValue(true);
(isLastLoadedProject as Mock).mockResolvedValue(true);
await expect(deleteProjectFile('loadedProject')).rejects.toThrow('Cannot delete currently loaded project');
});
it('throws an error if the project file does not exist', async () => {
(appStateProvider.isLastLoadedProject as Mock).mockResolvedValue(false);
(isLastLoadedProject as Mock).mockResolvedValue(false);
(doesProjectExist as Mock).mockReturnValue(null);
await expect(deleteProjectFile('nonexistentProject')).rejects.toThrow('Project file not found');
});
@@ -1,34 +0,0 @@
import { readFile } from 'fs/promises';
import { DatabaseModel } from 'ontime-types';
import { extname } from 'path';
// TODO: move to projectServiceUtils
/**
* Given an array of file names, filters out any files that do not have a '.json' extension.
* We assume these are project files
* @param files
* @returns
*/
export function filterProjectFiles(files: Array<string>): Array<string> {
return files.filter((file) => {
const ext = extname(file).toLowerCase();
return ext === '.json';
});
}
export async function parseProjectFile(filePath: string): Promise<Partial<DatabaseModel>> {
if (!filePath.endsWith('.json')) {
throw new Error('Invalid file type');
}
const rawdata = await readFile(filePath, 'utf-8');
const uploadedJson = JSON.parse(rawdata);
// at this point, we think this is a DatabaseModel
// verify by looking for the required fields
if (uploadedJson?.settings?.app !== 'ontime') {
throw new Error('Not an Ontime project file');
}
return uploadedJson;
}
@@ -1,13 +1,12 @@
import { MaybeString, ProjectFile } from 'ontime-types';
import { DatabaseModel, MaybeString, ProjectFile } from 'ontime-types';
import { access, rename, stat } from 'fs/promises';
import { join } from 'path';
import { existsSync } from 'fs';
import { copyFile, readFile, rename, stat } from 'fs/promises';
import { extname, join } from 'path';
import { resolveProjectsDirectory } from '../../setup/index.js';
import { resolveCorruptDirectory, resolveProjectsDirectory } from '../../setup/index.js';
import { getFilesFromFolder, removeFileExtension } from '../../utils/fileManagement.js';
import { filterProjectFiles } from './projectFileUtils.js';
/**
* Handles the upload of a new project file
* @param filePath
@@ -51,14 +50,12 @@ export async function getProjectFiles(): Promise<ProjectFile[]> {
* Checks whether a project of a given name exists
* @param name
*/
export async function doesProjectExist(name: string): Promise<MaybeString> {
try {
const projectFilePath = getPathToProject(name);
await access(projectFilePath);
export function doesProjectExist(name: string): MaybeString {
const projectFilePath = getPathToProject(name);
if (existsSync(projectFilePath)) {
return projectFilePath;
} catch (_) {
return null;
}
return null;
}
/**
@@ -67,3 +64,43 @@ export async function doesProjectExist(name: string): Promise<MaybeString> {
export function getPathToProject(name: string): string {
return join(resolveProjectsDirectory, name);
}
/**
* Makes a copy of a given project to the corrupted directory
*/
export async function copyCorruptFile(filePath: string, name: string): Promise<void> {
const newPath = join(resolveCorruptDirectory, name);
return copyFile(filePath, newPath);
}
/**
* Moves a file permanently to the corrupted directory
*/
export async function moveCorruptFile(filePath: string, name: string): Promise<void> {
const newPath = join(resolveCorruptDirectory, name);
return rename(filePath, newPath);
}
/**
* Given an array of file names, filters out any files that do not have a '.json' extension.
* We assume these are project files
*/
export function filterProjectFiles(files: Array<string>): Array<string> {
return files.filter((file) => {
const ext = extname(file).toLowerCase();
return ext === '.json';
});
}
/**
* Parses a project file and returns the JSON object
* @throws It will throw an error if it cannot read or parse the file
*/
export async function parseJsonFile(filePath: string): Promise<Partial<DatabaseModel>> {
if (!filePath.endsWith('.json')) {
throw new Error('Invalid file type');
}
const rawdata = await readFile(filePath, 'utf-8');
return JSON.parse(rawdata);
}
@@ -27,6 +27,19 @@ import {
customFieldChangelog,
} from '../rundownCache.js';
beforeAll(() => {
vi.mock('../../../classes/data-provider/DataProvider.js', () => {
return {
getDataProvider: vi.fn().mockImplementation(() => {
return {
setCustomFields: vi.fn().mockImplementation((newData) => newData),
setRundown: vi.fn().mockImplementation((newData) => newData),
};
}),
};
});
});
describe('generate()', () => {
it('creates normalised versions of a given rundown', () => {
const testRundown: OntimeRundown = [
@@ -849,23 +862,6 @@ describe('calculateRuntimeDelaysFrom()', () => {
describe('custom fields', () => {
describe('createCustomField()', () => {
beforeEach(() => {
vi.mock('../../classes/data-provider/DataProvider.js', () => {
return {
DataProvider: {
...vi.fn().mockImplementation(() => {
return {};
}),
getCustomFields: vi.fn().mockReturnValue({}),
setCustomFields: vi.fn().mockImplementation((newData) => {
return newData;
}),
persist: vi.fn().mockReturnValue({}),
},
};
});
});
it('creates a field from given parameters', async () => {
const expected = {
lighting: {
@@ -11,7 +11,7 @@ import {
} from 'ontime-types';
import { generateId, insertAtIndex, reorderArray, swapEventData, checkIsNextDay } from 'ontime-utils';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import { createPatch } from '../../utils/parser.js';
import { getTotalDuration } from '../timerUtils.js';
import { apply } from './delayUtils.js';
@@ -59,8 +59,8 @@ export async function init(initialRundown: Readonly<OntimeRundown>, customFields
persistedRundown = structuredClone(initialRundown) as OntimeRundown;
persistedCustomFields = structuredClone(customFields);
generate();
await DataProvider.setRundown(persistedRundown);
await DataProvider.setCustomFields(customFields);
await getDataProvider().setRundown(persistedRundown);
await getDataProvider().setCustomFields(customFields);
}
/**
@@ -248,8 +248,8 @@ export function mutateCache<T extends object>(mutation: MutatingFn<T>) {
});
// defer writing to the database
setImmediate(() => {
DataProvider.setRundown(persistedRundown);
setImmediate(async () => {
await getDataProvider().setRundown(persistedRundown);
});
return { newEvent, newRundown, didMutate };
@@ -413,9 +413,9 @@ function invalidateIfUsed(label: CustomFieldLabel) {
}
// ... and schedule a cache update
// schedule a non priority cache update
setImmediate(() => {
setImmediate(async () => {
generate();
DataProvider.setRundown(persistedRundown);
await getDataProvider().setRundown(persistedRundown);
});
}
@@ -424,8 +424,8 @@ function invalidateIfUsed(label: CustomFieldLabel) {
* @param persistedCustomFields
*/
function scheduleCustomFieldPersist(persistedCustomFields: CustomFields) {
setImmediate(() => {
DataProvider.setCustomFields(persistedCustomFields);
setImmediate(async () => {
await getDataProvider().setCustomFields(persistedCustomFields);
});
}
+2
View File
@@ -2,6 +2,8 @@ export const config = {
appState: 'app-state.json',
corrupt: 'corrupt files',
crash: 'crash logs',
demoProject: 'demo project.json',
newProject: 'new project.json',
database: {
testdb: 'test-db',
directory: 'db',
+15 -34
View File
@@ -1,6 +1,5 @@
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { readFileSync, writeFileSync } from 'fs';
import { config } from './config.js';
import { ensureDirectory } from '../utils/fileManagement.js';
@@ -70,40 +69,21 @@ export const resolvedPath = (): string => {
return devPath;
};
const testDbStartDirectory = isTest ? '../' : getAppDataPath();
export const externalsStartDirectory = isProduction ? getAppDataPath() : join(srcDirectory, 'external');
// resolve public directory
export const resolvePublicDirectoy = getAppDataPath();
ensureDirectory(resolvePublicDirectoy);
const testDbStartDirectory = isTest ? '../' : resolvePublicDirectoy;
export const externalsStartDirectory = isProduction ? resolvePublicDirectoy : join(srcDirectory, 'external');
// TODO: we only need one when they are all in the same folder
export const resolveExternalsDirectory = join(isProduction ? getAppDataPath() : srcDirectory, 'external');
export const resolveExternalsDirectory = join(isProduction ? resolvePublicDirectoy : srcDirectory, 'external');
// project files
export const appStatePath = join(getAppDataPath(), config.appState);
export const uploadsFolderPath = join(getAppDataPath(), config.uploads);
const ensureAppState = () => {
ensureDirectory(getAppDataPath());
writeFileSync(appStatePath, JSON.stringify({ lastLoadedProject: 'db.json' }));
};
const getLastLoadedProject = () => {
try {
const appState = JSON.parse(readFileSync(appStatePath, 'utf8'));
if (!appState.lastLoadedProject) {
ensureAppState();
}
return appState.lastLoadedProject;
} catch {
if (!isTest) {
ensureAppState();
}
}
};
const lastLoadedProject = isTest ? 'db.json' : getLastLoadedProject();
export const appStatePath = join(resolvePublicDirectoy, config.appState);
export const uploadsFolderPath = join(resolvePublicDirectoy, config.uploads);
// path to public db
export const resolveDbDirectory = join(testDbStartDirectory, isTest ? `../${config.database.testdb}` : config.projects);
export const resolveDbName = lastLoadedProject ? lastLoadedProject : config.database.filename;
export const resolveDbPath = join(resolveDbDirectory, resolveDbName);
export const pathToStartDb = isTest
? join(srcDirectory, '..', config.database.testdb, config.database.filename)
@@ -125,21 +105,22 @@ export const resolveDemoPath = config.demo.filename.map((file) => {
return join(resolveDemoDirectory, file);
});
// path to demo project
export const pathToStartDemo = config.demo.filename.map((file) => {
return join(srcDirectory, '/external/demo/', file);
});
// path to restore file
export const resolveRestoreFile = join(getAppDataPath(), config.restoreFile);
export const resolveRestoreFile = join(resolvePublicDirectoy, config.restoreFile);
// path to sheets folder
export const resolveSheetsDirectory = join(getAppDataPath(), config.sheets.directory);
export const resolveSheetsDirectory = join(resolvePublicDirectoy, config.sheets.directory);
// path to crash reports
export const resolveCrashReportDirectory = join(getAppDataPath(), config.crash);
export const resolveCrashReportDirectory = join(resolvePublicDirectoy, config.crash);
// path to projects
export const resolveProjectsDirectory = join(getAppDataPath(), config.projects);
export const resolveProjectsDirectory = join(resolvePublicDirectoy, config.projects);
// path to corrupt files
export const resolveCorruptDirectory = join(getAppDataPath(), config.corrupt);
export const resolveCorruptDirectory = join(resolvePublicDirectoy, config.corrupt);
-102
View File
@@ -1,102 +0,0 @@
import { DatabaseModel } from 'ontime-types';
import { Low } from 'lowdb';
import { JSONFilePreset } from 'lowdb/node';
import { copyFileSync, existsSync } from 'fs';
import { join } from 'path';
import { ensureDirectory } from '../utils/fileManagement.js';
import { dbModel } from '../models/dataModel.js';
import { pathToStartDb, resolveDbDirectory, resolveDbName } from './index.js';
import { parseProjectFile } from '../services/project-service/projectFileUtils.js';
import { parseJson } from '../utils/parser.js';
import { getErrorMessage } from 'ontime-utils';
import { appStateProvider } from '../services/app-state-service/AppStateService.js';
import { consoleError } from '../utils/console.js';
/**
* @description ensures directories exist and populates database
* @return {string} - path to db file
*/
const populateDb = (directory: string, filename: string): string => {
ensureDirectory(directory);
let dbPath = join(directory, filename);
// if everything goes well, the DB in disk is the one loaded
// if dbInDisk doesn't exist we want to use startup db
if (!existsSync(dbPath)) {
try {
const dbDirectory = resolveDbDirectory;
const startDbName = pathToStartDb.split('/').pop();
if (!startDbName) {
throw new Error('Invalid path to start database');
}
const newFileDirectory = join(dbDirectory, startDbName);
copyFileSync(pathToStartDb, newFileDirectory);
dbPath = newFileDirectory;
} catch (_) {
/* we do not handle this */
}
}
return dbPath;
};
/**
* @description loads ontime db
*/
async function loadDb(directory: string, filename: string) {
const dbInDisk = populateDb(directory, filename);
// TODO: should this be passed in somewhere?
let newData: DatabaseModel = dbModel;
try {
const maybeProjectFile = await parseProjectFile(dbInDisk);
const result = parseJson(maybeProjectFile);
await appStateProvider.setLastLoadedProject(filename);
newData = result.data;
} catch (error) {
consoleError(`Unable to parse project file: ${getErrorMessage(error)}`);
// we get here if the JSON file is corrupt
}
const db = await JSONFilePreset<DatabaseModel>(dbInDisk, newData);
db.data = newData;
return { db, data: newData };
}
export let db = {} as Low<DatabaseModel>;
export let data = {} as DatabaseModel;
export const dbLoadingProcess = loadDb(resolveDbDirectory, resolveDbName);
/**
* Initialises database at known location
*/
const init = async () => {
const dbProvider = await dbLoadingProcess;
db = dbProvider.db;
data = dbProvider.data;
};
/**
* Allows to switch the database to a new file
*/
export const switchDb = async (filePath: string, initialData: DatabaseModel = dbModel) => {
const newDb = await JSONFilePreset<DatabaseModel>(filePath, initialData);
// Read the database to initialize it
await newDb.read();
db = newDb;
data = db.data;
};
init();
@@ -45,6 +45,19 @@ const makeMockState = (patch: RuntimeState): RuntimeState => {
return deepmerge(mockState, patch);
};
beforeAll(() => {
vi.mock('../../classes/data-provider/DataProvider.js', () => {
return {
getDataProvider: vi.fn().mockImplementation(() => {
return {
setCustomFields: vi.fn().mockImplementation((newData) => newData),
setRundown: vi.fn().mockImplementation((newData) => newData),
};
}),
};
});
});
describe('mutation on runtimeState', () => {
beforeEach(() => {
clear();
@@ -1,5 +1,12 @@
import { describe, it, expect } from 'vitest';
import { appendToName, ensureJsonExtension } from '../fileManagement.js';
import { describe, it, expect, Mock } from 'vitest';
import * as fs from 'fs';
import { appendToName, ensureJsonExtension, generateUniqueFileName } from '../fileManagement.js';
// Mock fs.existsSync to control the test environment
vi.mock('fs', () => ({
existsSync: vi.fn(),
}));
describe('ensureJsonExtension', () => {
it('should add .json to a filename without an extension', () => {
@@ -49,3 +56,37 @@ describe('appendToName', () => {
expect(result).toBe('strange.file.name (recovered).json');
});
});
describe('generateUniqueFileName', () => {
const directory = '/test/directory';
const filename = 'testFile.txt';
const baseName = 'testFile';
const extension = '.txt';
beforeEach(() => {
// Clear all mocks before each test
vi.clearAllMocks();
});
it('should return original file name if there is no conflict', () => {
(fs.existsSync as Mock).mockReturnValue(false);
const uniqueFilename = generateUniqueFileName(directory, filename);
expect(uniqueFilename).toBe(filename);
});
it('should append a counter to the filename if a conflict exists', () => {
// Mock the first call to return true (file exists), then false
(fs.existsSync as Mock).mockReturnValueOnce(true).mockReturnValueOnce(false);
const expectedFilename = `${baseName} (1)${extension}`;
const uniqueFilename = generateUniqueFileName(directory, filename);
expect(uniqueFilename).toBe(expectedFilename);
});
it('should increment the counter for each conflict until a unique filename is found', () => {
// Mock the first two calls to return true (file exists), then false
(fs.existsSync as Mock).mockReturnValueOnce(true).mockReturnValueOnce(true).mockReturnValueOnce(false);
const expectedFilename = `${baseName} (2)${extension}`;
const uniqueFilename = generateUniqueFileName(directory, filename);
expect(uniqueFilename).toBe(expectedFilename);
});
});
+27 -13
View File
@@ -16,7 +16,7 @@ import {
import { dbModel } from '../../models/dataModel.js';
import { createEvent, getCustomFieldData, parseExcel, parseJson } from '../parser.js';
import { createEvent, getCustomFieldData, parseExcel, parseDatabaseModel } from '../parser.js';
import { makeString } from '../parserUtils.js';
import { parseRundown, parseUrlPresets, parseViewSettings } from '../parserFunctions.js';
import { ImportMap, MILLIS_PER_MINUTE } from 'ontime-utils';
@@ -27,6 +27,20 @@ const requiredSettings = {
version: 'any',
};
// mock data provider
beforeAll(() => {
vi.mock('../../classes/data-provider/DataProvider.js', () => {
return {
getDataProvider: vi.fn().mockImplementation(() => {
return {
setRundown: vi.fn().mockImplementation((newData) => newData),
setCustomFields: vi.fn().mockImplementation((newData) => newData),
};
}),
};
});
});
describe('test json parser with valid def', () => {
const testData: Partial<DatabaseModel> = {
rundown: [
@@ -187,7 +201,7 @@ describe('test json parser with valid def', () => {
viewSettings: {} as ViewSettings,
};
const { data } = parseJson(testData);
const { data } = parseDatabaseModel(testData);
it('has 7 events', () => {
const length = data.rundown.length;
@@ -257,7 +271,7 @@ describe('test parser edge cases', () => {
};
// @ts-expect-error -- we know this is wrong, testing imports outside domain
const { data } = parseJson(testData);
const { data } = parseDatabaseModel(testData);
expect(typeof (data.rundown[0] as OntimeEvent).cue).toBe('string');
expect(typeof (data.rundown[1] as OntimeEvent).cue).toBe('string');
});
@@ -274,7 +288,7 @@ describe('test parser edge cases', () => {
};
// @ts-expect-error -- we know this is wrong, testing imports outside domain
const { data } = parseJson(testData);
const { data } = parseDatabaseModel(testData);
expect(data.rundown[0].id).toBeDefined();
});
@@ -297,7 +311,7 @@ describe('test parser edge cases', () => {
};
//@ts-expect-error -- we know this is wrong, testing imports outside domain
const { data, errors } = parseJson(testData);
const { data, errors } = parseDatabaseModel(testData);
expect(data.rundown.length).toBe(1);
expect(errors.length).toBe(7);
});
@@ -319,7 +333,7 @@ describe('test parser edge cases', () => {
};
// @ts-expect-error -- we know this is wrong, testing imports outside domain
const { data } = parseJson(testData);
const { data } = parseDatabaseModel(testData);
expect(data.rundown.length).toBe(0);
});
@@ -332,7 +346,7 @@ describe('test parser edge cases', () => {
};
// @ts-expect-error -- we know this is wrong, testing imports outside domain
expect(() => parseJson(testData)).toThrow();
expect(() => parseDatabaseModel(testData)).toThrow();
});
});
@@ -375,7 +389,7 @@ describe('test corrupt data', () => {
};
// @ts-expect-error -- we know this is wrong, testing imports outside domain
const { data } = parseJson(emptyEvents);
const { data } = parseDatabaseModel(emptyEvents);
expect(data.rundown.length).toBe(2);
});
@@ -400,7 +414,7 @@ describe('test corrupt data', () => {
};
// @ts-expect-error -- we know this is wrong, testing imports outside domain
const { data } = parseJson(emptyEvents);
const { data } = parseDatabaseModel(emptyEvents);
expect(data.rundown.length).toBe(0);
});
@@ -418,7 +432,7 @@ describe('test corrupt data', () => {
};
// @ts-expect-error -- we know this is wrong, testing imports outside domain
const { data: parsedDef } = parseJson(emptyProjectData);
const { data: parsedDef } = parseDatabaseModel(emptyProjectData);
expect(parsedDef.project).toStrictEqual(dbModel.project);
});
@@ -433,13 +447,13 @@ describe('test corrupt data', () => {
};
// @ts-expect-error -- we know this is wrong, testing imports outside domain
const { data } = parseJson(missingSettings);
const { data } = parseDatabaseModel(missingSettings);
expect(data.settings).toStrictEqual(dbModel.settings);
});
it('fails with invalid JSON', () => {
// @ts-expect-error -- we know this is wrong, testing imports outside domain
expect(() => parseJson('some random dataset')).toThrow();
expect(() => parseDatabaseModel('some random dataset')).toThrow();
});
});
@@ -667,7 +681,7 @@ describe('test import of v2 datamodel', () => {
},
};
// @ts-expect-error -- we know this is wrong, testing imports outside domain
const { data: parsed, _errors } = parseJson(v2ProjectFile);
const { data: parsed, _errors } = parseDatabaseModel(v2ProjectFile);
expect(parsed.rundown.length).toBe(3);
expect(parsed.rundown[0]).toMatchObject({ type: SupportedEvent.Block });
expect(parsed.rundown[0]).toEqual(
+39 -1
View File
@@ -1,6 +1,6 @@
import { existsSync, mkdirSync } from 'fs';
import { readdir } from 'fs/promises';
import { parse } from 'path';
import { basename, extname, join, parse } from 'path';
/**
* @description Creates a directory if it doesn't exist
@@ -47,3 +47,41 @@ export function appendToName(filePath: string, append: string): string {
const extension = filePath.split('.').pop();
return filePath.replace(`.${extension}`, ` ${append}.${extension}`);
}
/**
* Generates a unique file name within the specified directory.
* If a file with the same name already exists, appends a counter to the filename.
*/
export function generateUniqueFileName(directory: string, filename: string): string {
const extension = extname(filename);
const baseName = basename(filename, extension);
let counter = 0;
let uniqueFilename = filename;
while (fileExists(uniqueFilename)) {
counter++;
// Append counter to filename if the file exists.
uniqueFilename = `${baseName} (${counter})${extension}`;
}
return uniqueFilename;
function fileExists(name: string) {
return existsSync(join(directory, name));
}
}
/**
* retrieves the filename from a given path
*/
export function getFileNameFromPath(filePath: string): string {
return basename(filePath);
}
/**
* Utility naivly checks for paths on whether it includes directories
*/
export function isPath(filePath: string): boolean {
return filePath !== basename(filePath);
}
@@ -1,26 +0,0 @@
import { existsSync } from 'fs';
import path from 'path';
/**
* Generates a unique file name within the specified directory.
* If a file with the same name already exists, appends a counter to the filename.
*/
export const generateUniqueFileName = (directory: string, filename: string): string => {
const baseName = path.basename(filename, path.extname(filename));
const extension = path.extname(filename);
let counter = 0;
let uniqueFilename = filename;
while (fileExists(uniqueFilename)) {
counter++;
// Append counter to filename if the file exists.
uniqueFilename = `${baseName} (${counter})${extension}`;
}
return uniqueFilename;
function fileExists(name: string) {
return existsSync(path.join(directory, name));
}
};
+2 -2
View File
@@ -288,11 +288,11 @@ export type ParsingError = {
};
/**
* @description JSON parser function for ontime project file
* @description handles parsing of ontime project file
* @param {object} jsonData - project file to be parsed
* @returns {object} - parsed object
*/
export function parseJson(jsonData: Partial<DatabaseModel>): { data: DatabaseModel; errors: ParsingError[] } {
export function parseDatabaseModel(jsonData: Partial<DatabaseModel>): { data: DatabaseModel; errors: ParsingError[] } {
// we need to parse settings first to make sure the data is ours
// this may throw
const settings = parseSettings(jsonData);