mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-12 02:43:50 +00:00
cdde054b14
Reviewing the branch turned up defects, several of them worse than the problems the original commits set out to solve. Destructive: deleting an automation removed its global triggers and only then discovered a rundown event also referenced it. The delete failed, the user cancelled, and their triggers were gone for good. The server reports trigger references before event ones, so this was reachable whenever both existed. Deleted triggers are now recreated when the delete is refused. Silently wrong: a template read the project from disk while writes are debounced by three seconds, so "make some automations, save them as a template" could produce a template without them. Verified by hand: an automation created milliseconds earlier now appears. The demo shipped half a pair. The danger warning set a stage message visible and nothing ever cleared it, so from the first event that hit its danger window the message covered the countdown for the rest of the session. The recipe library ships the clearing counterpart; the demo now does too, and the whole cycle is verified against a running server. Flood control undid itself. resetAutomationLogState ran on every onLoad, and roll mode loads at every event boundary, so the "logging suppressed" notice was re-emitted once per cue: exactly the flooding it exists to prevent. It also wiped the throttle for onLoad and onStop immediately before writing to it. Reset now happens on stop only, outside the early returns that made the first attempt at this a no-op. Trigger reconciliation diffed a mount-time selection against a live prop. Settings are polled, so a trigger created in another tab while the form was open would be deleted by a save that never saw it. It now diffs against the snapshot, and says which triggers a save will remove rather than removing several same-lifecycle triggers silently. Also: a rundowns template no longer carries a phantom empty rundown from makeNewProject; the partial-duplicate endpoint returns the name it actually used, since collisions get renamed; the template flow invalidates the project list rather than relying on a refetch on mount; the last-fired label drops to a one minute cadence instead of holding a 1Hz timer per automation forever; e2e cleanup moved to afterEach so a mid-test failure stops leaking state. The e2e spec has now been run against a real server, green on both the demo project and a blank one, leaving no residue. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LpbLJVVT26tzWkduck1M9H
434 lines
15 KiB
TypeScript
434 lines
15 KiB
TypeScript
import { copyFile, writeFile } from 'fs/promises';
|
|
import { join } from 'path';
|
|
|
|
import { DatabaseModel, LogOrigin, ProjectFileListResponse, TemplateSection } from 'ontime-types';
|
|
import { getErrorMessage, getFirstRundown } from 'ontime-utils';
|
|
|
|
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 { flushPendingWrites, 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 { 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();
|
|
|
|
// 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);
|
|
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* Creates a new project file containing only the given sections of an existing one.
|
|
* This is how a user makes a template: a small project holding, say, only automations
|
|
* or only custom fields, which can then be shared and applied with a partial load.
|
|
*
|
|
* Unlike createProjectWithPatch, this does NOT load the result. Saving a template must
|
|
* not pull the operator out of the show they are running.
|
|
*
|
|
* @throws if the source does not exist or cannot be parsed
|
|
*/
|
|
export async function createProjectFromSections(
|
|
sourceFilename: string,
|
|
newFilename: string,
|
|
sections: TemplateSection[],
|
|
): Promise<string> {
|
|
const projectFilePath = doesProjectExist(sourceFilename);
|
|
if (projectFilePath === null) {
|
|
throw new Error('Project file not found');
|
|
}
|
|
|
|
if (sections.length === 0) {
|
|
throw new Error('At least one section must be selected');
|
|
}
|
|
|
|
// writes are debounced, and the natural flow here is "make some automations, then save them
|
|
// as a template". Without this the template silently misses anything from the last few seconds
|
|
await flushPendingWrites();
|
|
|
|
const fileData = await parseJsonFile(projectFilePath);
|
|
const { data } = parseDatabaseModel(fileData);
|
|
|
|
const patch: Partial<DatabaseModel> = {};
|
|
for (const section of sections) {
|
|
// a rundown without its custom fields is not usable on the other side
|
|
if (section === 'rundowns') {
|
|
patch.customFields = data.customFields;
|
|
}
|
|
Object.assign(patch, { [section]: data[section] });
|
|
}
|
|
|
|
const template = safeMerge(makeNewProject(), patch);
|
|
|
|
// makeNewProject seeds an empty rundown and safeMerge merges rundowns by key, so without
|
|
// this the template would ship a phantom "Default" rundown alongside the real ones
|
|
if (patch.rundowns !== undefined) {
|
|
template.rundowns = patch.rundowns;
|
|
}
|
|
|
|
const fileNameWithExtension = generateUniqueFileName(publicDir.projectsDir, ensureJsonExtension(newFilename));
|
|
await writeFile(getPathToProject(fileNameWithExtension), JSON.stringify(template, null, 2), 'utf-8');
|
|
|
|
return fileNameWithExtension;
|
|
}
|
|
|
|
/**
|
|
* 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 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;
|
|
}
|