From 9af96e5cd78c975ce2d638a37616ec9ea2744534 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Fri, 5 Jul 2024 21:19:14 +0200 Subject: [PATCH] refactor: initialise data --- apps/server/src/api-data/db/db.controller.ts | 2 +- .../src/api-data/http/http.controller.ts | 10 +- .../server/src/api-data/osc/osc.controller.ts | 10 +- .../api-data/project/project.controller.ts | 9 +- .../api-data/settings/settings.controller.ts | 10 +- .../url-presets/urlPresets.controller.ts | 10 +- .../view-settings/viewSettings.controller.ts | 19 +- .../view-settings/viewSettings.validation.ts | 11 +- .../api-integration/integration.controller.ts | 19 +- .../src/api-integration/integration.utils.ts | 9 +- apps/server/src/app.ts | 26 +- .../src/classes/data-provider/DataProvider.ts | 229 ++++++---- .../data-provider/DataProvider.utils.ts | 6 +- ...der.test.ts => DataProvider.utils.test.ts} | 0 apps/server/src/models/demoProject.ts | 409 ++++++++++++++++++ .../app-state-service/AppStateService.ts | 71 ++- .../project-service/ProjectService.ts | 186 ++++++-- .../__tests__/ProjectService.test.ts | 10 +- .../project-service/projectFileUtils.ts | 34 -- .../project-service/projectServiceUtils.ts | 61 ++- .../__tests__/rundownCache.test.ts | 30 +- .../services/rundown-service/rundownCache.ts | 18 +- apps/server/src/setup/config.ts | 2 + apps/server/src/setup/index.ts | 49 +-- apps/server/src/setup/loadDb.ts | 102 ----- .../src/stores/__tests__/runtimeState.test.ts | 13 + .../utils/__tests__/fileManagement.test.ts | 45 +- .../server/src/utils/__tests__/parser.test.ts | 40 +- apps/server/src/utils/fileManagement.ts | 40 +- .../src/utils/generateUniqueFilename.ts | 26 -- apps/server/src/utils/parser.ts | 4 +- 31 files changed, 998 insertions(+), 512 deletions(-) rename apps/server/src/classes/data-provider/__tests__/{DataProvider.test.ts => DataProvider.utils.test.ts} (100%) create mode 100644 apps/server/src/models/demoProject.ts delete mode 100644 apps/server/src/services/project-service/projectFileUtils.ts delete mode 100644 apps/server/src/setup/loadDb.ts delete mode 100644 apps/server/src/utils/generateUniqueFilename.ts diff --git a/apps/server/src/api-data/db/db.controller.ts b/apps/server/src/api-data/db/db.controller.ts index 78a63be279..cdc50140a4 100644 --- a/apps/server/src/api-data/db/db.controller.ts +++ b/apps/server/src/api-data/db/db.controller.ts @@ -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.` }); } diff --git a/apps/server/src/api-data/http/http.controller.ts b/apps/server/src/api-data/http/http.controller.ts index 49f35ac225..5812743122 100644 --- a/apps/server/src/api-data/http/http.controller.ts +++ b/apps/server/src/api-data/http/http.controller.ts @@ -1,14 +1,14 @@ import type { ErrorResponse, HttpSettings } from 'ontime-types'; +import { getErrorMessage } 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 { httpIntegration } from '../../services/integration-service/HttpIntegration.js'; -import { getErrorMessage } from 'ontime-utils'; +import { getDataProvider } from '../../classes/data-provider/DataProvider.js'; export async function getHTTP(_req: Request, res: Response) { - 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) { - 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) { - res.json(DataProvider.getProjectData()); + res.json(getDataProvider().getProjectData()); } export async function postProjectData(req: Request, res: Response) { @@ -25,7 +26,7 @@ export async function postProjectData(req: Request, res: Response) { - 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) { - 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) { - const views = DataProvider.getViewSettings(); + const views = getDataProvider().getViewSettings(); res.status(200).send(views); } export async function postViewSettings(req: Request, res: Response) { - 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); diff --git a/apps/server/src/api-data/view-settings/viewSettings.validation.ts b/apps/server/src/api-data/view-settings/viewSettings.validation.ts index f102a58127..51cf92894e 100644 --- a/apps/server/src/api-data/view-settings/viewSettings.validation.ts +++ b/apps/server/src/api-data/view-settings/viewSettings.validation.ts @@ -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); diff --git a/apps/server/src/api-integration/integration.controller.ts b/apps/server/src/api-integration/integration.controller.ts index 10b06d5394..390c4f11dd 100644 --- a/apps/server/src/api-integration/integration.controller.ts +++ b/apps/server/src/api-integration/integration.controller.ts @@ -55,16 +55,17 @@ const actionHandlers: Record = { 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) { diff --git a/apps/server/src/api-integration/integration.utils.ts b/apps/server/src/api-integration/integration.utils.ts index bc0bbbf052..ff217636a7 100644 --- a/apps/server/src/api-integration/integration.utils.ts +++ b/apps/server/src/api-integration/integration.utils.ts @@ -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; diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 3d38fb4e0e..9aa099284d 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -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...'); diff --git a/apps/server/src/classes/data-provider/DataProvider.ts b/apps/server/src/classes/data-provider/DataProvider.ts index e3287a1698..fac55619a4 100644 --- a/apps/server/src/classes/data-provider/DataProvider.ts +++ b/apps/server/src/classes/data-provider/DataProvider.ts @@ -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 = Promise>; -export class DataProvider { - static getData() { - return data; - } +let db = {} as Low; - static async setProjectData(newData: Partial): ReadonlyPromise { - data.project = { ...data.project, ...newData }; - this.persist(); - return data.project; +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); + } } + const newDb = await JSONFilePreset(filePath, fallbackData); - static getProjectData(): Readonly { - return data.project; - } + // Read the database to initialize it + newDb.data = fallbackData; + await newDb.write(); + await newDb.read(); - static async setCustomFields(newData: CustomFields): ReadonlyPromise { - data.customFields = { ...newData }; - this.persist(); - return data.customFields; - } + db = newDb; +} - static getCustomFields(): Readonly { - return data.customFields; - } +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, + }; +} - static async setRundown(newData: OntimeRundown): ReadonlyPromise { - data.rundown = [...newData]; - this.persist(); - return data.rundown; - } +function getData(): Readonly { + return db.data; +} - static getSettings(): Readonly { - return data.settings; - } +async function setProjectData(newData: Partial): ReadonlyPromise { + db.data.project = { ...db.data.project, ...newData }; + await persist(); + return db.data.project; +} - static async setSettings(newData: Settings): ReadonlyPromise { - data.settings = { ...newData }; - this.persist(); - return data.settings; - } +function getProjectData(): Readonly { + return db.data.project; +} - static getOsc(): Readonly { - return data.osc; - } +async function setCustomFields(newData: CustomFields): ReadonlyPromise { + db.data.customFields = { ...newData }; + await persist(); + return db.data.customFields; +} - static getHttp(): Readonly { - return data.http; - } +function getCustomFields(): Readonly { + return db.data.customFields; +} - static getUrlPresets(): Readonly { - return data.urlPresets; - } +async function setRundown(newData: OntimeRundown): ReadonlyPromise { + db.data.rundown = [...newData]; + await persist(); + return db.data.rundown; +} - static async setUrlPresets(newData: URLPreset[]): ReadonlyPromise { - data.urlPresets = newData; - this.persist(); - return data.urlPresets; - } +function getSettings(): Readonly { + return db.data.settings; +} - static getViewSettings(): Readonly { - return data.viewSettings; - } +async function setSettings(newData: Settings): ReadonlyPromise { + db.data.settings = { ...newData }; + await persist(); + return db.data.settings; +} - static async setViewSettings(newData: ViewSettings): ReadonlyPromise { - data.viewSettings = { ...newData }; - this.persist(); - return data.viewSettings; - } +function getOsc(): Readonly { + return db.data.osc; +} - static async setOsc(newData: OSCSettings): ReadonlyPromise { - data.osc = { ...newData }; - this.persist(); - return data.osc; - } +function getHttp(): Readonly { + return db.data.http; +} - static async setHttp(newData: HttpSettings): ReadonlyPromise { - data.http = { ...newData }; - this.persist(); - return data.http; - } +function getUrlPresets(): Readonly { + return db.data.urlPresets; +} - static getRundown(): Readonly { - return data.rundown; - } +async function setUrlPresets(newData: URLPreset[]): ReadonlyPromise { + db.data.urlPresets = newData; + await persist(); + return db.data.urlPresets; +} - private static async persist() { - // TODO: this is already handled by lowDb - if (isTest) { - return; - } - await db.write(); - } +function getViewSettings(): Readonly { + return db.data.viewSettings; +} + +async function setViewSettings(newData: ViewSettings): ReadonlyPromise { + db.data.viewSettings = { ...newData }; + await persist(); + return db.data.viewSettings; +} - static async mergeIntoData(newData: Partial): ReadonlyPromise { - 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; +async function setOsc(newData: OSCSettings): ReadonlyPromise { + db.data.osc = { ...newData }; + await persist(); + return db.data.osc; +} - this.persist(); +async function setHttp(newData: HttpSettings): ReadonlyPromise { + db.data.http = { ...newData }; + await persist(); + return db.data.http; +} - return data; - } +function getRundown(): Readonly { + return db.data.rundown; +} + +async function mergeIntoData(newData: Partial): ReadonlyPromise { + 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(); } diff --git a/apps/server/src/classes/data-provider/DataProvider.utils.ts b/apps/server/src/classes/data-provider/DataProvider.utils.ts index 788e4344a0..64eb642f2e 100644 --- a/apps/server/src/classes/data-provider/DataProvider.utils.ts +++ b/apps/server/src/classes/data-provider/DataProvider.utils.ts @@ -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) { +export function safeMerge(existing: DatabaseModel, newData: Partial): DatabaseModel { const { rundown, project, settings, viewSettings, urlPresets, customFields, osc, http } = newData || {}; return { diff --git a/apps/server/src/classes/data-provider/__tests__/DataProvider.test.ts b/apps/server/src/classes/data-provider/__tests__/DataProvider.utils.test.ts similarity index 100% rename from apps/server/src/classes/data-provider/__tests__/DataProvider.test.ts rename to apps/server/src/classes/data-provider/__tests__/DataProvider.utils.test.ts diff --git a/apps/server/src/models/demoProject.ts b/apps/server/src/models/demoProject.ts new file mode 100644 index 0000000000..b8c72a7119 --- /dev/null +++ b/apps/server/src/models/demoProject.ts @@ -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: [], + }, +}; diff --git a/apps/server/src/services/app-state-service/AppStateService.ts b/apps/server/src/services/app-state-service/AppStateService.ts index 6957a88b28..b1deffc113 100644 --- a/apps/server/src/services/app-state-service/AppStateService.ts +++ b/apps/server/src/services/app-state-service/AppStateService.ts @@ -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(appStatePath); +const config = new Low(adapter, {}); -class AppState { - private config: Low; - private pathToFile: string; - private didInit = false; +export async function isLastLoadedProject(projectName: string): Promise { + const lastLoaded = await getLastLoadedProject(); + return lastLoaded === projectName; +} - constructor(appStatePath: string) { - this.pathToFile = appStatePath; - const adapter = new JSONFile(this.pathToFile); - this.config = new Low(adapter, {}); - } +export async function getLastLoadedProject(): Promise { + // in test environment, we want to start the demo project + if (isTest) return; - private async init() { - await this.config.read(); - await this.config.write(); - this.didInit = true; - } + await config.read(); + return config.data.lastLoadedProject; +} - private async get(): Promise { - if (!this.didInit) { - await this.init(); +export async function setLastLoadedProject(filename: string): Promise { + 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); } - await this.config.read(); - return this.config.data; } - async isLastLoadedProject(projectName: string): Promise { - const lastLoaded = await this.getLastLoadedProject(); - return lastLoaded === projectName; - } - - async getLastLoadedProject(): Promise { - const data = await this.get(); - return data.lastLoadedProject; - } - - async setLastLoadedProject(filename: string): Promise { - if (isTest) return; - - if (!this.didInit) { - await this.init(); - } - - this.config.data.lastLoadedProject = filename; - await this.config.write(); - } + config.data.lastLoadedProject = filename; + await config.write(); } - -export const appStateProvider = new AppState(appStatePath); diff --git a/apps/server/src/services/project-service/ProjectService.ts b/apps/server/src/services/project-service/ProjectService.ts index 9a5987a528..70c37beea2 100644 --- a/apps/server/src/services/project-service/ProjectService.ts +++ b/apps/server/src/services/project-service/ProjectService.ts @@ -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 { + 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 { + 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 { + // 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} - name of the loaded file + */ +export async function initialiseProject(): Promise { + // 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 { const files = await getProjectFiles(); - const lastLoadedProject = await appStateProvider.getLastLoadedProject(); + const lastLoadedProject = await getLastLoadedProject(); return { files, @@ -96,12 +196,12 @@ export async function getProjectList(): Promise { * 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 { - 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 { export async function patchCurrentProject(data: Partial) { 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) { diff --git a/apps/server/src/services/project-service/__tests__/ProjectService.test.ts b/apps/server/src/services/project-service/__tests__/ProjectService.test.ts index 7c72aced46..b716e5b6da 100644 --- a/apps/server/src/services/project-service/__tests__/ProjectService.test.ts +++ b/apps/server/src/services/project-service/__tests__/ProjectService.test.ts @@ -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'); }); diff --git a/apps/server/src/services/project-service/projectFileUtils.ts b/apps/server/src/services/project-service/projectFileUtils.ts deleted file mode 100644 index 9a8eb8b0aa..0000000000 --- a/apps/server/src/services/project-service/projectFileUtils.ts +++ /dev/null @@ -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): Array { - return files.filter((file) => { - const ext = extname(file).toLowerCase(); - return ext === '.json'; - }); -} - -export async function parseProjectFile(filePath: string): Promise> { - 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; -} diff --git a/apps/server/src/services/project-service/projectServiceUtils.ts b/apps/server/src/services/project-service/projectServiceUtils.ts index 7f7bc2cadd..4145c95d13 100644 --- a/apps/server/src/services/project-service/projectServiceUtils.ts +++ b/apps/server/src/services/project-service/projectServiceUtils.ts @@ -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 { * Checks whether a project of a given name exists * @param name */ -export async function doesProjectExist(name: string): Promise { - 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 { 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 { + 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 { + 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): Array { + 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> { + if (!filePath.endsWith('.json')) { + throw new Error('Invalid file type'); + } + + const rawdata = await readFile(filePath, 'utf-8'); + return JSON.parse(rawdata); +} diff --git a/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts b/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts index 90733f4ceb..9f2bdc9da7 100644 --- a/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts +++ b/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts @@ -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: { diff --git a/apps/server/src/services/rundown-service/rundownCache.ts b/apps/server/src/services/rundown-service/rundownCache.ts index faa5c52a21..4241310f79 100644 --- a/apps/server/src/services/rundown-service/rundownCache.ts +++ b/apps/server/src/services/rundown-service/rundownCache.ts @@ -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, 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(mutation: MutatingFn) { }); // 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); }); } diff --git a/apps/server/src/setup/config.ts b/apps/server/src/setup/config.ts index 9c5c048526..33033a886e 100644 --- a/apps/server/src/setup/config.ts +++ b/apps/server/src/setup/config.ts @@ -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', diff --git a/apps/server/src/setup/index.ts b/apps/server/src/setup/index.ts index 1dc04c86a5..35a2e7dc4e 100644 --- a/apps/server/src/setup/index.ts +++ b/apps/server/src/setup/index.ts @@ -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); diff --git a/apps/server/src/setup/loadDb.ts b/apps/server/src/setup/loadDb.ts deleted file mode 100644 index 84b0091be9..0000000000 --- a/apps/server/src/setup/loadDb.ts +++ /dev/null @@ -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(dbInDisk, newData); - db.data = newData; - - return { db, data: newData }; -} - -export let db = {} as Low; -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(filePath, initialData); - - // Read the database to initialize it - await newDb.read(); - - db = newDb; - data = db.data; -}; - -init(); diff --git a/apps/server/src/stores/__tests__/runtimeState.test.ts b/apps/server/src/stores/__tests__/runtimeState.test.ts index 2e4e06052c..68d9ed980f 100644 --- a/apps/server/src/stores/__tests__/runtimeState.test.ts +++ b/apps/server/src/stores/__tests__/runtimeState.test.ts @@ -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(); diff --git a/apps/server/src/utils/__tests__/fileManagement.test.ts b/apps/server/src/utils/__tests__/fileManagement.test.ts index 675ff42a88..4a170d69ea 100644 --- a/apps/server/src/utils/__tests__/fileManagement.test.ts +++ b/apps/server/src/utils/__tests__/fileManagement.test.ts @@ -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); + }); +}); diff --git a/apps/server/src/utils/__tests__/parser.test.ts b/apps/server/src/utils/__tests__/parser.test.ts index 42db73a8cc..d09d1ed029 100644 --- a/apps/server/src/utils/__tests__/parser.test.ts +++ b/apps/server/src/utils/__tests__/parser.test.ts @@ -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 = { 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( diff --git a/apps/server/src/utils/fileManagement.ts b/apps/server/src/utils/fileManagement.ts index 097b110ac4..8f006afb10 100644 --- a/apps/server/src/utils/fileManagement.ts +++ b/apps/server/src/utils/fileManagement.ts @@ -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); +} diff --git a/apps/server/src/utils/generateUniqueFilename.ts b/apps/server/src/utils/generateUniqueFilename.ts deleted file mode 100644 index 77e4d88707..0000000000 --- a/apps/server/src/utils/generateUniqueFilename.ts +++ /dev/null @@ -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)); - } -}; diff --git a/apps/server/src/utils/parser.ts b/apps/server/src/utils/parser.ts index ce45365d3b..d920d8a409 100644 --- a/apps/server/src/utils/parser.ts +++ b/apps/server/src/utils/parser.ts @@ -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): { data: DatabaseModel; errors: ParsingError[] } { +export function parseDatabaseModel(jsonData: Partial): { data: DatabaseModel; errors: ParsingError[] } { // we need to parse settings first to make sure the data is ours // this may throw const settings = parseSettings(jsonData);