Files
ontime/apps/server/src/services/project-service/ProjectService.ts
T
Claude 38e3ab979d fix: resync aux timer names on project load, consolidate name handling
Addresses the review findings on the aux timer naming feature.

- Aux timer names are project data, but they were only applied at
  bootstrap and on a settings POST. Loading another project left the
  previous project's names in the runtime store, so the controls and
  views disagreed with the settings panel until a restart. The names are
  now applied when a project is loaded, and patching the current project
  settings applies them and sends a settings refetch to the clients
  (loading a project already triggers a full refetch via the rundown).
- Consolidate the name handling into a single normaliser in ontime-utils,
  used by the project file parser, the API validation and to build the
  default value. This creates the property when importing project files
  saved before the feature existed, and enforces the name length limit
  server side rather than only in the form.
- loadNames normalises its input, so the runtime state is consistent
  regardless of the shape of the stored settings.
- Clarify that auxTimerNames is ordered (index 0 is aux timer 1), and
  derive the settings form from the shared aux timer count.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WCZejVTzuAY3tHTE6nB3JH
2026-07-30 17:31:27 +00:00

392 lines
13 KiB
TypeScript

import { copyFile } from 'fs/promises';
import { join } from 'path';
import { DatabaseModel, LogOrigin, ProjectFileListResponse, RefetchKey } from 'ontime-types';
import { getErrorMessage, getFirstRundown } from 'ontime-utils';
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
import { parseCustomFields } from '../../api-data/custom-fields/customFields.parser.js';
import { parseDatabaseModel } from '../../api-data/db/db.parser.js';
import { getCurrentRundown } from '../../api-data/rundown/rundown.dao.js';
import { parseRundowns } from '../../api-data/rundown/rundown.parser.js';
import { initRundown } from '../../api-data/rundown/rundown.service.js';
import { getDataProvider, initPersistence } from '../../classes/data-provider/DataProvider.js';
import { safeMerge } from '../../classes/data-provider/DataProvider.utils.js';
import { logger } from '../../classes/Logger.js';
import { makeNewProject } from '../../models/dataModel.js';
import { demoDb } from '../../models/demoProject.js';
import { config } from '../../setup/config.js';
import { publicDir } from '../../setup/index.js';
import { populateOntimeLogo } from '../../setup/loadOntimeLogo.js';
import {
appendToName,
deleteFile,
dockerSafeRename,
ensureDirectory,
ensureJsonExtension,
generateUniqueFileName,
getFileNameFromPath,
removeFileExtension,
} from '../../utils/fileManagement.js';
import { getLastLoaded, isLastLoadedProject, setLastLoaded } from '../app-state-service/AppStateService.js';
import { auxTimerService } from '../aux-timer-service/AuxTimerService.js';
import { runtimeService } from '../runtime-service/runtime.service.js';
import {
doesProjectExist,
getPathToProject,
getProjectFiles,
moveCorruptFile,
parseJsonFile,
} from './projectServiceUtils.js';
type ProjectState =
| {
status: 'PENDING';
currentProjectName: undefined;
}
| {
status: 'INITIALIZED';
currentProjectName: string;
};
let currentProjectState: ProjectState = {
status: 'PENDING',
currentProjectName: undefined,
};
// init dependencies
init();
/**
* Ensure services has its dependencies initialized
*/
function init() {
ensureDirectory(publicDir.projectsDir);
ensureDirectory(publicDir.corruptDir);
ensureDirectory(publicDir.logoDir);
ensureDirectory(publicDir.migrateDir);
}
export async function getCurrentProject(): Promise<{ filename: string; pathToFile: string }> {
if (currentProjectState.status === 'PENDING') {
await initialiseProject();
}
// we know the project is loaded since we force initialisation above
const pathToFile = getPathToProject(currentProjectState.currentProjectName as string);
return { filename: currentProjectState.currentProjectName as string, pathToFile };
}
/**
* Private function loads a project file and handles necessary side effects
* @param projectData
* @param fileName file name of the project including the extension
*/
async function loadProject(projectData: DatabaseModel, fileName: string, rundownId?: string) {
// change LowDB to point to new file
await initPersistence(getPathToProject(fileName), projectData);
logger.info(LogOrigin.Server, `Loaded project ${fileName}`);
// stop the runtime service
runtimeService.stop();
// the aux timer names belong to the project, apply the ones from the newly loaded project
auxTimerService.loadNames(projectData.settings.auxTimerNames);
// load the rundown given by key otherwise load the first in the project
const rundown =
rundownId && rundownId in projectData.rundowns
? projectData.rundowns[rundownId]
: getFirstRundown(projectData.rundowns);
// initialising the rundown with reload sends a refetch to the clients
await initRundown(rundown, projectData.customFields, true);
// persist the project selection
await setLastLoaded(fileName, rundown.id);
// update the service state
currentProjectState = {
status: 'INITIALIZED',
currentProjectName: fileName,
};
return fileName;
}
/**
* Loads the demo project
*/
export async function loadDemoProject(): Promise<string> {
populateOntimeLogo();
return createProject(config.demoProject, demoDb);
}
/**
* Private function loads a new, empty project
* to be composed in the loading functions
*/
async function loadNewProject(): Promise<string> {
const emptyProject = makeNewProject();
return createProject(config.newProject, emptyProject);
}
/**
* Private function handles side effects on corrupted files
* Corrupted files in this context contain data that failed domain validation
* @throws
* @param filePath path to the project type include the fileName and extension
* @param fileName as extracted from filePath, includes extension
*/
async function handleCorruptedFile(filePath: string, fileName: string): Promise<string> {
// copy file to corrupted folder
const copyPath = join(publicDir.corruptDir, fileName);
await copyFile(filePath, copyPath).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 dockerSafeRename(filePath, newPath);
return getFileNameFromPath(newPath);
}
/**
* Private function handles side effects on migrated files
* A file was migrated to a newer version, and the old one kept as backup
* @throws
* @param filePath path to the project type include the fileName and extension
* @param fileName as extracted from filePath, includes extension
*/
async function handleMigratedFile(filePath: string, fileName: string): Promise<string> {
// copy file to migrated folder
const copyPath = join(publicDir.migrateDir, fileName);
await copyFile(filePath, copyPath).catch((_) => {
/* while we have to catch the error, we dont need to handle it */
});
// and make a new file with the migrated data
const newPath = appendToName(filePath, '(migrated)');
await dockerSafeRename(filePath, newPath);
return getFileNameFromPath(newPath);
}
/**
* Coordinates the initial load of a project on app startup
* This is different from the load project since we need to always load something
* @returns {Promise<string>} - name of the loaded file
*/
export async function initialiseProject(): Promise<string> {
// check what was loaded before
const lastLoaded = await getLastLoaded();
// in normal circumstances we dont have a previous project if it is the first app start
// in previousLoaded case we want to load a demo project
if (!lastLoaded?.projectName) {
return loadDemoProject();
}
try {
const projectName = await loadProjectFile(lastLoaded.projectName, {
rundownId: lastLoaded.rundownId,
initialLoad: true,
});
return projectName;
} catch (error) {
// if we are here, most likely the json parsing failed and the file is corrupt
logger.warning(
LogOrigin.Server,
`Unable to load previous project ${lastLoaded.projectName}: ${getErrorMessage(error)}`,
);
try {
const pathToFile = getPathToProject(lastLoaded.projectName);
await moveCorruptFile(pathToFile, lastLoaded.projectName);
} 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
* @throws
* @param fileName file name of the project including the extension
*/
export async function loadProjectFile(
fileName: string,
options?: { rundownId?: string; initialLoad?: boolean },
): Promise<string> {
const filePath = doesProjectExist(fileName);
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 parseJsonFile(filePath);
const result = parseDatabaseModel(fileData, options?.initialLoad);
let parsedFileName = fileName;
if (result.migrated) {
logger.warning(LogOrigin.Server, 'The imported project is migrate, the original file has been backed up');
parsedFileName = await handleMigratedFile(filePath, parsedFileName);
} else if (result.errors.length > 0) {
logger.warning(LogOrigin.Server, 'Project loaded with errors');
parsedFileName = await handleCorruptedFile(filePath, parsedFileName);
}
const projectName = await loadProject(result.data, parsedFileName, options?.rundownId);
return projectName;
}
/**
* Gathers data related to the project list
*/
export async function getProjectList(): Promise<ProjectFileListResponse> {
const files = await getProjectFiles();
const lastLoaded = await getLastLoaded();
return {
files,
lastLoadedProject: lastLoaded?.projectName ? removeFileExtension(lastLoaded?.projectName) : '',
};
}
/**
* Duplicates an existing project file
*/
export async function duplicateProjectFile(originalFile: string, newFilename: string) {
const projectFilePath = doesProjectExist(originalFile);
if (projectFilePath === null) {
throw new Error('Project file not found');
}
const duplicateProjectFilePath = doesProjectExist(newFilename);
if (duplicateProjectFilePath !== null) {
throw new Error(`Project file with name ${newFilename} already exists`);
}
const pathToDuplicate = getPathToProject(newFilename);
await copyFile(projectFilePath, pathToDuplicate);
return;
}
/**
* Renames an existing project file
* @throws
*/
export async function renameProjectFile(originalFile: string, newFilename: string): Promise<string> {
const projectFilePath = doesProjectExist(originalFile);
if (projectFilePath === null) {
throw new Error('Project file not found');
}
const newProjectFilePath = doesProjectExist(newFilename);
if (newProjectFilePath !== null) {
throw new Error(`Project file with name ${newFilename} already exists`);
}
const pathToRenamed = getPathToProject(newFilename);
await dockerSafeRename(projectFilePath, pathToRenamed);
// Update the last loaded project config if current loaded project is the one being renamed
const isLoaded = await isLastLoadedProject(originalFile);
if (isLoaded) {
const fileData = await parseJsonFile(pathToRenamed);
const projectData = parseDatabaseModel(fileData);
const newFileName = await loadProject(projectData.data, newFilename);
return newFileName;
}
return newFilename;
}
/**
* Creates a new project file and applies its result
* @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: DatabaseModel): Promise<string> {
const fileNameWithExtension = generateUniqueFileName(publicDir.projectsDir, ensureJsonExtension(fileName));
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<DatabaseModel>): Promise<string> {
const newProject = makeNewProject();
const sanitisedData = safeMerge(newProject, initialData);
return createProject(fileName, sanitisedData);
}
/**
* Deletes a project file
*/
export async function deleteProjectFile(filename: string) {
if (filename === currentProjectState.currentProjectName) {
throw new Error('Cannot delete currently loaded project');
}
const projectFilePath = doesProjectExist(filename);
if (projectFilePath === null) {
throw new Error('Project file not found');
}
await deleteFile(projectFilePath);
}
/**
* applies a partial database model
*/
export async function patchCurrentProject(data: Partial<DatabaseModel>) {
runtimeService.stop();
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- we need to remove the fields before merging
const { rundowns, customFields, ...rest } = data;
// we can pass some stuff straight to the data provider
await getDataProvider().mergeIntoData(rest);
// the settings may contain new aux timer names, apply them and notify the clients
if (rest.settings) {
auxTimerService.loadNames(getDataProvider().getSettings().auxTimerNames);
sendRefetch(RefetchKey.Settings);
}
// the rundown depends on custom fields
// so custom fields needs to be checked first
if (customFields) {
const parsedCustomFields = parseCustomFields(data);
await getDataProvider().mergeIntoData({ customFields: parsedCustomFields });
}
// then we can check the rundown
if (rundowns) {
// 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();
return updatedData;
}