mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-06 07:53:54 +00:00
refactor: initialise data
This commit is contained in:
committed by
Carlos Valente
parent
dae32e89a3
commit
84792bc00d
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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,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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user