diff --git a/apps/server/src/api-data/automation/automation.parser.ts b/apps/server/src/api-data/automation/automation.parser.ts index 23b193493..e01c126e6 100644 --- a/apps/server/src/api-data/automation/automation.parser.ts +++ b/apps/server/src/api-data/automation/automation.parser.ts @@ -1,19 +1,21 @@ import { DatabaseModel, AutomationSettings, NormalisedAutomation, Trigger } from 'ontime-types'; -import { dbModel } from '../../models/dataModel.js'; import type { ErrorEmitter } from '../../utils/parserUtils.js'; +import { getPartialProject } from '../../models/dataModel.js'; export function parseAutomationSettings(data: Partial, emitError?: ErrorEmitter): AutomationSettings { + const defaultAutomation: AutomationSettings = getPartialProject('automation'); + if (!data.automation) { emitError?.('No data found to import'); - return { ...dbModel.automation }; + return defaultAutomation; } console.log('Found Automation settings, importing...'); return { - enabledAutomations: data.automation.enabledAutomations ?? dbModel.automation.enabledAutomations, - enabledOscIn: data.automation.enabledOscIn ?? dbModel.automation.enabledOscIn, - oscPortIn: data.automation.oscPortIn ?? dbModel.automation.oscPortIn, + enabledAutomations: data.automation.enabledAutomations ?? defaultAutomation.enabledAutomations, + enabledOscIn: data.automation.enabledOscIn ?? defaultAutomation.enabledOscIn, + oscPortIn: data.automation.oscPortIn ?? defaultAutomation.oscPortIn, triggers: parseTriggers(data.automation.triggers), automations: parseAutomations(data.automation.automations), }; diff --git a/apps/server/src/api-data/db/db.controller.ts b/apps/server/src/api-data/db/db.controller.ts index 6badc9d4a..ca6d286cb 100644 --- a/apps/server/src/api-data/db/db.controller.ts +++ b/apps/server/src/api-data/db/db.controller.ts @@ -49,15 +49,9 @@ export async function patchPartialProjectFile(req: Request, res: Response) { try { - const newFileName = await projectService.createProject(req.body.filename || 'untitled', { - project: { - title: req.body?.title ?? '', - description: req.body?.description ?? '', - url: req.body?.url ?? '', - info: req.body?.info ?? '', - logo: req.body?.logo ?? null, - custom: req.body?.custom ?? [], - }, + const { filename, ...project } = req.body; + const newFileName = await projectService.createProjectWithPatch(filename, { + project, }); res.status(200).send({ diff --git a/apps/server/src/api-data/db/migration/db.migration.v3.ts b/apps/server/src/api-data/db/migration/db.migration.v3.ts index 6f64492cc..84d7c3e72 100644 --- a/apps/server/src/api-data/db/migration/db.migration.v3.ts +++ b/apps/server/src/api-data/db/migration/db.migration.v3.ts @@ -17,11 +17,12 @@ import { URLPreset, ViewSettings, } from 'ontime-types'; -import { is } from '../../../utils/is.js'; -import { dbModel } from '../../../models/dataModel.js'; import { customFieldLabelToKey, checkRegex, isKnownTimerType, validateEndAction } from 'ontime-utils'; + +import { is } from '../../../utils/is.js'; import { event as eventModel } from '../../../models/eventsDefinition.js'; import { ONTIME_VERSION } from '../../../ONTIME_VERSION.js'; +import { getPartialProject } from '../../../models/dataModel.js'; // the methodology of the migrations is to just change the necessary keys to match with v4 // and then let the normal project parser handle ensuring the the file is correct @@ -148,8 +149,8 @@ export function migrateProjectData(jsonData: object): ProjectData | undefined { description, url: backstageUrl, info: backstageInfo, - logo: logo ?? dbModel.project.logo, - custom: custom ?? dbModel.project.custom, + logo: logo ?? null, + custom: custom ?? [], }; } } @@ -244,7 +245,7 @@ export function migrateAutomations(jsonData: object): AutomationSettings | undef } let foundOldSetting = false; - const migratedOldStuff = structuredClone(dbModel.automation); + const migratedOldStuff: AutomationSettings = getPartialProject('automation'); const migratedAutomations: NormalisedAutomation = {}; const migratedTriggers: Trigger[] = []; diff --git a/apps/server/src/api-data/db/migration/migration.test.ts b/apps/server/src/api-data/db/migration/migration.test.ts index 18cb79638..6eb3e9bc0 100644 --- a/apps/server/src/api-data/db/migration/migration.test.ts +++ b/apps/server/src/api-data/db/migration/migration.test.ts @@ -14,7 +14,7 @@ import { ViewSettings, } from 'ontime-types'; import * as v3 from './db.migration.v3.js'; -import { dbModel } from '../../../models/dataModel.js'; + import { ONTIME_VERSION } from '../../../ONTIME_VERSION.js'; describe('v3 to v4', () => { @@ -469,7 +469,7 @@ describe('v3 to v4', () => { const expectedAutomation: AutomationSettings = { enabledAutomations: true, enabledOscIn: true, - oscPortIn: dbModel.automation.oscPortIn, + oscPortIn: 8888, triggers: [ { id: '1ge4r8-T', diff --git a/apps/server/src/api-data/project-data/projectData.parser.ts b/apps/server/src/api-data/project-data/projectData.parser.ts index 4629c2c8e..5357aaa7f 100644 --- a/apps/server/src/api-data/project-data/projectData.parser.ts +++ b/apps/server/src/api-data/project-data/projectData.parser.ts @@ -1,25 +1,27 @@ import { DatabaseModel, ProjectData } from 'ontime-types'; -import { dbModel } from '../../models/dataModel.js'; import { ErrorEmitter } from '../../utils/parserUtils.js'; +import { getPartialProject } from '../../models/dataModel.js'; /** * Parse event portion of an entry */ export function parseProjectData(data: Partial, emitError?: ErrorEmitter): ProjectData { + const defaultProject: ProjectData = getPartialProject('project'); + if (!data.project) { emitError?.('No data found to import'); - return { ...dbModel.project }; + return defaultProject; } console.log('Found project data, importing...'); return { - title: data.project.title ?? dbModel.project.title, - description: data.project.description ?? dbModel.project.description, - url: data.project.url ?? dbModel.project.url, - info: data.project.info ?? dbModel.project.info, - logo: data.project.logo ?? dbModel.project.logo, - custom: data.project.custom ?? dbModel.project.custom, + title: data.project.title ?? defaultProject.title, + description: data.project.description ?? defaultProject.description, + url: data.project.url ?? defaultProject.url, + info: data.project.info ?? defaultProject.info, + logo: data.project.logo ?? defaultProject.logo, + custom: data.project.custom ?? defaultProject.custom, }; } diff --git a/apps/server/src/api-data/rundown/__mocks__/rundown.mocks.ts b/apps/server/src/api-data/rundown/__mocks__/rundown.mocks.ts index eeee948a4..ea96d9092 100644 --- a/apps/server/src/api-data/rundown/__mocks__/rundown.mocks.ts +++ b/apps/server/src/api-data/rundown/__mocks__/rundown.mocks.ts @@ -8,7 +8,7 @@ import { OntimeMilestone, } from 'ontime-types'; -import { defaultRundown } from '../../../models/dataModel.js'; +import { makeNewRundown } from '../../../models/dataModel.js'; const baseEvent = { type: SupportedEntry.Event, @@ -63,7 +63,7 @@ export function makeOntimeMilestone(patch: Partial): OntimeMile */ export function makeRundown(patch: Partial): Rundown { return { - ...defaultRundown, + ...makeNewRundown(), ...patch, }; } diff --git a/apps/server/src/api-data/rundown/__tests__/rundown.parser.test.ts b/apps/server/src/api-data/rundown/__tests__/rundown.parser.test.ts index ead985caf..35ceb2058 100644 --- a/apps/server/src/api-data/rundown/__tests__/rundown.parser.test.ts +++ b/apps/server/src/api-data/rundown/__tests__/rundown.parser.test.ts @@ -1,15 +1,25 @@ import { SupportedEntry, OntimeEvent, OntimeGroup, Rundown, CustomFields } from 'ontime-types'; -import { defaultRundown } from '../../../models/dataModel.js'; -import { makeOntimeGroup, makeOntimeEvent, makeOntimeMilestone } from '../__mocks__/rundown.mocks.js'; +import { makeNewRundown } from '../../../models/dataModel.js'; +import { makeOntimeGroup, makeOntimeEvent, makeOntimeMilestone } from '../__mocks__/rundown.mocks.js'; import { parseRundowns, parseRundown, sanitiseCustomFields } from '../rundown.parser.js'; describe('parseRundowns()', () => { it('returns a default project rundown if nothing is given', () => { const errorEmitter = vi.fn(); + const defaultRundown = makeNewRundown(); const result = parseRundowns({}, {}, errorEmitter); - expect(result).toStrictEqual({ default: defaultRundown }); + const rundownIds = Object.keys(result); + + expect(rundownIds).toHaveLength(1); + expect(result[rundownIds[0]]).toMatchObject({ + id: rundownIds[0], + title: defaultRundown.title, + order: expect.any(Array), + entries: expect.any(Object), + }); + // one for not having custom fields // one for not having a rundown expect(errorEmitter).toHaveBeenCalledTimes(1); @@ -17,6 +27,7 @@ describe('parseRundowns()', () => { it('ensures the rundown IDs are consistent', () => { const errorEmitter = vi.fn(); + const defaultRundown = makeNewRundown(); const r1 = { ...defaultRundown, id: '1' }; const r2 = { ...defaultRundown, id: '2' }; const result = parseRundowns( diff --git a/apps/server/src/api-data/rundown/rundown.parser.ts b/apps/server/src/api-data/rundown/rundown.parser.ts index c4d5fb1d9..cb4269ae0 100644 --- a/apps/server/src/api-data/rundown/rundown.parser.ts +++ b/apps/server/src/api-data/rundown/rundown.parser.ts @@ -19,8 +19,8 @@ import { } from 'ontime-types'; import { isObjectEmpty, generateId, getLinkedTimes, getTimeFrom, isNewLatest } from 'ontime-utils'; -import { defaultRundown } from '../../models/dataModel.js'; import { delay as delayDef } from '../../models/eventsDefinition.js'; +import { makeNewRundown } from '../../models/dataModel.js'; import type { ErrorEmitter } from '../../utils/parserUtils.js'; import { calculateDayOffset, cleanupCustomFields, createGroup, createEvent, createMilestone } from './rundown.utils.js'; @@ -39,10 +39,9 @@ export function parseRundowns( // this is important since the rest of the app assumes this exist if (!data.rundowns || isObjectEmpty(data.rundowns)) { emitError?.('No data found to import'); + const defaultRundown = makeNewRundown(); return { - [defaultRundown.id]: { - ...defaultRundown, - }, + [defaultRundown.id]: defaultRundown, }; } diff --git a/apps/server/src/api-data/rundown/rundown.router.ts b/apps/server/src/api-data/rundown/rundown.router.ts index 1b018e113..1e711cf5e 100644 --- a/apps/server/src/api-data/rundown/rundown.router.ts +++ b/apps/server/src/api-data/rundown/rundown.router.ts @@ -1,9 +1,14 @@ import { ErrorResponse, OntimeEntry, ProjectRundownsList, Rundown } from 'ontime-types'; -import { generateId, getErrorMessage } from 'ontime-utils'; +import { getErrorMessage } from 'ontime-utils'; import type { Request, Response } from 'express'; import express from 'express'; +import { getDataProvider } from '../../classes/data-provider/DataProvider.js'; +import { makeNewRundown } from '../../models/dataModel.js'; + +import { paramsWithId } from '../validation-utils/validationFunction.js'; + import { getCurrentRundown } from './rundown.dao.js'; import { addEntry, @@ -30,9 +35,6 @@ import { validateRundownMutation, clonePostValidator, } from './rundown.validation.js'; -import { paramsWithId } from '../validation-utils/validationFunction.js'; -import { getDataProvider } from '../../classes/data-provider/DataProvider.js'; -import { defaultRundown } from '../../models/dataModel.js'; import { duplicateRundown, normalisedToRundownArray } from './rundown.utils.js'; export const router = express.Router(); @@ -85,8 +87,9 @@ router.post('/:id/load', paramsWithId, async (req: Request, res: Response) => { try { - const id = generateId(); - await getDataProvider().setRundown(id, { ...defaultRundown, id, title: req.body.title }); + const emptyRundown = makeNewRundown(); + emptyRundown.title = req.body.title; + await getDataProvider().setRundown(emptyRundown.id, emptyRundown); const projectRundowns = getDataProvider().getProjectRundowns(); res.status(201).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) }); diff --git a/apps/server/src/api-data/settings/settings.parser.ts b/apps/server/src/api-data/settings/settings.parser.ts index 3aabac0fd..9c8c380ad 100644 --- a/apps/server/src/api-data/settings/settings.parser.ts +++ b/apps/server/src/api-data/settings/settings.parser.ts @@ -1,11 +1,13 @@ import { DatabaseModel, Settings } from 'ontime-types'; -import { dbModel } from '../../models/dataModel.js'; +import { getPartialProject } from '../../models/dataModel.js'; /** * Parse settings portion of a project file */ export function parseSettings(data: Partial): Settings { + const defaultSettings: Settings = getPartialProject('settings'); + // skip if file definition is missing // TODO: skip parsing if the version is not correct if (!data.settings || data.settings?.version == null) { @@ -15,11 +17,11 @@ export function parseSettings(data: Partial): Settings { console.log('Found settings, importing...'); return { - version: dbModel.settings.version, - serverPort: data.settings.serverPort ?? dbModel.settings.serverPort, - editorKey: data.settings.editorKey ?? null, - operatorKey: data.settings.operatorKey ?? null, - timeFormat: data.settings.timeFormat ?? '24', - language: data.settings.language ?? 'en', + version: defaultSettings.version, + serverPort: data.settings.serverPort ?? defaultSettings.serverPort, + editorKey: data.settings.editorKey ?? defaultSettings.editorKey, + operatorKey: data.settings.operatorKey ?? defaultSettings.operatorKey, + timeFormat: data.settings.timeFormat ?? defaultSettings.timeFormat, + language: data.settings.language ?? defaultSettings.language, }; } diff --git a/apps/server/src/api-data/view-settings/viewSettings.parser.ts b/apps/server/src/api-data/view-settings/viewSettings.parser.ts index 558dfdb15..bfbd40173 100644 --- a/apps/server/src/api-data/view-settings/viewSettings.parser.ts +++ b/apps/server/src/api-data/view-settings/viewSettings.parser.ts @@ -1,23 +1,25 @@ import { DatabaseModel, ViewSettings } from 'ontime-types'; -import { dbModel } from '../../models/dataModel.js'; +import { getPartialProject } from '../../models/dataModel.js'; import { ErrorEmitter } from '../../utils/parserUtils.js'; /** * Parse viewSettings portion of a project file */ export function parseViewSettings(data: Partial, emitError?: ErrorEmitter): ViewSettings { + const defaultViewSettings: ViewSettings = getPartialProject('viewSettings'); + if (!data.viewSettings) { emitError?.('No data found to import'); - return { ...dbModel.viewSettings }; + return defaultViewSettings; } console.log('Found view settings, importing...'); return { - dangerColor: data.viewSettings.dangerColor ?? dbModel.viewSettings.dangerColor, - normalColor: data.viewSettings.normalColor ?? dbModel.viewSettings.normalColor, - overrideStyles: data.viewSettings.overrideStyles ?? dbModel.viewSettings.overrideStyles, - warningColor: data.viewSettings.warningColor ?? dbModel.viewSettings.warningColor, + dangerColor: data.viewSettings.dangerColor ?? defaultViewSettings.dangerColor, + normalColor: data.viewSettings.normalColor ?? defaultViewSettings.normalColor, + overrideStyles: data.viewSettings.overrideStyles ?? defaultViewSettings.overrideStyles, + warningColor: data.viewSettings.warningColor ?? defaultViewSettings.warningColor, }; } diff --git a/apps/server/src/models/dataModel.ts b/apps/server/src/models/dataModel.ts index 47abd6668..41ba26ffd 100644 --- a/apps/server/src/models/dataModel.ts +++ b/apps/server/src/models/dataModel.ts @@ -1,7 +1,8 @@ import { DatabaseModel, Rundown } from 'ontime-types'; import { ONTIME_VERSION } from '../ONTIME_VERSION.js'; +import { generateId } from 'ontime-utils'; -export const defaultRundown: Rundown = { +const defaultRundown: Rundown = { id: 'default', title: 'Default', order: [], @@ -10,7 +11,7 @@ export const defaultRundown: Rundown = { revision: 0, }; -export const dbModel: DatabaseModel = { +const dbModel: DatabaseModel = { rundowns: { default: { ...defaultRundown }, }, @@ -46,3 +47,36 @@ export const dbModel: DatabaseModel = { automations: {}, }, }; + +/** + * Creates a new project with a single rundown of given Id + */ +export function makeNewProject(defaultRundownId?: string): DatabaseModel { + const rundown = makeNewRundown(defaultRundownId); + const newProject = structuredClone(dbModel); + newProject.rundowns = { + [rundown.id]: rundown, + }; + + return newProject; +} + +/** + * Creates a new rundown with given Id + */ +export function makeNewRundown(id?: string): Rundown { + const rundownId = id || generateId(); + const newRundown = structuredClone(defaultRundown); + newRundown.id = rundownId; + return newRundown; +} + +/** + * Get a top level property of the DatabaseModel + */ +export function getPartialProject(key: T): DatabaseModel[T] { + if (Object.hasOwn(dbModel, key)) { + return structuredClone(dbModel[key]); + } + throw new Error(`Key ${key} does not exist on DatabaseModel`); +} diff --git a/apps/server/src/services/project-service/ProjectService.ts b/apps/server/src/services/project-service/ProjectService.ts index 16005daf5..1b9d605dd 100644 --- a/apps/server/src/services/project-service/ProjectService.ts +++ b/apps/server/src/services/project-service/ProjectService.ts @@ -2,6 +2,7 @@ import { DatabaseModel, LogOrigin, ProjectFileListResponse } from 'ontime-types' import { getErrorMessage, getFirstRundown } from 'ontime-utils'; import { copyFile } from 'fs/promises'; +import { join } from 'path'; import { logger } from '../../classes/Logger.js'; import { publicDir } from '../../setup/index.js'; @@ -15,15 +16,16 @@ import { getFileNameFromPath, removeFileExtension, } from '../../utils/fileManagement.js'; -import { dbModel } from '../../models/dataModel.js'; import { parseRundowns } from '../../api-data/rundown/rundown.parser.js'; import { demoDb } from '../../models/demoProject.js'; import { config } from '../../setup/config.js'; import { getDataProvider, initPersistence } from '../../classes/data-provider/DataProvider.js'; -import { safeMerge } from '../../classes/data-provider/DataProvider.utils.js'; import { initRundown } from '../../api-data/rundown/rundown.service.js'; import { parseDatabaseModel } from '../../api-data/db/db.parser.js'; import { parseCustomFields } from '../../api-data/custom-fields/customFields.parser.js'; +import { makeNewProject } from '../../models/dataModel.js'; +import { safeMerge } from '../../classes/data-provider/DataProvider.utils.js'; +import { getCurrentRundown } from '../../api-data/rundown/rundown.dao.js'; import { getLastLoaded, isLastLoadedProject, setLastLoaded } from '../app-state-service/AppStateService.js'; import { runtimeService } from '../runtime-service/runtime.service.js'; @@ -35,7 +37,6 @@ import { moveCorruptFile, parseJsonFile, } from './projectServiceUtils.js'; -import { join } from 'path'; type ProjectState = | { @@ -94,10 +95,6 @@ async function loadProject(projectData: DatabaseModel, fileName: string, rundown ? projectData.rundowns[rundownId] : getFirstRundown(projectData.rundowns); - if (!rundown) { - throw new Error('No rundown found in project'); - } - await initRundown(rundown, projectData.customFields, true); // persist the project selection @@ -124,7 +121,8 @@ export async function loadDemoProject(): Promise { * to be composed in the loading functions */ async function loadNewProject(): Promise { - return createProject(config.newProject, dbModel); + const emptyProject = makeNewProject(); + return createProject(config.newProject, emptyProject); } /** @@ -297,13 +295,23 @@ export async function renameProjectFile(originalFile: string, newFilename: strin * @param fileName file name of the project including the extension * @param initialData db to initialize the project with */ -export async function createProject(fileName: string, initialData: Partial): Promise { - const data = safeMerge(dbModel, initialData); +export async function createProject(fileName: string, initialData: DatabaseModel): Promise { const fileNameWithExtension = generateUniqueFileName(publicDir.projectsDir, ensureJsonExtension(fileName)); - await loadProject(data, fileNameWithExtension); + await loadProject(initialData, fileNameWithExtension); return fileNameWithExtension; } +/** + * Creates a new project file from a patch and applies its result + * @param fileName file name of the project including the extension + * @param initialData patch of DB to initialize the project with + */ +export async function createProjectWithPatch(fileName: string, initialData: Partial): Promise { + const newProject = makeNewProject(); + const sanitisedData = safeMerge(newProject, initialData); + return createProject(fileName, sanitisedData); +} + /** * Deletes a project file */ @@ -328,6 +336,7 @@ export async function patchCurrentProject(data: Partial) { // eslint-disable-next-line @typescript-eslint/no-unused-vars -- we need to remove the fields before merging const { rundowns, customFields, ...rest } = data; + // we can pass some stuff straight to the data provider await getDataProvider().mergeIntoData(rest); @@ -340,9 +349,23 @@ export async function patchCurrentProject(data: Partial) { // then we can check the rundown if (rundowns) { - const parsedCustomFields = await getDataProvider().getCustomFields(); - const parsedRundowns = parseRundowns(data, parsedCustomFields); - await getDataProvider().mergeIntoData({ rundowns: parsedRundowns }); + // use the already parsed custom fields if available, otherwise fetch from provider + const projectCustomFields = getDataProvider().getCustomFields(); + const parsedRundowns = parseRundowns(data, projectCustomFields); + const currentRundown = getCurrentRundown(); + + const mergedData = await getDataProvider().mergeIntoData({ rundowns: parsedRundowns }); + + // check if the currently loaded rundown was modified + const didOverrideCurrentRundown = currentRundown.id in parsedRundowns; + + if (didOverrideCurrentRundown) { + // verify the rundown exists in the merged data before reinitializing + const updatedCurrentRundown = mergedData.rundowns[currentRundown.id]; + if (updatedCurrentRundown) { + await initRundown(updatedCurrentRundown, projectCustomFields, true); + } + } } const updatedData = await getDataProvider().getData();